archy/neode-ui/src/main.ts
archipelago 2d8ade629b fix(ui): log global errors silently instead of popping a toast + overlay
The global error handler (Vue errorHandler + window error + unhandledrejection)
fired a red 'Something went wrong: <raw msg>' toast AND an auto on-device overlay
on every caught error — deliberately loud for bug-bash, but it surfaces benign,
non-actionable noise (e.g. a transient RPC rejection during a ws reconnect, or
the service worker failing to register over a self-signed cert) right in the
user's face.

Demote the catch-all to SILENT capture: keep console.error + the
window.__archyErrors ring buffer, and expose the screenshot-able overlay
on-demand via window.__archyShowErrors() — but never auto-pop. Components that
need to report a specific, actionable failure still call toast.error() directly.

Also filter known-benign environmental noise (PWA service-worker registration
failing over a self-signed cert — needs a trusted cert, #56) so it doesn't even
occupy a ring-buffer slot and push out real errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 05:55:49 -04:00

166 lines
8.1 KiB
TypeScript

import { createApp } from 'vue'
import { createPinia } from 'pinia'
import './style.css'
import App from './App.vue'
import router from './router'
import i18n from './i18n'
import { displayVersion } from '@/utils/version'
// Clipboard polyfill for HTTP (non-secure) contexts where navigator.clipboard is unavailable
if (!navigator.clipboard) {
Object.defineProperty(navigator, 'clipboard', {
value: {
async writeText(text: string) {
const ta = document.createElement('textarea')
ta.value = text
ta.style.cssText = 'position:fixed;opacity:0'
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
},
async readText() { return '' },
},
})
}
const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
app.use(router)
app.use(i18n)
// Global version formatter — normalizes version labels to a single "v" prefix
// (some sources already carry one, which produced "vv1.2.3"). Use `$ver(x)` in
// templates instead of hard-coding a `v` prefix.
app.config.globalProperties.$ver = displayVersion
// Keep a small ring buffer of the most recent errors on `window.__archyErrors`
// so a crash that only reproduces inside a specific runtime (e.g. the Android
// companion WebView, where there's no easy console) can be retrieved after the
// fact — read it from chrome://inspect, or we can surface it in a debug view.
interface ArchyErrorEntry { when: string; source: string; message: string; info?: string; stack?: string }
const errorLog: ArchyErrorEntry[] = []
;(window as unknown as { __archyErrors?: ArchyErrorEntry[] }).__archyErrors = errorLog
// On-screen, screenshot-able error overlay. Built with plain DOM (not a Vue
// component) so it still appears when the crash is in Vue itself or in a runtime
// with no dev console (the Android companion WebView — chrome://inspect isn't
// always reachable). The user can read/screenshot the real error and the stack
// straight off the device, and "Copy" puts the full ring buffer on the clipboard.
function showErrorOverlay() {
const id = 'archy-error-overlay'
let box = document.getElementById(id)
if (!box) {
box = document.createElement('div')
box.id = id
box.style.cssText = [
'position:fixed', 'left:8px', 'right:8px', 'bottom:8px', 'z-index:2147483647',
'max-height:45vh', 'overflow:auto', 'background:rgba(20,0,0,0.92)', 'color:#ffd7d7',
'font:12px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace', 'padding:10px 12px',
'border:1px solid #ff5a5a', 'border-radius:10px', 'white-space:pre-wrap',
'word-break:break-word', '-webkit-user-select:text', 'user-select:text',
].join(';')
document.body.appendChild(box)
}
const latest = errorLog[errorLog.length - 1]
const dump = errorLog
.map((e) => `[${e.source}] ${e.message}${e.info ? ` (${e.info})` : ''}${e.stack ? `\n${e.stack.split('\n').slice(0, 4).join('\n')}` : ''}`)
.join('\n──\n')
box.innerHTML = ''
const bar = document.createElement('div')
bar.style.cssText = 'display:flex;gap:8px;justify-content:space-between;align-items:center;margin-bottom:6px'
const title = document.createElement('b')
title.textContent = `⚠ App error (${errorLog.length}) — screenshot or Copy`
title.style.color = '#ff8a8a'
const btns = document.createElement('div')
const mkBtn = (label: string, fn: () => void) => {
const b = document.createElement('button')
b.textContent = label
b.style.cssText = 'margin-left:6px;background:#ff5a5a;color:#1a0000;border:0;border-radius:6px;padding:3px 9px;font-weight:700'
b.onclick = fn
return b
}
btns.appendChild(mkBtn('Copy', () => { navigator.clipboard?.writeText(JSON.stringify(errorLog, null, 2)) }))
btns.appendChild(mkBtn('Dismiss', () => { box?.remove() }))
bar.appendChild(title); bar.appendChild(btns)
const body = document.createElement('div')
body.textContent = latest ? dump : 'no error captured'
box.appendChild(bar); box.appendChild(body)
}
function recordError(source: string, err: unknown, info?: string) {
const e = err as { message?: string; stack?: string } | undefined
const message = (e?.message ?? String(err)) || 'unknown error'
const entry: ArchyErrorEntry = { when: new Date().toISOString(), source, message, info, stack: e?.stack }
errorLog.push(entry)
if (errorLog.length > 25) errorLog.shift()
// Log SILENTLY: a global handler error is almost always something we should
// fix at the source, not interrupt the user for. Keep the full record on the
// console + the window.__archyErrors ring buffer, and make the screenshot-able
// overlay available ON DEMAND (window.__archyShowErrors(), or the debug view)
// — but do NOT auto-pop a red toast / overlay over the UI. Components that
// need to tell the user about a *specific, actionable* failure still call
// toast.error() directly; this catch-all stays out of the way.
console.error(`[${source}]`, err, info ?? '')
}
// Expose the on-demand error overlay + ring buffer so a crash that only repros
// in a runtime without a console (Android companion WebView) is still
// retrievable: call `window.__archyShowErrors()` to screenshot/Copy them.
;(window as unknown as { __archyShowErrors?: () => void }).__archyShowErrors = () => {
try { showErrorOverlay() } catch { /* overlay is best-effort */ }
}
app.config.errorHandler = (err, _instance, info) => recordError('Vue Error', err, info)
// After a frontend deploy the browser's cached index.html still points at the
// OLD hashed chunks (e.g. AppSession-Cq93o4ao.js), which 404 — Vite then throws
// "Failed to fetch dynamically imported module". The fix is to reload once so
// the browser pulls the fresh index + chunk map. Guard with sessionStorage so a
// genuinely-broken chunk can't trap us in a reload loop.
function isStaleChunkError(err: unknown): boolean {
const msg = (err as { message?: string })?.message ?? String(err ?? '')
return /Failed to fetch dynamically imported module|error loading dynamically imported module|Importing a module script failed|ChunkLoadError|dynamically imported module/i.test(msg)
}
function reloadOnceForStaleChunk(err: unknown): boolean {
if (!isStaleChunkError(err)) return false
try {
const KEY = 'archy-chunk-reload-at'
const last = Number(sessionStorage.getItem(KEY) || '0')
// Only reload if we haven't already tried in the last 10s (loop guard).
if (Date.now() - last < 10_000) return false
sessionStorage.setItem(KEY, String(Date.now()))
} catch { /* sessionStorage unavailable — reload anyway, once is better than stuck */ }
// Cache-bust the navigation so the stale index isn't served again.
location.reload()
return true
}
// Known-benign environmental noise — expected on some deployments and not
// actionable by the user or us, so it shouldn't even occupy a ring-buffer slot
// (which would push out real errors). The PWA service worker can't register
// over a self-signed cert (it needs a trusted cert or localhost); on those
// nodes the SW/offline cache simply doesn't run, which is fine. Logged at debug
// only. (A trusted cert is the real fix — tracked separately, #56.)
function isBenignEnvironmentError(err: unknown): boolean {
const msg = (err as { message?: string })?.message ?? String(err ?? '')
return /Failed to register a ServiceWorker|ServiceWorker.*(SSL|certificate|SecurityError)|An SSL certificate error occurred when fetching the script/i.test(msg)
}
// Vue's errorHandler only catches errors raised synchronously inside Vue's
// lifecycle/reactivity. Async rejections and plain runtime errors (e.g. a JS
// API missing in an older WebView) slip past it, so catch those too.
window.addEventListener('error', (ev) => {
if (reloadOnceForStaleChunk(ev.error ?? ev.message)) return
if (isBenignEnvironmentError(ev.error ?? ev.message)) { console.debug('[benign]', ev.message); return }
recordError('window.error', ev.error ?? ev.message)
})
window.addEventListener('unhandledrejection', (ev) => {
if (reloadOnceForStaleChunk(ev.reason)) return
if (isBenignEnvironmentError(ev.reason)) { console.debug('[benign]', ev.reason); return }
recordError('unhandledrejection', ev.reason)
})
app.mount('#app')