@makershq/react 0.0.0-stage → 0.3.1

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.
Files changed (66) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +56 -2
  3. package/THIRD_PARTY_NOTICES.md +77 -0
  4. package/dist/api.d.ts +82 -0
  5. package/dist/api.js +183 -0
  6. package/dist/cache.d.ts +22 -0
  7. package/dist/cache.js +86 -0
  8. package/dist/components/icons/external-link/index.d.ts +52 -0
  9. package/dist/components/icons/external-link/index.js +60 -0
  10. package/dist/components/icons/index.d.ts +31 -0
  11. package/dist/components/icons/index.js +56 -0
  12. package/dist/components/icons/reduced-motion.d.ts +4 -0
  13. package/dist/components/icons/reduced-motion.js +24 -0
  14. package/dist/components/icons/x/index.d.ts +98 -0
  15. package/dist/components/icons/x/index.js +87 -0
  16. package/dist/components/release-markdown/index.d.ts +22 -0
  17. package/dist/components/release-markdown/index.js +101 -0
  18. package/dist/components/release-notes/index.d.ts +51 -0
  19. package/dist/components/release-notes/index.js +89 -0
  20. package/dist/connectors/use-release-note-list-connector.d.ts +24 -0
  21. package/dist/connectors/use-release-note-list-connector.js +219 -0
  22. package/dist/index.d.ts +20 -0
  23. package/dist/index.js +13 -0
  24. package/dist/styles.css +2 -0
  25. package/dist/styles.d.ts +8 -0
  26. package/dist/tauri/components/native-child/index.d.ts +19 -0
  27. package/dist/tauri/components/native-child/index.js +146 -0
  28. package/dist/tauri/components/native-child/theme.d.ts +12 -0
  29. package/dist/tauri/components/native-child/theme.js +45 -0
  30. package/dist/tauri/components/release-notes-host/index.d.ts +36 -0
  31. package/dist/tauri/components/release-notes-host/index.js +81 -0
  32. package/dist/tauri/components/release-notes-settings/index.d.ts +10 -0
  33. package/dist/tauri/components/release-notes-settings/index.js +23 -0
  34. package/dist/tauri/components/update-window/index.d.ts +20 -0
  35. package/dist/tauri/components/update-window/index.js +68 -0
  36. package/dist/tauri/context.d.ts +20 -0
  37. package/dist/tauri/context.js +22 -0
  38. package/dist/tauri/contracts.d.ts +84 -0
  39. package/dist/tauri/contracts.js +7 -0
  40. package/dist/tauri/hooks/use-release-notes.d.ts +92 -0
  41. package/dist/tauri/hooks/use-release-notes.js +101 -0
  42. package/dist/tauri/host-controller.d.ts +134 -0
  43. package/dist/tauri/host-controller.js +951 -0
  44. package/dist/tauri/index.d.ts +10 -0
  45. package/dist/tauri/index.js +9 -0
  46. package/dist/tauri/peer-protocol.d.ts +36 -0
  47. package/dist/tauri/peer-protocol.js +43 -0
  48. package/dist/tauri/preferences.d.ts +18 -0
  49. package/dist/tauri/preferences.js +39 -0
  50. package/dist/tauri/protocol.d.ts +71 -0
  51. package/dist/tauri/protocol.js +106 -0
  52. package/dist/tauri/remote-settings-client.d.ts +72 -0
  53. package/dist/tauri/remote-settings-client.js +265 -0
  54. package/dist/website/components/update-toast/index.d.ts +14 -0
  55. package/dist/website/components/update-toast/index.js +22 -0
  56. package/dist/website/components/website-updates/index.d.ts +24 -0
  57. package/dist/website/components/website-updates/index.js +35 -0
  58. package/dist/website/components/whats-new-panel/index.d.ts +23 -0
  59. package/dist/website/components/whats-new-panel/index.js +38 -0
  60. package/dist/website/preferences.d.ts +32 -0
  61. package/dist/website/preferences.js +85 -0
  62. package/dist/website/use-website-updates-connector.d.ts +34 -0
  63. package/dist/website/use-website-updates-connector.js +318 -0
  64. package/dist/website/version.d.ts +27 -0
  65. package/dist/website/version.js +73 -0
  66. package/package.json +71 -3
