arcane-os 0.12.0 → 0.13.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/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.13.0
4
+
5
+ - Add the reusable `pwa-install.html` component and shared browser installation
6
+ lifecycle through `arcane-os/pwa`. Offer a compact themed Install action and
7
+ explicit Close, retain session dismissal, support inline placement, and surface
8
+ native prompt errors without claiming installation completion.
9
+ - Mount the component from generated PWA bootstraps independently of service
10
+ worker registration and application rendering. Use native installation and
11
+ display-mode events without polling, automatic native prompts, or focus capture.
12
+ - Return cached resources immediately while conditional background refreshes
13
+ are pending or in flight, so page requests do not wait on network revalidation.
14
+ - Document browser icon eligibility and the separate responsibilities of the
15
+ Web App Manifest, application file selection, and offline resource inventory.
16
+
3
17
  ## 0.12.0
4
18
 
5
19
  - Refresh the selected authored app descriptor's package projection before
package/README.md CHANGED
@@ -19,7 +19,7 @@ version-locked SDK runtime, while an integrated Arcane checkout uses its live
19
19
  `arcane/` runtime. Both profiles preserve the same app URLs, theme, packaging,
20
20
  event, cancellation, and browser run contracts.
21
21
 
22
- This checkout defines the `0.12.0` SDK contract. Applications pin one exact npm
22
+ This checkout defines the `0.13.0` SDK contract. Applications pin one exact npm
23
23
  version and lockfile; registry state is deliberately not baked into application
24
24
  artifacts.
25
25
 
@@ -30,6 +30,9 @@ page-load revalidation and persistent resource caches. The SDK records one
30
30
  completed-check timestamp per app/cache in DBOPFS and checks after 120 seconds
31
31
  in development or 15 minutes in packaged browser delivery. Apps retain ownership
32
32
  of branding, offline page selection and network-dependent product behavior.
33
+ Enabled browser delivery also mounts the shared, themed installation suggestion.
34
+ It appears when the browser offers installation, includes Install and Close,
35
+ and remembers dismissal for the tab session without interrupting page startup.
33
36
 
34
37
  That registry query is a maintainer action, not an application behavior. Apps
35
38
  never poll npm for SDK updates or replace their own SDK or synchronized runtime.
@@ -0,0 +1,242 @@
1
+ import Is from './dependencies/strong-type/index.js';
2
+ import {createArcaneEventSource} from './event-manager.mjs';
3
+
4
+ const is = new Is(false);
5
+ export const PWA_INSTALL_STATE_EVENT = 'arcane.pwa.install.state';
6
+ let sharedOwner = null;
7
+ let mountedPrompt = null;
8
+
9
+ /** Capture native installation availability once per page, before loading UI. */
10
+ export function getPwaInstall() {
11
+ if (sharedOwner && sharedOwner.state.status !== 'disposed') {
12
+ return sharedOwner;
13
+ }
14
+ const owner = {get state() { return snapshot(); }, subscribe, prompt, dismiss, dispose};
15
+ const source = createArcaneEventSource(owner, {
16
+ source: 'arcane.pwa.install', eventTypes: [PWA_INSTALL_STATE_EVENT]
17
+ });
18
+ const listeners = [];
19
+ const displayMode = globalThis.matchMedia?.(
20
+ '(display-mode: standalone), (display-mode: minimal-ui), '
21
+ + '(display-mode: fullscreen), (display-mode: window-controls-overlay)'
22
+ );
23
+ const manifestUrl = globalThis.document?.querySelector('link[rel~="manifest"]')?.href;
24
+ const dismissalKey = `arcane.pwa.install.dismissed:${manifestUrl ?? globalThis.location?.href ?? ''}`;
25
+ let deferredPrompt = null;
26
+ let disposed = false;
27
+ let dismissed = false;
28
+ let status = isRunningAsApp() ? 'running' : 'waiting';
29
+ let outcome = null;
30
+ let error = null;
31
+ try {
32
+ dismissed = globalThis.sessionStorage?.getItem(dismissalKey) === 'true';
33
+ } catch (storageError) {
34
+ console.warn('Arcane PWA install dismissal could not be read:', storageError);
35
+ }
36
+
37
+ function isRunningAsApp() {
38
+ return displayMode?.matches === true || globalThis.navigator?.standalone === true;
39
+ }
40
+
41
+ function snapshot() {
42
+ return {status, available: deferredPrompt !== null && !disposed,
43
+ dismissed, outcome, error};
44
+ }
45
+
46
+ function publish(nextStatus, nextError = null) {
47
+ if (disposed) return;
48
+ status = nextStatus;
49
+ error = nextError;
50
+ source.dispatch(PWA_INSTALL_STATE_EVENT, snapshot());
51
+ }
52
+
53
+ function observe(target, type, listener) {
54
+ if (!is.function(target?.addEventListener)) return;
55
+ target.addEventListener(type, listener);
56
+ listeners.push(function removeInstallListener() {
57
+ target.removeEventListener(type, listener);
58
+ });
59
+ }
60
+
61
+ function rememberDismissal() {
62
+ dismissed = true;
63
+ try {
64
+ globalThis.sessionStorage?.setItem(dismissalKey, 'true');
65
+ } catch (storageError) {
66
+ console.warn('Arcane PWA install dismissal could not be saved:', storageError);
67
+ }
68
+ }
69
+
70
+ function onBeforeInstallPrompt(event) {
71
+ if (disposed || isRunningAsApp() || status === 'installed' || status === 'accepted') return;
72
+ event.preventDefault();
73
+ deferredPrompt = event;
74
+ outcome = null;
75
+ publish('available');
76
+ }
77
+
78
+ function onInstalled() {
79
+ deferredPrompt = null;
80
+ // This event may precede Android's completion of WebAPK creation.
81
+ publish('installed');
82
+ }
83
+
84
+ function onDisplayModeChange() {
85
+ if (isRunningAsApp()) {
86
+ deferredPrompt = null;
87
+ publish('running');
88
+ } else if (status === 'running') {
89
+ publish('waiting');
90
+ }
91
+ }
92
+
93
+ function onPageHide(event) {
94
+ if (!event.persisted) dispose();
95
+ }
96
+
97
+ function subscribe(listener, {emitCurrent = true, signal} = {}) {
98
+ function forwardInstallState(event) { listener(event.detail); }
99
+ const unsubscribe = source.on(PWA_INSTALL_STATE_EVENT, forwardInstallState,
100
+ signal ? {signal} : undefined);
101
+ try {
102
+ if (emitCurrent && !signal?.aborted) listener(snapshot());
103
+ } catch (listenerError) {
104
+ unsubscribe();
105
+ throw listenerError;
106
+ }
107
+ return unsubscribe;
108
+ }
109
+
110
+ function prompt() {
111
+ if (disposed || !deferredPrompt) return Promise.resolve(null);
112
+ const event = deferredPrompt;
113
+ deferredPrompt = null;
114
+ publish('prompting');
115
+ let result;
116
+ try {
117
+ // Native user activation must reach prompt() in the same click stack.
118
+ result = event.prompt();
119
+ } catch (promptError) {
120
+ return rejectPrompt(promptError);
121
+ }
122
+ return Promise.resolve(result).then(async function receiveInstallChoice(value) {
123
+ const choice = value ?? await event.userChoice;
124
+ if (!choice || !['accepted', 'dismissed'].includes(choice.outcome)) {
125
+ throw new Error('The browser did not return an installation choice.');
126
+ }
127
+ if (!disposed) {
128
+ outcome = choice.outcome;
129
+ if (status === 'installed' || status === 'running') {
130
+ publish(status);
131
+ } else {
132
+ if (outcome === 'dismissed') rememberDismissal();
133
+ publish(outcome);
134
+ }
135
+ }
136
+ return choice;
137
+ }).catch(rejectPrompt);
138
+ }
139
+
140
+ function rejectPrompt(promptError) {
141
+ if (status !== 'installed' && status !== 'running') publish('error', promptError);
142
+ return Promise.reject(promptError);
143
+ }
144
+
145
+ function dismiss() {
146
+ if (disposed) return snapshot();
147
+ rememberDismissal();
148
+ publish(status, error);
149
+ return snapshot();
150
+ }
151
+
152
+ function dispose() {
153
+ if (disposed) return;
154
+ deferredPrompt = null;
155
+ publish('disposed');
156
+ disposed = true;
157
+ for (const removeListener of listeners) removeListener();
158
+ listeners.length = 0;
159
+ source.dispose();
160
+ }
161
+
162
+ observe(globalThis, 'beforeinstallprompt', onBeforeInstallPrompt);
163
+ observe(globalThis, 'appinstalled', onInstalled);
164
+ observe(displayMode, 'change', onDisplayModeChange);
165
+ observe(globalThis, 'pagehide', onPageHide);
166
+ sharedOwner = owner;
167
+ return owner;
168
+ }
169
+
170
+ /** Mount one shared, initially hidden install component without delaying the app. */
171
+ export function mountPwaInstallPrompt({appName = ''} = {}) {
172
+ const owner = getPwaInstall();
173
+ if (mountedPrompt) return mountedPrompt;
174
+ mountedPrompt = mountComponent().catch(function releaseFailedMount(error) {
175
+ mountedPrompt = null;
176
+ throw error;
177
+ });
178
+ return mountedPrompt;
179
+
180
+ async function mountComponent() {
181
+ if (!globalThis.document) return null;
182
+ // Both modules may start independently; saved theme loading is not a barrier.
183
+ await Promise.all([
184
+ import(new URL('../modules/HTMLImport.js', import.meta.url).href),
185
+ import(new URL('../modules/ThemeBootstrap.js', import.meta.url).href)
186
+ ]);
187
+ if (owner.state.status === 'disposed') return null;
188
+ if (!document.body) {
189
+ await new Promise(function waitForComponentParent(resolve) {
190
+ const unsubscribe = owner.subscribe(function observeParentWaitDisposal(state) {
191
+ if (state.status === 'disposed') completeParentWait();
192
+ }, {emitCurrent: false});
193
+ function completeParentWait() {
194
+ document.removeEventListener('DOMContentLoaded', completeParentWait);
195
+ unsubscribe();
196
+ resolve();
197
+ }
198
+ document.addEventListener('DOMContentLoaded', completeParentWait, {once: true});
199
+ });
200
+ }
201
+ if (owner.state.status === 'disposed') return null;
202
+ const host = document.createElement('html-import');
203
+ host.hidden = true;
204
+ host.dataset.appName = String(appName);
205
+ host.dataset.arcanePwaInstall = '';
206
+ host.setAttribute('href', new URL('../components/pwa-install.html', import.meta.url).href);
207
+ return new Promise(function waitForInstallComponent(resolve, reject) {
208
+ const observer = new MutationObserver(function observeRemovedInstallComponent() {
209
+ if (!host.isConnected) cancelMount();
210
+ });
211
+ const unsubscribe = owner.subscribe(function observeDisposedInstallOwner(state) {
212
+ if (state.status === 'disposed') cancelMount();
213
+ }, {emitCurrent: false});
214
+ function cleanup() {
215
+ observer.disconnect();
216
+ unsubscribe();
217
+ host.removeEventListener('html-import-ready', onReady);
218
+ host.removeEventListener('html-import-error', onError);
219
+ }
220
+ function cancelMount() {
221
+ cleanup();
222
+ host.remove();
223
+ const error = new Error('The PWA install component was removed before it became ready.');
224
+ error.name = 'AbortError';
225
+ reject(error);
226
+ }
227
+ function onReady() {
228
+ cleanup();
229
+ resolve(host);
230
+ }
231
+ function onError(event) {
232
+ cleanup();
233
+ host.remove();
234
+ reject(event.detail?.error ?? new Error('The PWA install component could not load.'));
235
+ }
236
+ host.addEventListener('html-import-ready', onReady);
237
+ host.addEventListener('html-import-error', onError);
238
+ document.body.append(host);
239
+ observer.observe(document.documentElement, {childList: true, subtree: true});
240
+ });
241
+ }
242
+ }
@@ -1,5 +1,7 @@
1
1
  import {createArcaneEventSource} from './event-manager.mjs';
