@yuneta/gobj-ui 5.10.0 → 5.11.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yuneta/gobj-ui",
3
- "version": "5.10.0",
3
+ "version": "5.11.0",
4
4
  "type": "module",
5
5
  "main": "dist/gobj-ui.cjs.js",
6
6
  "module": "dist/gobj-ui.es.js",
@@ -0,0 +1,249 @@
1
+ /***********************************************************************
2
+ * yui_install.js
3
+ *
4
+ * Can this app be installed, and has anyone offered?
5
+ *
6
+ * Chrome decides on its own whether to show its install banner, and
7
+ * the heuristic behind that decision is not ours to read: once the
8
+ * mini-infobar has been dismissed — or once the app has been
9
+ * installed and removed — the browser goes quiet on this origin for
10
+ * about three months. The app then LOOKS uninstallable when it is
11
+ * only unadvertised, and the way in is buried in the browser menu.
12
+ *
13
+ * So the browser's own banner is refused and the event it came with
14
+ * is kept. Asking becomes the app's job, at a moment the app
15
+ * chooses, and prompt() on that saved event opens the real system
16
+ * dialog — the same one, just not on Chrome's schedule.
17
+ *
18
+ * THE EVENT ARRIVES BEFORE THIS BUNDLE IS PARSED, and one nobody
19
+ * caught cannot be asked for back. So the catching is done by a
20
+ * tiny script the app serves from `public/install-prompt.js` and
21
+ * loads with `<script src>` — NOT inline: every SPA here ships a
22
+ * CSP with `script-src 'self'`, which drops an inline block without
23
+ * running it and without failing loudly.
24
+ *
25
+ * Copyright (c) 2026, ArtGins.
26
+ * All Rights Reserved.
27
+ ***********************************************************************/
28
+ import {
29
+ log_error,
30
+ kw_get_local_storage_value,
31
+ kw_set_local_storage_value,
32
+ } from "@yuneta/gobj-js";
33
+
34
+ import {yui_shell_confirm_yesno} from "./shell_modals.js";
35
+
36
+ /* Where the early script leaves the event, and the name it re-emits
37
+ * under. Both are part of the contract with public/install-prompt.js. */
38
+ const STASH = "__yuneta_install__";
39
+ const SIGNAL = "yuneta:installable";
40
+
41
+ /* "We already asked" — per browser, not per session: asking once a
42
+ * launch is nagging, and the answer rarely changes within a day. */
43
+ const ASKED_KEY = "install_asked";
44
+
45
+ const listeners = new Set();
46
+
47
+ const S = {
48
+ installed: false, /* seen installed, this session */
49
+ asking: false, /* a dialog is up */
50
+ };
51
+
52
+ /***************************************************************
53
+ * Tell me when the offer changes.
54
+ * Returns the unsubscribe function.
55
+ ***************************************************************/
56
+ function yui_install_subscribe(fn)
57
+ {
58
+ listeners.add(fn);
59
+ return function unsubscribe() {
60
+ listeners.delete(fn);
61
+ };
62
+ }
63
+
64
+ function emit()
65
+ {
66
+ for(const fn of listeners) {
67
+ try {
68
+ fn();
69
+ } catch(e) {
70
+ log_error(`yui_install: listener failed: ${e}`);
71
+ }
72
+ }
73
+ }
74
+
75
+ /***************************************************************
76
+ * The deferred event, stashed by the early script. It is
77
+ * single-use: once prompt() has been called the browser
78
+ * will not honour it again, so it is dropped there.
79
+ ***************************************************************/
80
+ function deferred()
81
+ {
82
+ return window[STASH] || null;
83
+ }
84
+
85
+ /***************************************************************
86
+ * Are we already running as an installed app? Two answers,
87
+ * because neither is complete alone: the display mode is
88
+ * what the launcher gives us, and the flag is what we
89
+ * learned by installing during this very session (the
90
+ * window we are in was opened as a tab and does not change
91
+ * mode).
92
+ ***************************************************************/
93
+ function yui_install_is_installed()
94
+ {
95
+ if(S.installed) {
96
+ return true;
97
+ }
98
+ try {
99
+ return !!(window.matchMedia &&
100
+ window.matchMedia("(display-mode: standalone)").matches);
101
+ } catch(e) {
102
+ return false;
103
+ }
104
+ }
105
+
106
+ /***************************************************************
107
+ * Is there a REAL install to offer? Never true on a browser
108
+ * that does not do this (Firefox, Safari): there is no
109
+ * event to save, so nothing is promised that cannot be
110
+ * delivered.
111
+ ***************************************************************/
112
+ function yui_install_can()
113
+ {
114
+ return !!deferred() && !yui_install_is_installed();
115
+ }
116
+
117
+ /***************************************************************
118
+ * Open the system install dialog.
119
+ *
120
+ * MUST be reached from a user gesture, and NOTHING may be
121
+ * awaited between the gesture and prompt(): the browser
122
+ * drops transient activation and refuses to open. Calling
123
+ * it from the .then() of a click-resolved promise is still
124
+ * a microtask, which keeps the activation — an await on
125
+ * anything slower does not.
126
+ *
127
+ * Resolves "accepted", "dismissed", or null when there was
128
+ * nothing to prompt with. A dismissal is not final: Chrome
129
+ * offers again on a later visit.
130
+ ***************************************************************/
131
+ function yui_install_prompt()
132
+ {
133
+ const ev = deferred();
134
+ if(!ev) {
135
+ return Promise.resolve(null);
136
+ }
137
+ window[STASH] = null;
138
+
139
+ let choice;
140
+ try {
141
+ ev.prompt();
142
+ choice = ev.userChoice;
143
+ } catch(e) {
144
+ log_error(`yui_install: prompt() failed: ${e}`);
145
+ emit();
146
+ return Promise.resolve(null);
147
+ }
148
+
149
+ return Promise.resolve(choice).then(function(res) {
150
+ const outcome = res? res.outcome : null;
151
+ if(outcome === "accepted") {
152
+ S.installed = true;
153
+ }
154
+ emit();
155
+ return outcome;
156
+ }, function(e) {
157
+ log_error(`yui_install: userChoice failed: ${e}`);
158
+ emit();
159
+ return null;
160
+ });
161
+ }
162
+
163
+ /***************************************************************
164
+ * Start listening. Call once at boot, after the shell
165
+ * exists. The event itself is caught by the early script
166
+ * and re-emitted under our own name, which is what we pick
167
+ * up here.
168
+ ***************************************************************/
169
+ function yui_install_start_watch()
170
+ {
171
+ window.addEventListener(SIGNAL, function() {
172
+ emit();
173
+ });
174
+ /* Installed from the browser menu, or from our dialog. Either way
175
+ * the offer is over and every trace of it goes. */
176
+ window.addEventListener("appinstalled", function() {
177
+ S.installed = true;
178
+ window[STASH] = null;
179
+ emit();
180
+ });
181
+ }
182
+
183
+ /***************************************************************
184
+ * Ask, once, when there is something to ask about.
185
+ *
186
+ * Chrome does not decide an origin is installable the
187
+ * instant the page loads, so this usually has nothing to
188
+ * work with yet; it then waits for the offer and asks when
189
+ * it lands.
190
+ *
191
+ * `opts.t` is the app's translator, `opts.message` the
192
+ * question. Answering either way — or dismissing — spends
193
+ * the one ask; the browser menu is still there, and the
194
+ * app can call yui_install_prompt() from a button of its
195
+ * own whenever it likes.
196
+ ***************************************************************/
197
+ function yui_install_ask_once(shell, opts)
198
+ {
199
+ opts = opts || {};
200
+
201
+ if(!shell) {
202
+ log_error("yui_install_ask_once(): no shell");
203
+ return;
204
+ }
205
+ if(kw_get_local_storage_value(ASKED_KEY, false, false)) {
206
+ return; /* once is once */
207
+ }
208
+
209
+ const ask = function() {
210
+ if(S.asking || !yui_install_can()) {
211
+ return;
212
+ }
213
+ S.asking = true;
214
+ kw_set_local_storage_value(ASKED_KEY, true);
215
+
216
+ yui_shell_confirm_yesno(shell, opts.message || "Install this app?", {
217
+ t: opts.t,
218
+ yes_label: opts.yes_label || "install",
219
+ no_label: opts.no_label || "not now"
220
+ }).then(function(yes) {
221
+ S.asking = false;
222
+ if(yes) {
223
+ /* No await before this: see yui_install_prompt(). */
224
+ yui_install_prompt();
225
+ }
226
+ });
227
+ };
228
+
229
+ if(yui_install_can()) {
230
+ ask();
231
+ return;
232
+ }
233
+
234
+ const unsubscribe = yui_install_subscribe(function() {
235
+ if(yui_install_can()) {
236
+ unsubscribe();
237
+ ask();
238
+ }
239
+ });
240
+ }
241
+
242
+ export {
243
+ yui_install_start_watch,
244
+ yui_install_subscribe,
245
+ yui_install_can,
246
+ yui_install_is_installed,
247
+ yui_install_prompt,
248
+ yui_install_ask_once,
249
+ };
@@ -0,0 +1,142 @@
1
+ /***********************************************************************
2
+ * yui_install.test.js
3
+ *
4
+ * The install offer, without a browser. What matters here is the
5
+ * GATING — never promise an install this browser cannot deliver,
6
+ * and never ask twice — because that is the part that decides
7
+ * whether a user is helped or nagged.
8
+ ***********************************************************************/
9
+ import { test, expect, beforeEach, afterEach, vi } from "vitest";
10
+
11
+ const STASH = "__yuneta_install__";
12
+
13
+ /* `S.installed` is per-SESSION state by design: once an install is
14
+ * accepted the module stops offering for the life of the page. So each
15
+ * test gets a fresh module, or the first acceptance would silence every
16
+ * test after it -- which is exactly what happened when they shared one. */
17
+ async function fresh()
18
+ {
19
+ vi.resetModules();
20
+ return await import("./yui_install.js");
21
+ }
22
+
23
+ function fake_event(outcome)
24
+ {
25
+ return {
26
+ prompted: false,
27
+ prompt() {
28
+ this.prompted = true;
29
+ },
30
+ userChoice: Promise.resolve({outcome: outcome})
31
+ };
32
+ }
33
+
34
+ let saved_window;
35
+
36
+ beforeEach(() => {
37
+ saved_window = globalThis.window;
38
+ /* A window faithful enough to import against: gobj-js reads
39
+ * window.console at module scope, so a bare {} breaks the import
40
+ * itself rather than the test. */
41
+ globalThis.window = {
42
+ console: console,
43
+ addEventListener: () => {},
44
+ removeEventListener: () => {},
45
+ localStorage: {
46
+ getItem: () => null,
47
+ setItem: () => {},
48
+ removeItem: () => {}
49
+ },
50
+ matchMedia: () => ({matches: false})
51
+ };
52
+ window[STASH] = null;
53
+ });
54
+
55
+ afterEach(() => {
56
+ /* Put the global back: this file swaps out window wholesale, and a
57
+ * leaked fake would poison whatever test file runs next. */
58
+ globalThis.window = saved_window;
59
+ });
60
+
61
+ test("can(): false when the browser never offered", async () => {
62
+ const {yui_install_can} = await fresh();
63
+ expect(yui_install_can()).toBe(false);
64
+ });
65
+
66
+ test("can(): true once the early script stashed an event", async () => {
67
+ const {yui_install_can} = await fresh();
68
+ window[STASH] = fake_event("accepted");
69
+ expect(yui_install_can()).toBe(true);
70
+ });
71
+
72
+ test("can(): false when already running installed", async () => {
73
+ const {yui_install_can} = await fresh();
74
+ window[STASH] = fake_event("accepted");
75
+ window.matchMedia = () => ({matches: true}); /* display-mode: standalone */
76
+ expect(yui_install_can()).toBe(false);
77
+ });
78
+
79
+ test("is_installed(): reads the display mode", async () => {
80
+ const {yui_install_is_installed} = await fresh();
81
+ window.matchMedia = () => ({matches: true});
82
+ expect(yui_install_is_installed()).toBe(true);
83
+ });
84
+
85
+ test("is_installed(): a matchMedia that throws is not installed", async () => {
86
+ const {yui_install_is_installed} = await fresh();
87
+ window.matchMedia = () => { throw new Error("nope"); };
88
+ expect(yui_install_is_installed()).toBe(false);
89
+ });
90
+
91
+ test("prompt(): nothing to prompt with resolves null", async () => {
92
+ const {yui_install_prompt} = await fresh();
93
+ await expect(yui_install_prompt()).resolves.toBe(null);
94
+ });
95
+
96
+ test("prompt(): calls prompt() and reports the outcome", async () => {
97
+ const {yui_install_prompt} = await fresh();
98
+ let ev = fake_event("accepted");
99
+ window[STASH] = ev;
100
+ await expect(yui_install_prompt()).resolves.toBe("accepted");
101
+ expect(ev.prompted).toBe(true);
102
+ });
103
+
104
+ test("prompt(): the event is SINGLE USE -- a second call has nothing", async () => {
105
+ const {yui_install_prompt} = await fresh();
106
+ window[STASH] = fake_event("dismissed");
107
+ await expect(yui_install_prompt()).resolves.toBe("dismissed");
108
+ await expect(yui_install_prompt()).resolves.toBe(null);
109
+ });
110
+
111
+ test("prompt(): a dismissal does NOT count as installed", async () => {
112
+ const {yui_install_prompt, yui_install_can} = await fresh();
113
+ window[STASH] = fake_event("dismissed");
114
+ await yui_install_prompt();
115
+ window[STASH] = fake_event("accepted");
116
+ expect(yui_install_can()).toBe(true); /* still offerable */
117
+ });
118
+
119
+ test("subscribe(): listeners hear the outcome, and unsubscribe works", async () => {
120
+ const {yui_install_prompt, yui_install_subscribe} = await fresh();
121
+ let beats = 0;
122
+ let off = yui_install_subscribe(() => { beats++; });
123
+ window[STASH] = fake_event("accepted");
124
+ await yui_install_prompt();
125
+ expect(beats).toBe(1);
126
+ off();
127
+ window[STASH] = fake_event("accepted");
128
+ await yui_install_prompt();
129
+ expect(beats).toBe(1);
130
+ });
131
+
132
+ test("subscribe(): a throwing listener does not break the others", async () => {
133
+ const {yui_install_prompt, yui_install_subscribe} = await fresh();
134
+ let ok = 0;
135
+ let off1 = yui_install_subscribe(() => { throw new Error("boom"); });
136
+ let off2 = yui_install_subscribe(() => { ok++; });
137
+ window[STASH] = fake_event("accepted");
138
+ await yui_install_prompt();
139
+ expect(ok).toBe(1);
140
+ off1();
141
+ off2();
142
+ });