@oxy.so/core 7.9.2 → 7.10.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.
- package/dist/cjs/api/storeInstaller.js +46 -0
- package/dist/cjs/index.js +10 -4
- package/dist/cjs/session/SessionClient.js +126 -12
- package/dist/esm/api/storeInstaller.js +40 -0
- package/dist/esm/index.js +3 -0
- package/dist/esm/session/SessionClient.js +126 -12
- package/dist/types/api/storeInstaller.d.ts +155 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/session/SessionClient.d.ts +8 -0
- package/package.json +1 -1
- package/src/api/storeInstaller.ts +158 -0
- package/src/index.ts +15 -0
- package/src/session/SessionClient.ts +149 -13
|
@@ -36,6 +36,8 @@ export class SessionClient {
|
|
|
36
36
|
this.tokenUnsub = null;
|
|
37
37
|
this.started = false;
|
|
38
38
|
this.lifecycleGeneration = 0;
|
|
39
|
+
/** Changes whenever the locally projected device changes, including ABA. */
|
|
40
|
+
this.deviceGeneration = 0;
|
|
39
41
|
this.fullSignOutPersistence = null;
|
|
40
42
|
/** Same-origin cross-tab state-propagation channel; null on platforms without BroadcastChannel. */
|
|
41
43
|
this.channel = null;
|
|
@@ -65,6 +67,25 @@ export class SessionClient {
|
|
|
65
67
|
throw new Error('Device session request was superseded');
|
|
66
68
|
return response;
|
|
67
69
|
}
|
|
70
|
+
/** Capture the local device before a request, including device ABA. */
|
|
71
|
+
captureDeviceRequest() {
|
|
72
|
+
return { generation: this.deviceGeneration, initialDevice: this.state?.deviceId ?? null };
|
|
73
|
+
}
|
|
74
|
+
isCurrentDeviceRequest(request, response) {
|
|
75
|
+
if (request.generation === this.deviceGeneration)
|
|
76
|
+
return true;
|
|
77
|
+
if (request.initialDevice !== null || this.deviceGeneration !== request.generation + 1)
|
|
78
|
+
return false;
|
|
79
|
+
if (response === null || typeof response !== 'object')
|
|
80
|
+
return false;
|
|
81
|
+
const record = response;
|
|
82
|
+
const deviceId = record.state?.deviceId ?? record.directory?.deviceId ?? record.deviceId;
|
|
83
|
+
return typeof deviceId === 'string' && deviceId === this.state?.deviceId;
|
|
84
|
+
}
|
|
85
|
+
assertCurrentDeviceRequest(request, response) {
|
|
86
|
+
if (!this.isCurrentDeviceRequest(request, response))
|
|
87
|
+
throw new Error('Device session request was superseded');
|
|
88
|
+
}
|
|
68
89
|
/** Stop and discard only this client's local device projection; no server revocation. */
|
|
69
90
|
resetLocalState() {
|
|
70
91
|
this.lifecycleGeneration += 1;
|
|
@@ -213,6 +234,8 @@ export class SessionClient {
|
|
|
213
234
|
return false;
|
|
214
235
|
}
|
|
215
236
|
const previousState = this.state;
|
|
237
|
+
if (previousState?.deviceId !== next.deviceId)
|
|
238
|
+
this.deviceGeneration += 1;
|
|
216
239
|
this.state = next;
|
|
217
240
|
if (next.accounts.length === 0) {
|
|
218
241
|
// A device with nobody signed in has no principals either. The directory
|
|
@@ -321,11 +344,13 @@ export class SessionClient {
|
|
|
321
344
|
.ensureActiveToken(next)
|
|
322
345
|
.then(finishApply)
|
|
323
346
|
.catch((error) => {
|
|
324
|
-
if (generation !== this.lifecycleGeneration)
|
|
347
|
+
if (generation !== this.lifecycleGeneration || this.state !== next)
|
|
325
348
|
return;
|
|
326
349
|
logger.warn('[SessionClient] ensureActiveToken failed — reverting session state', { component: 'SessionClient' }, error);
|
|
327
350
|
// Do NOT notify under a mismatched bearer. Revert to the last applied
|
|
328
351
|
// state so subscribers keep observing the account whose token is planted.
|
|
352
|
+
if (this.state?.deviceId !== previousState?.deviceId)
|
|
353
|
+
this.deviceGeneration += 1;
|
|
329
354
|
this.state = previousState ?? null;
|
|
330
355
|
});
|
|
331
356
|
}
|
|
@@ -375,6 +400,21 @@ export class SessionClient {
|
|
|
375
400
|
if (this.fullSignOutPersistence?.state === this.state)
|
|
376
401
|
await this.fullSignOutPersistence.pending;
|
|
377
402
|
}
|
|
403
|
+
/** Keep mutation callers from reporting success after a device change during persistence. */
|
|
404
|
+
async applySyncForMutation(raw) {
|
|
405
|
+
const before = this.captureDeviceRequest();
|
|
406
|
+
const lifecycle = this.lifecycleGeneration;
|
|
407
|
+
const checked = deviceSessionSyncCheck(raw, []);
|
|
408
|
+
const responseDevice = checked.ok ? checked.value.state.deviceId : before.initialDevice;
|
|
409
|
+
const expectedGeneration = before.generation + (responseDevice === before.initialDevice ? 0 : 1);
|
|
410
|
+
const pending = this.applySync(raw);
|
|
411
|
+
const generation = this.deviceGeneration;
|
|
412
|
+
await pending;
|
|
413
|
+
if (generation !== expectedGeneration ||
|
|
414
|
+
generation !== this.deviceGeneration ||
|
|
415
|
+
lifecycle !== this.lifecycleGeneration)
|
|
416
|
+
throw new Error('Device session request was superseded');
|
|
417
|
+
}
|
|
378
418
|
/**
|
|
379
419
|
* The apply + token-plant half of {@link applySync}, on an ALREADY-VALIDATED
|
|
380
420
|
* sync. Split out so the context-aware removal lane — whose response is a
|
|
@@ -394,14 +434,15 @@ export class SessionClient {
|
|
|
394
434
|
const applied = this.applyState(sync.state, 'request', sync.activeToken?.accessToken);
|
|
395
435
|
// Equal-revision restate (this revision was already applied by a preceding
|
|
396
436
|
// socket push): `applyState` no-ops without planting, but the token still
|
|
397
|
-
// needs planting.
|
|
398
|
-
//
|
|
399
|
-
//
|
|
400
|
-
// being the PINNED one (same rule as the applied path in `applyState`).
|
|
437
|
+
// needs planting. Only the exact same device revision is an equal-revision
|
|
438
|
+
// restate: an older response may identify a different operator of the same
|
|
439
|
+
// account. Also require the current account and the identity pin to match.
|
|
401
440
|
const pinnedAccountId = this.pinnedAccountId();
|
|
402
441
|
if (!applied &&
|
|
403
442
|
sync.activeToken &&
|
|
404
443
|
this.state &&
|
|
444
|
+
sync.state.deviceId === this.state.deviceId &&
|
|
445
|
+
sync.state.revision === this.state.revision &&
|
|
405
446
|
sync.state.activeAccountId !== null &&
|
|
406
447
|
sync.state.activeAccountId === this.state.activeAccountId &&
|
|
407
448
|
(pinnedAccountId === null || sync.state.activeAccountId === pinnedAccountId) &&
|
|
@@ -451,9 +492,12 @@ export class SessionClient {
|
|
|
451
492
|
}
|
|
452
493
|
/** `GET /session/device/directory` → {@link applyDirectory}. No notify. */
|
|
453
494
|
async fetchDirectory() {
|
|
495
|
+
const request = this.captureDeviceRequest();
|
|
454
496
|
const res = await this.requestDevice('GET', '/session/device/directory', undefined, {
|
|
455
497
|
cache: false,
|
|
456
498
|
});
|
|
499
|
+
if (!this.isCurrentDeviceRequest(request, res))
|
|
500
|
+
return false;
|
|
457
501
|
return this.applyDirectory(res);
|
|
458
502
|
}
|
|
459
503
|
/**
|
|
@@ -515,14 +559,13 @@ export class SessionClient {
|
|
|
515
559
|
if (!accessToken) {
|
|
516
560
|
return;
|
|
517
561
|
}
|
|
562
|
+
const pinnedAccountId = this.pinnedAccountId();
|
|
518
563
|
const subjectAccountId = resolveActiveContext(directory)?.subject.accountId ?? null;
|
|
519
564
|
if (subjectAccountId === null) {
|
|
520
565
|
return;
|
|
521
566
|
}
|
|
522
|
-
|
|
523
|
-
if (pinnedAccountId !== null && subjectAccountId !== pinnedAccountId) {
|
|
567
|
+
if (pinnedAccountId !== null && subjectAccountId !== pinnedAccountId)
|
|
524
568
|
return;
|
|
525
|
-
}
|
|
526
569
|
if (accessToken === this.host.getAccessToken()) {
|
|
527
570
|
return;
|
|
528
571
|
}
|
|
@@ -562,9 +605,12 @@ export class SessionClient {
|
|
|
562
605
|
}
|
|
563
606
|
}
|
|
564
607
|
async bootstrap() {
|
|
608
|
+
const request = this.captureDeviceRequest();
|
|
565
609
|
const res = await this.requestDevice('GET', '/session/device/state', undefined, {
|
|
566
610
|
cache: false,
|
|
567
611
|
});
|
|
612
|
+
if (!this.isCurrentDeviceRequest(request, res))
|
|
613
|
+
return;
|
|
568
614
|
await this.applySync(res);
|
|
569
615
|
}
|
|
570
616
|
/**
|
|
@@ -608,13 +654,41 @@ export class SessionClient {
|
|
|
608
654
|
* compatibility path for callers still keyed on account ids.
|
|
609
655
|
*/
|
|
610
656
|
async activateContext(contextId) {
|
|
657
|
+
const request = this.captureDeviceRequest();
|
|
611
658
|
const res = await this.requestDevice('POST', '/session/device/activate', { contextId }, { cache: false });
|
|
659
|
+
this.assertCurrentDeviceRequest(request, res);
|
|
612
660
|
const activation = checkContract(deviceActivateResponseCheck, res);
|
|
613
661
|
if (!activation) {
|
|
614
662
|
logger.warn('[SessionClient] discarded invalid activation response');
|
|
615
663
|
return;
|
|
616
664
|
}
|
|
665
|
+
if (this.pinnedAccountId() === null) {
|
|
666
|
+
const active = resolveActiveContext(activation.directory);
|
|
667
|
+
if (!active || activation.directory.activeContextId !== contextId)
|
|
668
|
+
throw new Error('Device session request was superseded');
|
|
669
|
+
const claims = activation.activeToken?.accessToken
|
|
670
|
+
? decodeTokenClaims(activation.activeToken.accessToken)
|
|
671
|
+
: null;
|
|
672
|
+
const contextClaim = claims?.device_context_id;
|
|
673
|
+
const actor = claims?.act;
|
|
674
|
+
const actorClaim = actor !== null && typeof actor === 'object' ? actor.sub : undefined;
|
|
675
|
+
const subjectClaim = claims?.userId ?? claims?.id ?? claims?.sub;
|
|
676
|
+
// Decoded claims are only a consistency check, never authorization.
|
|
677
|
+
// Legacy and opaque bearers may omit these claims entirely.
|
|
678
|
+
if ((typeof contextClaim === 'string' && contextClaim !== active.contextId) ||
|
|
679
|
+
(typeof actorClaim === 'string' && actorClaim !== active.actor.userId) ||
|
|
680
|
+
(typeof subjectClaim === 'string' && subjectClaim !== active.subject.accountId))
|
|
681
|
+
throw new Error('Device session request was superseded');
|
|
682
|
+
}
|
|
617
683
|
const known = this.knownRevisionFor(activation.directory.deviceId);
|
|
684
|
+
if (known !== null && activation.directory.revision < known)
|
|
685
|
+
throw new Error('Device session request was superseded');
|
|
686
|
+
const held = this.directory;
|
|
687
|
+
if (this.pinnedAccountId() === null &&
|
|
688
|
+
held?.deviceId === activation.directory.deviceId &&
|
|
689
|
+
held.revision === activation.directory.revision &&
|
|
690
|
+
held.activeContextId !== activation.directory.activeContextId)
|
|
691
|
+
throw new Error('Device session request was superseded');
|
|
618
692
|
const moved = known === null || activation.directory.revision > known;
|
|
619
693
|
this.plantActiveContextToken(activation.directory, activation.activeToken?.accessToken);
|
|
620
694
|
// Applied BEFORE the reconcile, and only then: the reconcile's own
|
|
@@ -624,8 +698,21 @@ export class SessionClient {
|
|
|
624
698
|
// already holding in hand.
|
|
625
699
|
const applied = this.applyDirectory(activation.directory);
|
|
626
700
|
if (moved) {
|
|
701
|
+
const beforeReconcile = this.captureDeviceRequest();
|
|
702
|
+
const lifecycle = this.lifecycleGeneration;
|
|
627
703
|
await this.reconcileFlatState(activation.directory);
|
|
704
|
+
const landedOnTarget = beforeReconcile.initialDevice !== activation.directory.deviceId &&
|
|
705
|
+
this.deviceGeneration === beforeReconcile.generation + 1 &&
|
|
706
|
+
this.state?.deviceId === activation.directory.deviceId;
|
|
707
|
+
if (lifecycle !== this.lifecycleGeneration ||
|
|
708
|
+
(this.deviceGeneration !== beforeReconcile.generation && !landedOnTarget))
|
|
709
|
+
throw new Error('Device session request was superseded');
|
|
628
710
|
}
|
|
711
|
+
if (this.pinnedAccountId() === null &&
|
|
712
|
+
(this.knownRevisionFor(activation.directory.deviceId) !== activation.directory.revision ||
|
|
713
|
+
this.directory?.deviceId !== activation.directory.deviceId ||
|
|
714
|
+
this.directory.activeContextId !== contextId))
|
|
715
|
+
throw new Error('Device session request was superseded');
|
|
629
716
|
if (applied) {
|
|
630
717
|
this.notify();
|
|
631
718
|
}
|
|
@@ -634,8 +721,18 @@ export class SessionClient {
|
|
|
634
721
|
}
|
|
635
722
|
}
|
|
636
723
|
async switchAccount(accountId) {
|
|
724
|
+
const request = this.captureDeviceRequest();
|
|
637
725
|
const res = await this.requestDevice('POST', '/session/device/switch', { accountId }, { cache: false });
|
|
638
|
-
|
|
726
|
+
this.assertCurrentDeviceRequest(request, res);
|
|
727
|
+
const checked = deviceSessionSyncCheck(res, []);
|
|
728
|
+
const isStale = () => checked.ok &&
|
|
729
|
+
this.state?.deviceId === checked.value.state.deviceId &&
|
|
730
|
+
this.state.revision > checked.value.state.revision;
|
|
731
|
+
if (isStale())
|
|
732
|
+
throw new Error('Device session request was superseded');
|
|
733
|
+
await this.applySyncForMutation(res);
|
|
734
|
+
if (isStale())
|
|
735
|
+
throw new Error('Device session request was superseded');
|
|
639
736
|
this.postCommitPing();
|
|
640
737
|
}
|
|
641
738
|
/**
|
|
@@ -650,10 +747,12 @@ export class SessionClient {
|
|
|
650
747
|
* {@link signOutPrincipal} for the two that can tell those apart.
|
|
651
748
|
*/
|
|
652
749
|
async signOut(target) {
|
|
750
|
+
const request = this.captureDeviceRequest();
|
|
653
751
|
const res = await this.requestDevice('POST', '/session/device/signout', target, {
|
|
654
752
|
cache: false,
|
|
655
753
|
});
|
|
656
|
-
|
|
754
|
+
this.assertCurrentDeviceRequest(request, res);
|
|
755
|
+
await this.applySyncForMutation(res);
|
|
657
756
|
this.postCommitPing();
|
|
658
757
|
if (this.state?.accounts.length === 0)
|
|
659
758
|
await this.persistFullSignOut();
|
|
@@ -695,21 +794,34 @@ export class SessionClient {
|
|
|
695
794
|
* applied this revision.
|
|
696
795
|
*/
|
|
697
796
|
async removeFromDevice(target) {
|
|
797
|
+
const request = this.captureDeviceRequest();
|
|
698
798
|
const res = await this.requestDevice('POST', '/session/device/signout', target, {
|
|
699
799
|
cache: false,
|
|
700
800
|
});
|
|
801
|
+
this.assertCurrentDeviceRequest(request, res);
|
|
701
802
|
const removal = checkContract(deviceDirectorySyncCheck, res);
|
|
702
803
|
if (!removal) {
|
|
703
804
|
logger.warn('[SessionClient] discarded invalid device removal response');
|
|
704
805
|
return;
|
|
705
806
|
}
|
|
706
807
|
const directoryApplied = this.applyDirectory(removal.directory);
|
|
808
|
+
const beforeCommit = this.captureDeviceRequest();
|
|
809
|
+
const commitLifecycle = this.lifecycleGeneration;
|
|
810
|
+
const expectedGeneration = beforeCommit.generation + (beforeCommit.initialDevice === removal.state.deviceId ? 0 : 1);
|
|
707
811
|
const stateApplied = this.commitSync({
|
|
708
812
|
state: removal.state,
|
|
709
813
|
activeToken: removal.activeToken,
|
|
710
814
|
});
|
|
711
|
-
if (
|
|
815
|
+
if (expectedGeneration !== this.deviceGeneration ||
|
|
816
|
+
commitLifecycle !== this.lifecycleGeneration)
|
|
817
|
+
throw new Error('Device session request was superseded');
|
|
818
|
+
if (this.fullSignOutPersistence?.state === this.state) {
|
|
819
|
+
const generation = this.deviceGeneration;
|
|
820
|
+
const lifecycle = this.lifecycleGeneration;
|
|
712
821
|
await this.fullSignOutPersistence.pending;
|
|
822
|
+
if (generation !== this.deviceGeneration || lifecycle !== this.lifecycleGeneration)
|
|
823
|
+
throw new Error('Device session request was superseded');
|
|
824
|
+
}
|
|
713
825
|
// `commitSync` publishes whenever the flat state moved. When only the
|
|
714
826
|
// directory did — a socket push already applied this revision — the
|
|
715
827
|
// directory half would otherwise never reach a subscriber.
|
|
@@ -721,10 +833,12 @@ export class SessionClient {
|
|
|
721
833
|
await this.persistFullSignOut();
|
|
722
834
|
}
|
|
723
835
|
async addCurrentAccount() {
|
|
836
|
+
const request = this.captureDeviceRequest();
|
|
724
837
|
const res = await this.requestDevice('POST', '/session/device/add', undefined, {
|
|
725
838
|
cache: false,
|
|
726
839
|
});
|
|
727
|
-
|
|
840
|
+
this.assertCurrentDeviceRequest(request, res);
|
|
841
|
+
await this.applySyncForMutation(res);
|
|
728
842
|
this.postCommitPing();
|
|
729
843
|
}
|
|
730
844
|
/**
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The store's installer: how a store installs, opens and updates an application on a machine
|
|
3
|
+
* that installs applications, beside `oxy.store`, which reads the catalogue.
|
|
4
|
+
*
|
|
5
|
+
* `oxy.store` says what the store lists; an installer says what the machine can do with a
|
|
6
|
+
* listing. A runtime that installs applications registers one (OxyOS's Bloom host registers one
|
|
7
|
+
* backed by the system's install service before the application's code runs, OxyOS ADR 0030
|
|
8
|
+
* decision 9; ADR 0053 here). Where none is registered — the web, iOS, Android — there is
|
|
9
|
+
* nothing to install, and a store shows what it shows without one. An application never asks
|
|
10
|
+
* which platform it runs on: it asks whether there is an installer.
|
|
11
|
+
*
|
|
12
|
+
* The installer is the store's hands, never its judge. It is given a listing's slug and nothing
|
|
13
|
+
* else: the machine resolves slug → package → release from the store's signed metadata, verifies
|
|
14
|
+
* it, and asks the person in a sheet of its own that no application can draw or press. The store
|
|
15
|
+
* learns the outcome and nothing of the sheet. Its errors say whether the machine already told the
|
|
16
|
+
* person (`told`), so a store never repeats what the system said.
|
|
17
|
+
*
|
|
18
|
+
* React-free and dependency-free, as everything in `@oxy.so/core`.
|
|
19
|
+
*/
|
|
20
|
+
/** Where a listing stands on this machine, for the action a store's page shows. */
|
|
21
|
+
export type StoreInstallState =
|
|
22
|
+
/** The store has a release this machine can run, and it is not installed: *Get*. */
|
|
23
|
+
{
|
|
24
|
+
state: 'notInstalled';
|
|
25
|
+
}
|
|
26
|
+
/** Being installed or updated: a progress ring. `fraction` is null while it cannot be told. */
|
|
27
|
+
| {
|
|
28
|
+
state: 'installing';
|
|
29
|
+
fraction: number | null;
|
|
30
|
+
}
|
|
31
|
+
/** Installed and current: *Open*. */
|
|
32
|
+
| {
|
|
33
|
+
state: 'installed';
|
|
34
|
+
version: string;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Installed, with a newer release: *Update*. `needsConsent` when the newer release asks for
|
|
38
|
+
* more than the installed one, which the machine asks the person for in its own sheet.
|
|
39
|
+
*/
|
|
40
|
+
| {
|
|
41
|
+
state: 'updateAvailable';
|
|
42
|
+
version: string;
|
|
43
|
+
newVersion: string;
|
|
44
|
+
needsConsent: boolean;
|
|
45
|
+
}
|
|
46
|
+
/** The store has no release this machine can run: *Not available for this computer*. */
|
|
47
|
+
| {
|
|
48
|
+
state: 'unavailable';
|
|
49
|
+
};
|
|
50
|
+
/** Why the machine could not do what the store asked. */
|
|
51
|
+
export type StoreInstallerFailure =
|
|
52
|
+
/** The machine's install service is not running. */
|
|
53
|
+
'serviceUnavailable'
|
|
54
|
+
/** The store has no release of it this machine can run. */
|
|
55
|
+
| 'notAvailable'
|
|
56
|
+
/** It is not installed (opening or updating it). */
|
|
57
|
+
| 'notInstalled'
|
|
58
|
+
/** The installer refused the request (it lacks the right to install, or it was not a person's press). */
|
|
59
|
+
| 'refused'
|
|
60
|
+
/** The install or update itself failed (a download, a verification, a compile). */
|
|
61
|
+
| 'failed';
|
|
62
|
+
/** A failure of an installer's request. */
|
|
63
|
+
export declare class StoreInstallerError extends Error {
|
|
64
|
+
readonly reason: StoreInstallerFailure;
|
|
65
|
+
/**
|
|
66
|
+
* Whether the machine already told the person, in its own sheet. A store says nothing more
|
|
67
|
+
* when it did: the system's word is the one the person reads.
|
|
68
|
+
*/
|
|
69
|
+
readonly told: boolean;
|
|
70
|
+
constructor(reason: StoreInstallerFailure, options?: {
|
|
71
|
+
told?: boolean;
|
|
72
|
+
message?: string;
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
/** An update the machine has for an installed application. */
|
|
76
|
+
export interface StoreUpdate {
|
|
77
|
+
/** The installer's own id for the application; what `apply` takes. Never a slug. */
|
|
78
|
+
id: string;
|
|
79
|
+
/** Its name in the person's language, from the store's signed metadata. */
|
|
80
|
+
name: string;
|
|
81
|
+
/** The version installed, and the one the update brings. */
|
|
82
|
+
version: string;
|
|
83
|
+
newVersion: string;
|
|
84
|
+
/** Bytes the machine would fetch: only what it does not have already. */
|
|
85
|
+
downloadSize: number;
|
|
86
|
+
/** What is new, in the person's language, or null when the release says nothing. */
|
|
87
|
+
releaseNotes: string | null;
|
|
88
|
+
/** The update asks for more than the installed version: the machine asks the person first. */
|
|
89
|
+
needsConsent: boolean;
|
|
90
|
+
/** Applied, waiting for the running copy to quit: it runs at its next launch. */
|
|
91
|
+
atNextLaunch: boolean;
|
|
92
|
+
}
|
|
93
|
+
/** The machine's updates for this account: one state, which every page that shows updates reads. */
|
|
94
|
+
export interface StoreUpdates {
|
|
95
|
+
updates: StoreUpdate[];
|
|
96
|
+
/** A check of the store is running. */
|
|
97
|
+
checking: boolean;
|
|
98
|
+
/** When the store was last read (ISO 8601), or null. */
|
|
99
|
+
lastChecked: string | null;
|
|
100
|
+
/** The last check failed. */
|
|
101
|
+
checkFailed: boolean;
|
|
102
|
+
}
|
|
103
|
+
/** The machine's updates, as one state that changes by itself. */
|
|
104
|
+
export interface StoreUpdatesSource {
|
|
105
|
+
/** The state last told, or null before the machine has said. */
|
|
106
|
+
current(): StoreUpdates | null;
|
|
107
|
+
/**
|
|
108
|
+
* The state as it is now, asked of the machine without reading the store. Rejects with a
|
|
109
|
+
* `StoreInstallerError` when the machine cannot say (its install service is not running).
|
|
110
|
+
*/
|
|
111
|
+
read(): Promise<StoreUpdates>;
|
|
112
|
+
/** Reads the store now. A check asked while one runs joins it. */
|
|
113
|
+
check(): Promise<StoreUpdates>;
|
|
114
|
+
/** Applies these updates; one that needs consent is asked in the machine's sheet first. */
|
|
115
|
+
apply(ids: readonly string[]): Promise<void>;
|
|
116
|
+
/** Applies every update, asking for consent in turn after the rest. */
|
|
117
|
+
applyAll(): Promise<void>;
|
|
118
|
+
/** Told each change; returns the unsubscribe. */
|
|
119
|
+
subscribe(listener: (updates: StoreUpdates) => void): () => void;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* What a runtime that installs applications gives a store. Every action that changes anything is
|
|
123
|
+
* asked from a person's press: the machine refuses what the person did not ask for.
|
|
124
|
+
*/
|
|
125
|
+
export interface StoreInstaller {
|
|
126
|
+
/**
|
|
127
|
+
* Where each listing stands. Rejects with a `StoreInstallerError` when the machine cannot say
|
|
128
|
+
* (its install service is not running): a store then shows its usual action and lets the
|
|
129
|
+
* machine say why when it is pressed.
|
|
130
|
+
*/
|
|
131
|
+
status(slugs: readonly string[]): Promise<Record<string, StoreInstallState>>;
|
|
132
|
+
/**
|
|
133
|
+
* Installs a listing's application: the machine's sheet, then the install. Resolves when the
|
|
134
|
+
* person declined in the sheet or the install is done; the progress in between is told to
|
|
135
|
+
* `subscribe`'s listeners as `installing`.
|
|
136
|
+
*/
|
|
137
|
+
install(slug: string): Promise<'installed' | 'declined'>;
|
|
138
|
+
/** Updates it, asking in the machine's sheet when the update needs the person's consent. */
|
|
139
|
+
update(slug: string): Promise<'updated' | 'declined'>;
|
|
140
|
+
/** Removes it and its data, after the machine's sheet. */
|
|
141
|
+
uninstall(slug: string): Promise<'removed' | 'declined'>;
|
|
142
|
+
/** Opens the installed application. */
|
|
143
|
+
open(slug: string): Promise<void>;
|
|
144
|
+
/** Told each listing whose state changed; returns the unsubscribe. */
|
|
145
|
+
subscribe(listener: (slug: string, state: StoreInstallState) => void): () => void;
|
|
146
|
+
/** The account's updates (a store's *Updates* page). */
|
|
147
|
+
readonly updates: StoreUpdatesSource;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Registers the machine's installer, or removes it with null. A runtime calls it once, before
|
|
151
|
+
* the application's code runs; nothing else should.
|
|
152
|
+
*/
|
|
153
|
+
export declare function setStoreInstaller(next: StoreInstaller | null): void;
|
|
154
|
+
/** The machine's installer, or null where nothing installs applications. */
|
|
155
|
+
export declare function getStoreInstaller(): StoreInstaller | null;
|
package/dist/types/index.d.ts
CHANGED
|
@@ -45,6 +45,8 @@ export type { AppsApi, AppAssertionsApi, Application, ApplicationType, Applicati
|
|
|
45
45
|
export type { LinkedAccountsApi } from './api/linkedAccounts';
|
|
46
46
|
export type { AgencyApi, AccountCapabilityPolicy, AvailableCapabilityCatalog, CapabilityExecutionAuthorization, CreateDelegationGrantInput, DelegationCatalogBinding, DelegationGrantView, PutAccountCapabilityPolicyInput, UpdateDelegationGrantInput, } from './api/agency';
|
|
47
47
|
export type { StoreApi, StoreCategory, StoreRating, StoreListingSummary, StoreListingDetail, StoreScreenshot, StoreScreenshotPlatform, StoreReview, StoreOwnReview, WriteStoreReviewInput, StoreListingStatus, PublisherListing, WriteListingInput, AddScreenshotInput, UpdateScreenshotInput, StorePage, StorePageOptions, StoreReviewsOptions, } from './api/store';
|
|
48
|
+
export { getStoreInstaller, setStoreInstaller, StoreInstallerError, } from './api/storeInstaller';
|
|
49
|
+
export type { StoreInstallState, StoreInstaller, StoreInstallerFailure, StoreUpdate, StoreUpdates, StoreUpdatesSource, } from './api/storeInstaller';
|
|
48
50
|
export type { BillingApi, SubscriptionPlan, SubscriptionStatus, SubscriptionFeatures, Subscription, Payment, WalletTransactionType, WalletTransactionStatus, WalletTransaction, WalletPagination, WalletTransactionsPage, } from './api/billing';
|
|
49
51
|
export { REPUTATION_CACHE_PREFIX } from './api/reputation';
|
|
50
52
|
export type { ReputationApi, ReputationPage } from './api/reputation';
|
|
@@ -110,6 +110,8 @@ export declare class SessionClient {
|
|
|
110
110
|
private tokenUnsub;
|
|
111
111
|
private started;
|
|
112
112
|
private lifecycleGeneration;
|
|
113
|
+
/** Changes whenever the locally projected device changes, including ABA. */
|
|
114
|
+
private deviceGeneration;
|
|
113
115
|
private fullSignOutPersistence;
|
|
114
116
|
/** Same-origin cross-tab state-propagation channel; null on platforms without BroadcastChannel. */
|
|
115
117
|
private channel;
|
|
@@ -120,6 +122,10 @@ export declare class SessionClient {
|
|
|
120
122
|
constructor(host: SessionClientHost, options?: SessionClientOptions);
|
|
121
123
|
private persistFullSignOut;
|
|
122
124
|
private requestDevice;
|
|
125
|
+
/** Capture the local device before a request, including device ABA. */
|
|
126
|
+
private captureDeviceRequest;
|
|
127
|
+
private isCurrentDeviceRequest;
|
|
128
|
+
private assertCurrentDeviceRequest;
|
|
123
129
|
/** Stop and discard only this client's local device projection; no server revocation. */
|
|
124
130
|
resetLocalState(): void;
|
|
125
131
|
getState(): DeviceSessionState | null;
|
|
@@ -189,6 +195,8 @@ export declare class SessionClient {
|
|
|
189
195
|
* account that is no longer active.
|
|
190
196
|
*/
|
|
191
197
|
private applySync;
|
|
198
|
+
/** Keep mutation callers from reporting success after a device change during persistence. */
|
|
199
|
+
private applySyncForMutation;
|
|
192
200
|
/**
|
|
193
201
|
* The apply + token-plant half of {@link applySync}, on an ALREADY-VALIDATED
|
|
194
202
|
* sync. Split out so the context-aware removal lane — whose response is a
|
package/package.json
CHANGED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The store's installer: how a store installs, opens and updates an application on a machine
|
|
3
|
+
* that installs applications, beside `oxy.store`, which reads the catalogue.
|
|
4
|
+
*
|
|
5
|
+
* `oxy.store` says what the store lists; an installer says what the machine can do with a
|
|
6
|
+
* listing. A runtime that installs applications registers one (OxyOS's Bloom host registers one
|
|
7
|
+
* backed by the system's install service before the application's code runs, OxyOS ADR 0030
|
|
8
|
+
* decision 9; ADR 0053 here). Where none is registered — the web, iOS, Android — there is
|
|
9
|
+
* nothing to install, and a store shows what it shows without one. An application never asks
|
|
10
|
+
* which platform it runs on: it asks whether there is an installer.
|
|
11
|
+
*
|
|
12
|
+
* The installer is the store's hands, never its judge. It is given a listing's slug and nothing
|
|
13
|
+
* else: the machine resolves slug → package → release from the store's signed metadata, verifies
|
|
14
|
+
* it, and asks the person in a sheet of its own that no application can draw or press. The store
|
|
15
|
+
* learns the outcome and nothing of the sheet. Its errors say whether the machine already told the
|
|
16
|
+
* person (`told`), so a store never repeats what the system said.
|
|
17
|
+
*
|
|
18
|
+
* React-free and dependency-free, as everything in `@oxy.so/core`.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Where a listing stands on this machine, for the action a store's page shows. */
|
|
22
|
+
export type StoreInstallState =
|
|
23
|
+
/** The store has a release this machine can run, and it is not installed: *Get*. */
|
|
24
|
+
| { state: 'notInstalled' }
|
|
25
|
+
/** Being installed or updated: a progress ring. `fraction` is null while it cannot be told. */
|
|
26
|
+
| { state: 'installing'; fraction: number | null }
|
|
27
|
+
/** Installed and current: *Open*. */
|
|
28
|
+
| { state: 'installed'; version: string }
|
|
29
|
+
/**
|
|
30
|
+
* Installed, with a newer release: *Update*. `needsConsent` when the newer release asks for
|
|
31
|
+
* more than the installed one, which the machine asks the person for in its own sheet.
|
|
32
|
+
*/
|
|
33
|
+
| { state: 'updateAvailable'; version: string; newVersion: string; needsConsent: boolean }
|
|
34
|
+
/** The store has no release this machine can run: *Not available for this computer*. */
|
|
35
|
+
| { state: 'unavailable' };
|
|
36
|
+
|
|
37
|
+
/** Why the machine could not do what the store asked. */
|
|
38
|
+
export type StoreInstallerFailure =
|
|
39
|
+
/** The machine's install service is not running. */
|
|
40
|
+
| 'serviceUnavailable'
|
|
41
|
+
/** The store has no release of it this machine can run. */
|
|
42
|
+
| 'notAvailable'
|
|
43
|
+
/** It is not installed (opening or updating it). */
|
|
44
|
+
| 'notInstalled'
|
|
45
|
+
/** The installer refused the request (it lacks the right to install, or it was not a person's press). */
|
|
46
|
+
| 'refused'
|
|
47
|
+
/** The install or update itself failed (a download, a verification, a compile). */
|
|
48
|
+
| 'failed';
|
|
49
|
+
|
|
50
|
+
/** A failure of an installer's request. */
|
|
51
|
+
export class StoreInstallerError extends Error {
|
|
52
|
+
readonly reason: StoreInstallerFailure;
|
|
53
|
+
/**
|
|
54
|
+
* Whether the machine already told the person, in its own sheet. A store says nothing more
|
|
55
|
+
* when it did: the system's word is the one the person reads.
|
|
56
|
+
*/
|
|
57
|
+
readonly told: boolean;
|
|
58
|
+
|
|
59
|
+
constructor(reason: StoreInstallerFailure, options: { told?: boolean; message?: string } = {}) {
|
|
60
|
+
super(options.message ?? reason);
|
|
61
|
+
this.name = 'StoreInstallerError';
|
|
62
|
+
this.reason = reason;
|
|
63
|
+
this.told = options.told ?? false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** An update the machine has for an installed application. */
|
|
68
|
+
export interface StoreUpdate {
|
|
69
|
+
/** The installer's own id for the application; what `apply` takes. Never a slug. */
|
|
70
|
+
id: string;
|
|
71
|
+
/** Its name in the person's language, from the store's signed metadata. */
|
|
72
|
+
name: string;
|
|
73
|
+
/** The version installed, and the one the update brings. */
|
|
74
|
+
version: string;
|
|
75
|
+
newVersion: string;
|
|
76
|
+
/** Bytes the machine would fetch: only what it does not have already. */
|
|
77
|
+
downloadSize: number;
|
|
78
|
+
/** What is new, in the person's language, or null when the release says nothing. */
|
|
79
|
+
releaseNotes: string | null;
|
|
80
|
+
/** The update asks for more than the installed version: the machine asks the person first. */
|
|
81
|
+
needsConsent: boolean;
|
|
82
|
+
/** Applied, waiting for the running copy to quit: it runs at its next launch. */
|
|
83
|
+
atNextLaunch: boolean;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The machine's updates for this account: one state, which every page that shows updates reads. */
|
|
87
|
+
export interface StoreUpdates {
|
|
88
|
+
updates: StoreUpdate[];
|
|
89
|
+
/** A check of the store is running. */
|
|
90
|
+
checking: boolean;
|
|
91
|
+
/** When the store was last read (ISO 8601), or null. */
|
|
92
|
+
lastChecked: string | null;
|
|
93
|
+
/** The last check failed. */
|
|
94
|
+
checkFailed: boolean;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The machine's updates, as one state that changes by itself. */
|
|
98
|
+
export interface StoreUpdatesSource {
|
|
99
|
+
/** The state last told, or null before the machine has said. */
|
|
100
|
+
current(): StoreUpdates | null;
|
|
101
|
+
/**
|
|
102
|
+
* The state as it is now, asked of the machine without reading the store. Rejects with a
|
|
103
|
+
* `StoreInstallerError` when the machine cannot say (its install service is not running).
|
|
104
|
+
*/
|
|
105
|
+
read(): Promise<StoreUpdates>;
|
|
106
|
+
/** Reads the store now. A check asked while one runs joins it. */
|
|
107
|
+
check(): Promise<StoreUpdates>;
|
|
108
|
+
/** Applies these updates; one that needs consent is asked in the machine's sheet first. */
|
|
109
|
+
apply(ids: readonly string[]): Promise<void>;
|
|
110
|
+
/** Applies every update, asking for consent in turn after the rest. */
|
|
111
|
+
applyAll(): Promise<void>;
|
|
112
|
+
/** Told each change; returns the unsubscribe. */
|
|
113
|
+
subscribe(listener: (updates: StoreUpdates) => void): () => void;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* What a runtime that installs applications gives a store. Every action that changes anything is
|
|
118
|
+
* asked from a person's press: the machine refuses what the person did not ask for.
|
|
119
|
+
*/
|
|
120
|
+
export interface StoreInstaller {
|
|
121
|
+
/**
|
|
122
|
+
* Where each listing stands. Rejects with a `StoreInstallerError` when the machine cannot say
|
|
123
|
+
* (its install service is not running): a store then shows its usual action and lets the
|
|
124
|
+
* machine say why when it is pressed.
|
|
125
|
+
*/
|
|
126
|
+
status(slugs: readonly string[]): Promise<Record<string, StoreInstallState>>;
|
|
127
|
+
/**
|
|
128
|
+
* Installs a listing's application: the machine's sheet, then the install. Resolves when the
|
|
129
|
+
* person declined in the sheet or the install is done; the progress in between is told to
|
|
130
|
+
* `subscribe`'s listeners as `installing`.
|
|
131
|
+
*/
|
|
132
|
+
install(slug: string): Promise<'installed' | 'declined'>;
|
|
133
|
+
/** Updates it, asking in the machine's sheet when the update needs the person's consent. */
|
|
134
|
+
update(slug: string): Promise<'updated' | 'declined'>;
|
|
135
|
+
/** Removes it and its data, after the machine's sheet. */
|
|
136
|
+
uninstall(slug: string): Promise<'removed' | 'declined'>;
|
|
137
|
+
/** Opens the installed application. */
|
|
138
|
+
open(slug: string): Promise<void>;
|
|
139
|
+
/** Told each listing whose state changed; returns the unsubscribe. */
|
|
140
|
+
subscribe(listener: (slug: string, state: StoreInstallState) => void): () => void;
|
|
141
|
+
/** The account's updates (a store's *Updates* page). */
|
|
142
|
+
readonly updates: StoreUpdatesSource;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
let installer: StoreInstaller | null = null;
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Registers the machine's installer, or removes it with null. A runtime calls it once, before
|
|
149
|
+
* the application's code runs; nothing else should.
|
|
150
|
+
*/
|
|
151
|
+
export function setStoreInstaller(next: StoreInstaller | null): void {
|
|
152
|
+
installer = next;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The machine's installer, or null where nothing installs applications. */
|
|
156
|
+
export function getStoreInstaller(): StoreInstaller | null {
|
|
157
|
+
return installer;
|
|
158
|
+
}
|