mouaif 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +140 -0
- package/bin/mouaif.js +281 -0
- package/frontend/dist/assets/AgentFilePicker-CcKLJorU.js +1 -0
- package/frontend/dist/assets/CliModal-Hs5phmNZ.js +7 -0
- package/frontend/dist/assets/DictationPage-BI23lp42.js +2 -0
- package/frontend/dist/assets/FileEditor-DDl31c6d.js +2 -0
- package/frontend/dist/assets/GitModal-3EC_gpJ5.js +2 -0
- package/frontend/dist/assets/Inspector-Ba3R1w04.js +73 -0
- package/frontend/dist/assets/SettingsAbout-bvZGDEDw.js +1 -0
- package/frontend/dist/assets/SettingsActions-Dk6WX9jv.js +1 -0
- package/frontend/dist/assets/SettingsAgents-BNV0MgDB.js +1 -0
- package/frontend/dist/assets/SettingsDefaults-DbMmQbzc.js +1 -0
- package/frontend/dist/assets/SettingsHiddenContent-BZ2sloH1.js +1 -0
- package/frontend/dist/assets/SettingsMcp-DOrfbQd1.js +1 -0
- package/frontend/dist/assets/SettingsMcpEdit-BGMQ2CWC.js +3 -0
- package/frontend/dist/assets/SettingsMcpRegistry-BywXee_A.js +1 -0
- package/frontend/dist/assets/SettingsNotifications-B0LEs11a.js +1 -0
- package/frontend/dist/assets/SettingsPricing-BAg33iVF.js +1 -0
- package/frontend/dist/assets/SettingsProject-DNrKhCcZ.js +14 -0
- package/frontend/dist/assets/SettingsProjects-IqkBfDcm.js +1 -0
- package/frontend/dist/assets/SettingsPrompts-BgeiASuk.js +1 -0
- package/frontend/dist/assets/SettingsProviders-k0xJN0IK.js +1 -0
- package/frontend/dist/assets/SettingsTags-B5kjFdQi.js +1 -0
- package/frontend/dist/assets/agentNavigation-BiiCpFz5.js +1 -0
- package/frontend/dist/assets/codemirror-Bp6CUUFk.js +30 -0
- package/frontend/dist/assets/index-BGvI4n0T.js +61 -0
- package/frontend/dist/assets/index-Bgg1gnDf.css +1 -0
- package/frontend/dist/assets/index-C1sQFIC-.css +1 -0
- package/frontend/dist/assets/index-CANPYzQg.css +1 -0
- package/frontend/dist/assets/index-Crn1LdzK.css +1 -0
- package/frontend/dist/assets/index-FbCWDPiB.css +1 -0
- package/frontend/dist/assets/projectQS-D1cSZ7Gr.js +1 -0
- package/frontend/dist/assets/virtual-list-6H9b4K51.js +1 -0
- package/frontend/dist/icons/favicon-32.png +0 -0
- package/frontend/dist/icons/icon-180-apple.png +0 -0
- package/frontend/dist/icons/icon-192.png +0 -0
- package/frontend/dist/icons/icon-512.png +0 -0
- package/frontend/dist/icons/icon-maskable-512.png +0 -0
- package/frontend/dist/index.html +83 -0
- package/frontend/dist/manifest.webmanifest +33 -0
- package/frontend/dist/sw.js +482 -0
- package/package.json +98 -0
- package/scripts/patch-zimmerframe.js +58 -0
- package/src/access-auth.js +515 -0
- package/src/agentFeatures.js +294 -0
- package/src/agentFiles.js +164 -0
- package/src/agentSkills.js +147 -0
- package/src/agents.js +230 -0
- package/src/ai-chat.js +21 -0
- package/src/ai-endpoints.js +1880 -0
- package/src/ai-stream.js +2048 -0
- package/src/ai.js +68 -0
- package/src/auth.js +391 -0
- package/src/chatdb.js +816 -0
- package/src/chats.js +275 -0
- package/src/custom-actions.js +65 -0
- package/src/files.js +431 -0
- package/src/hideFileContent.js +327 -0
- package/src/http-server.js +535 -0
- package/src/index.js +15 -0
- package/src/inspector.js +731 -0
- package/src/inspectorProfiles.js +503 -0
- package/src/live-chat.js +107 -0
- package/src/mcp.js +1517 -0
- package/src/messages.js +238 -0
- package/src/modelList.js +137 -0
- package/src/notifications.js +52 -0
- package/src/oauth-anthropic.js +280 -0
- package/src/oauth-github-copilot.js +417 -0
- package/src/oauth-mcp.js +216 -0
- package/src/oauth-openrouter.js +285 -0
- package/src/package-version.js +20 -0
- package/src/projects.js +285 -0
- package/src/promptProfiles.js +256 -0
- package/src/prompts.js +384 -0
- package/src/providerShapes.js +44 -0
- package/src/providers/base.js +41 -0
- package/src/providers/index.js +25 -0
- package/src/push.js +315 -0
- package/src/qr.js +192 -0
- package/src/restart.js +47 -0
- package/src/server-handlers-access.js +306 -0
- package/src/server-handlers-actions.js +100 -0
- package/src/server-handlers-ai.js +248 -0
- package/src/server-handlers-auth.js +273 -0
- package/src/server-handlers-chats.js +1436 -0
- package/src/server-handlers-git.js +467 -0
- package/src/server-handlers-mcp-oauth.js +56 -0
- package/src/server-handlers-misc.js +783 -0
- package/src/server-handlers-projects.js +289 -0
- package/src/server-handlers-prompts.js +259 -0
- package/src/server-handlers-push.js +102 -0
- package/src/server-handlers-settings.js +406 -0
- package/src/server-handlers-tools.js +654 -0
- package/src/server-handlers-transcribe.js +399 -0
- package/src/server-shared.js +780 -0
- package/src/server-web-static.js +191 -0
- package/src/settings.js +898 -0
- package/src/statusBar.js +541 -0
- package/src/tags.js +414 -0
- package/src/toolFeedback.js +225 -0
- package/src/tools/ask.js +154 -0
- package/src/tools/authorization.js +932 -0
- package/src/tools/files.js +1150 -0
- package/src/tools/progress.js +71 -0
- package/src/tools/restart.js +32 -0
- package/src/tools/searchEngine.js +957 -0
- package/src/tools/shell.js +341 -0
- package/src/tools/subagent.js +47 -0
- package/src/tools/task.js +234 -0
- package/src/tools/webpreview.js +448 -0
- package/src/trace.js +103 -0
- package/src/transcribe.js +683 -0
- package/src/usage.js +389 -0
- package/src/util.js +151 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
.hidden-content__head .view-back{min-width:2.75rem;min-height:2.75rem}.hidden-content__sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}.hidden-content{display:flex;flex-direction:column;gap:.875rem;min-width:0;width:100%;max-width:52rem;margin:0 auto;padding:.75rem max(.75rem,env(safe-area-inset-right)) max(1rem,env(safe-area-inset-bottom)) max(.75rem,env(safe-area-inset-left));box-sizing:border-box}.hidden-content p{margin:0}.hidden-content__intro{font-size:.9rem;line-height:1.5}.hidden-content__scope{padding:.75rem;border-left:.2rem solid var(--accent);border-radius:var(--r-sm);background:var(--surface-2);font-size:.8rem;line-height:1.5;color:var(--fg-soft)}.hidden-content__path,.hidden-content__scope code{overflow-wrap:anywhere}.hidden-content__path{font-family:var(--font-mono);font-size:.9rem}.hidden-content__muted{display:block;color:var(--muted);font-size:.8rem;line-height:1.5;overflow-wrap:anywhere}.hidden-content>.btn{align-self:flex-start}.hidden-content__files{margin:0;padding:0;list-style:none;border:1px solid var(--border);border-radius:var(--r-md);overflow:hidden}.hidden-content__files li+li{border-top:1px solid var(--border)}.hidden-content__row{display:flex;align-items:stretch;background:var(--surface)}.hidden-content__file{flex:1 1 auto;min-width:0;display:flex;align-items:center;justify-content:space-between;gap:.75rem;min-height:3.5rem;padding:.875rem .5rem .875rem .875rem;background:transparent;color:var(--fg);border:0;text-align:left;cursor:pointer}.hidden-content__file-main{min-width:0;display:grid;gap:.25rem}.hidden-content__remove{flex:0 0 auto;min-width:2.75rem;min-height:2.75rem;margin:.5rem .5rem .5rem 0;display:inline-flex;align-items:center;justify-content:center;padding:0 .75rem;background:transparent;color:var(--danger);border:1px solid var(--border);border-radius:var(--r-sm);cursor:pointer;font:inherit;font-size:.8rem;font-weight:600}.hidden-content__empty{padding:1rem;background:var(--surface);border:1px dashed var(--border);border-radius:var(--r-md);color:var(--muted)}.hidden-content__notice:empty{display:none}.hidden-content__fields{border:0;margin:0;padding:0;min-width:0;display:flex;flex-direction:column;gap:.75rem}.hidden-content .btn,.hidden-content .input,.hidden-content summary{min-height:2.75rem;min-width:2.75rem}.hidden-content .input{font-size:1rem;box-sizing:border-box}.hidden-content__editor{max-height:48dvh;min-height:8rem;overflow:hidden;border:1px solid var(--border);border-radius:var(--r-md);background:var(--surface)}.hidden-content__editor .cm-editor{height:100%}.hidden-content__editor .cm-scroller{font-family:ui-monospace,Cascadia Mono,JetBrains Mono,Menlo,Consolas,monospace;font-size:13px;line-height:1.5}.hidden-content__editor .cm-content{padding:8px 0}.hidden-content__editor .cm-gutters{background:#282c34;border-right:1px solid #21252b;color:var(--muted)}.hidden-content__editor .hc__toggle{cursor:pointer}.hidden-content__editor .hc__toggle-marker{display:inline-flex;align-items:center;justify-content:center;width:2rem;height:100%;text-align:center;color:var(--accent);font-weight:700}.hidden-content__editor .hc__line-hidden{background:var(--accent-soft)}.hidden-content__editor .hc__char-hidden{background:var(--accent-soft);border-bottom:2px dotted var(--accent)}.hidden-content__toolbar{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem;padding-top:.5rem}.hidden-content__toolbar>.hidden-content__muted{flex:1 1 auto;min-width:0}.hidden-content button:focus-visible,.hidden-content summary:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}.hidden-content button:disabled{opacity:.5;cursor:default}.hidden-content__manual{border:1px solid var(--border);border-radius:var(--r-md);padding:0 .75rem .75rem}.hidden-content__manual summary{display:list-item;align-content:center;cursor:pointer;font-size:.9rem;padding:.5rem 0;box-sizing:border-box}.hidden-content__manual:not([open]){padding-bottom:0}.hidden-content__range{display:flex;align-items:flex-end;gap:.5rem;margin:.75rem 0}.hidden-content__range label{flex:1;min-width:0;display:grid;gap:.25rem;font-size:.8rem}.hidden-content__range input{width:100%}.hidden-content__preview-error{display:grid;gap:.5rem}.hidden-content__preview-error .btn{justify-self:start}.hidden-content__footer{position:sticky;bottom:0;z-index:2;padding:.75rem 0 max(.75rem,env(safe-area-inset-bottom));border-top:1px solid var(--border);background:var(--bg);display:grid;gap:.5rem}.hidden-content__selection{min-width:0;font-size:.9rem}.hidden-content__selection>.hidden-content__muted{max-height:3rem;overflow:auto}.hidden-content__actions{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem}.hidden-content__actions>.hidden-content__hide-action{margin-right:auto}.hidden-content__error{color:var(--danger);font-size:.85rem;line-height:1.5}.hidden-content .afp__iconbtn{min-height:2.75rem;min-width:2.75rem}.hidden-content .afp__row{min-height:2.75rem}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
function n(e){return e?"?projectDir="+encodeURIComponent(e):""}export{n as p};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
function k(w){const{itemHeight:e,overscan:u,scrollTop:g,viewportHeight:l,count:a}=w;if(!e||e<=0)throw new Error("itemHeight must be > 0");if(a===0)return{start:0,end:0,padTop:0,padBottom:0};const y=Math.floor(g/e),p=Math.ceil(l/e),h=Math.min(Math.max(0,y-u),a),s=Math.min(a,Math.max(h,y+p+u));return{start:h,end:s,padTop:h*e,padBottom:Math.max(0,(a-s)*e)}}const z=Object.freeze({itemHeight:44,overscan:4,render:function(w,e){e.textContent=String(w)}});function F(w){if(typeof window>"u"||typeof document>"u")throw new Error("createVirtualList requires a browser environment");const e=Object.assign({},z,w||{});if(!e.scroller||!e.scroller.nodeType)throw new Error("scroller element is required");let u=Array.isArray(e.data)?e.data.slice():[],g=!1,l=null,a=null,y=!1;const p=typeof e.key=="function"?e.key:null,h=p?new Map:null,s=document.createElement("div");s.style.position="relative",s.style.width="100%",s.style.willChange="transform";const n=[];function M(o){for(;n.length<o;){const t=document.createElement("div");t.style.position="absolute",t.style.left="0",t.style.right="0",t.style.height=e.itemHeight+"px",t.style.willChange="transform",s.appendChild(t),n.push(t)}for(;n.length>o;){const t=n.pop();t.parentNode&&t.parentNode.removeChild(t)}}function x(){if(y)return;g=!1;const o=e.scroller.scrollTop,t=e.scroller.clientHeight,i=k({itemHeight:e.itemHeight,overscan:e.overscan,scrollTop:o,viewportHeight:t,count:u.length});if(!(l&&l.start===i.start&&l.end===i.end&&l.padTop===i.padTop&&l.padBottom===i.padBottom)){if(l=i,s.style.height=i.padTop+(i.end-i.start)*e.itemHeight+i.padBottom+"px",M(i.end-i.start),p){const c=new Array(n.length),f=new Set;for(let r=0;r<n.length;r++){const m=i.start+r,d=u[m];if(d===void 0)continue;const N=p(d),T=h.get(N);T&&n.indexOf(T)>=0&&!f.has(T)&&(c[r]=T,f.add(T))}for(let r=0;r<n.length;r++)if(!c[r])for(let m=0;m<n.length;m++){const d=n[m];if(!f.has(d)){c[r]=d,f.add(d);break}}n.length=0;for(const r of c)r&&n.push(r);h.clear()}for(let c=0;c<n.length;c++){const f=n[c],r=i.start+c,m=i.padTop+c*e.itemHeight;f.style.transform="translateY("+m+"px)";const d=u[r];d!==void 0&&(p&&h.set(p(d),f),e.render(d,f,r))}}}function v(){if(!g){g=!0;var o=window.requestAnimationFrame||function(t){return setTimeout(t,16)};a=o(x)}}function H(){v()}function E(){v()}e.scroller.addEventListener("scroll",H,{passive:!0}),window.addEventListener("resize",E,{passive:!0}),e.scroller.appendChild(s),v();function A(o){u=Array.isArray(o)?o.slice():[],l=null,v()}function C(){return u.slice()}function b(){l=null,v()}function L(o){const t=Math.max(0,Math.min(u.length-1,o|0));g&&x(),e.scroller.scrollTop=t*e.itemHeight,l=null,x()}function B(){for(a!==null&&(window.cancelAnimationFrame?window.cancelAnimationFrame(a):clearTimeout(a),a=null),y=!0,e.scroller.removeEventListener("scroll",H),window.removeEventListener("resize",E,{passive:!0});n.length;){const o=n.pop();o.parentNode&&o.parentNode.removeChild(o)}s.parentNode&&s.parentNode.removeChild(s),l=null}return{setData:A,getData:C,refresh:b,scrollToIndex:L,destroy:B,_range:function(){return l}}}export{F as c};
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<!--
|
|
3
|
+
mouaif web entry. Served by `mouaif serve` at / (the root).
|
|
4
|
+
|
|
5
|
+
Build:
|
|
6
|
+
npm run build:web # writes frontend/dist/
|
|
7
|
+
|
|
8
|
+
Dev (with HMR):
|
|
9
|
+
npm run dev:web # vite dev server on :5173 (optional)
|
|
10
|
+
|
|
11
|
+
The script and style references are relative so Vite can resolve and
|
|
12
|
+
hash them at build time. The base URL in vite.config.js keeps the
|
|
13
|
+
bundle absolute at the root when it is served.
|
|
14
|
+
|
|
15
|
+
This page is the mobile UI for mouaif. It is a Preact + Vite app
|
|
16
|
+
served from frontend/dist/ by the Node server. See docs/decisions.md
|
|
17
|
+
sections 7 and 8.
|
|
18
|
+
|
|
19
|
+
PWA plumbing:
|
|
20
|
+
- <link rel="manifest"> declares the app metadata. Served from
|
|
21
|
+
public/manifest.webmanifest (Vite copies public/ verbatim into
|
|
22
|
+
dist/, so the absolute URL /manifest.webmanifest matches
|
|
23
|
+
what the browser expects).
|
|
24
|
+
- apple-mobile-web-app-* meta tags make the page installable on
|
|
25
|
+
iOS Safari (Apple doesn't honour the manifest scope field, so
|
|
26
|
+
the iOS-specific tags are required for an Add-to-Home-Screen
|
|
27
|
+
experience there).
|
|
28
|
+
- <link rel="apple-touch-icon"> points at the same 180x180 PNG
|
|
29
|
+
the manifest exposes. iOS picks this up when the user taps
|
|
30
|
+
Share → Add to Home Screen.
|
|
31
|
+
- The service worker is registered from src/main.jsx (after the
|
|
32
|
+
first paint, in production builds only — dev mode skips it so
|
|
33
|
+
HMR stays fast and stale code doesn't get cached).
|
|
34
|
+
-->
|
|
35
|
+
<html lang="en">
|
|
36
|
+
<head>
|
|
37
|
+
<meta charset="utf-8" />
|
|
38
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content" />
|
|
39
|
+
<meta name="theme-color" content="#111418" />
|
|
40
|
+
<meta name="color-scheme" content="dark" />
|
|
41
|
+
<meta name="description" content="mouaif — chat with AI providers, manage prompts, run tools against your projects." />
|
|
42
|
+
<meta name="apple-mobile-web-app-capable" content="yes" />
|
|
43
|
+
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
|
44
|
+
<meta name="apple-mobile-web-app-title" content="mouaif" />
|
|
45
|
+
<meta name="mobile-web-app-capable" content="yes" />
|
|
46
|
+
<link rel="manifest" href="/manifest.webmanifest" />
|
|
47
|
+
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32.png" />
|
|
48
|
+
<link rel="apple-touch-icon" href="/icons/icon-180-apple.png" />
|
|
49
|
+
<title>mouaif</title>
|
|
50
|
+
<!--
|
|
51
|
+
Paint the page background before the bundle's stylesheet arrives.
|
|
52
|
+
|
|
53
|
+
Until this rule existed the app's first frame was the browser's default
|
|
54
|
+
canvas: #fff, a full-screen WHITE flash, because the document's only
|
|
55
|
+
background came from the `@import`-ed index CSS and the external
|
|
56
|
+
stylesheet is requested before the module script runs. On a cold load,
|
|
57
|
+
a hard reload, or a profile where the SW has not yet taken over, that
|
|
58
|
+
is one or more frames of pure white between the commit and the
|
|
59
|
+
stylesheet — reported as "grey flashes with page reload".
|
|
60
|
+
|
|
61
|
+
Two details make this the right fix rather than a workaround:
|
|
62
|
+
- the inline <style> is in the document, so it applies at the first
|
|
63
|
+
paint — the white canvas is never composited;
|
|
64
|
+
- the color is the SAME --bg token the app settles on (#0a0d12, the
|
|
65
|
+
literal value of `--bg` in frontend/src/base.css). The bundle's CSS
|
|
66
|
+
re-declares it with the accent glow a tick later, so the swap is an
|
|
67
|
+
invisible repaint of the same base colour instead of a step.
|
|
68
|
+
|
|
69
|
+
Keep the value in sync with --bg in frontend/src/base.css, and keep this
|
|
70
|
+
rule BEFORE the bundler-injected `<link rel="stylesheet">` (Vite appends
|
|
71
|
+
it at the end of <head>): the bundle's `html` rule sets the same
|
|
72
|
+
`background` property at equal specificity, so coming later on equal
|
|
73
|
+
footing is exactly what lets the glow take over once it has loaded.
|
|
74
|
+
scripts/test-index-first-paint.mjs asserts both facts.
|
|
75
|
+
-->
|
|
76
|
+
<style>html{background:#0a0d12}</style>
|
|
77
|
+
<script type="module" crossorigin src="/assets/index-BGvI4n0T.js"></script>
|
|
78
|
+
<link rel="stylesheet" crossorigin href="/assets/index-CANPYzQg.css">
|
|
79
|
+
</head>
|
|
80
|
+
<body>
|
|
81
|
+
<main id="app"></main>
|
|
82
|
+
</body>
|
|
83
|
+
</html>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "mouaif",
|
|
3
|
+
"short_name": "mouaif",
|
|
4
|
+
"description": "Mobile UI for mouaif — chat with AI providers, manage prompts, and run tools against your projects.",
|
|
5
|
+
"id": "/",
|
|
6
|
+
"start_url": "/",
|
|
7
|
+
"scope": "/",
|
|
8
|
+
"display": "standalone",
|
|
9
|
+
"orientation": "portrait",
|
|
10
|
+
"theme_color": "#111418",
|
|
11
|
+
"background_color": "#0c0f14",
|
|
12
|
+
"categories": ["productivity", "developer", "utilities"],
|
|
13
|
+
"icons": [
|
|
14
|
+
{
|
|
15
|
+
"src": "/icons/icon-192.png",
|
|
16
|
+
"sizes": "192x192",
|
|
17
|
+
"type": "image/png",
|
|
18
|
+
"purpose": "any"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"src": "/icons/icon-512.png",
|
|
22
|
+
"sizes": "512x512",
|
|
23
|
+
"type": "image/png",
|
|
24
|
+
"purpose": "any"
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"src": "/icons/icon-maskable-512.png",
|
|
28
|
+
"sizes": "512x512",
|
|
29
|
+
"type": "image/png",
|
|
30
|
+
"purpose": "maskable"
|
|
31
|
+
}
|
|
32
|
+
]
|
|
33
|
+
}
|
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
// mouaif web — service worker (precache the app shell, network-first
|
|
2
|
+
// for everything else).
|
|
3
|
+
//
|
|
4
|
+
// Build:
|
|
5
|
+
// This file is the source. A small Vite plugin in vite.config.js
|
|
6
|
+
// reads it, sets CACHE_VERSION to a content hash, and emits the
|
|
7
|
+
// result as sw.js at the dist root. The Node server serves it at
|
|
8
|
+
// /sw.js with Cache-Control: no-cache and
|
|
9
|
+
// Service-Worker-Allowed: /.
|
|
10
|
+
//
|
|
11
|
+
// Strategy:
|
|
12
|
+
// - install: precache the shell (index.html, manifest, icons).
|
|
13
|
+
// - activate: delete any old mouaif-v* caches.
|
|
14
|
+
// - fetch:
|
|
15
|
+
// * same-origin GET under the app scope (/):
|
|
16
|
+
// - navigation requests (HTML): network-first, fall back
|
|
17
|
+
// to the cached shell so a cold offline launch still
|
|
18
|
+
// renders the UI.
|
|
19
|
+
// - static assets (CSS/JS/PNG/icons/webmanifest):
|
|
20
|
+
// cache-first (they're fingerprinted, so cache hits
|
|
21
|
+
// are always valid).
|
|
22
|
+
// * anything else (cross-origin, /api/*, /events, /data, POST,
|
|
23
|
+
// SSE, WebSocket): bypass the SW entirely. The chat UI is
|
|
24
|
+
// fundamentally a live API surface; we cannot meaningfully
|
|
25
|
+
// cache SSE streams or live HTML pages, and serving a stale
|
|
26
|
+
// index.html from a previous version while the JS bundle
|
|
27
|
+
// URL is new (or vice versa) was the original half-broken
|
|
28
|
+
// UI bug.
|
|
29
|
+
//
|
|
30
|
+
// Scope:
|
|
31
|
+
// The registration is `scope: '/'` (see main.jsx). The SW intercepts
|
|
32
|
+
// same-origin GET requests under the root, so it must explicitly
|
|
33
|
+
// bypass every non-PWA surface: /api/*, /events, /data, /oauth/*.
|
|
34
|
+
// Those are checked in isShellAssetPath below.
|
|
35
|
+
|
|
36
|
+
/* eslint-disable no-restricted-globals */
|
|
37
|
+
|
|
38
|
+
const CACHE_VERSION = 'f3b647b9';
|
|
39
|
+
const CACHE_NAME = 'mouaif-v' + CACHE_VERSION;
|
|
40
|
+
const SHELL_CACHE = 'mouaif-shell-v' + CACHE_VERSION;
|
|
41
|
+
|
|
42
|
+
// Files to precache on install. The routes are absolute-from-root
|
|
43
|
+
// because the SW runs over the root scope. The hashed JS/CSS
|
|
44
|
+
// names aren't known at build time, so the precache list contains
|
|
45
|
+
// only the stable, unhashed shell entries (HTML, manifest, icons).
|
|
46
|
+
// Hashed assets are cached on first fetch via the cache-first
|
|
47
|
+
// branch of the fetch handler.
|
|
48
|
+
const SHELL_URLS = [
|
|
49
|
+
'/',
|
|
50
|
+
'/manifest.webmanifest',
|
|
51
|
+
'/icons/icon-192.png',
|
|
52
|
+
'/icons/icon-512.png',
|
|
53
|
+
'/icons/icon-maskable-512.png'
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
self.addEventListener('install', (event) => {
|
|
57
|
+
// Precache the shell — best-effort. A failure on a single icon
|
|
58
|
+
// (e.g. the user opened / once before icons were built) does
|
|
59
|
+
// not block activation: we still want to skip waiting so a new
|
|
60
|
+
// version can take over without a force-reload.
|
|
61
|
+
event.waitUntil((async () => {
|
|
62
|
+
const cache = await caches.open(SHELL_CACHE);
|
|
63
|
+
const results = await Promise.allSettled(SHELL_URLS.map((u) => cache.add(u)));
|
|
64
|
+
const failed = results.filter((r) => r.status === 'rejected');
|
|
65
|
+
if (failed.length) {
|
|
66
|
+
// eslint-disable-next-line no-console
|
|
67
|
+
console.warn('[mouaif-sw] precache partial:', failed.length, 'of', SHELL_URLS.length);
|
|
68
|
+
}
|
|
69
|
+
// Stay in the waiting state until the user accepts the page's update
|
|
70
|
+
// banner. This avoids activating a new worker under an old JS bundle.
|
|
71
|
+
})());
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
self.addEventListener('activate', (event) => {
|
|
75
|
+
event.waitUntil((async () => {
|
|
76
|
+
// Drop caches that don't belong to the current build. The
|
|
77
|
+
// include() check skips any SW-managed cache for a different
|
|
78
|
+
// origin if a future feature grows one; today there's only one
|
|
79
|
+
// mouaif prefix.
|
|
80
|
+
const keys = await caches.keys();
|
|
81
|
+
await Promise.all(keys.map((k) => {
|
|
82
|
+
if (k.startsWith('mouaif-') && k !== CACHE_NAME && k !== SHELL_CACHE) {
|
|
83
|
+
return caches.delete(k);
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}));
|
|
87
|
+
// Take control of every open client so the new version is live
|
|
88
|
+
// without waiting for the user to navigate.
|
|
89
|
+
await self.clients.claim();
|
|
90
|
+
})());
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
function isShellAssetPath(pathname) {
|
|
94
|
+
// The SW scope is '/' (the whole origin), so be strict: intercept
|
|
95
|
+
// only the mobile UI's static shell. Every live API surface is
|
|
96
|
+
// signaled to fall through to the network untouched — /api/* (live
|
|
97
|
+
// data, SSE), /events (SSE), /data (REST), /oauth/* (redirect
|
|
98
|
+
// callbacks), and the favicon. Without this the root-scoped SW
|
|
99
|
+
// would cache API responses as static assets and serve stale data.
|
|
100
|
+
if (pathname === '/api' || pathname.startsWith('/api/')) return false;
|
|
101
|
+
if (pathname === '/events' || pathname.startsWith('/events')) return false;
|
|
102
|
+
if (pathname === '/data' || pathname.startsWith('/data')) return false;
|
|
103
|
+
if (pathname === '/oauth' || pathname.startsWith('/oauth/')) return false;
|
|
104
|
+
if (pathname === '/favicon.ico') return false;
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function isNavigationRequest(request) {
|
|
109
|
+
return request.mode === 'navigate' || (request.method === 'GET' && request.headers.get('accept') && request.headers.get('accept').includes('text/html'));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function networkFirstNavigation(request) {
|
|
113
|
+
// Try the network. If it responds (any 2xx/3xx), update the
|
|
114
|
+
// shell cache and return the network response. If it fails
|
|
115
|
+
// (offline, DNS error), fall back to the cached shell. The
|
|
116
|
+
// fallback uses a fresh Request keyed at the root because the
|
|
117
|
+
// original request may have included a query string we don't
|
|
118
|
+
// care about for the offline shell.
|
|
119
|
+
const cache = await caches.open(SHELL_CACHE);
|
|
120
|
+
try {
|
|
121
|
+
const fresh = await fetch(request);
|
|
122
|
+
if (fresh && fresh.ok) {
|
|
123
|
+
cache.put('/', fresh.clone()).catch(() => {});
|
|
124
|
+
}
|
|
125
|
+
return fresh;
|
|
126
|
+
} catch (err) {
|
|
127
|
+
const cached = await cache.match('/');
|
|
128
|
+
if (cached) return cached;
|
|
129
|
+
throw err;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function cacheFirstAsset(request) {
|
|
134
|
+
const cache = await caches.open(CACHE_NAME);
|
|
135
|
+
const cached = await cache.match(request);
|
|
136
|
+
if (cached) return cached;
|
|
137
|
+
// No cache hit. Fetch, then store on success so the next reload
|
|
138
|
+
// is instant. Non-2xx responses are not cached; a 404 for a
|
|
139
|
+
// missing icon shouldn't pollute the cache forever.
|
|
140
|
+
const fresh = await fetch(request);
|
|
141
|
+
if (fresh && fresh.ok) {
|
|
142
|
+
cache.put(request, fresh.clone()).catch(() => {});
|
|
143
|
+
}
|
|
144
|
+
return fresh;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
self.addEventListener('fetch', (event) => {
|
|
148
|
+
const request = event.request;
|
|
149
|
+
if (request.method !== 'GET') return; // POST/PUT/DELETE/PATCH always hit the network
|
|
150
|
+
const url = new URL(request.url);
|
|
151
|
+
if (url.origin !== self.location.origin) return; // cross-origin: bypass
|
|
152
|
+
|
|
153
|
+
// The SW scope is /, so be explicit — anything under
|
|
154
|
+
// /api/, /events, /data, /oauth/ must reach the server
|
|
155
|
+
// untouched. The same-origin check above already excludes
|
|
156
|
+
// cross-origin traffic; this rule excludes any same-origin
|
|
157
|
+
// endpoint that isn't a PWA asset.
|
|
158
|
+
if (!isShellAssetPath(url.pathname)) return;
|
|
159
|
+
|
|
160
|
+
if (isNavigationRequest(request)) {
|
|
161
|
+
event.respondWith(networkFirstNavigation(request));
|
|
162
|
+
} else {
|
|
163
|
+
event.respondWith(cacheFirstAsset(request));
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// ---- Fresh page visibility (notification suppression) -----------------
|
|
168
|
+
//
|
|
169
|
+
// WindowClient.focused / visibilityState are stale on Safari and iOS PWA.
|
|
170
|
+
// Query every live page when a push arrives and suppress only when the page
|
|
171
|
+
// answers with the target chat currently visible. No cached view state is
|
|
172
|
+
// trusted: a suspended page cannot answer and must still receive the alert.
|
|
173
|
+
|
|
174
|
+
function chatIdFromHash(hash) {
|
|
175
|
+
const match = /^#\/chat\/([^/?#]+)/.exec(typeof hash === 'string' ? hash : '');
|
|
176
|
+
if (!match) return '';
|
|
177
|
+
try { return decodeURIComponent(match[1]); } catch { return match[1]; }
|
|
178
|
+
}
|
|
179
|
+
function reportedViewMatchesChat(view, targetUrl) {
|
|
180
|
+
if (!view || !view.visible) return false;
|
|
181
|
+
const reportedChatId = chatIdFromHash(view.hash);
|
|
182
|
+
const targetChatId = chatIdFromHash(targetUrl.hash);
|
|
183
|
+
return !!reportedChatId && reportedChatId === targetChatId;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
const pendingViewQueries = new Map(); // queryId -> { clientId, finish(view) }
|
|
188
|
+
let viewQuerySequence = 0;
|
|
189
|
+
|
|
190
|
+
function queryClientView(client) {
|
|
191
|
+
// Ask each live page for fresh state when a push arrives. A page that
|
|
192
|
+
// cannot answer within the deadline is treated as hidden/suspended.
|
|
193
|
+
return new Promise((resolve) => {
|
|
194
|
+
if (!client || typeof client.postMessage !== 'function') {
|
|
195
|
+
resolve(null);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
const queryId = 'view-' + Date.now() + '-' + (++viewQuerySequence);
|
|
199
|
+
const channel = typeof MessageChannel === 'function' ? new MessageChannel() : null;
|
|
200
|
+
let settled = false;
|
|
201
|
+
const finish = (view) => {
|
|
202
|
+
if (settled) return;
|
|
203
|
+
settled = true;
|
|
204
|
+
clearTimeout(timer);
|
|
205
|
+
pendingViewQueries.delete(queryId);
|
|
206
|
+
try { if (channel) channel.port1.close(); } catch { /* best-effort */ }
|
|
207
|
+
resolve(view && typeof view === 'object' ? { ...view, at: Date.now() } : null);
|
|
208
|
+
};
|
|
209
|
+
pendingViewQueries.set(queryId, { clientId: client.id || '', finish });
|
|
210
|
+
const timer = setTimeout(() => finish(null), 1000);
|
|
211
|
+
if (channel) {
|
|
212
|
+
channel.port1.onmessage = (event) => finish(event.data);
|
|
213
|
+
if (typeof channel.port1.start === 'function') channel.port1.start();
|
|
214
|
+
}
|
|
215
|
+
try {
|
|
216
|
+
const message = { type: 'GET_VISIBILITY_STATE', queryId };
|
|
217
|
+
if (channel) client.postMessage(message, [channel.port2]);
|
|
218
|
+
else client.postMessage(message);
|
|
219
|
+
} catch {
|
|
220
|
+
// Some WebKit versions reject a transferred MessagePort even though
|
|
221
|
+
// ordinary Client.postMessage works. Retry without transfer and let
|
|
222
|
+
// VISIBILITY_STATE_RESPONSE resolve this query.
|
|
223
|
+
try { client.postMessage({ type: 'GET_VISIBILITY_STATE', queryId }); }
|
|
224
|
+
catch { finish(null); }
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
self.addEventListener('message', (event) => {
|
|
230
|
+
// The page may post `{ type: 'SKIP_WAITING' }` after the user
|
|
231
|
+
// accepts an "Update available — reload" prompt.
|
|
232
|
+
if (event.data && event.data.type === 'SKIP_WAITING') {
|
|
233
|
+
self.skipWaiting();
|
|
234
|
+
}
|
|
235
|
+
// iOS WebKit may drop the MessagePort transferred with a visibility
|
|
236
|
+
// query. The page mirrors its answer as a regular service-worker message;
|
|
237
|
+
// resolve the matching fresh query only when it came from that client.
|
|
238
|
+
if (event.data && event.data.type === 'VISIBILITY_STATE_RESPONSE' && event.data.queryId) {
|
|
239
|
+
const pending = pendingViewQueries.get(event.data.queryId);
|
|
240
|
+
const sourceId = event.source && event.source.id ? event.source.id : '';
|
|
241
|
+
if (pending && (!pending.clientId || pending.clientId === sourceId)) pending.finish(event.data.state);
|
|
242
|
+
}
|
|
243
|
+
// Navigate to a specific URL (e.g. deep link from notification click).
|
|
244
|
+
// Clients that want to move an EXISTING app window (iOS PWA: no
|
|
245
|
+
// WindowClient.navigate()) post the full target URL here; the page
|
|
246
|
+
// swaps its own hash. This handler only opens a window when the
|
|
247
|
+
// caller knows no client exists (notification click with no
|
|
248
|
+
// matching window falls back to clients.openWindow itself).
|
|
249
|
+
if (event.data && event.data.type === 'NAVIGATE' && event.data.url) {
|
|
250
|
+
clients.openWindow(event.data.url);
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// ---- Push notifications ------------------------------------------------
|
|
255
|
+
|
|
256
|
+
self.addEventListener('push', (event) => {
|
|
257
|
+
let data;
|
|
258
|
+
try { data = event.data ? event.data.json() : {}; } catch { data = {}; }
|
|
259
|
+
const { title, body, tag, renotify, icon, badge, data: payload, actions, requireInteraction } = data;
|
|
260
|
+
if (!title && !body) return;
|
|
261
|
+
|
|
262
|
+
event.waitUntil((async () => {
|
|
263
|
+
const windows = await clients.matchAll({ type: 'window', includeUncontrolled: true });
|
|
264
|
+
const targetUrl = payload && payload.url ? new URL(payload.url, self.location.origin) : null;
|
|
265
|
+
const freshViews = targetUrl ? await Promise.all(windows.map(queryClientView)) : [];
|
|
266
|
+
// Suppress only when a page answers this push-time query and confirms
|
|
267
|
+
// that the target chat is visible. A suspended or recently closed PWA
|
|
268
|
+
// can remain in clients.matchAll() with stale `focused: true` and a
|
|
269
|
+
// stale cached visibility report while its JavaScript cannot answer.
|
|
270
|
+
// Treating that stale state as visible intermittently discarded the
|
|
271
|
+
// authorization push exactly when the app was no longer usable. If no
|
|
272
|
+
// fresh answer arrives, showing the alert is the safe outcome.
|
|
273
|
+
const chatVisible = !!targetUrl && freshViews.some((view) =>
|
|
274
|
+
reportedViewMatchesChat(view, targetUrl));
|
|
275
|
+
const queued = await self.registration.getNotifications();
|
|
276
|
+
if (chatVisible) {
|
|
277
|
+
// Entering or staying in the target chat makes its queued alerts stale.
|
|
278
|
+
// Clear them as well as suppressing the incoming push so the tray does
|
|
279
|
+
// not keep showing notifications for content already on screen.
|
|
280
|
+
if (payload && payload.chatId) {
|
|
281
|
+
queued.forEach((notification) => {
|
|
282
|
+
if (notification.data && notification.data.chatId === payload.chatId) notification.close();
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
const notifTag = tag || 'default';
|
|
288
|
+
// iOS can retain replaced notifications. Prune both the exact tag and
|
|
289
|
+
// every legacy status-slot tag for this chat before showing a progress,
|
|
290
|
+
// completion, or error update.
|
|
291
|
+
const statusKinds = new Set(['progress', 'completion', 'error']);
|
|
292
|
+
const authorizationKinds = new Set(['ask_user', 'tool_authorization']);
|
|
293
|
+
const incomingIsStatus = payload && statusKinds.has(payload.kind);
|
|
294
|
+
const incomingIsAuthorization = payload && authorizationKinds.has(payload.kind);
|
|
295
|
+
queued.forEach((notification) => {
|
|
296
|
+
const sameTag = notifTag !== 'default' && notification.tag === notifTag;
|
|
297
|
+
const sameChat = notification.data && payload
|
|
298
|
+
&& notification.data.chatId === payload.chatId;
|
|
299
|
+
const sameStatusSlot = incomingIsStatus && sameChat
|
|
300
|
+
&& statusKinds.has(notification.data.kind);
|
|
301
|
+
const sameAuthorizationSlot = incomingIsAuthorization && sameChat
|
|
302
|
+
&& authorizationKinds.has(notification.data.kind);
|
|
303
|
+
if (sameTag || sameStatusSlot || sameAuthorizationSlot) notification.close();
|
|
304
|
+
});
|
|
305
|
+
await self.registration.showNotification(title || 'mouaif', {
|
|
306
|
+
body: body || '',
|
|
307
|
+
tag: notifTag,
|
|
308
|
+
renotify: renotify !== false,
|
|
309
|
+
icon: icon || '/icons/icon-192.png',
|
|
310
|
+
badge: badge || '/icons/favicon-32.png',
|
|
311
|
+
data: payload || {},
|
|
312
|
+
actions: actions || [{ action: 'open', title: 'Open chat' }],
|
|
313
|
+
requireInteraction: requireInteraction === true,
|
|
314
|
+
vibrate: requireInteraction === true ? [150, 80, 150] : [100]
|
|
315
|
+
});
|
|
316
|
+
})());
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
async function openNotificationTarget(data) {
|
|
320
|
+
const urlToOpen = data.url
|
|
321
|
+
|| (data.chatId && data.projectDir
|
|
322
|
+
? '/#/chat/' + data.chatId + '?projectDir=' + encodeURIComponent(data.projectDir)
|
|
323
|
+
: '/');
|
|
324
|
+
const target = new URL(urlToOpen, self.location.origin);
|
|
325
|
+
const clientList = await clients.matchAll({ type: 'window', includeUncontrolled: true });
|
|
326
|
+
// The client's URL always carries its *current* hash (never the one we
|
|
327
|
+
// want to move to), so the match below must look at the pathname only —
|
|
328
|
+
// comparing hashes here would miss every existing window and reopen the
|
|
329
|
+
// app in a new tab on every click.
|
|
330
|
+
const isAppWindow = (client) => {
|
|
331
|
+
let url;
|
|
332
|
+
try { url = new URL(client.url); } catch { return false; }
|
|
333
|
+
return url.origin === self.location.origin && url.pathname === '/';
|
|
334
|
+
};
|
|
335
|
+
const appWindow = clientList.find(isAppWindow);
|
|
336
|
+
if (appWindow && 'focus' in appWindow) {
|
|
337
|
+
// iOS Safari does not support WindowClient.navigate(): posting the
|
|
338
|
+
// target URL to the page is the only way to move an existing PWA
|
|
339
|
+
// window there. The page swaps the hash (or reloads when it is
|
|
340
|
+
// already there) and then focuses itself.
|
|
341
|
+
//
|
|
342
|
+
// Persist the click target as a fallback BEFORE posting the message.
|
|
343
|
+
// A suspended iOS PWA can be woken by focus without its JS running to
|
|
344
|
+
// receive the NAVIGATE postMessage (WebKit drops it), and the app then
|
|
345
|
+
// reopens at start_url instead of the clicked chat. Writing the same
|
|
346
|
+
// target the cold-launch path uses lets the woken page consume it on
|
|
347
|
+
// load / focus / visible and recover the navigation. The page clears
|
|
348
|
+
// the store once a NAVIGATE is actually handled, so a successful move
|
|
349
|
+
// never re-triggers.
|
|
350
|
+
await writeClickTarget(target.href);
|
|
351
|
+
appWindow.postMessage({ type: 'NAVIGATE', url: target.href });
|
|
352
|
+
return appWindow.focus();
|
|
353
|
+
}
|
|
354
|
+
// No matchable window: persist the click target BEFORE attempting to
|
|
355
|
+
// open one. iOS PWA: when the app is closed or suspended, tapping the
|
|
356
|
+
// notification relaunches the installed app at its start_url (/),
|
|
357
|
+
// and clients.openWindow() is not supported there — the click would
|
|
358
|
+
// land on the home screen. Writing the URL to IndexedDB lets the
|
|
359
|
+
// freshly launched page consume it and navigate to the chat (see
|
|
360
|
+
// frontend/src/sw-registration.js — consumePendingNotificationClick).
|
|
361
|
+
await writeClickTarget(target.href);
|
|
362
|
+
try {
|
|
363
|
+
const opened = await clients.openWindow(target.href);
|
|
364
|
+
// openWindow succeeded (desktop / non-iOS). Only clear the stored
|
|
365
|
+
// target when the new window actually loaded the clicked chat URL —
|
|
366
|
+
// compare the hash, since the window's URL always carries its current
|
|
367
|
+
// hash and that is the only thing we need to match. iOS can resolve
|
|
368
|
+
// openWindow() WITHOUT navigating to the clicked URL (it may reopen
|
|
369
|
+
// start_url / instead), so clearing here on a non-match would discard
|
|
370
|
+
// the only navigation hint and drop the user on the chats list. A
|
|
371
|
+
// non-matching window keeps the target; the freshly loaded page's
|
|
372
|
+
// consumePendingNotificationClick() then recovers it. Keeping it on
|
|
373
|
+
// desktop when the URLs do match is impossible, so this is safe.
|
|
374
|
+
const openedUrl = (opened && typeof opened.url === 'string')
|
|
375
|
+
? new URL(opened.url, self.location.origin) : null;
|
|
376
|
+
if (openedUrl && openedUrl.hash === target.hash) await clearClickTarget();
|
|
377
|
+
return opened;
|
|
378
|
+
} catch {
|
|
379
|
+
// Fall through; the launched page consumes the IndexedDB click target.
|
|
380
|
+
return undefined;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// ---- IndexedDB click-target handoff --------------------------------
|
|
385
|
+
//
|
|
386
|
+
// The service worker and the page share a tiny store of the most recent
|
|
387
|
+
// notification click URL. iOS PWA cannot rely on clients.openWindow()
|
|
388
|
+
// when the app is closed; the SW writes the URL here, the page reads
|
|
389
|
+
// and clears it on load (or on visibilitychange when the app comes
|
|
390
|
+
// back), and navigates if it points at a chat.
|
|
391
|
+
const CLICK_DB = 'mouaif-push-click';
|
|
392
|
+
const CLICK_STORE = 'clicks';
|
|
393
|
+
const CLICK_KEY = 'latest';
|
|
394
|
+
|
|
395
|
+
function clickDb() {
|
|
396
|
+
return new Promise((resolve, reject) => {
|
|
397
|
+
if (!self.indexedDB) return reject(new Error('no indexedDB'));
|
|
398
|
+
const req = self.indexedDB.open(CLICK_DB, 1);
|
|
399
|
+
req.onupgradeneeded = () => {
|
|
400
|
+
const db = req.result;
|
|
401
|
+
if (!db.objectStoreNames.contains(CLICK_STORE)) db.createObjectStore(CLICK_STORE);
|
|
402
|
+
};
|
|
403
|
+
req.onsuccess = () => resolve(req.result);
|
|
404
|
+
req.onerror = () => reject(req.error || new Error('indexedDB open failed'));
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function writeClickTarget(url) {
|
|
409
|
+
return clickDb().then((db) => new Promise((resolve) => {
|
|
410
|
+
const tx = db.transaction(CLICK_STORE, 'readwrite');
|
|
411
|
+
tx.objectStore(CLICK_STORE).put({ url, at: Date.now() }, CLICK_KEY);
|
|
412
|
+
tx.oncomplete = () => resolve();
|
|
413
|
+
tx.onerror = () => resolve();
|
|
414
|
+
})).catch(() => {});
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function clearClickTarget() {
|
|
418
|
+
return clickDb().then((db) => new Promise((resolve) => {
|
|
419
|
+
const tx = db.transaction(CLICK_STORE, 'readwrite');
|
|
420
|
+
tx.objectStore(CLICK_STORE).delete(CLICK_KEY);
|
|
421
|
+
tx.oncomplete = () => resolve();
|
|
422
|
+
tx.onerror = () => resolve();
|
|
423
|
+
})).catch(() => {});
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function readAndClearClickTarget() {
|
|
427
|
+
return clickDb().then((db) => new Promise((resolve) => {
|
|
428
|
+
const tx = db.transaction(CLICK_STORE, 'readwrite');
|
|
429
|
+
const store = tx.objectStore(CLICK_STORE);
|
|
430
|
+
const get = store.get(CLICK_KEY);
|
|
431
|
+
get.onsuccess = () => {
|
|
432
|
+
const row = get.result;
|
|
433
|
+
if (row && row.url && Date.now() - (row.at || 0) < 60 * 1000) {
|
|
434
|
+
store.delete(CLICK_KEY);
|
|
435
|
+
resolve(row.url);
|
|
436
|
+
} else {
|
|
437
|
+
if (row) store.delete(CLICK_KEY);
|
|
438
|
+
resolve(null);
|
|
439
|
+
}
|
|
440
|
+
};
|
|
441
|
+
get.onerror = () => resolve(null);
|
|
442
|
+
})).catch(() => null);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
async function submitNotificationDecision(data, action) {
|
|
446
|
+
if (!data.projectDir || !data.chatId || !data.callId) throw new Error('missing decision context');
|
|
447
|
+
let decision = action;
|
|
448
|
+
let payload;
|
|
449
|
+
if (action.startsWith('answer-')) {
|
|
450
|
+
const index = Number(action.slice('answer-'.length));
|
|
451
|
+
const option = Array.isArray(data.options) ? data.options[index] : null;
|
|
452
|
+
if (!option || !option.value) throw new Error('missing answer option');
|
|
453
|
+
decision = 'allow-once';
|
|
454
|
+
payload = { choice: option.value, extra: '' };
|
|
455
|
+
}
|
|
456
|
+
const response = await fetch('/api/tools/authorization/decision', {
|
|
457
|
+
method: 'POST',
|
|
458
|
+
credentials: 'include',
|
|
459
|
+
headers: { 'Content-Type': 'application/json' },
|
|
460
|
+
body: JSON.stringify({
|
|
461
|
+
projectDir: data.projectDir,
|
|
462
|
+
chatId: data.chatId,
|
|
463
|
+
callId: data.callId,
|
|
464
|
+
decision,
|
|
465
|
+
payload
|
|
466
|
+
})
|
|
467
|
+
});
|
|
468
|
+
if (!response.ok) throw new Error('decision failed');
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
self.addEventListener('notificationclick', (event) => {
|
|
472
|
+
const data = event.notification.data || {};
|
|
473
|
+
event.notification.close();
|
|
474
|
+
const action = event.action || 'open';
|
|
475
|
+
if (action === 'open') event.waitUntil(openNotificationTarget(data));
|
|
476
|
+
else event.waitUntil(submitNotificationDecision(data, action).catch(() => openNotificationTarget(data)));
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
self.addEventListener('notificationclose', (event) => {
|
|
480
|
+
// Notification dismissed by user — could send a beacon for analytics
|
|
481
|
+
// but is intentionally a no-op for now.
|
|
482
|
+
});
|