@@ -0,0 +1,951 @@
1
+ /** Coordinates native child windows while the main app retains its updater resources.
2
+ *
3
+ * Owns preferences, narrowly authenticated events and exact installed-version presentation.
4
+ *
5
+ * Keep callback execution in this host; child windows receive serializable state only.
6
+ */
7
+ import { getName, getVersion } from '@tauri-apps/api/app';
8
+ import { emitTo } from '@tauri-apps/api/event';
9
+ import { getCurrentWebviewWindow, WebviewWindow, } from '@tauri-apps/api/webviewWindow';
10
+ import { load } from '@tauri-apps/plugin-store';
11
+ import { valid } from 'semver';
12
+ import { fetchReleasePage } from '../api.js';
13
+ import { isInstalledUpgrade, readPreferences } from './preferences.js';
14
+ import { ACTION_EVENT, CHILD_PREFIX, CHILD_QUERY, isActionEnvelope, STATE_EVENT, } from './protocol.js';
15
+ import { PEER_CONNECT_EVENT, PEER_ACTION_EVENT, PEER_RESULT_EVENT, isPeerLabel, isPeerAction, } from './peer-protocol.js';
16
+ const hostLifecycles = new Map();
17
+ const NATIVE_THEME_ERROR = 'Native window appearance could not be updated. Check the main window theme capability.';
18
+ /** Host-only state owner for one project and native main window. */
19
+ export class NativeHostController {
20
+ projectId;
21
+ updater;
22
+ hostWindowLabel;
23
+ presentation;
24
+ state;
25
+ snapshotRevision = 0;
26
+ presentationGeneration = 0;
27
+ latestUpdateGeneration = 0;
28
+ automaticAttempt = 0;
29
+ listeners = new Set();
30
+ children = new Map();
31
+ peers = new Map();
32
+ openings = new Map();
33
+ store = null;
34
+ disposers = [];
35
+ disposed = false;
36
+ automaticVersion = null;
37
+ automaticPending = false;
38
+ persistence = Promise.resolve();
39
+ operation = 0;
40
+ installation = null;
41
+ ready;
42
+ initializationError = null;
43
+ releaseLease;
44
+ disposal = null;
45
+ hostWindow = null;
46
+ nativeThemeUpdates = null;
47
+ nativeThemeRequest = 0;
48
+ /** Initializes the single host and its app-local preference store.
49
+ * @param projectId - Public release-note project.
50
+ * @param icon - Consumer-owned app icon URL.
51
+ * @param updater - Consumer-owned updater callbacks.
52
+ * @param theme - App's selected theme.
53
+ * @param hostWindowLabel - Label of the sole callback-owning native window.
54
+ * @param presentation - Fixed presentation policy for this lifecycle.
55
+ */
56
+ constructor(projectId, icon, updater, theme, hostWindowLabel, presentation = 'native') {
57
+ this.projectId = projectId;
58
+ this.updater = updater;
59
+ this.hostWindowLabel = hostWindowLabel;
60
+ this.presentation = presentation;
61
+ this.state = {
62
+ ready: false,
63
+ presentation,
64
+ readerRequest: null,
65
+ updateRequest: null,
66
+ projectId,
67
+ name: '',
68
+ currentVersion: '',
69
+ icon,
70
+ theme,
71
+ preferences: readPreferences(null),
72
+ status: 'idle',
73
+ candidate: null,
74
+ progress: null,
75
+ canCancel: Boolean(updater.cancel),
76
+ error: null,
77
+ readerVersion: null,
78
+ };
79
+ const previous = hostLifecycles.get(hostWindowLabel) ?? Promise.resolve();
80
+ let releaseLease;
81
+ const lease = new Promise((resolve) => {
82
+ releaseLease = resolve;
83
+ });
84
+ this.releaseLease = () => {
85
+ releaseLease();
86
+ if (hostLifecycles.get(hostWindowLabel) === lease)
87
+ hostLifecycles.delete(hostWindowLabel);
88
+ };
89
+ hostLifecycles.set(hostWindowLabel, lease);
90
+ // Official Store.load caches one resource per path. A replacement host must wait
91
+ // until its predecessor finishes initialization and closes that shared resource.
92
+ this.ready = previous.then(() => this.disposed ? undefined : this.initialize());
93
+ }
94
+ /** Subscribes to host state.
95
+ * @param listener - React external-store listener.
96
+ * @returns Unsubscribe callback.
97
+ */
98
+ subscribe = (listener) => {
99
+ this.listeners.add(listener);
100
+ return () => this.listeners.delete(listener);
101
+ };
102
+ /** Returns the stable current state snapshot.
103
+ * @returns Serializable native presentation state.
104
+ */
105
+ getSnapshot = () => this.state;
106
+ async initialize() {
107
+ try {
108
+ const current = getCurrentWebviewWindow();
109
+ if (current.label !== this.hostWindowLabel)
110
+ throw new Error('ReleaseNotesHost must run in its configured host window.');
111
+ if (this.presentation === 'native')
112
+ this.hostWindow = current;
113
+ const [name, currentVersion] = await Promise.all([
114
+ getName(),
115
+ getVersion(),
116
+ ]);
117
+ if (this.disposed)
118
+ return;
119
+ const store = await load('makers-hq-release-notes.json', {
120
+ autoSave: false,
121
+ defaults: {},
122
+ });
123
+ if (this.disposed) {
124
+ await store.close();
125
+ return;
126
+ }
127
+ this.store = store;
128
+ const preferences = readPreferences(await store.get(this.projectId));
129
+ if (this.disposed)
130
+ return;
131
+ this.patch({ name, currentVersion, preferences });
132
+ void this.synchronizeNativeTheme();
133
+ const unlisten = await current.listen(ACTION_EVENT, ({ payload }) => {
134
+ for (const [kind, record] of this.children) {
135
+ if (isActionEnvelope(payload, CHILD_PREFIX + kind, record.token)) {
136
+ void this.dispatch(kind, payload.action, payload.generation).catch(() => this.fail('The action could not be completed. Please try again.'));
137
+ break;
138
+ }
139
+ }
140
+ });
141
+ if (this.disposed) {
142
+ unlisten();
143
+ return;
144
+ }
145
+ this.disposers.push(unlisten);
146
+ const connectCleanup = await current.listen(PEER_CONNECT_EVENT, ({ payload }) => {
147
+ void this.connectPeer(payload).catch(() => undefined);
148
+ });
149
+ if (this.disposed) {
150
+ connectCleanup();
151
+ return;
152
+ }
153
+ this.disposers.push(connectCleanup);
154
+ const actionCleanup = await current.listen(PEER_ACTION_EVENT, ({ payload }) => {
155
+ void this.dispatchPeer(payload).catch(() => undefined);
156
+ });
157
+ if (this.disposed) {
158
+ actionCleanup();
159
+ return;
160
+ }
161
+ this.disposers.push(actionCleanup);
162
+ const focusCleanup = await current.onFocusChanged(({ payload }) => {
163
+ if (payload)
164
+ void this.retryAutomatic();
165
+ });
166
+ if (this.disposed) {
167
+ focusCleanup();
168
+ return;
169
+ }
170
+ this.disposers.push(focusCleanup);
171
+ const online = () => void this.retryAutomatic();
172
+ window.addEventListener('online', online);
173
+ this.disposers.push(() => window.removeEventListener('online', online));
174
+ if (!preferences.acknowledgedVersion ||
175
+ (!preferences.showAfterUpdate &&
176
+ isInstalledUpgrade(currentVersion, preferences.acknowledgedVersion))) {
177
+ await this.savePreferences({ acknowledgedVersion: currentVersion });
178
+ }
179
+ this.automaticPending =
180
+ preferences.showAfterUpdate &&
181
+ isInstalledUpgrade(currentVersion, preferences.acknowledgedVersion);
182
+ this.patch({ ready: true });
183
+ // Do not await the public API during host initialization or block manual actions.
184
+ void this.retryAutomatic();
185
+ }
186
+ catch {
187
+ if (!this.disposed) {
188
+ this.initializationError = new Error('Release notes could not start. Check the Tauri app, event, window and store capabilities.');
189
+ this.fail(this.initializationError.message);
190
+ }
191
+ }
192
+ }
193
+ patch(patch) {
194
+ if (this.disposed)
195
+ return;
196
+ this.state = { ...this.state, ...patch };
197
+ this.snapshotRevision++;
198
+ for (const listener of this.listeners)
199
+ listener();
200
+ for (const [label, record] of this.peers)
201
+ void this.sendPeerState(label, record).catch(() => undefined);
202
+ for (const [kind, record] of this.children)
203
+ void this.sendState(kind, record).catch(() => undefined);
204
+ }
205
+ fail(message) {
206
+ this.patch({ status: 'error', error: message, progress: null });
207
+ }
208
+ async sendState(kind, record) {
209
+ await emitTo({ kind: 'WebviewWindow', label: CHILD_PREFIX + kind }, STATE_EVENT, {
210
+ token: record.token,
211
+ sender: this.hostWindowLabel,
212
+ revision: this.snapshotRevision,
213
+ state: this.state,
214
+ });
215
+ }
216
+ async sendPeerState(label, peer) {
217
+ await emitTo({ kind: 'WebviewWindow', label }, STATE_EVENT, {
218
+ sender: this.hostWindowLabel,
219
+ token: peer.token,
220
+ connectionNonce: peer.nonce,
221
+ revision: this.snapshotRevision,
222
+ state: this.state,
223
+ });
224
+ }
225
+ async connectPeer(value) {
226
+ if (this.disposed || !value || typeof value !== 'object')
227
+ return;
228
+ const message = value;
229
+ if (!isPeerLabel(message.sender, this.hostWindowLabel) ||
230
+ typeof message.nonce !== 'string' ||
231
+ !/^[a-f0-9-]{36}$/.test(message.nonce))
232
+ return;
233
+ // Tauri capabilities remain local-only. The nonce response is targeted to the
234
+ // existing native label, never to a caller-provided URL or a claimed origin.
235
+ const window = await WebviewWindow.getByLabel(message.sender);
236
+ if (!window || this.disposed)
237
+ return;
238
+ const existing = this.peers.get(message.sender);
239
+ if (existing?.nonce === message.nonce) {
240
+ await this.sendPeerState(message.sender, existing);
241
+ return;
242
+ }
243
+ existing?.cleanup?.();
244
+ const record = {
245
+ token: crypto.randomUUID(),
246
+ nonce: message.nonce,
247
+ };
248
+ this.peers.set(message.sender, record);
249
+ const cleanup = await window.once('tauri://destroyed', () => {
250
+ if (this.peers.get(message.sender) === record)
251
+ this.peers.delete(message.sender);
252
+ });
253
+ if (this.disposed || this.peers.get(message.sender) !== record) {
254
+ cleanup();
255
+ return;
256
+ }
257
+ record.cleanup = cleanup;
258
+ await this.sendPeerState(message.sender, record);
259
+ }
260
+ async dispatchPeer(value) {
261
+ if (this.disposed || !value || typeof value !== 'object')
262
+ return;
263
+ const message = value;
264
+ if (!isPeerLabel(message.sender, this.hostWindowLabel) ||
265
+ typeof message.request !== 'string' ||
266
+ !/^[a-f0-9-]{36}$/.test(message.request) ||
267
+ !isPeerAction(message.action))
268
+ return;
269
+ const peer = this.peers.get(message.sender);
270
+ if (!peer || peer.token !== message.token)
271
+ return;
272
+ const action = message.action;
273
+ if (action.type === 'disconnect') {
274
+ peer.cleanup?.();
275
+ this.peers.delete(message.sender);
276
+ return;
277
+ }
278
+ let ok = true;
279
+ try {
280
+ if (action.type === 'open')
281
+ await this.openFrom(message.sender);
282
+ else if (action.type === 'check')
283
+ await this.check(message.sender);
284
+ else if (action.type === 'preference')
285
+ await this.setShowAfterUpdate(action.value);
286
+ else if (action.type === 'presentation') {
287
+ if (this.presentation !== 'headless')
288
+ throw new Error('Headless presentation is disabled.');
289
+ await this.presentationAction(action.kind, action.generation, action.action);
290
+ }
291
+ }
292
+ catch {
293
+ ok = false;
294
+ }
295
+ if (!this.disposed && this.peers.get(message.sender) === peer)
296
+ await emitTo({ kind: 'WebviewWindow', label: message.sender }, PEER_RESULT_EVENT, {
297
+ sender: this.hostWindowLabel,
298
+ token: peer.token,
299
+ request: message.request,
300
+ ok,
301
+ });
302
+ }
303
+ async savePreferences(preferences) {
304
+ // Serialize writes so rapid changes cannot persist stale snapshots last.
305
+ const store = this.store;
306
+ if (!store)
307
+ throw new Error('Release-note preference store is unavailable.');
308
+ const save = this.persistence
309
+ .catch(() => undefined)
310
+ .then(async () => {
311
+ const next = { ...this.state.preferences, ...preferences };
312
+ await store.set(this.projectId, next);
313
+ await store.save();
314
+ this.patch({ preferences: next });
315
+ });
316
+ this.persistence = save;
317
+ await save;
318
+ }
319
+ async ensureReady() {
320
+ await this.ready;
321
+ if (this.initializationError)
322
+ throw this.initializationError;
323
+ if (this.disposed)
324
+ throw new Error('Release notes host was closed.');
325
+ }
326
+ /** Updates consumer callbacks and appearance without recreating native resources.
327
+ * @param updater - Latest app-owned callbacks.
328
+ * @param icon - Current app icon URL.
329
+ * @param theme - Current app theme.
330
+ */
331
+ configure(updater, icon, theme) {
332
+ this.updater = updater;
333
+ const changed = theme !== this.state.theme;
334
+ this.patch({
335
+ icon,
336
+ theme,
337
+ canCancel: Boolean(updater.cancel) &&
338
+ (this.state.status !== 'progress' || this.state.canCancel),
339
+ });
340
+ if (changed)
341
+ void this.synchronizeNativeTheme();
342
+ }
343
+ /** Synchronizes consumer theme changes to existing child windows.
344
+ * @param theme - System, light or dark consumer preference.
345
+ */
346
+ setTheme(theme) {
347
+ const changed = theme !== this.state.theme;
348
+ this.patch({ theme });
349
+ if (changed)
350
+ void this.synchronizeNativeTheme();
351
+ }
352
+ // Main is the only native appearance writer. macOS/Linux apply this app-wide;
353
+ // Windows also needs each SDK window updated. Coalesce changes across IPC waits.
354
+ synchronizeNativeTheme() {
355
+ // Headless consumers own every native appearance decision and need no capability.
356
+ if (this.presentation !== 'native')
357
+ return Promise.resolve();
358
+ this.nativeThemeRequest++;
359
+ if (this.nativeThemeUpdates)
360
+ return this.nativeThemeUpdates;
361
+ this.nativeThemeUpdates = Promise.resolve().then(async () => {
362
+ let handled = 0;
363
+ try {
364
+ while (!this.disposed &&
365
+ this.hostWindow &&
366
+ handled !== this.nativeThemeRequest) {
367
+ handled = this.nativeThemeRequest;
368
+ const theme = this.state.theme === 'system' ? null : this.state.theme;
369
+ const targets = [
370
+ { window: this.hostWindow, current: () => true },
371
+ ...[...this.children]
372
+ .filter(([, record]) => record.created)
373
+ .map(([kind, record]) => ({
374
+ window: record.window,
375
+ current: () => this.children.get(kind) === record,
376
+ })),
377
+ ];
378
+ let failed = false;
379
+ for (const target of targets) {
380
+ if (this.disposed)
381
+ return;
382
+ if (!target.current())
383
+ continue;
384
+ try {
385
+ await target.window.setTheme(theme);
386
+ }
387
+ catch {
388
+ if (target.current())
389
+ failed = true;
390
+ }
391
+ }
392
+ if (handled !== this.nativeThemeRequest)
393
+ continue;
394
+ // Appearance failure must not interrupt an active update or hide its error.
395
+ if (failed && !this.state.error)
396
+ this.patch({ error: NATIVE_THEME_ERROR });
397
+ else if (!failed && this.state.error === NATIVE_THEME_ERROR)
398
+ this.patch({ error: null });
399
+ }
400
+ }
401
+ finally {
402
+ this.nativeThemeUpdates = null;
403
+ }
404
+ });
405
+ return this.nativeThemeUpdates;
406
+ }
407
+ /** Opens or focuses the single changelog window.
408
+ * @returns Completion after the native window is ready.
409
+ */
410
+ open = () => this.openFrom(this.hostWindowLabel);
411
+ async openFrom(originLabel) {
412
+ await this.ensureReady();
413
+ this.automaticVersion = null;
414
+ this.automaticAttempt++;
415
+ this.requestPresentation('reader', originLabel, { readerVersion: null });
416
+ try {
417
+ if (this.presentation === 'native')
418
+ await this.openWindow('reader', originLabel);
419
+ }
420
+ catch (error) {
421
+ this.patch({
422
+ error: 'The release notes window could not open. Check Tauri window creation and event capabilities.',
423
+ });
424
+ throw error;
425
+ }
426
+ }
427
+ /** Offers a background updater candidate while respecting the skipped version.
428
+ * @param candidate - Version supplied by the consumer's existing background checker.
429
+ * @param candidate.version - Exact candidate version.
430
+ * @param candidate.notes - Optional plain-text updater notes.
431
+ * @returns Completion after a matching update window is open, or a quiet skip.
432
+ */
433
+ notifyUpdateAvailable = async (candidate) => {
434
+ await this.ensureReady();
435
+ this.validateCandidate(candidate);
436
+ if (candidate.version === this.state.preferences.skippedVersion ||
437
+ this.state.status === 'progress')
438
+ return;
439
+ this.requestPresentation('update', this.hostWindowLabel, {
440
+ candidate: {
441
+ version: candidate.version,
442
+ ...(candidate.notes ? { notes: candidate.notes } : {}),
443
+ },
444
+ status: 'available',
445
+ error: null,
446
+ progress: null,
447
+ });
448
+ try {
449
+ if (this.presentation === 'native')
450
+ await this.openWindow('update');
451
+ }
452
+ catch (error) {
453
+ this.patch({
454
+ error: 'The update window could not open. Check Tauri window creation and event capabilities.',
455
+ });
456
+ throw error;
457
+ }
458
+ };
459
+ validateCandidate(candidate) {
460
+ if (!candidate ||
461
+ !valid(candidate.version) ||
462
+ (candidate.notes !== undefined &&
463
+ (typeof candidate.notes !== 'string' ||
464
+ candidate.notes.length > 1_000_000)))
465
+ throw new Error('The app updater returned an invalid update candidate.');
466
+ }
467
+ /** Runs an explicit user check, bypassing skipped-version suppression.
468
+ * @param originLabel - Existing settings window to receive focus after closing the update.
469
+ * @returns Completion after the updater result is shown.
470
+ */
471
+ check = async (originLabel = this.hostWindowLabel) => {
472
+ await this.ensureReady();
473
+ if (this.state.status === 'checking' || this.state.status === 'progress')
474
+ return;
475
+ const existingUpdate = this.children.get('update');
476
+ if (existingUpdate)
477
+ existingUpdate.originLabel = originLabel;
478
+ const request = this.requestPresentation('update', originLabel, {
479
+ status: 'checking',
480
+ error: null,
481
+ candidate: null,
482
+ });
483
+ try {
484
+ const candidate = await this.updater.check();
485
+ if (this.disposed || this.latestUpdateGeneration !== request.generation)
486
+ return;
487
+ if (candidate)
488
+ this.validateCandidate(candidate);
489
+ this.patch({
490
+ candidate,
491
+ status: candidate ? 'available' : 'current',
492
+ progress: null,
493
+ });
494
+ if (this.presentation === 'native' &&
495
+ this.state.updateRequest === request)
496
+ await this.openWindow('update', originLabel);
497
+ }
498
+ catch {
499
+ if (this.latestUpdateGeneration !== request.generation)
500
+ return;
501
+ this.fail('Could not check for updates. Check your connection and try again.');
502
+ }
503
+ };
504
+ /** Saves the shared after-update preference.
505
+ * @param value - Whether future upgrades should open a changelog.
506
+ * @returns Completion after persistence and child synchronization.
507
+ */
508
+ setShowAfterUpdate = async (value) => {
509
+ await this.ensureReady();
510
+ try {
511
+ await this.savePreferences({ showAfterUpdate: value });
512
+ }
513
+ catch (error) {
514
+ this.patch({
515
+ error: 'Your release-note preference could not be saved. Please try again.',
516
+ });
517
+ throw error;
518
+ }
519
+ };
520
+ async retryAutomatic() {
521
+ if (!this.automaticPending ||
522
+ this.disposed ||
523
+ this.automaticVersion ||
524
+ this.state.readerRequest)
525
+ return;
526
+ this.automaticVersion = this.state.currentVersion;
527
+ const attempt = ++this.automaticAttempt;
528
+ let request = null;
529
+ try {
530
+ const page = await fetchReleasePage(this.projectId, {
531
+ version: this.state.currentVersion,
532
+ });
533
+ if (this.disposed ||
534
+ attempt !== this.automaticAttempt ||
535
+ !this.state.preferences.showAfterUpdate ||
536
+ !page.items.some((release) => release.version === this.state.currentVersion)) {
537
+ if (attempt === this.automaticAttempt)
538
+ this.automaticVersion = null;
539
+ return;
540
+ }
541
+ request = this.requestPresentation('reader', this.hostWindowLabel, {
542
+ readerVersion: this.state.currentVersion,
543
+ });
544
+ if (this.presentation === 'native')
545
+ await this.openWindow('reader');
546
+ }
547
+ catch {
548
+ // A failed child must not leave an invisible request blocking future retries.
549
+ if (request)
550
+ this.dismissPresentation('reader', request.generation);
551
+ // Missing publication or connectivity defers the exact-version acknowledgement.
552
+ if (attempt === this.automaticAttempt)
553
+ this.automaticVersion = null;
554
+ }
555
+ }
556
+ requestPresentation(kind, originWindowLabel, patch = {}) {
557
+ const request = {
558
+ generation: ++this.presentationGeneration,
559
+ originWindowLabel,
560
+ };
561
+ if (kind === 'update')
562
+ this.latestUpdateGeneration = request.generation;
563
+ this.patch({
564
+ ...patch,
565
+ ...(kind === 'reader'
566
+ ? { readerRequest: request }
567
+ : { updateRequest: request }),
568
+ });
569
+ return request;
570
+ }
571
+ settleUpdatePresentation(originWindowLabel, patch) {
572
+ if (this.state.updateRequest)
573
+ this.requestPresentation('update', originWindowLabel, patch);
574
+ else
575
+ this.patch(patch);
576
+ }
577
+ dismissPresentation(kind, generation) {
578
+ const request = kind === 'reader' ? this.state.readerRequest : this.state.updateRequest;
579
+ if (request?.generation !== generation)
580
+ return;
581
+ if (kind === 'reader') {
582
+ this.automaticAttempt++;
583
+ if (this.automaticPending)
584
+ this.automaticVersion = null;
585
+ this.patch({ readerRequest: null });
586
+ }
587
+ else
588
+ this.patch({ updateRequest: null });
589
+ }
590
+ /** Accepts one action bound to the active headless presentation.
591
+ * @param kind - Reader or update presentation.
592
+ * @param generation - Private presentation identity captured by the hook.
593
+ * @param action - Bounded presentation command.
594
+ * @param action.type - Fixed command discriminator.
595
+ * @param action.version - Exact displayed candidate or rendered release version.
596
+ * @param action.cached - Whether rendered content came from offline storage.
597
+ * @returns Command acceptance; installation and cancellation progress through shared state.
598
+ */
599
+ presentationAction = async (kind, generation, action) => {
600
+ await this.ensureReady();
601
+ if (this.presentation !== 'headless')
602
+ throw new Error('Use headless presentation actions only in headless mode.');
603
+ const request = kind === 'reader' ? this.state.readerRequest : this.state.updateRequest;
604
+ if (request?.generation !== generation)
605
+ return;
606
+ if (action.type === 'dismiss') {
607
+ this.dismissPresentation(kind, generation);
608
+ return;
609
+ }
610
+ if (kind === 'reader') {
611
+ if (action.type === 'rendered' && !action.cached) {
612
+ try {
613
+ await this.dispatch(kind, { type: 'rendered', version: action.version }, generation);
614
+ }
615
+ catch {
616
+ this.patch({
617
+ error: 'The release-note acknowledgment could not be saved. Please try again.',
618
+ });
619
+ }
620
+ }
621
+ return;
622
+ }
623
+ if (action.type === 'rendered')
624
+ return;
625
+ const candidate = this.state.candidate;
626
+ if ((action.type === 'install' || action.type === 'skip') && !candidate)
627
+ return;
628
+ const command = action.type === 'install' || action.type === 'skip'
629
+ ? { type: action.type, version: action.version }
630
+ : { type: 'cancel' };
631
+ if (action.type === 'install' || action.type === 'cancel') {
632
+ void this.dispatch(kind, command, generation).catch(() => this.fail('The action could not be completed. Please try again.'));
633
+ }
634
+ else {
635
+ try {
636
+ await this.dispatch(kind, command, generation);
637
+ }
638
+ catch (error) {
639
+ this.patch({
640
+ error: 'The update preference could not be saved. Please try again.',
641
+ });
642
+ throw error;
643
+ }
644
+ }
645
+ };
646
+ async openWindow(kind, originLabel = this.hostWindowLabel) {
647
+ if (this.disposed)
648
+ throw new Error('Release notes host was closed.');
649
+ const opening = this.openings.get(kind);
650
+ if (opening) {
651
+ await opening;
652
+ const record = this.children.get(kind);
653
+ if (record) {
654
+ record.originLabel = originLabel;
655
+ await record.window.setFocus();
656
+ }
657
+ return;
658
+ }
659
+ const task = this.createOrFocus(kind, originLabel);
660
+ this.openings.set(kind, task);
661
+ try {
662
+ await task;
663
+ }
664
+ finally {
665
+ this.openings.delete(kind);
666
+ }
667
+ }
668
+ async createOrFocus(kind, originLabel) {
669
+ const label = CHILD_PREFIX + kind;
670
+ const existing = await WebviewWindow.getByLabel(label);
671
+ if (this.disposed)
672
+ return;
673
+ if (existing && this.children.has(kind)) {
674
+ const record = this.children.get(kind);
675
+ record.originLabel = originLabel;
676
+ await this.synchronizeNativeTheme();
677
+ if (this.disposed || this.children.get(kind) !== record)
678
+ return;
679
+ await existing.show();
680
+ await existing.setFocus();
681
+ await this.sendState(kind, record);
682
+ return;
683
+ }
684
+ if (existing)
685
+ await existing.destroy();
686
+ const url = new URL(window.location.href);
687
+ // A child uses the host's exact entry point, with no remote URL or app navigation.
688
+ url.searchParams.set(CHILD_QUERY, kind);
689
+ url.searchParams.set('__makers_hq_host', this.hostWindowLabel);
690
+ const token = crypto.randomUUID();
691
+ url.searchParams.set('__makers_hq_token', token);
692
+ const child = new WebviewWindow(label, {
693
+ url: url.href,
694
+ title: kind === 'reader' ? `New in ${this.state.name}` : 'Software update',
695
+ width: 680,
696
+ height: 620,
697
+ minWidth: 360,
698
+ minHeight: 420,
699
+ decorations: true,
700
+ // On macOS, keep native controls without Tauri's default full-size content
701
+ // view, which can leave standard title bars light despite native Dark theme.
702
+ titleBarStyle: 'transparent',
703
+ ...(this.state.theme === 'system' ? {} : { theme: this.state.theme }),
704
+ visible: false,
705
+ center: true,
706
+ });
707
+ const record = {
708
+ window: child,
709
+ token,
710
+ originLabel,
711
+ created: false,
712
+ };
713
+ this.children.set(kind, record);
714
+ try {
715
+ await new Promise((resolve, reject) => {
716
+ let createdCleanup;
717
+ let errorCleanup;
718
+ const timer = setTimeout(() => {
719
+ createdCleanup?.();
720
+ errorCleanup?.();
721
+ reject(new Error('Native release notes window timed out. Check window creation capabilities.'));
722
+ }, 15000);
723
+ void child
724
+ .once('tauri://created', () => {
725
+ clearTimeout(timer);
726
+ errorCleanup?.();
727
+ resolve();
728
+ })
729
+ .then((cleanup) => {
730
+ createdCleanup = cleanup;
731
+ });
732
+ void child
733
+ .once('tauri://error', () => {
734
+ clearTimeout(timer);
735
+ createdCleanup?.();
736
+ reject(new Error('Native release notes window creation failed. Check capabilities.'));
737
+ })
738
+ .then((cleanup) => {
739
+ errorCleanup = cleanup;
740
+ });
741
+ });
742
+ if (this.disposed) {
743
+ await child.destroy();
744
+ return;
745
+ }
746
+ record.created = true;
747
+ await this.synchronizeNativeTheme();
748
+ if (this.disposed) {
749
+ await child.destroy();
750
+ return;
751
+ }
752
+ record.cleanup = await child.once('tauri://destroyed', () => {
753
+ if (this.children.get(kind) !== record)
754
+ return;
755
+ this.children.delete(kind);
756
+ const request = kind === 'reader'
757
+ ? this.state.readerRequest
758
+ : this.state.updateRequest;
759
+ if (request)
760
+ this.dismissPresentation(kind, request.generation);
761
+ void WebviewWindow.getByLabel(record.originLabel)
762
+ .then((origin) => origin?.setFocus())
763
+ .catch(() => undefined);
764
+ });
765
+ await child.show();
766
+ await child.setFocus();
767
+ }
768
+ catch (error) {
769
+ this.children.delete(kind);
770
+ await child.destroy().catch(() => undefined);
771
+ throw error;
772
+ }
773
+ }
774
+ async dispatch(kind, action, generation) {
775
+ if (action.type === 'ready') {
776
+ const record = this.children.get(kind);
777
+ if (record)
778
+ await this.sendState(kind, record);
779
+ return;
780
+ }
781
+ if (action.type === 'preference') {
782
+ await this.setShowAfterUpdate(action.value);
783
+ return;
784
+ }
785
+ if (action.type === 'open-reader') {
786
+ await this.openFrom(this.children.get(kind)?.originLabel ?? this.hostWindowLabel);
787
+ return;
788
+ }
789
+ if (action.type === 'check') {
790
+ await this.check(this.children.get(kind)?.originLabel ?? this.hostWindowLabel);
791
+ return;
792
+ }
793
+ const request = kind === 'reader' ? this.state.readerRequest : this.state.updateRequest;
794
+ if (!request || request.generation !== generation)
795
+ return;
796
+ if (action.type === 'rendered' && kind === 'reader') {
797
+ if (action.version === this.automaticVersion &&
798
+ action.version === this.state.currentVersion &&
799
+ this.automaticPending) {
800
+ await this.savePreferences({ acknowledgedVersion: action.version });
801
+ this.automaticPending = false;
802
+ this.automaticVersion = null;
803
+ }
804
+ return;
805
+ }
806
+ if (kind !== 'update')
807
+ return;
808
+ if (action.type === 'cancel' &&
809
+ this.state.status === 'progress' &&
810
+ this.state.progress?.phase === 'downloading' &&
811
+ this.updater.cancel &&
812
+ this.state.canCancel) {
813
+ const installation = this.installation;
814
+ this.requestPresentation('update', request.originWindowLabel, {
815
+ canCancel: false,
816
+ });
817
+ const operation = ++this.operation;
818
+ try {
819
+ await this.updater.cancel();
820
+ if (operation === this.operation) {
821
+ this.installation = null;
822
+ this.settleUpdatePresentation(request.originWindowLabel, {
823
+ status: 'available',
824
+ progress: null,
825
+ canCancel: Boolean(this.updater.cancel),
826
+ });
827
+ }
828
+ }
829
+ catch {
830
+ if (operation === this.operation) {
831
+ if (installation?.settled === 'failed')
832
+ this.settleUpdatePresentation(request.originWindowLabel, {
833
+ status: 'available',
834
+ progress: null,
835
+ canCancel: Boolean(this.updater.cancel),
836
+ error: 'The update stopped, but cancellation could not be confirmed. Please try again.',
837
+ });
838
+ else {
839
+ if (installation)
840
+ this.operation = installation.id;
841
+ this.patch({
842
+ error: 'The download could not be cancelled. The app may still be installing.',
843
+ canCancel: false,
844
+ });
845
+ }
846
+ }
847
+ }
848
+ return;
849
+ }
850
+ if (action.type !== 'install' && action.type !== 'skip')
851
+ return;
852
+ if (this.state.status !== 'available' ||
853
+ action.version !== this.state.candidate?.version)
854
+ return;
855
+ if (action.type === 'skip') {
856
+ await this.savePreferences({ skippedVersion: action.version });
857
+ if (this.state.updateRequest === request) {
858
+ this.dismissPresentation('update', request.generation);
859
+ await this.children.get('update')?.window.close();
860
+ }
861
+ return;
862
+ }
863
+ const operation = ++this.operation;
864
+ const installation = { id: operation, settled: 'pending' };
865
+ this.installation = installation;
866
+ this.requestPresentation('update', request.originWindowLabel, {
867
+ status: 'progress',
868
+ canCancel: Boolean(this.updater.cancel),
869
+ progress: { phase: 'downloading', downloadedBytes: 0 },
870
+ error: null,
871
+ });
872
+ try {
873
+ await this.updater.install(action.version, (progress) => {
874
+ if (operation === this.operation && validProgress(progress))
875
+ this.patch({ progress });
876
+ });
877
+ installation.settled = 'succeeded';
878
+ // A successful consumer normally restarts; no SDK restart or second confirmation.
879
+ }
880
+ catch {
881
+ installation.settled = 'failed';
882
+ if (operation === this.operation)
883
+ this.settleUpdatePresentation(request.originWindowLabel, {
884
+ status: 'available',
885
+ progress: null,
886
+ canCancel: Boolean(this.updater.cancel),
887
+ error: this.state.progress?.phase === 'restarting'
888
+ ? 'The update is installed, but the app could not restart. Try again to restart it.'
889
+ : 'The update could not be installed. Please try again.',
890
+ });
891
+ }
892
+ }
893
+ /** Releases listeners, local resources and native child windows on host unmount.
894
+ * @returns Completion after pending preference/appearance writes and child cleanup.
895
+ */
896
+ dispose() {
897
+ if (!this.disposal)
898
+ this.disposal = this.cleanup();
899
+ return this.disposal;
900
+ }
901
+ async cleanup() {
902
+ this.disposed = true;
903
+ await this.ready;
904
+ this.operation++;
905
+ for (const cleanup of this.disposers)
906
+ cleanup();
907
+ this.disposers = [];
908
+ // A replacement host acquires its lease only after old native writes settle.
909
+ await this.nativeThemeUpdates;
910
+ for (const record of this.children.values()) {
911
+ record.cleanup?.();
912
+ await record.window.destroy().catch(() => undefined);
913
+ }
914
+ this.children.clear();
915
+ for (const peer of this.peers.values())
916
+ peer.cleanup?.();
917
+ this.peers.clear();
918
+ await this.persistence.catch(() => undefined);
919
+ try {
920
+ await this.store?.close();
921
+ }
922
+ finally {
923
+ this.listeners.clear();
924
+ this.releaseLease();
925
+ }
926
+ }
927
+ }
928
+ function validProgress(progress) {
929
+ if (!progress || typeof progress !== 'object')
930
+ return false;
931
+ if (progress.phase === 'installing' || progress.phase === 'restarting')
932
+ return true;
933
+ return (progress.phase === 'downloading' &&
934
+ Number.isFinite(progress.downloadedBytes) &&
935
+ progress.downloadedBytes >= 0 &&
936
+ (progress.totalBytes === undefined ||
937
+ (Number.isFinite(progress.totalBytes) &&
938
+ progress.totalBytes >= progress.downloadedBytes)));
939
+ }
940
+ /** Creates the private native lifecycle owner for a single host window.
941
+ * @param projectId - Public release-note project.
942
+ * @param icon - Consumer icon URL.
943
+ * @param updater - Consumer updater callbacks.
944
+ * @param theme - Consumer theme.
945
+ * @param hostWindowLabel - Callback-owning native window.
946
+ * @param presentation - Fixed native or headless presentation policy.
947
+ * @returns Internal lifecycle owner; never exported by the package entry point.
948
+ */
949
+ export function createNativeHostController(projectId, icon, updater, theme, hostWindowLabel, presentation = 'native') {
950
+ return new NativeHostController(projectId, icon, updater, theme, hostWindowLabel, presentation);
951
+ }