archy/neode-ui/src/main.ts

149 lines
6.8 KiB
TypeScript
Raw Normal View History

2026-01-24 22:59:20 +00:00
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 '' },
},
})
}
import { useToast } from '@/composables/useToast'
2026-01-24 22:59:20 +00:00
const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
app.use(router)
app.use(i18n)
2026-01-24 22:59:20 +00:00
// 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()
console.error(`[${source}]`, err, info ?? '')
// Surface the real message (truncated) instead of a generic toast — this is a
// test/bug-bash build, and "Something went wrong" hides exactly what we need.
const short = message.length > 140 ? `${message.slice(0, 140)}` : message
try {
useToast().error(`Something went wrong: ${short}`)
} catch { /* toast itself failed — the console + ring buffer still have it */ }
// Always show the on-device overlay so the error is visible without a console.
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
}
// 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
recordError('window.error', ev.error ?? ev.message)
})
window.addEventListener('unhandledrejection', (ev) => {
if (reloadOnceForStaleChunk(ev.reason)) return
recordError('unhandledrejection', ev.reason)
})
2026-01-24 22:59:20 +00:00
app.mount('#app')