2
2
 
3
+ export {PWA_INSTALL_STATE_EVENT, getPwaInstall, mountPwaInstallPrompt} from './pwa-install.mjs';
4
+
3
5
  export const PWA_STATE_EVENT = 'arcane.pwa.state';
4
6
 
5
7
  export function registerPwa({workerUrl = './arcane-sw.js', scope} = {}) {
@@ -6,7 +6,7 @@
6
6
  "minimumVersion": "22.23.2 for Node entrypoints",
7
7
  "moduleSystem": "ESM"
8
8
  },
9
- "memberCount": 205,
9
+ "memberCount": 208,
10
10
  "members": [
11
11
  {
12
12
  "id": "root:APP_BUNDLE_DESCRIPTOR_NAME",
@@ -2996,6 +2996,48 @@
2996
2996
  "protocol": "Native registration, updatefound, statechange and controllerchange events",
2997
2997
  "normalization": "Synchronous owner exposes ready, state, subscribe, update and dispose; current state replays by default, failures remain observable, disposal does not unregister the worker or delete saved data."
2998
2998
  },
2999
+ {
3000
+ "id": "pwa:PWA_INSTALL_STATE_EVENT",
3001
+ "name": "PWA_INSTALL_STATE_EVENT",
3002
+ "displayName": "PWA_INSTALL_STATE_EVENT",
3003
+ "kind": "constant",
3004
+ "signature": "const PWA_INSTALL_STATE_EVENT",
3005
+ "entrypoints": ["arcane-os/pwa"],
3006
+ "primaryImport": "arcane-os/pwa",
3007
+ "group": "Progressive web applications",
3008
+ "summary": "Names the shared native installation availability and choice state event.",
3009
+ "availability": "Browser installation lifecycle; importable without starting observation",
3010
+ "protocol": "Existing Arcane event owner and native browser installation events",
3011
+ "normalization": "The exact event name is arcane.pwa.install.state."
3012
+ },
3013
+ {
3014
+ "id": "pwa:getPwaInstall",
3015
+ "name": "getPwaInstall",
3016
+ "displayName": "getPwaInstall()",
3017
+ "kind": "function",
3018
+ "signature": "getPwaInstall()",
3019
+ "entrypoints": ["arcane-os/pwa"],
3020
+ "primaryImport": "arcane-os/pwa",
3021
+ "group": "Progressive web applications",
3022
+ "summary": "Returns one shared page owner for native installation availability, prompt choices and session dismissal.",
3023
+ "availability": "Browser native installation events; waiting state while no prompt is available",
3024
+ "protocol": "Native beforeinstallprompt, appinstalled and display-mode change events",
3025
+ "normalization": "Synchronous owner exposes state, subscribe, prompt, dismiss and dispose; subscriptions replay current state, prompt invokes the native event directly in the user click and consumes it once, and session dismissal preserves a retained event for explicit inline installation."
3026
+ },
3027
+ {
3028
+ "id": "pwa:mountPwaInstallPrompt",
3029
+ "name": "mountPwaInstallPrompt",
3030
+ "displayName": "mountPwaInstallPrompt()",
3031
+ "kind": "function",
3032
+ "signature": "mountPwaInstallPrompt({appName = ''} = {})",
3033
+ "entrypoints": ["arcane-os/pwa"],
3034
+ "primaryImport": "arcane-os/pwa",
3035
+ "group": "Progressive web applications",
3036
+ "summary": "Starts shared installation observation and mounts one initially hidden, themed installation suggestion without delaying application rendering.",
3037
+ "availability": "Browser document and managed HTML import; resolves to null without a document",
3038
+ "protocol": "Shared native install owner and pwa-install.html component lifecycle",
3039
+ "normalization": "Repeated calls return the same mounting promise; the first appName initializes the component, success resolves to its ready html-import host, absence of a document or disposal before mounting resolves to null, and component loading failures reject. Removal or disposal during loading rejects with AbortError. A rejected mount permits another explicit attempt. Generated PWA bootstraps invoke it automatically."
3040
+ },
2999
3041
  {
3000
3042
  "id": "browser-speech:BROWSER_SPEECH_ARTIFACT_GRAPH_PROTOCOL",
3001
3043
  "name": "BROWSER_SPEECH_ARTIFACT_GRAPH_PROTOCOL",
@@ -7,7 +7,7 @@
7
7
  "path": "runtime/arcane/components",
8
8
  "sdkVersion": "0.7.2"
9
9
  },
10
- "componentCount": 40,
10
+ "componentCount": 41,
11
11
  "loader": "/arcane/modules/HTMLImport.js",
12
12
  "artifacts": [
13
13
  {
@@ -645,6 +645,34 @@
645
645
  "transport": "HTMLImport + DOM; injected Arcane/provider modules where listed",
646
646
  "normalization": "Normalized form values"
647
647
  },
648
+ {
649
+ "file": "runtime/arcane/components/pwa-install.html",
650
+ "name": "pwa-install.html",
651
+ "purpose": "Presents a dismissible browser installation action with floating or inline placement.",
652
+ "methods": [
653
+ "configure()",
654
+ "install()",
655
+ "dismiss()",
656
+ "destroy()",
657
+ "state",
658
+ "ready"
659
+ ],
660
+ "events": [
661
+ "pwa-install-ready",
662
+ "pwa-install-change",
663
+ "pwa-install-dismissed"
664
+ ],
665
+ "slots": [],
666
+ "dependencies": [
667
+ "strong-type",
668
+ "arcane-os/pwa",
669
+ "arcane-os/event-manager",
670
+ "arcane-os/logging"
671
+ ],
672
+ "availability": "Browser installation requires a browser-provided beforeinstallprompt event; otherwise hidden",
673
+ "transport": "HTMLImport + DOM; shared PWA installation owner and browser-native prompt",
674
+ "normalization": "Browser install availability and outcome supplied by the shared PWA owner"
675
+ },
648
676
  {
649
677
  "file": "runtime/arcane/components/record-timeline.html",
650
678
  "name": "record-timeline.html",
@@ -2,7 +2,9 @@
2
2
 
3
3
  An application supplies its installation identity and offline resource selection.
4
4
  The SDK generates the Web App Manifest, offline inventory, service worker and
5
- nonblocking registration module. Native packages keep their existing lifecycle.
5
+ nonblocking registration module. Its shared installation owner and dismissible
6
+ component expose the browser's available install action. Native packages keep
7
+ their existing lifecycle.
6
8
 
7
9
  ## Application configuration
8
10
 
@@ -63,7 +65,7 @@ Browser packaging emits these files at the selected deployment root:
63
65
  | `arcane.webmanifest` | Browser installation metadata. |
64
66
  | `arcane-offline.json` | App ID/version, SDK version, deployment revision, resource URLs and explicit navigation aliases. |
65
67
  | `arcane-sw.js` | Stable worker URL with the selected offline manifest embedded in its source. |
66
- | `arcane-pwa.mjs` | Independent registration module importing the SDK client. |
68
+ | `arcane-pwa.mjs` | Independent registration and installation-component bootstrap importing the SDK client. |
67
69
 
68
70
  Each packaged output gets one deployment revision shared by its offline
69
71
  manifest and worker. It distinguishes separately generated outputs even when
@@ -74,6 +76,9 @@ It follows actual resource references to include meaningful query variants.
74
76
  Generated application pages receive a manifest link and an `async` module
75
77
  marked `data-arcane-pwa`. Existing application scripts retain their order.
76
78
  PWA registration does not wait for models, storage, preferences or page rendering.
79
+ The same bootstrap starts one initially hidden `pwa-install.html` component with
80
+ the generated manifest's app name. Component loading and worker registration
81
+ proceed independently.
77
82
 
78
83
  The selected PWA browser delivery removes `v` and `arcaneVersion` from actual
79
84
  local resource references, including the managed import map. Other query fields,
@@ -91,7 +96,10 @@ Package-only applications retain their existing descriptor workflow.
91
96
 
92
97
  Add a file or directory to `package.include` to make it part of the app's
93
98
  resources. A new file inside an already included directory needs no separate
94
- entry. If `package.pwa.offline.include` is nonempty, the resource must also
99
+ entry. `package.include` is an application resource selection, not a file list
100
+ inside the Web App Manifest. `arcane.webmanifest` contains browser installation
101
+ metadata; `arcane-offline.json` contains the selected offline resource inventory.
102
+ If `package.pwa.offline.include` is nonempty, the resource must also
95
103
  match that offline selection and must not match `offline.exclude`. Adding a
96
104
  path only to the offline selection does not add it to the app's resources.
97
105
  Restart after changing descriptor settings. Edits to selected source files are
@@ -104,6 +112,8 @@ and packaged-preview serving, including conditional resource responses.
104
112
  Every Arcane development server and packaged browser preview serves HTTPS,
105
113
  including localhost. Configure the workspace certificate pair before starting
106
114
  the ordinary command; see [development HTTPS setup](cli.md#development-https-setup).
115
+ `--public` selects the IPv4 wildcard bind address; it does not enable PWA
116
+ configuration, change manifest metadata, or determine browser installability.
107
117
 
108
118
  Source inventory work begins when the browser requests the worker or current
109
119
  offline manifest, after the page can start. It traverses the selected route
@@ -153,7 +163,15 @@ the previous timestamp so the next page load can retry. Each cached response
153
163
  retains its own `Last-Modified` header, but there are no per-file check times.
154
164
  The SDK imposes no age-based cache deletion and
155
165
  retains resource bodies across app and SDK version changes. Requests for a page
156
- do not wait for the complete resource inventory to finish checking. The SDK
166
+ do not wait for the complete resource inventory to finish checking. A file
167
+ already in the current resource cache also returns immediately when its own
168
+ conditional check is pending or in flight. That background check keeps its
169
+ existing owner and updates the stored response for subsequent requests.
170
+ The page receives the cached response's original status, commonly `200`, even
171
+ when the separate conditional network response is `304`. Status alone does
172
+ not identify a network transfer; use the browser's response source and timing
173
+ details to distinguish cache access from worker startup, queueing and network.
174
+ The SDK
157
175
  uses at most four concurrent background resource requests and starts no timer
158
176
  or polling loop between page loads.
159
177
 
@@ -205,6 +223,151 @@ Switching a server from a packaged release to live development does not replace
205
223
  an already active release worker inside an open document. The same native
206
224
  worker lifecycle applies.
207
225
 
226
+ ## Installation component
227
+
228
+ Starting with SDK `0.13.0`, enabled PWA pages automatically mount the shared
229
+ [`pwa-install.html` component](runtime-components.md#pwa-installhtml). It appears
230
+ when the browser supplies an installation prompt, offers **Install** and a
231
+ clearly labeled close control, and does not move focus when it appears.
232
+ The floating suggestion has no automatic dismissal timer. Closing it remembers
233
+ the choice for the current tab session and manifest URL, so another page
234
+ load does not immediately show it again. A storage failure leaves the current
235
+ page's dismissal functional and reports the error through console diagnostics.
236
+
237
+ An application can also place the same component inline through `html-import`
238
+ with `data-presentation="inline"`. Both presentations share one page-owned
239
+ native installation event. Dismissing the floating suggestion does not consume
240
+ that event or disable an explicitly placed inline component. Closing an inline
241
+ instance hides only that instance. See the component reference for its
242
+ configuration, methods and events.
243
+
244
+ The browser controls the URL-bar installation indicator and native prompt.
245
+ The SDK cannot force either to appear. Without a captured
246
+ `beforeinstallprompt`, the component remains hidden; that waiting state does
247
+ not establish that installation is unsupported. The browser may still be
248
+ evaluating the app, may already have it installed, or may only support a
249
+ manual browser-menu installation path.
250
+
251
+ ### Browser installation requirements
252
+
253
+ Inspect the loaded page's manifest link and the browser's manifest diagnostics
254
+ when an install action is missing. Confirm that the generated manifest has the
255
+ intended name, `start_url`, scope and app display mode, and that its icon URLs
256
+ resolve to actual images with the declared dimensions. For Chromium's manifest
257
+ install promotion, provide a `purpose: "any"` icon, or omit `purpose` to use
258
+ that default, in PNG, SVG or WebP format. Its strict installation icon selector
259
+ excludes JPEG even when the same image renders successfully on the page.
260
+ Do not change a file's extension or MIME declaration without converting the
261
+ actual image at the application's asset owner. See Chromium's
262
+ [icon selection implementation](https://raw.githubusercontent.com/chromium/chromium/main/third_party/blink/common/manifest/manifest_icon_selector.cc).
263
+
264
+ Providing 192-by-192 and 512-by-512 raster icons follows the
265
+ [browser guidance](https://web.dev/articles/add-manifest). Their absence alone
266
+ does not prove the failure: Chromium can select one larger supported icon.
267
+ Keep actual icon dimensions in `sizes`. Browser diagnostics about missing
268
+ `screenshots` concern the richer installation dialog; screenshots are optional
269
+ and are separate from a usable installation icon.
270
+
271
+ Browser installation requires HTTPS or the browser's localhost/loopback
272
+ exception. A device-facing LAN address is not loopback. Arcane's development
273
+ server still follows its own HTTPS serving contract above. Browser engagement,
274
+ installation state and platform support also affect whether native promotion
275
+ appears; worker cache readiness is not an installation UI prerequisite. See
276
+ [browser installation requirements](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Guides/Making_PWAs_installable).
277
+
278
+ Browsers without `beforeinstallprompt` can offer manual installation. For
279
+ example, current iPhone Safari uses Share, **Add to Home Screen**, **Open as
280
+ Web App**, then **Add**. A product may explain that browser-owned path in its
281
+ help, but should not present it as a programmatic SDK install action. The SDK
282
+ does not infer installation support from the user-agent string. See
283
+ [Apple's installation instructions](https://support.apple.com/guide/iphone/open-as-web-app-iphea86e5236/ios).
284
+
285
+ ## getPwaInstall()
286
+
287
+ Import `getPwaInstall` and `PWA_INSTALL_STATE_EVENT` from `arcane-os/pwa`.
288
+ `getPwaInstall()` synchronously returns the shared page owner with `state`,
289
+ `subscribe`, `prompt`, `dismiss` and `dispose`. Call it early when owning a
290
+ separate install entry point so it can capture `beforeinstallprompt` before
291
+ loading the UI. The generated bootstrap already does this through
292
+ `mountPwaInstallPrompt()`.
293
+
294
+ `state` contains `status`, `available`, `dismissed`, `outcome` and `error`.
295
+ Status is `waiting`, `available`, `prompting`, `accepted`, `dismissed`,
296
+ `installed`, `running`, `error` or `disposed`. `available` means a native event
297
+ is retained; a dismissed floating suggestion can still have `available: true`.
298
+ `outcome` is the browser's `accepted` or `dismissed` choice, or `null` before a
299
+ choice. `error` carries the complete prompt error, or `null`.
300
+
301
+ `subscribe(listener, {emitCurrent: true, signal} = {})` immediately replays
302
+ state by default and returns an unsubscribe function. Later state travels
303
+ through the existing Arcane event owner using `PWA_INSTALL_STATE_EVENT`
304
+ (`arcane.pwa.install.state`). A subscription does not wait for worker
305
+ registration, storage initialization or model readiness.
306
+
307
+ Call `prompt()` directly from the user's install click, before any asynchronous
308
+ wait. It invokes the browser prompt in the same call stack, consumes the event
309
+ once, and returns a promise for the browser's choice. It resolves to `null`
310
+ when there is no retained event or the owner is disposed. Failure publishes
311
+ `error` state and rejects. A new native event is required for another prompt.
312
+
313
+ ```javascript
314
+ import {getPwaInstall} from 'arcane-os/pwa';
315
+
316
+ const install = getPwaInstall();
317
+ const installButton = document.querySelector('#install');
318
+
319
+ install.subscribe(function showInstallAvailability(state) {
320
+ installButton.hidden = !state.available;
321
+ });
322
+ installButton.addEventListener('click', function requestInstallation() {
323
+ install.prompt().catch(function reportInstallFailure(error) {
324
+ console.error(error);
325
+ });
326
+ });
327
+ ```
328
+
329
+ `dismiss()` remembers the session choice without consuming the retained event
330
+ and returns the current state. A browser-native dismissed choice is remembered
331
+ too. `appinstalled` clears the event and publishes `installed`; running in an
332
+ app display mode publishes `running` and suppresses the prompt. These states
333
+ do not establish offline readiness. On Android, `appinstalled` can arrive
334
+ before WebAPK creation finishes. See the
335
+ [browser lifecycle distinction](https://web.dev/learn/pwa/detection/).
336
+
337
+ `dispose()` removes the shared owner's native listeners and subscriptions.
338
+ Leaving the page disposes it automatically, except when the browser retains
339
+ the page in its back/forward cache.
340
+ Because the owner is shared, an individual component should dispose its own
341
+ subscription instead. A later `getPwaInstall()` creates a new owner after
342
+ disposal; it cannot recover a native event that was already consumed.
343
+
344
+ ## mountPwaInstallPrompt()
345
+
346
+ `mountPwaInstallPrompt({appName = ''} = {})` starts native install observation
347
+ synchronously, then loads the shared HTML import and theme modules concurrently
348
+ and appends one initially hidden component when the document body is available.
349
+ It returns the same mounting promise on repeated calls; the first call supplies
350
+ the initial app name. The promise resolves to the ready `html-import` host, or
351
+ `null` without a document or when the owner is disposed before mounting. It
352
+ rejects if component loading fails, or with `AbortError` when the loading host
353
+ is removed or its owner disposed. A rejected mount releases its slot so an
354
+ explicit later call can try again. Observe the rejection without making page
355
+ rendering wait for it.
356
+
357
+ The generated PWA bootstrap calls this automatically using the manifest name.
358
+ Applications need not add another floating suggestion. A separate entry point
359
+ can call it explicitly:
360
+
361
+ ```javascript
362
+ import {mountPwaInstallPrompt} from 'arcane-os/pwa';
363
+
364
+ mountPwaInstallPrompt({appName: 'Example Library'}).catch(
365
+ function reportInstallComponentFailure(error) {
366
+ console.error(error);
367
+ }
368
+ );
369
+ ```
370
+
208
371
  ## registerPwa()
209
372
 
210
373
  Import `registerPwa` and `PWA_STATE_EVENT` from `arcane-os/pwa` through the
@@ -82,6 +82,7 @@ appropriate.
82
82
  | [`modal.html`](#modalhtml) | Generic modal with population, open/close, actions, and sequential task execution. | `populate()`<br>`open()`<br>`close()`<br>`runTasks()`<br>`destroy()` | `modal-ready`<br>`modal-opened`<br>`modal-closed`<br>`modal-action` | Modal state normalized; injected task results mixed |
83
83
  | [`output-panel.html`](#output-panelhtml) | Presents status, output, body, coverage, actions, pending, error, and cleared states. | `configure()`<br>`setOutput()`<br>`setBody()`<br>`setCoverage()`<br>`setActions()`<br>`setPending()`<br>`setStatus()`<br>`setError()`<br>`clear()`<br>`destroy()` | `output-panel-ready`<br>`output-panel-state`<br>`output-panel-change`<br>`output-panel-action`<br>`output-panel-error`<br>`output-panel-cleared` | DOM-normalized |
84
84
  | [`preferences-form.html`](#preferences-formhtml) | Builds a schema-driven preferences form with submit, reset, busy, and status behavior. | `configure()`<br>`getValues()`<br>`setValues()`<br>`setBusy()`<br>`setStatus()`<br>`destroy()` | `preferences-form-ready`<br>`preferences-change`<br>`preferences-submit`<br>`preferences-reset` | Normalized form values |
85
+ | [`pwa-install.html`](#pwa-installhtml) | Presents a dismissible browser installation action with floating or inline placement. | `configure()`<br>`install()`<br>`dismiss()`<br>`destroy()`<br>`state`<br>`ready` | `pwa-install-ready`<br>`pwa-install-change`<br>`pwa-install-dismissed` | Browser install availability and outcome supplied by the shared PWA owner |
85
86
  | [`record-timeline.html`](#record-timelinehtml) | Displays complete chronological records/evidence and emits open actions. | `setItems()`<br>`populate()`<br>`destroy()` | `record-timeline-ready`<br>`record-timeline-open` | Complete item fields and inventories preserved |
86
87
  | [`relationship-board.html`](#relationship-boardhtml) | Displays complete normalized relationship nodes/edges in graph and list forms. | `setGraph()`<br>`populate()`<br>`destroy()` | `relationship-board-ready`<br>`relationship-node-open`<br>`relationship-edge-open` | Complete graph inventories and fields preserved |
87
88
  | [`screen-capture.html`](#screen-capturehtml) | Presents image, video, or GIF display-capture workflow. | `capture` (`ScreenCapture` instance)<br>`destroy()` | `screen-capture-ready`<br>`screen-capture-result` | State/result normalized; media permission/codec failures mixed |
@@ -1018,6 +1019,69 @@ Events: `preferences-form-ready`, `preferences-change`, `preferences-submit`, `p
1018
1019
  </html-import>
1019
1020
  ```
1020
1021
 
1022
+ ## pwa-install.html
1023
+
1024
+ ### Overview
1025
+
1026
+ A compact installation suggestion using the shared [PWA installation owner](pwa.md).
1027
+ The default floating panel appears near the top right only when the browser offers
1028
+ installation. It has an Install action and an explicit close button, takes no
1029
+ focus automatically, and has no dismissal timer. It uses the Arcane theme and
1030
+ primitives, wraps complete labels and errors, and scrolls its own content when
1031
+ the available height is limited. The parent page loads `ThemeBootstrap.js` to
1032
+ apply the user's appearance preferences.
1033
+
1034
+ ### Public surface
1035
+
1036
+ `configure({appName, installLabel, closeLabel, promptingLabel, description,
1037
+ presentation})` updates display configuration and returns its current record.
1038
+ Labels remain complete strings. `appName` initially uses `data-app-name` or
1039
+ `this app`; the default button label is `Install`. `presentation` is `floating`
1040
+ by default or `inline`, initially read from `data-presentation`. Inline placement
1041
+ uses the parent's layout. Set `description` to an empty string when a compact
1042
+ placement needs no supporting text; supplied descriptions remain visible.
1043
+
1044
+ `install()` calls the shared owner's native prompt synchronously and returns its
1045
+ promise of the browser outcome or `null` when no prompt is available. Call it
1046
+ directly from a user action. The component's Install button already does this.
1047
+ An explicit request's complete failure message remains visible until dismissed;
1048
+ the method rejects with the same error. The browser controls actual installation.
1049
+ Acceptance hides the suggestion without claiming installation has completed.
1050
+
1051
+ `dismiss()` closes the component. Floating dismissal also uses the owner's
1052
+ session dismissal; an explicit inline component ignores that shared dismissal
1053
+ and closes only its own instance. Closing retains any unused native prompt at
1054
+ the shared owner. `destroy()` removes the component's listeners and subscription,
1055
+ disposes its event source, hides its host, and marks `ready` false; it does not
1056
+ dispose the shared owner or change browser installation state. Both methods
1057
+ return true while active and false after destruction; `destroy()` is idempotent.
1058
+
1059
+ The readonly `state` property returns the shared owner's current install-state
1060
+ record; `ready` becomes true after methods and the state subscription are attached.
1061
+ `pwa-install-ready` carries `{ready, state}`; `pwa-install-change` carries
1062
+ `{state, visible}` for each observed owner update; `pwa-install-dismissed` carries
1063
+ `{presentation, state}`. These follow the canonical event projection contract.
1064
+
1065
+ ### Availability and normalization
1066
+
1067
+ The component requires HTMLImport and a DOM renderer. Native installation
1068
+ availability comes from the browser's `beforeinstallprompt` event through
1069
+ `getPwaInstall()`. Without an available event, the suggestion stays hidden;
1070
+ absence does not identify why installation is unavailable. Installed-app events,
1071
+ accepted prompts, and an already running installed display mode hide it.
1072
+
1073
+ ### Example
1074
+
1075
+ ```html
1076
+ <html-import
1077
+ id="install-app"
1078
+ href="/arcane/components/pwa-install.html"
1079
+ data-app-name="Example Library"
1080
+ data-presentation="inline"
1081
+ hidden>
1082
+ </html-import>
1083
+ ```
1084
+
1021
1085
  ## record-timeline.html
1022
1086
 
1023
1087
  ### Overview
@@ -39,7 +39,7 @@ for the installed-inventory-derived physical-runtime contract in SDK `0.5.18`.
39
39
  | `arcane-os/speech-playback` | Portable speech preparation, playback state, and injected media adapters. |
40
40
  | `arcane-os/speech-text` | Speech-input formatting cleanup for complete text and streamed chunks. |
41
41
  | `arcane-os/browser-device` | Synchronous mobile or desktop identity hints for application-owned settings. |
42
- | `arcane-os/pwa` | Nonblocking PWA registration, native worker updates and observable lifecycle state. |
42
+ | `arcane-os/pwa` | Nonblocking PWA registration, worker updates, native installation state and a dismissible installation component. |
43
43
  | `arcane-os/ai/browser-wasm` | Caller-selected browser-local Wllama inference, complete DBOPFS model storage, streaming, cancellation, and structural tool-call results. |
44
44
  | `arcane-os/ai/browser-speech` | Caller-selected browser-local Whisper STT and Kokoro TTS provider mechanisms, ordinary upstream assets, materialized/native routing, Workers, and cancellation. |
45
45
  | `arcane-os/mail` | Portable Mail runtime, durable outbox, complete transport responses, and provider-neutral acceptance contracts. |
@@ -129,6 +129,9 @@ browser map are cataloged separately in [Runtime modules](runtime-modules.md).
129
129
  | `getBrowserDeviceClass()` | function | `arcane-os/browser-device` | Browser device settings | Node and Browser; synchronous identity hint with no model, storage, or GPU operation |
130
130
  | `PWA_STATE_EVENT` | constant | `arcane-os/pwa` | Progressive web applications | Browser lifecycle event name; importable without registration |
131
131
  | `registerPwa()` | function | `arcane-os/pwa` | Progressive web applications | Browser service workers; synchronous unsupported state when unavailable |
132
+ | `PWA_INSTALL_STATE_EVENT` | constant | `arcane-os/pwa` | Progressive web applications | Browser installation event name; importable without starting observation |
133
+ | `getPwaInstall()` | function | `arcane-os/pwa` | Progressive web applications | Browser native installation events; waiting state while no prompt is available |
134
+ | `mountPwaInstallPrompt()` | function | `arcane-os/pwa` | Progressive web applications | Browser document and managed HTML import; resolves to null without a document |
132
135
  | `createDbopfsSpeechArtifactStore()` | function | `arcane-os/ai/browser-speech` | Browser speech providers | Browser with ready DBOPFS, Web Locks, Fetch, File/Blob, and object URLs |
133
136
  | `removeBrowserSpeechModelCache()` | function | `arcane-os/ai/browser-speech` | Browser speech providers | Browser CacheStorage; explicit removal of one selected upstream model |
134
137
  | `createNativeBuildPlan()` | function | `arcane-os` | Targets, native plans, and providers | Node; selected browser/native target or provider as documented |
@@ -6124,6 +6127,99 @@ current-state replay, disposal, offline resource ownership and update behavior.
6124
6127
  The generated bootstrap registers automatically; a manual caller owns a separate
6125
6128
  entry point and must observe its `ready` rejection.
6126
6129
 
6130
+ ## PWA_INSTALL_STATE_EVENT
6131
+
6132
+ ### Overview
6133
+
6134
+ The exact event name `arcane.pwa.install.state` identifies native installation
6135
+ availability and choice state through the existing Arcane event owner. It is
6136
+ separate from service-worker registration state. Importing it starts no
6137
+ observation or installation.
6138
+
6139
+ ### Example
6140
+
6141
+ ```javascript
6142
+ import {PWA_INSTALL_STATE_EVENT} from 'arcane-os/pwa';
6143
+ console.log(PWA_INSTALL_STATE_EVENT);
6144
+ ```
6145
+
6146
+ See the [installation state contract](pwa.md#getpwainstall) for the complete
6147
+ payload and current-state subscription behavior.
6148
+
6149
+ ## getPwaInstall()
6150
+
6151
+ ### Overview
6152
+
6153
+ `getPwaInstall()` returns one synchronous page owner exposing `state`,
6154
+ `subscribe`, `prompt`, `dismiss` and `dispose`. It captures the browser's
6155
+ `beforeinstallprompt` event and observes `appinstalled` and app display-mode
6156
+ changes. A missing event leaves installation availability unknown and the owner
6157
+ waiting; it does not prove browser incompatibility.
6158
+
6159
+ State contains `status`, `available`, `dismissed`, `outcome` and `error`.
6160
+ Subscriptions replay current state by default. `prompt()` must be called
6161
+ directly within the install click to preserve native user activation. It
6162
+ consumes the event once and returns the browser choice, resolves to `null` when
6163
+ unavailable, or rejects with the actual error. `dismiss()` remembers the
6164
+ session's floating-suggestion dismissal without consuming a retained event.
6165
+
6166
+ ### Example
6167
+
6168
+ ```javascript
6169
+ import {getPwaInstall} from 'arcane-os/pwa';
6170
+
6171
+ const install = getPwaInstall();
6172
+ const button = document.querySelector('#install');
6173
+ install.subscribe(function updateInstallButton(state) {
6174
+ button.hidden = !state.available;
6175
+ });
6176
+ button.addEventListener('click', function requestInstallation() {
6177
+ install.prompt().catch(function reportInstallFailure(error) {
6178
+ console.error(error);
6179
+ });
6180
+ });
6181
+ ```
6182
+
6183
+ The [PWA guide](pwa.md#getpwainstall) defines every status, event lifetime,
6184
+ session dismissal, errors and disposal. Disposing this shared owner removes
6185
+ its page-level observation; components normally unsubscribe only their own
6186
+ listener. Installation state does not establish offline readiness or completed
6187
+ Android WebAPK creation.
6188
+
6189
+ ## mountPwaInstallPrompt()
6190
+
6191
+ ### Overview
6192
+
6193
+ `mountPwaInstallPrompt({appName = ''} = {})` starts the shared install observer
6194
+ before asynchronously mounting one initially hidden `pwa-install.html`
6195
+ component. Repeated calls return the same mounting promise. It resolves to the
6196
+ ready `html-import` host, or `null` without a document or when the owner is
6197
+ disposed before mounting. Component-load failure rejects; removal or disposal
6198
+ during loading rejects with `AbortError`. A rejected mount permits another
6199
+ explicit attempt. The first call
6200
+ supplies the initial app name.
6201
+
6202
+ The generated PWA bootstrap calls this automatically with the manifest name.
6203
+ Component mounting and worker registration run independently, and application
6204
+ rendering must not await them. The floating component offers Install and a
6205
+ manual close control; an application can also place the same component inline.
6206
+
6207
+ ### Example
6208
+
6209
+ ```javascript
6210
+ import {mountPwaInstallPrompt} from 'arcane-os/pwa';
6211
+
6212
+ mountPwaInstallPrompt({appName: 'Example Library'}).catch(
6213
+ function reportInstallComponentFailure(error) {
6214
+ console.error(error);
6215
+ }
6216
+ );
6217
+ ```
6218
+
6219
+ See the [mounting contract](pwa.md#mountpwainstallprompt) and
6220
+ [component reference](runtime-components.md#pwa-installhtml). The browser owns
6221
+ native installation eligibility and URL-bar promotion.
6222
+
6127
6223
  ## createBrowserWasmLlmProvider()
6128
6224
 
6129
6225
  ### Overview
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arcane-os",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Arcane OS JavaScript SDK, project-local CLI, browser runtime, and repository-portable application packager.",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",
@@ -67,7 +67,7 @@
67
67
  "test": "npm run test:unit && npm run test:functional && npm run test:integration && npm run test:regression",
68
68
  "test:release": "node ./bin/arcane-test.mjs test/npm-release.test.mjs",
69
69
  "test:unit": "node ./bin/arcane-test.mjs test/app-descriptor.test.mjs test/app-schema.test.mjs test/contracts.test.mjs test/doctor.test.mjs test/mail-credentials.test.mjs test/mail-outbox.test.mjs test/mail-public-api.test.mjs test/mail-send.test.mjs test/mail-transport.test.mjs test/targets.test.mjs test/workspace-operation-lock.test.mjs",
70
- "test:functional": "node ./bin/arcane-test.mjs test/browser-speech-providers.test.mjs test/browser-wasm-gpu-notice.test.mjs test/browser-wasm-download-resume.test.mjs test/cli.test.mjs test/dbopfs-document-library.test.mjs test/dev-server.test.mjs test/dev-pwa.test.mjs test/dom-event-instrumentation.test.mjs test/event-manager.test.mjs test/events.test.mjs test/import-map.test.mjs test/mail-cli.test.mjs test/mail-runtime.test.mjs test/mail-server.test.mjs test/packaging.test.mjs test/pwa-packaging.test.mjs test/pwa-client.test.mjs test/pwa-worker.test.mjs test/persistent-ai-chat-session.test.mjs test/reference-completeness.test.mjs test/runtime-api-behavior.test.mjs test/runtime.test.mjs test/scaffold.test.mjs test/speech-playback.test.mjs test/site.test.mjs test/update-check.test.mjs",
70
+ "test:functional": "node ./bin/arcane-test.mjs test/browser-speech-providers.test.mjs test/browser-wasm-gpu-notice.test.mjs test/browser-wasm-download-resume.test.mjs test/cli.test.mjs test/dbopfs-document-library.test.mjs test/dev-server.test.mjs test/dev-pwa.test.mjs test/dom-event-instrumentation.test.mjs test/event-manager.test.mjs test/events.test.mjs test/import-map.test.mjs test/mail-cli.test.mjs test/mail-runtime.test.mjs test/mail-server.test.mjs test/packaging.test.mjs test/pwa-packaging.test.mjs test/pwa-client.test.mjs test/pwa-install.test.mjs test/pwa-worker.test.mjs test/persistent-ai-chat-session.test.mjs test/reference-completeness.test.mjs test/runtime-api-behavior.test.mjs test/runtime.test.mjs test/scaffold.test.mjs test/speech-playback.test.mjs test/site.test.mjs test/update-check.test.mjs",
71
71
  "test:integration": "node ./bin/arcane-test.mjs test/integrated-shared.test.mjs test/integrated-workspace.test.mjs test/mail-browser.test.mjs test/native-plan.test.mjs test/native-provider-loader.test.mjs test/npm-release.test.mjs test/release-bundle.test.mjs test/release-capability-smoke.test.mjs test/shared-payload-batch.test.mjs test/tarball.test.mjs test/browser-wasm-cpu.test.mjs test/wllama-webgpu-runtime.test.mjs",
72
72
  "test:regression": "node ./bin/arcane-test.mjs test/channel-workflows.test.mjs test/html-import-registration.test.mjs test/logging-regression.test.mjs test/markdown-speech.test.mjs test/prepared-speech.test.mjs test/native-provider-generation.test.mjs test/speech-queue-regression.test.mjs test/testing.test.mjs test/test-sets.test.mjs",
73
73
  "check": "node tools/check-source.mjs && npm test",
@@ -0,0 +1,216 @@
1
+ <link rel="stylesheet" href="./arcane/css/theme.css?v=1">
2
+ <link rel="stylesheet" href="./arcane/css/primitives.css?v=1">
3
+ <style>
4
+ :host{
5
+ display:block;
6
+ inset-block-start:4.5rem;
7
+ inset-inline-end:1rem;
8
+ max-width:calc(100vw - 2rem);
9
+ min-width:0;
10
+ position:fixed;
11
+ width:22rem;
12
+ z-index:110;
13
+ }
14
+ :host([hidden]){display:none}
15
+ :host([data-presentation="inline"]){
16
+ max-width:100%;
17
+ position:static;
18
+ width:fit-content;
19
+ }
20
+ .panel{
21
+ background:var(--arcane-surface-raised);
22
+ border:1px solid var(--arcane-border);
23
+ border-radius:var(--arcane-radius-medium);
24
+ box-shadow:var(--arcane-shadow-medium);
25
+ box-sizing:border-box;
26
+ color:var(--text-color);
27
+ display:grid;
28
+ gap:var(--arcane-space-2);
29
+ max-height:calc(100vh - 5.5rem);
30
+ max-height:calc(100dvh - 5.5rem);
31
+ overflow:auto;
32
+ padding:var(--arcane-space-3);
33
+ }
34
+ .heading{align-items:start;display:flex;gap:var(--arcane-space-2)}
35
+ .copy{flex:1;min-width:0}
36
+ h2,p{margin:0;overflow-wrap:anywhere;white-space:pre-wrap}
37
+ h2{font-size:1rem;line-height:1.4}
38
+ .description{color:var(--arcane-muted);font-size:.9rem;line-height:1.45}
39
+ .close{flex:none}
40
+ .install{justify-self:start;max-width:100%;overflow-wrap:anywhere;white-space:normal}
41
+ .status{font-size:.9rem;line-height:1.45}
42
+ .status[data-error="true"]{color:var(--arcane-danger)}
43
+ :host([data-presentation="inline"]) .panel{box-shadow:none}
44
+ @media(prefers-reduced-motion:reduce){.install{transition:none}}
45
+ @media(forced-colors:active){.panel,.install,.close{border:1px solid CanvasText}}
46
+ </style>
47
+
48
+ <section class="panel" aria-labelledby="installTitle">
49
+ <div class="heading">
50
+ <div class="copy">
51
+ <h2 id="installTitle">Install this app</h2>
52
+ <p id="description" class="description">Open this app directly from your device.</p>
53
+ </div>
54
+ <button id="close" class="close arcane-close-button" type="button" aria-label="Close installation suggestion">&#x2715;</button>
55
+ </div>
56
+ <button id="install" class="install arcane-button" type="button">Install</button>
57
+ <p id="status" class="status" role="status" aria-live="polite" hidden></p>
58
+ </section>
59
+
60
+ <script type="module">
61
+ const host=this;
62
+ host.hidden=true;
63
+ const [
64
+ {default:Is},
65
+ {getPwaInstall},
66
+ {createArcaneEventSource,projectArcaneDOMEvent},
67
+ {arcaneLogging}
68
+ ]=await Promise.all([
69
+ import('strong-type'),
70
+ import('arcane-os/pwa'),
71
+ import('arcane-os/event-manager'),
72
+ import('arcane-os/logging')
73
+ ]);
74
+ const is=new Is(false);
75
+ const root=host.shadowRoot;
76
+ const title=root.querySelector('#installTitle');
77
+ const description=root.querySelector('#description');
78
+ const installButton=root.querySelector('#install');
79
+ const closeButton=root.querySelector('#close');
80
+ const status=root.querySelector('#status');
81
+ const lifecycleController=new AbortController();
82
+ const events=createArcaneEventSource(host,{
83
+ source:'arcane.component.pwa-install',
84
+ eventTypes:['pwa-install-ready','pwa-install-change','pwa-install-dismissed']
85
+ });
86
+ const owner=getPwaInstall();
87
+ let options={
88
+ appName:host.dataset.appName||'this app',
89
+ installLabel:'Install',
90
+ closeLabel:'Close installation suggestion',
91
+ promptingLabel:'Choose an option in the browser installation prompt.',
92
+ description:'Open this app directly from your device.',
93
+ presentation:host.dataset.presentation||'floating'
94
+ };
95
+ let currentState=owner.state;
96
+ let locallyDismissed=false;
97
+ let pending=false;
98
+ let requestError=null;
99
+ let destroyed=false;
100
+
101
+ host.configure=configure;
102
+ host.install=install;
103
+ host.dismiss=dismiss;
104
+ host.destroy=destroy;
105
+ Object.defineProperty(host,'state',{configurable:true,get:getState});
106
+
107
+ function emit(type,detail){
108
+ if(destroyed)return false;
109
+ const publication=events.dispatch(type,detail);
110
+ return projectArcaneDOMEvent(host,publication.occurrence,{bubbles:true,composed:true});
111
+ }
112
+
113
+ function getState(){
114
+ return {...currentState};
115
+ }
116
+
117
+ function configure(input={}){
118
+ if(destroyed)return false;
119
+ if(input===null||!is.object(input)||is.array(input)){
120
+ throw new TypeError('PWA install configuration must be an object.');
121
+ }
122
+ const next={...options,...input};
123
+ for(const key of ['appName','installLabel','closeLabel','promptingLabel','description']){
124
+ if(!is.string(next[key]))throw new TypeError(`PWA install ${key} must be a string.`);
125
+ }
126
+ if(next.presentation!=='floating'&&next.presentation!=='inline'){
127
+ throw new TypeError('PWA install presentation must be floating or inline.');
128
+ }
129
+ options=next;
130
+ host.dataset.presentation=options.presentation;
131
+ title.textContent=`Install ${options.appName}`;
132
+ description.textContent=options.description;
133
+ installButton.textContent=options.installLabel;
134
+ closeButton.setAttribute('aria-label',options.closeLabel);
135
+ render();
136
+ return {...options};
137
+ }
138
+
139
+ function render(){
140
+ if(destroyed)return;
141
+ const terminal=['accepted','installed','running','disposed'].includes(currentState.status);
142
+ if(terminal)requestError=null;
143
+ const dismissed=locallyDismissed
144
+ ||(options.presentation==='floating'&&currentState.dismissed);
145
+ host.hidden=terminal||dismissed||(!currentState.available&&!pending&&!requestError);
146
+ installButton.disabled=pending||!currentState.available;
147
+ const message=requestError
148
+ ?is.string(requestError.message)?requestError.message:String(requestError)
149
+ :pending?options.promptingLabel:'';
150
+ status.textContent=message;
151
+ status.hidden=!message;
152
+ status.dataset.error=String(Boolean(requestError));
153
+ }
154
+
155
+ function observeInstallState(state){
156
+ if(destroyed)return;
157
+ currentState=state;
158
+ render();
159
+ emit('pwa-install-change',{state:getState(),visible:!host.hidden});
160
+ }
161
+
162
+ function install(){
163
+ if(destroyed||pending||!currentState.available)return Promise.resolve(null);
164
+ locallyDismissed=false;
165
+ requestError=null;
166
+ pending=true;
167
+ render();
168
+ let operation;
169
+ try{
170
+ // Keep the browser prompt inside the initiating click's user activation.
171
+ operation=owner.prompt();
172
+ }catch(error){
173
+ operation=Promise.reject(error);
174
+ }
175
+ return Promise.resolve(operation).catch(function reportPromptError(error){
176
+ if(!destroyed)requestError=error;
177
+ throw error;
178
+ }).finally(function settlePrompt(){
179
+ if(destroyed)return;
180
+ pending=false;
181
+ render();
182
+ });
183
+ }
184
+
185
+ function dismiss(){
186
+ if(destroyed)return false;
187
+ locallyDismissed=true;
188
+ requestError=null;
189
+ if(options.presentation==='floating')owner.dismiss();
190
+ render();
191
+ emit('pwa-install-dismissed',{presentation:options.presentation,state:getState()});
192
+ return true;
193
+ }
194
+
195
+ function destroy(){
196
+ if(destroyed)return false;
197
+ destroyed=true;
198
+ lifecycleController.abort('pwa-install-destroyed');
199
+ events.dispose();
200
+ host.hidden=true;
201
+ host.ready=false;
202
+ return true;
203
+ }
204
+
205
+ installButton.addEventListener('click',function requestInstallation(){
206
+ install().catch(function reportInstallationFailure(error){
207
+ arcaneLogging.error('Unable to open the browser installation prompt:',error);
208
+ });
209
+ },{signal:lifecycleController.signal});
210
+ closeButton.addEventListener('click',dismiss,{signal:lifecycleController.signal});
211
+
212
+ configure();
213
+ owner.subscribe(observeInstallState,{signal:lifecycleController.signal});
214
+ host.ready=true;
215
+ emit('pwa-install-ready',{ready:true,state:getState()});
216
+ </script>
@@ -403,12 +403,10 @@ function installPwaWorker(manifest, clientUrl) {
403
403
  if (!ownedUrls.has(url)) {
404
404
  return {response: await fetch(request), done: Promise.resolve(null)};
405
405
  }
406
- if (!pendingChecks.has(url) && !resourceJobs.has(url)) {
407
- const cache = await caches.open(cacheName);
408
- const cached = await cache.match(url);
409
- if (cached) {
410
- return {response: cached, done: Promise.resolve(null)};
411
- }
406
+ const cache = await caches.open(cacheName);
407
+ const cached = await cache.match(url);
408
+ if (cached) {
409
+ return {response: cached, done: Promise.resolve(null)};
412
410
  }
413
411
  const job = resourceJob(request, url, pendingChecks.has(url));
414
412
  return {response: resourceResponse(await job.result), done: job.done};
package/src/pwa.mjs CHANGED
@@ -277,7 +277,13 @@ export function createPwaArtifacts(
277
277
  ),
278
278
  navigationAliases: navigationAliases ?? {[basePath]: app.entry}
279
279
  };
280
- const bootstrap = `import {registerPwa} from ${JSON.stringify(`${runtimeBase}pwa.mjs`)};
280
+ const bootstrap = `import {registerPwa, mountPwaInstallPrompt} from ${JSON.stringify(`${runtimeBase}pwa.mjs`)};
281
+
282
+ mountPwaInstallPrompt({appName: ${JSON.stringify(manifest.name)}}).catch(
283
+ function reportPwaInstallComponentFailure(error) {
284
+ console.error('Arcane PWA install component failed:', error);
285
+ }
286
+ );
281
287
 
282
288
  const controller = registerPwa(
283
289
  {