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>
166 lines
8.1 KiB
TypeScript
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')
|