@brass-build/client 0.4.1 → 0.5.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/AGENTS.md +124 -45
- package/CHANGELOG.md +80 -4
- package/README.md +666 -179
- package/dist/api-fetch.d.ts +8 -0
- package/dist/api-fetch.d.ts.map +1 -1
- package/dist/api-fetch.js +35 -8
- package/dist/api-fetch.js.map +1 -1
- package/dist/batched-get.d.ts +11 -0
- package/dist/batched-get.d.ts.map +1 -0
- package/dist/batched-get.js +92 -0
- package/dist/batched-get.js.map +1 -0
- package/dist/call-service.d.ts +4 -0
- package/dist/call-service.d.ts.map +1 -1
- package/dist/call-service.js +66 -3
- package/dist/call-service.js.map +1 -1
- package/dist/client.d.ts +48 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +918 -115
- package/dist/client.js.map +1 -1
- package/dist/document-checkpoints.d.ts +25 -2
- package/dist/document-checkpoints.d.ts.map +1 -1
- package/dist/document-checkpoints.js +258 -61
- package/dist/document-checkpoints.js.map +1 -1
- package/dist/document-commands.d.ts.map +1 -1
- package/dist/document-commands.js +5 -3
- package/dist/document-commands.js.map +1 -1
- package/dist/document-contents.d.ts +10 -0
- package/dist/document-contents.d.ts.map +1 -1
- package/dist/document-contents.js +23 -1
- package/dist/document-contents.js.map +1 -1
- package/dist/document-set-subscription.d.ts +56 -0
- package/dist/document-set-subscription.d.ts.map +1 -0
- package/dist/document-set-subscription.js +240 -0
- package/dist/document-set-subscription.js.map +1 -0
- package/dist/document-signals.d.ts +6 -0
- package/dist/document-signals.d.ts.map +1 -1
- package/dist/document-signals.js +31 -1
- package/dist/document-signals.js.map +1 -1
- package/dist/document-streams.d.ts +36 -2
- package/dist/document-streams.d.ts.map +1 -1
- package/dist/document-streams.js +69 -18
- package/dist/document-streams.js.map +1 -1
- package/dist/document-thumbnail-capture.d.ts +12 -2
- package/dist/document-thumbnail-capture.d.ts.map +1 -1
- package/dist/document-thumbnail-capture.js +103 -27
- package/dist/document-thumbnail-capture.js.map +1 -1
- package/dist/documents-namespace.d.ts +92 -10
- package/dist/documents-namespace.d.ts.map +1 -1
- package/dist/documents-namespace.js +285 -39
- package/dist/documents-namespace.js.map +1 -1
- package/dist/documents.d.ts +173 -8
- package/dist/documents.d.ts.map +1 -1
- package/dist/documents.js +1778 -310
- package/dist/documents.js.map +1 -1
- package/dist/errors.d.ts +3 -3
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/fetch-retry.d.ts +1 -0
- package/dist/fetch-retry.d.ts.map +1 -1
- package/dist/fetch-retry.js +2 -0
- package/dist/fetch-retry.js.map +1 -1
- package/dist/file-choice.d.ts +2 -0
- package/dist/file-choice.d.ts.map +1 -0
- package/dist/file-choice.js +27 -0
- package/dist/file-choice.js.map +1 -0
- package/dist/guest-link.d.ts +18 -1
- package/dist/guest-link.d.ts.map +1 -1
- package/dist/guest-link.js +329 -1
- package/dist/guest-link.js.map +1 -1
- package/dist/handle-types.d.ts +67 -5
- package/dist/handle-types.d.ts.map +1 -1
- package/dist/handles.d.ts +15 -19
- package/dist/handles.d.ts.map +1 -1
- package/dist/handles.js +231 -76
- package/dist/handles.js.map +1 -1
- package/dist/hosted-ui.d.ts +61 -0
- package/dist/hosted-ui.d.ts.map +1 -1
- package/dist/hosted-ui.js +94 -42
- package/dist/hosted-ui.js.map +1 -1
- package/dist/import-contract.d.ts +74 -0
- package/dist/import-contract.d.ts.map +1 -0
- package/dist/import-contract.js +163 -0
- package/dist/import-contract.js.map +1 -0
- package/dist/import-guest.d.ts +9 -0
- package/dist/import-guest.d.ts.map +1 -0
- package/dist/import-guest.js +155 -0
- package/dist/import-guest.js.map +1 -0
- package/dist/import-report.d.ts +17 -0
- package/dist/import-report.d.ts.map +1 -0
- package/dist/import-report.js +7 -0
- package/dist/import-report.js.map +1 -0
- package/dist/importer-wait.d.ts +2 -0
- package/dist/importer-wait.d.ts.map +1 -0
- package/dist/importer-wait.js +5 -0
- package/dist/importer-wait.js.map +1 -0
- package/dist/importer.d.ts +18 -4
- package/dist/importer.d.ts.map +1 -1
- package/dist/importer.js +322 -105
- package/dist/importer.js.map +1 -1
- package/dist/in-development.d.ts +2 -0
- package/dist/in-development.d.ts.map +1 -0
- package/dist/in-development.js +10 -0
- package/dist/in-development.js.map +1 -0
- package/dist/index.d.ts +16 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +24 -8
- package/dist/index.js.map +1 -1
- package/dist/init-keys.d.ts +1 -1
- package/dist/init-keys.d.ts.map +1 -1
- package/dist/init-keys.js +6 -1
- package/dist/init-keys.js.map +1 -1
- package/dist/input-documents.d.ts +67 -0
- package/dist/input-documents.d.ts.map +1 -0
- package/dist/input-documents.js +693 -0
- package/dist/input-documents.js.map +1 -0
- package/dist/inputs-status.d.ts +80 -0
- package/dist/inputs-status.d.ts.map +1 -0
- package/dist/inputs-status.js +460 -0
- package/dist/inputs-status.js.map +1 -0
- package/dist/inputs.d.ts +2 -0
- package/dist/inputs.d.ts.map +1 -0
- package/dist/inputs.js +793 -0
- package/dist/inputs.js.map +1 -0
- package/dist/internal.d.ts +6 -1
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +4 -1
- package/dist/internal.js.map +1 -1
- package/dist/join-content.d.ts +33 -0
- package/dist/join-content.d.ts.map +1 -0
- package/dist/join-content.js +114 -0
- package/dist/join-content.js.map +1 -0
- package/dist/join-stamp.d.ts +3 -0
- package/dist/join-stamp.d.ts.map +1 -0
- package/dist/join-stamp.js +12 -0
- package/dist/join-stamp.js.map +1 -0
- package/dist/manifest-drift.d.ts +9 -0
- package/dist/manifest-drift.d.ts.map +1 -0
- package/dist/manifest-drift.js +46 -0
- package/dist/manifest-drift.js.map +1 -0
- package/dist/open-document.d.ts +109 -2
- package/dist/open-document.d.ts.map +1 -1
- package/dist/open-document.js +1822 -811
- package/dist/open-document.js.map +1 -1
- package/dist/page-restore.d.ts +12 -0
- package/dist/page-restore.d.ts.map +1 -0
- package/dist/page-restore.js +23 -0
- package/dist/page-restore.js.map +1 -0
- package/dist/placement.d.ts +13 -0
- package/dist/placement.d.ts.map +1 -0
- package/dist/placement.js +34 -0
- package/dist/placement.js.map +1 -0
- package/dist/presence.d.ts +1 -0
- package/dist/presence.d.ts.map +1 -1
- package/dist/presence.js +16 -4
- package/dist/presence.js.map +1 -1
- package/dist/read-only-document.d.ts +87 -0
- package/dist/read-only-document.d.ts.map +1 -0
- package/dist/read-only-document.js +4 -0
- package/dist/read-only-document.js.map +1 -0
- package/dist/record-paging.d.ts +63 -6
- package/dist/record-paging.d.ts.map +1 -1
- package/dist/record-paging.js +122 -29
- package/dist/record-paging.js.map +1 -1
- package/dist/references.d.ts +40 -0
- package/dist/references.d.ts.map +1 -0
- package/dist/references.js +135 -0
- package/dist/references.js.map +1 -0
- package/dist/request-lane.d.ts +5 -0
- package/dist/request-lane.d.ts.map +1 -0
- package/dist/request-lane.js +48 -0
- package/dist/request-lane.js.map +1 -0
- package/dist/sealed-arrow.d.ts +170 -0
- package/dist/sealed-arrow.d.ts.map +1 -0
- package/dist/sealed-arrow.js +769 -0
- package/dist/sealed-arrow.js.map +1 -0
- package/dist/sealed-cache.d.ts +21 -0
- package/dist/sealed-cache.d.ts.map +1 -0
- package/dist/sealed-cache.js +247 -0
- package/dist/sealed-cache.js.map +1 -0
- package/dist/sealed-encode.d.ts +87 -0
- package/dist/sealed-encode.d.ts.map +1 -0
- package/dist/sealed-encode.js +876 -0
- package/dist/sealed-encode.js.map +1 -0
- package/dist/sealed-handles.d.ts +45 -0
- package/dist/sealed-handles.d.ts.map +1 -0
- package/dist/sealed-handles.js +408 -0
- package/dist/sealed-handles.js.map +1 -0
- package/dist/sealed-store.d.ts +242 -0
- package/dist/sealed-store.d.ts.map +1 -0
- package/dist/sealed-store.js +1155 -0
- package/dist/sealed-store.js.map +1 -0
- package/dist/serializer.d.ts +6 -15
- package/dist/serializer.d.ts.map +1 -1
- package/dist/session-toolbar.d.ts +20 -5
- package/dist/session-toolbar.d.ts.map +1 -1
- package/dist/session-toolbar.js +419 -67
- package/dist/session-toolbar.js.map +1 -1
- package/dist/session.d.ts +38 -1
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +562 -79
- package/dist/session.js.map +1 -1
- package/dist/sha256.d.ts +3 -0
- package/dist/sha256.d.ts.map +1 -0
- package/dist/sha256.js +74 -0
- package/dist/sha256.js.map +1 -0
- package/dist/stable-handles.d.ts +19 -0
- package/dist/stable-handles.d.ts.map +1 -0
- package/dist/stable-handles.js +193 -0
- package/dist/stable-handles.js.map +1 -0
- package/dist/staged-seal.d.ts +2 -0
- package/dist/staged-seal.d.ts.map +1 -0
- package/dist/staged-seal.js +76 -0
- package/dist/staged-seal.js.map +1 -0
- package/dist/subscriber-delivery.d.ts +8 -0
- package/dist/subscriber-delivery.d.ts.map +1 -0
- package/dist/subscriber-delivery.js +31 -0
- package/dist/subscriber-delivery.js.map +1 -0
- package/dist/tail-op-import.d.ts +10 -0
- package/dist/tail-op-import.d.ts.map +1 -0
- package/dist/tail-op-import.js +33 -0
- package/dist/tail-op-import.js.map +1 -0
- package/dist/topic-lifecycle.d.ts +6 -0
- package/dist/topic-lifecycle.d.ts.map +1 -1
- package/dist/topic-lifecycle.js +32 -2
- package/dist/topic-lifecycle.js.map +1 -1
- package/dist/trial-replace-dialog.d.ts +10 -0
- package/dist/trial-replace-dialog.d.ts.map +1 -0
- package/dist/trial-replace-dialog.js +153 -0
- package/dist/trial-replace-dialog.js.map +1 -0
- package/dist/types.d.ts +8 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/undo.d.ts +60 -5
- package/dist/undo.d.ts.map +1 -1
- package/dist/undo.js +344 -75
- package/dist/undo.js.map +1 -1
- package/dist/version-bar.d.ts +1 -0
- package/dist/version-bar.d.ts.map +1 -1
- package/dist/version-bar.js +2 -1
- package/dist/version-bar.js.map +1 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/dist/ws-client.d.ts +21 -0
- package/dist/ws-client.d.ts.map +1 -1
- package/dist/ws-client.js +192 -8
- package/dist/ws-client.js.map +1 -1
- package/dist/ws-frame-limit.d.ts +1 -0
- package/dist/ws-frame-limit.d.ts.map +1 -1
- package/dist/ws-frame-limit.js.map +1 -1
- package/docs/app-manifest.md +121 -17
- package/docs/custom-chrome.md +24 -16
- package/docs/hosted-ui-advanced.md +38 -6
- package/docs/imported-documents.md +104 -51
- package/docs/presence.md +16 -4
- package/docs/schemas.md +8 -5
- package/docs/services.md +6 -6
- package/docs/streams.md +139 -150
- package/docs/text-fields.md +26 -19
- package/package.json +5 -2
- package/src/api-fetch.ts +53 -3
- package/src/auth-flow.ts +1 -1
- package/src/batched-get.ts +117 -0
- package/src/call-service.ts +78 -3
- package/src/client.ts +1022 -112
- package/src/document-checkpoints.ts +307 -65
- package/src/document-commands.ts +5 -5
- package/src/document-contents.ts +27 -1
- package/src/document-set-subscription.ts +274 -0
- package/src/document-signals.ts +47 -5
- package/src/document-streams.ts +85 -16
- package/src/document-thumbnail-capture.ts +118 -32
- package/src/documents-namespace.ts +539 -84
- package/src/documents.ts +2017 -348
- package/src/errors.ts +17 -6
- package/src/fetch-retry.ts +5 -0
- package/src/file-choice.ts +26 -0
- package/src/guest-link.ts +362 -1
- package/src/handle-types.ts +73 -47
- package/src/handles.ts +253 -83
- package/src/hosted-ui.ts +211 -50
- package/src/import-contract.ts +227 -0
- package/src/import-guest.ts +193 -0
- package/src/import-report.ts +24 -0
- package/src/importer-wait.ts +4 -0
- package/src/importer.ts +386 -131
- package/src/in-development.ts +10 -0
- package/src/index.ts +76 -16
- package/src/init-keys.ts +6 -1
- package/src/input-documents.ts +819 -0
- package/src/inputs-status.ts +598 -0
- package/src/inputs.ts +1090 -0
- package/src/internal.ts +7 -0
- package/src/join-content.ts +135 -0
- package/src/join-stamp.ts +12 -0
- package/src/manifest-drift.ts +54 -0
- package/src/open-document.ts +2346 -930
- package/src/page-restore.ts +38 -0
- package/src/placement.ts +54 -0
- package/src/presence.ts +21 -5
- package/src/read-only-document.ts +133 -0
- package/src/record-paging.ts +147 -25
- package/src/references.ts +207 -0
- package/src/request-lane.ts +59 -0
- package/src/sealed-arrow.ts +843 -0
- package/src/sealed-cache.ts +267 -0
- package/src/sealed-encode.ts +1015 -0
- package/src/sealed-handles.ts +486 -0
- package/src/sealed-store.ts +1330 -0
- package/src/serializer.ts +8 -20
- package/src/session-toolbar.ts +463 -72
- package/src/session.ts +740 -99
- package/src/sha256.ts +65 -0
- package/src/stable-handles.ts +235 -0
- package/src/staged-seal.ts +90 -0
- package/src/subscriber-delivery.ts +37 -0
- package/src/tail-op-import.ts +30 -0
- package/src/topic-lifecycle.ts +38 -2
- package/src/trial-replace-dialog.ts +163 -0
- package/src/types.ts +6 -6
- package/src/undo.ts +386 -73
- package/src/version-bar.ts +5 -1
- package/src/version.ts +1 -1
- package/src/ws-client.ts +208 -16
- package/src/ws-frame-limit.ts +1 -0
package/dist/session.js
CHANGED
|
@@ -6,8 +6,14 @@
|
|
|
6
6
|
// registered canvas it also auto-captures the thumbnail. The app renders the
|
|
7
7
|
// commands and draws from `session.document`. Every primitive the session
|
|
8
8
|
// calls stays public, so an app can ignore the session and wire them directly.
|
|
9
|
+
import { placeThroughHostedSurfaces } from './placement.js';
|
|
9
10
|
import { BrassError } from './errors.js';
|
|
10
11
|
import { makeLiveCommand } from './document-commands.js';
|
|
12
|
+
import { appCallbackError } from './subscriber-delivery.js';
|
|
13
|
+
// Thrown through a guest open whose link leads its claimant to sign in: the
|
|
14
|
+
// open ends with no document and no error, on the signed-out surface.
|
|
15
|
+
class SignInToFollowLink extends Error {
|
|
16
|
+
}
|
|
11
17
|
// Built-in orphan command labels (no active doc needed). Sentence case, ASCII
|
|
12
18
|
// ellipsis, per the copy-style rules. Both stay bare: an app's own word for
|
|
13
19
|
// what it holds is rarely "document" (a sketch, a model, a sheet), so a
|
|
@@ -15,6 +21,9 @@ import { makeLiveCommand } from './document-commands.js';
|
|
|
15
21
|
const SESSION_LABELS = {
|
|
16
22
|
new: 'New',
|
|
17
23
|
open: 'Open...',
|
|
24
|
+
// Without an account there is nothing to browse, so Open takes a file from
|
|
25
|
+
// the reader's own machine.
|
|
26
|
+
upload: 'Upload...',
|
|
18
27
|
};
|
|
19
28
|
function toError(err) {
|
|
20
29
|
return err instanceof Error ? err : new Error(String(err));
|
|
@@ -22,14 +31,17 @@ function toError(err) {
|
|
|
22
31
|
// Fallback copy for a failed open with no server message to render (a
|
|
23
32
|
// network drop, no HTTP response). The HTTP case carries the server's own
|
|
24
33
|
// message instead, so the platform evolves the wording without an app change.
|
|
25
|
-
const OPEN_FAILED_MESSAGE = 'This
|
|
34
|
+
const OPEN_FAILED_MESSAGE = 'This could not be opened';
|
|
35
|
+
// The session holds a live copy, so a frozen one is opened beside it.
|
|
36
|
+
const FROZEN_IN_SESSION = 'The session holds a live copy of its document. Open a frozen copy with Brass.openDocumentById';
|
|
37
|
+
const PREPARING_MESSAGE = 'Preparing for editing...';
|
|
26
38
|
// Copy parked on `session.error` when the open document is evicted remotely
|
|
27
39
|
// (a `doc_gone` the local user did not initiate). Matches the wording a
|
|
28
|
-
// post-eviction op frame degrades to ("
|
|
40
|
+
// post-eviction op frame degrades to ("this is no longer available"),
|
|
29
41
|
// so a delete observed live and a fresh visit to the same deleted id read the
|
|
30
42
|
// same. A reload re-opens the id, hits the server 404, and swaps in the
|
|
31
43
|
// server-authored copy.
|
|
32
|
-
const DOCUMENT_GONE_MESSAGE = 'This
|
|
44
|
+
const DOCUMENT_GONE_MESSAGE = 'This is no longer available';
|
|
33
45
|
// The error parked on `session.error` for a failed open, shaped so its
|
|
34
46
|
// `.message` is always presentable copy an app can render verbatim. An HTTP
|
|
35
47
|
// failure is returned as-is: its `.message` is the server's (non-disclosing,
|
|
@@ -66,6 +78,9 @@ export function createDocumentSession(deps) {
|
|
|
66
78
|
// changes, so a per-commit `doc.subscribe` that doesn't move a command's
|
|
67
79
|
// enabled state stays quiet.
|
|
68
80
|
let lastSignature = null;
|
|
81
|
+
// Counts the documents bound, so a new copy of the same id (a reopen for
|
|
82
|
+
// editing, a reload) changes the signature and reaches subscribers.
|
|
83
|
+
let bindCount = 0;
|
|
69
84
|
// Outstanding `whenReady()` promises, settled in `emit()`: resolve once a
|
|
70
85
|
// document binds, reject if the user signs out before one ever does.
|
|
71
86
|
const whenReadyPending = [];
|
|
@@ -95,17 +110,53 @@ export function createDocumentSession(deps) {
|
|
|
95
110
|
// delete retracts its id, so a later remote delete of the same doc is not
|
|
96
111
|
// misread as local.
|
|
97
112
|
const localDeletes = new Set();
|
|
113
|
+
// Set for the length of an open started without an account (see `status`).
|
|
114
|
+
let accountFreeOpen = false;
|
|
115
|
+
// Documents the session opened read-only by request, as opposed to ones a
|
|
116
|
+
// reader's access made read-only. A reopen of one (a move, a reload) keeps
|
|
117
|
+
// that mode.
|
|
118
|
+
const readOnlyOpens = new WeakSet();
|
|
119
|
+
// The read intent of one open: the caller's ask where it made one, else the
|
|
120
|
+
// mode the app set at init.
|
|
121
|
+
function inMode(options, asked) {
|
|
122
|
+
const readOnly = asked ?? (deps.openReadOnly?.() === true ? true : undefined);
|
|
123
|
+
return readOnly === undefined ? options : { ...options, readOnly };
|
|
124
|
+
}
|
|
125
|
+
// Opens that asked to read, keyed by the open's own promise, so `activate`
|
|
126
|
+
// records the document it binds from one in `readOnlyOpens`.
|
|
127
|
+
const readOnlyAsks = new WeakSet();
|
|
128
|
+
function remember(opened, options) {
|
|
129
|
+
if (options.readOnly ?? options.documentOptions?.readOnly === true) {
|
|
130
|
+
readOnlyAsks.add(opened);
|
|
131
|
+
}
|
|
132
|
+
return opened;
|
|
133
|
+
}
|
|
134
|
+
function openById(docId, options, consent) {
|
|
135
|
+
const opened = consent === undefined
|
|
136
|
+
? deps.openDocumentById(docId, options)
|
|
137
|
+
: deps.openDocumentById(docId, options, consent);
|
|
138
|
+
return remember(opened, options);
|
|
139
|
+
}
|
|
140
|
+
function openByLink(openGuest, token, options) {
|
|
141
|
+
return remember(openGuest(token, options), options);
|
|
142
|
+
}
|
|
98
143
|
function status() {
|
|
99
144
|
// 'unknown' before auth settles; the onUserChange handler re-fires
|
|
100
145
|
// (start() / emit()) and flips this to its resolved value.
|
|
101
146
|
if (deps.authStatus() === 'unknown')
|
|
102
147
|
return 'unknown';
|
|
148
|
+
if (deps.initFailure?.())
|
|
149
|
+
return 'error';
|
|
103
150
|
// A guest (opened via `?brass_link=`) is not signed in but still has a
|
|
104
151
|
// read-only document to load, so it never reports 'signed-out' (which
|
|
105
152
|
// would render the app's sign-in surface instead of the document).
|
|
106
153
|
const isGuest = deps.guestLinkToken?.() != null;
|
|
107
|
-
|
|
154
|
+
// An open started without an account loads before there is a link to be
|
|
155
|
+
// a guest on (a pick sends its file first), and the visitor is watching
|
|
156
|
+
// that load, not the sign-in surface.
|
|
157
|
+
if (!deps.isSignedIn() && !isGuest && !(loading && accountFreeOpen)) {
|
|
108
158
|
return 'signed-out';
|
|
159
|
+
}
|
|
109
160
|
if (loading)
|
|
110
161
|
return 'loading';
|
|
111
162
|
if (document)
|
|
@@ -129,15 +180,25 @@ export function createDocumentSession(deps) {
|
|
|
129
180
|
const load = loading ? `${loadingMessage ?? ''}:${loadingProgress ?? ''}` : '';
|
|
130
181
|
// `openError` message joins the signature so a switch from one failed open
|
|
131
182
|
// to a different one (same 'error' status, no bound doc) still re-fires.
|
|
132
|
-
const
|
|
183
|
+
const shown = openError ?? deps.initFailure?.() ?? null;
|
|
184
|
+
const err = shown ? `${shown.status ?? ''}:${shown.message}` : '';
|
|
133
185
|
// The previewed version and the browsing flag are their own terms because
|
|
134
186
|
// nothing else in this signature follows them: a preview -> preview step
|
|
135
187
|
// holds the id, the name, the status and every command's enabled state
|
|
136
188
|
// (edit affordances are already disabled by the first checkout), so
|
|
137
189
|
// without these the step is a silent emit and the chrome rendering the
|
|
138
190
|
// preview keeps showing the version the reader stepped away from.
|
|
139
|
-
const ver = `${document?.
|
|
140
|
-
|
|
191
|
+
const ver = `${document?.previewVersion ?? ''}:${document?.versionBrowsing ? 1 : 0}`;
|
|
192
|
+
// The expiry moves once, to null, when the document is claimed; the
|
|
193
|
+
// chrome naming it has to hear that.
|
|
194
|
+
const expiry = document?.expiresAt?.getTime() ?? '';
|
|
195
|
+
return `${status()}|${bindCount}|${document?.id ?? ''}|${documentName ?? ''}|${load}|${err}|${ver}|${expiry}|${cmds}`;
|
|
196
|
+
}
|
|
197
|
+
// A listener that throws usually leaves the app showing nothing, so the
|
|
198
|
+
// app's `onError` hears about it as well as the console.
|
|
199
|
+
function reportListenerThrow(err) {
|
|
200
|
+
console.error('[Brass] session listener threw:', err);
|
|
201
|
+
deps.reportError(appCallbackError(err));
|
|
141
202
|
}
|
|
142
203
|
function emit() {
|
|
143
204
|
const sig = signature();
|
|
@@ -157,7 +218,7 @@ export function createDocumentSession(deps) {
|
|
|
157
218
|
}
|
|
158
219
|
catch (err) {
|
|
159
220
|
// A throwing listener must not strand the others.
|
|
160
|
-
|
|
221
|
+
reportListenerThrow(err);
|
|
161
222
|
}
|
|
162
223
|
}
|
|
163
224
|
}
|
|
@@ -177,12 +238,14 @@ export function createDocumentSession(deps) {
|
|
|
177
238
|
for (const { reject } of pending)
|
|
178
239
|
reject(error);
|
|
179
240
|
}
|
|
180
|
-
else if (current === 'error' &&
|
|
181
|
-
// A user-initiated open failed terminally
|
|
182
|
-
// awaiter forever. Same error object the
|
|
241
|
+
else if (current === 'error' && session.error) {
|
|
242
|
+
// A user-initiated open failed terminally, or init could not start;
|
|
243
|
+
// reject rather than hang the awaiter forever. Same error object the
|
|
244
|
+
// session surfaces on `.error`.
|
|
245
|
+
const failure = session.error;
|
|
183
246
|
const pending = whenReadyPending.splice(0, whenReadyPending.length);
|
|
184
247
|
for (const { reject } of pending)
|
|
185
|
-
reject(
|
|
248
|
+
reject(failure);
|
|
186
249
|
}
|
|
187
250
|
// No branch for 'empty': it is both the idle pre-open state (an awaiter
|
|
188
251
|
// legitimately waits for a future open) and the post-close state. A close
|
|
@@ -211,10 +274,70 @@ export function createDocumentSession(deps) {
|
|
|
211
274
|
notify: emit,
|
|
212
275
|
});
|
|
213
276
|
}
|
|
214
|
-
|
|
277
|
+
// New and Open start a document without an account where the app can mint
|
|
278
|
+
// one, so a visitor's own buttons and the menu run the same commands.
|
|
279
|
+
// A failed init leaves the page signed out with nothing either command
|
|
280
|
+
// could reach, so neither is offered.
|
|
281
|
+
const withoutAccount = () => {
|
|
282
|
+
if (deps.initFailure?.())
|
|
283
|
+
return false;
|
|
284
|
+
const status = deps.authStatus();
|
|
285
|
+
return status === 'signed-out' || status === 'guest';
|
|
286
|
+
};
|
|
287
|
+
// The server refuses every account-free route for an app that does not
|
|
288
|
+
// offer signed-out use, so New and Open are withdrawn once it says so
|
|
289
|
+
// rather than offered only to show that refusal. Read lazily, from the
|
|
290
|
+
// first check made while signed out, and settled for the page.
|
|
291
|
+
let signedOutRefusal = null;
|
|
292
|
+
let signedOutProbe = null;
|
|
293
|
+
const refusedWithoutAccount = () => {
|
|
294
|
+
if (signedOutProbe === null && deps.signedOutUseRefusal) {
|
|
295
|
+
const read = deps.signedOutUseRefusal;
|
|
296
|
+
signedOutProbe = (async () => {
|
|
297
|
+
try {
|
|
298
|
+
signedOutRefusal = await read();
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
signedOutRefusal = null;
|
|
302
|
+
}
|
|
303
|
+
if (signedOutRefusal !== null)
|
|
304
|
+
emit();
|
|
305
|
+
})();
|
|
306
|
+
}
|
|
307
|
+
return signedOutRefusal !== null;
|
|
308
|
+
};
|
|
309
|
+
const canStartWithoutAccount = () => withoutAccount() &&
|
|
310
|
+
!refusedWithoutAccount() &&
|
|
311
|
+
deps.mintAnonymousDocument !== undefined &&
|
|
312
|
+
deps.enterGuestMode !== undefined &&
|
|
313
|
+
deps.openDocumentByLink !== undefined;
|
|
314
|
+
const canPickWithoutAccount = () => canStartWithoutAccount() &&
|
|
315
|
+
deps.chooseAnonymousFile !== undefined &&
|
|
316
|
+
deps.mintAnonymousDocumentFromFile !== undefined;
|
|
317
|
+
// An app that creates no documents offers no New. Read lazily, from the
|
|
318
|
+
// first check, and settled for the page; until it answers, New is offered.
|
|
319
|
+
let offersNew = true;
|
|
320
|
+
let offersNewProbe = null;
|
|
321
|
+
const appOffersNew = () => {
|
|
322
|
+
if (offersNewProbe === null && deps.appOffersNew) {
|
|
323
|
+
const read = deps.appOffersNew;
|
|
324
|
+
offersNewProbe = (async () => {
|
|
325
|
+
try {
|
|
326
|
+
offersNew = await read();
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
offersNew = true;
|
|
330
|
+
}
|
|
331
|
+
if (!offersNew)
|
|
332
|
+
emit();
|
|
333
|
+
})();
|
|
334
|
+
}
|
|
335
|
+
return offersNew;
|
|
336
|
+
};
|
|
337
|
+
const cmdNew = buildOrphanCommand('new', SESSION_LABELS.new, 'session', 'menu', () => appOffersNew() && (deps.isSignedIn() || canStartWithoutAccount()), async () => {
|
|
215
338
|
await create();
|
|
216
339
|
});
|
|
217
|
-
const cmdOpen = buildOrphanCommand('open', SESSION_LABELS.open, 'session', 'menu', deps.isSignedIn, async () => {
|
|
340
|
+
const cmdOpen = buildOrphanCommand('open', () => (withoutAccount() ? SESSION_LABELS.upload : SESSION_LABELS.open), 'session', 'menu', () => deps.isSignedIn() || canPickWithoutAccount(), async () => {
|
|
218
341
|
await pick();
|
|
219
342
|
});
|
|
220
343
|
const orphanCommands = [cmdNew, cmdOpen];
|
|
@@ -249,6 +372,8 @@ export function createDocumentSession(deps) {
|
|
|
249
372
|
sessionCanvasUnregister = null;
|
|
250
373
|
}
|
|
251
374
|
const previous = document;
|
|
375
|
+
if (next !== previous)
|
|
376
|
+
bindCount += 1;
|
|
252
377
|
document = next;
|
|
253
378
|
documentName = name;
|
|
254
379
|
if (next === null) {
|
|
@@ -262,6 +387,17 @@ export function createDocumentSession(deps) {
|
|
|
262
387
|
// A close failure (already-closed, freed WASM) is non-fatal.
|
|
263
388
|
}
|
|
264
389
|
}
|
|
390
|
+
if (next && next !== previous) {
|
|
391
|
+
// `reopen` on the bound copy goes through the session, so the new copy
|
|
392
|
+
// binds in its place. A copy the session no longer holds reopens the
|
|
393
|
+
// way the layer that opened it would.
|
|
394
|
+
const outside = next._reopenerFn;
|
|
395
|
+
next._setReopener((mode) => document === next
|
|
396
|
+
? reopenBound(next, mode)
|
|
397
|
+
: outside !== null
|
|
398
|
+
? outside(mode)
|
|
399
|
+
: Promise.reject(new BrassError('This document cannot be reopened', { status: 0 })));
|
|
400
|
+
}
|
|
265
401
|
if (next) {
|
|
266
402
|
// `subscribe` fires on every change including local mutations (the
|
|
267
403
|
// session's listeners drive UI that re-renders on local edits). The
|
|
@@ -274,6 +410,8 @@ export function createDocumentSession(deps) {
|
|
|
274
410
|
// the pinned branch settles once the default is known.
|
|
275
411
|
if (event.kind === 'branches')
|
|
276
412
|
syncUrlForActive('replace');
|
|
413
|
+
if (event.kind === 'change' && event.source !== 'remote')
|
|
414
|
+
noteTrialEdit(next);
|
|
277
415
|
// Take the doc's name whenever it moves. A rename reaches the doc
|
|
278
416
|
// from the server's `doc_meta` frame, which sets it directly, and
|
|
279
417
|
// `documentName` is what the emit signature reads: without this the
|
|
@@ -312,15 +450,34 @@ export function createDocumentSession(deps) {
|
|
|
312
450
|
// keeps its topic subscription and its LoroDoc until it is closed, so
|
|
313
451
|
// every move would otherwise leave one resident for the life of the
|
|
314
452
|
// page, taking a copy of every op the reopened document receives.
|
|
453
|
+
//
|
|
454
|
+
// A guest holds no access by id, so it re-opens through its link,
|
|
455
|
+
// as its first open did.
|
|
456
|
+
//
|
|
457
|
+
// A copy that is already read-only (a reload onto converted or
|
|
458
|
+
// sealed content, or one discarding a refused change) stays bound
|
|
459
|
+
// until the re-opened one replaces it, so the reader keeps seeing
|
|
460
|
+
// the document instead of a loading surface between two copies of
|
|
461
|
+
// it. Nothing can be written to it in the meantime. An editable one
|
|
462
|
+
// is detached as any other open detaches its predecessor.
|
|
315
463
|
const branch = urlBranch(next);
|
|
316
464
|
const evicted = next;
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
465
|
+
const keepBound = next.readOnly;
|
|
466
|
+
const mode = readOnlyOpens.has(next) ? { readOnly: true } : {};
|
|
467
|
+
const guestToken = deps.guestLinkToken?.();
|
|
468
|
+
const openGuest = deps.openDocumentByLink;
|
|
469
|
+
if (!keepBound) {
|
|
470
|
+
document = null;
|
|
471
|
+
documentName = null;
|
|
472
|
+
}
|
|
473
|
+
void activate((onSnapshotProgress) => guestToken && openGuest
|
|
474
|
+
? openByLink(openGuest, guestToken, { ...mode, onSnapshotProgress })
|
|
475
|
+
: openById(id, {
|
|
476
|
+
...withDefaults(),
|
|
477
|
+
...(branch ? { branch } : {}),
|
|
478
|
+
...mode,
|
|
479
|
+
onSnapshotProgress,
|
|
480
|
+
}, 'skip'), null, 'skip', branch, true, keepBound)
|
|
324
481
|
.catch(() => markGoneRemote())
|
|
325
482
|
.finally(() => closeQuietly(evicted));
|
|
326
483
|
return;
|
|
@@ -426,9 +583,16 @@ export function createDocumentSession(deps) {
|
|
|
426
583
|
// boot last-selected fallback, whose failure is a stale-persistence
|
|
427
584
|
// self-heal that should settle silently on empty (the user asked for
|
|
428
585
|
// nothing specific).
|
|
429
|
-
surfaceOpenError = true
|
|
586
|
+
surfaceOpenError = true,
|
|
587
|
+
// Re-opening the document already bound: it stays bound, and the session
|
|
588
|
+
// stays 'ready', until the new copy replaces it. A failure detaches it as
|
|
589
|
+
// though it had been detached at the start.
|
|
590
|
+
keepBound = false,
|
|
591
|
+
// With `keepBound`: a failure leaves the bound document in place and only
|
|
592
|
+
// rejects the caller.
|
|
593
|
+
keepOnFailure = false) {
|
|
430
594
|
const generation = ++openGeneration;
|
|
431
|
-
loading =
|
|
595
|
+
loading = !keepBound;
|
|
432
596
|
loadingMessage = null;
|
|
433
597
|
loadingProgress = null;
|
|
434
598
|
// A new open attempt supersedes any prior failure's error state.
|
|
@@ -439,11 +603,13 @@ export function createDocumentSession(deps) {
|
|
|
439
603
|
// 'loading' until the new doc binds; a failed open restores the previous
|
|
440
604
|
// binding. The doc-event/gone subscriptions stay attached (`setDocument`
|
|
441
605
|
// re-points them, and the restore path watches the same doc).
|
|
442
|
-
|
|
443
|
-
const previousName = documentName;
|
|
606
|
+
let previousDocument = keepBound ? null : document;
|
|
607
|
+
const previousName = keepBound ? null : documentName;
|
|
444
608
|
const previousSpecifiedBranch = activeSpecifiedBranch;
|
|
445
|
-
|
|
446
|
-
|
|
609
|
+
if (!keepBound) {
|
|
610
|
+
document = null;
|
|
611
|
+
documentName = null;
|
|
612
|
+
}
|
|
447
613
|
// What the join's spilled ops have cost so far, and what the whole
|
|
448
614
|
// download was declared to be. Carried across the two phases so the pair
|
|
449
615
|
// reads as one figure. Per open, because a second open of the same
|
|
@@ -453,9 +619,19 @@ export function createDocumentSession(deps) {
|
|
|
453
619
|
emit();
|
|
454
620
|
// Surface snapshot-download progress. Ignored once a newer open supersedes
|
|
455
621
|
// this generation, so a slow stale download can't repaint the new open.
|
|
456
|
-
const onSnapshotProgress = (loaded, total, phase) => {
|
|
622
|
+
const onSnapshotProgress = (loaded, total, phase, message) => {
|
|
457
623
|
if (generation !== openGeneration)
|
|
458
|
-
return;
|
|
624
|
+
return false;
|
|
625
|
+
// The platform is making the document editable, or replacing a
|
|
626
|
+
// reader's content, and the open waits for it with nothing bound
|
|
627
|
+
// meanwhile. A replacement is described in the platform's own words.
|
|
628
|
+
if (phase === 'preparing' || phase === 'importing') {
|
|
629
|
+
loadingProgress = null;
|
|
630
|
+
loadingMessage =
|
|
631
|
+
phase === 'preparing' ? PREPARING_MESSAGE : message !== undefined && message !== '' ? message : 'Loading...';
|
|
632
|
+
emit();
|
|
633
|
+
return true;
|
|
634
|
+
}
|
|
459
635
|
// One download, reported once. A document whose ops spilled to storage
|
|
460
636
|
// fetches them from the join before the snapshot, so a load has two
|
|
461
637
|
// stretches of bytes arriving over the network with a reader watching.
|
|
@@ -504,6 +680,7 @@ export function createDocumentSession(deps) {
|
|
|
504
680
|
loadingMessage = `Downloading... ${(loaded / 1_000_000).toFixed(1)} MB`;
|
|
505
681
|
}
|
|
506
682
|
emit();
|
|
683
|
+
return true;
|
|
507
684
|
};
|
|
508
685
|
// Give up on this open. A warm swap (previous doc present) rebinds it and
|
|
509
686
|
// only rejects the caller, so the working surface is never clobbered; with
|
|
@@ -512,6 +689,13 @@ export function createDocumentSession(deps) {
|
|
|
512
689
|
// and read as the app's document-less home. `display` is what the app
|
|
513
690
|
// renders; it defaults to the platform's own open-failure copy.
|
|
514
691
|
function abandonOpen(err, display) {
|
|
692
|
+
if (keepOnFailure && generation === openGeneration) {
|
|
693
|
+
loading = false;
|
|
694
|
+
loadingMessage = null;
|
|
695
|
+
loadingProgress = null;
|
|
696
|
+
emit();
|
|
697
|
+
throw err;
|
|
698
|
+
}
|
|
515
699
|
if (generation === openGeneration) {
|
|
516
700
|
loading = false;
|
|
517
701
|
loadingMessage = null;
|
|
@@ -519,7 +703,11 @@ export function createDocumentSession(deps) {
|
|
|
519
703
|
// 'replace' restores the URL without planting an entry for an open
|
|
520
704
|
// that never happened.
|
|
521
705
|
activeSpecifiedBranch = previousSpecifiedBranch;
|
|
522
|
-
if (
|
|
706
|
+
if (err instanceof SignInToFollowLink) {
|
|
707
|
+
// The URL names the document the sign-in returns to.
|
|
708
|
+
setDocument(previousDocument, previousName, 'skip');
|
|
709
|
+
}
|
|
710
|
+
else if (surfaceOpenError && previousDocument === null) {
|
|
523
711
|
openError = display ?? toDisplayError(err);
|
|
524
712
|
// Leave the URL pointing at the doc that failed ('skip', not the
|
|
525
713
|
// 'replace' below that clears it for a null document): the error
|
|
@@ -538,13 +726,36 @@ export function createDocumentSession(deps) {
|
|
|
538
726
|
}
|
|
539
727
|
throw err;
|
|
540
728
|
}
|
|
729
|
+
// A file the open sends before there is a document to read.
|
|
730
|
+
const onUploadProgress = (loaded, total) => {
|
|
731
|
+
if (generation !== openGeneration)
|
|
732
|
+
return;
|
|
733
|
+
loadingProgress = total > 0 ? Math.min(1, loaded / total) : null;
|
|
734
|
+
loadingMessage =
|
|
735
|
+
loadingProgress === null
|
|
736
|
+
? 'Uploading...'
|
|
737
|
+
: `Uploading... ${Math.floor(loadingProgress * 100)}%`;
|
|
738
|
+
emit();
|
|
739
|
+
};
|
|
740
|
+
// A load that moves the page onto another document's link takes the
|
|
741
|
+
// outgoing document's credential with it, so that document closes then
|
|
742
|
+
// rather than after the bind, and a failed open has nothing to rebind.
|
|
743
|
+
const releasePrevious = () => {
|
|
744
|
+
closeQuietly(previousDocument);
|
|
745
|
+
previousDocument = null;
|
|
746
|
+
};
|
|
541
747
|
let doc;
|
|
748
|
+
let askedToRead = false;
|
|
542
749
|
try {
|
|
543
|
-
|
|
750
|
+
const opening = load(onSnapshotProgress, onUploadProgress, releasePrevious);
|
|
751
|
+
askedToRead = readOnlyAsks.has(opening);
|
|
752
|
+
doc = await opening;
|
|
544
753
|
}
|
|
545
754
|
catch (err) {
|
|
546
755
|
abandonOpen(err);
|
|
547
756
|
}
|
|
757
|
+
if (askedToRead)
|
|
758
|
+
readOnlyOpens.add(doc);
|
|
548
759
|
if (generation !== openGeneration) {
|
|
549
760
|
// A newer open / close superseded this one. Discard the orphan (so its
|
|
550
761
|
// WS subscription doesn't leak) plus the doc this generation detached.
|
|
@@ -552,13 +763,14 @@ export function createDocumentSession(deps) {
|
|
|
552
763
|
closeQuietly(previousDocument);
|
|
553
764
|
return doc;
|
|
554
765
|
}
|
|
555
|
-
//
|
|
556
|
-
//
|
|
557
|
-
//
|
|
558
|
-
//
|
|
559
|
-
// and bind only on
|
|
560
|
-
// the content. A pending push is non-blocking and binds
|
|
561
|
-
|
|
766
|
+
// An open by id resolves with the document's content or rejects, waiting
|
|
767
|
+
// out any import first. A trial document opened through its link can
|
|
768
|
+
// arrive empty while the visitor's file is imported, with a pending
|
|
769
|
+
// blocking status; hold the session in 'loading' (surfacing the server
|
|
770
|
+
// message) and bind only on the terminal status, so the editor's first
|
|
771
|
+
// paint sees the content. A pending push is non-blocking and binds
|
|
772
|
+
// immediately.
|
|
773
|
+
const work = doc._workStatusNow;
|
|
562
774
|
if (work !== null && work.pending && work.blocking !== false) {
|
|
563
775
|
loadingMessage = work.message;
|
|
564
776
|
loadingProgress = work.progress ?? null;
|
|
@@ -600,7 +812,7 @@ export function createDocumentSession(deps) {
|
|
|
600
812
|
// platform's own message is what the app renders.
|
|
601
813
|
if (doc._contentLoadFailed) {
|
|
602
814
|
closeQuietly(doc);
|
|
603
|
-
const failure = new BrassError(doc.
|
|
815
|
+
const failure = new BrassError(doc._workStatusNow?.message ?? OPEN_FAILED_MESSAGE, { status: 0 });
|
|
604
816
|
abandonOpen(failure, failure);
|
|
605
817
|
}
|
|
606
818
|
loading = false;
|
|
@@ -637,14 +849,24 @@ export function createDocumentSession(deps) {
|
|
|
637
849
|
return document;
|
|
638
850
|
const openGuest = deps.openDocumentByLink;
|
|
639
851
|
try {
|
|
640
|
-
return await activate((onSnapshotProgress) => openGuest
|
|
852
|
+
return await activate((onSnapshotProgress) => openByLink(openGuest, guestToken, inMode({ onSnapshotProgress }, undefined)).catch(async (err) => {
|
|
853
|
+
const claimed = await claimedDocumentOf(guestToken, err);
|
|
854
|
+
if (claimed === null)
|
|
855
|
+
throw err;
|
|
856
|
+
if (claimed === 'sign-in')
|
|
857
|
+
throw new SignInToFollowLink();
|
|
858
|
+
return openById(claimed.docId, inMode({ onSnapshotProgress }, undefined), 'skip');
|
|
859
|
+
}), null, 'skip', null);
|
|
641
860
|
}
|
|
642
|
-
catch {
|
|
861
|
+
catch (err) {
|
|
862
|
+
forgetUnsavedWorkIfGone(guestToken, err);
|
|
643
863
|
return null;
|
|
644
864
|
}
|
|
645
865
|
}
|
|
646
|
-
if (
|
|
866
|
+
if (document)
|
|
647
867
|
return document;
|
|
868
|
+
if (!deps.isSignedIn())
|
|
869
|
+
return restoreUnsavedWork();
|
|
648
870
|
// A pick handed back by the hosted picker's redirect fallback opens
|
|
649
871
|
// before the ordinary URL / last-selection restore, and independently of
|
|
650
872
|
// `syncDocToUrl`. `takePickSelection` strips the one-shot params as it
|
|
@@ -655,10 +877,10 @@ export function createDocumentSession(deps) {
|
|
|
655
877
|
try {
|
|
656
878
|
// 'replace': a boot restore pins the URL selection in place, not a
|
|
657
879
|
// navigation, so it must not plant a history entry.
|
|
658
|
-
return await activate((onSnapshotProgress) =>
|
|
880
|
+
return await activate((onSnapshotProgress) => openById(sel.docId, inMode({
|
|
659
881
|
...(sel.branchId ? { branch: sel.branchId } : {}),
|
|
660
882
|
onSnapshotProgress,
|
|
661
|
-
}, 'skip'), null, 'replace', sel.branchId);
|
|
883
|
+
}, undefined), 'skip'), null, 'replace', sel.branchId);
|
|
662
884
|
}
|
|
663
885
|
catch {
|
|
664
886
|
// The user navigated to a specific `?doc=` and it could not be
|
|
@@ -673,7 +895,7 @@ export function createDocumentSession(deps) {
|
|
|
673
895
|
const last = deps.lastSelectedDocument();
|
|
674
896
|
if (last) {
|
|
675
897
|
try {
|
|
676
|
-
return await activate((onSnapshotProgress) =>
|
|
898
|
+
return await activate((onSnapshotProgress) => openById(last.doc_id, inMode({ onSnapshotProgress }, undefined), 'skip'), last.name, 'replace', null,
|
|
677
899
|
// A stale last-selection is a persistence self-heal, not a request
|
|
678
900
|
// the user made; settle silently on empty rather than parking an
|
|
679
901
|
// error.
|
|
@@ -685,6 +907,64 @@ export function createDocumentSession(deps) {
|
|
|
685
907
|
}
|
|
686
908
|
return null;
|
|
687
909
|
}
|
|
910
|
+
// Whether a failed link open says the link itself is gone. The view route
|
|
911
|
+
// answers `404` for a token it does not know, `410` for one that expired
|
|
912
|
+
// or was revoked and `403` for one its organization's policy now refuses;
|
|
913
|
+
// anything else (no response, a shed request) says nothing about the link.
|
|
914
|
+
function linkGone(err) {
|
|
915
|
+
return (err instanceof BrassError &&
|
|
916
|
+
(err.status === 403 || err.status === 404 || err.status === 410));
|
|
917
|
+
}
|
|
918
|
+
// The document a revoked link leads this person to, when their own claim
|
|
919
|
+
// revoked it. Asked only on a 410, the one refusal a claim produces.
|
|
920
|
+
async function claimedDocumentOf(token, err) {
|
|
921
|
+
if (!(err instanceof BrassError) || err.status !== 410 || !deps.followClaimedLink) {
|
|
922
|
+
return null;
|
|
923
|
+
}
|
|
924
|
+
try {
|
|
925
|
+
return await deps.followClaimedLink(token);
|
|
926
|
+
}
|
|
927
|
+
catch {
|
|
928
|
+
return null;
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
// A stashed token the platform no longer answers names nothing to
|
|
932
|
+
// continue, so it is forgotten where the refusal is heard, whether the
|
|
933
|
+
// open was the boot of its URL or the restore below.
|
|
934
|
+
function forgetUnsavedWorkIfGone(token, err) {
|
|
935
|
+
if (!linkGone(err))
|
|
936
|
+
return;
|
|
937
|
+
if (deps.unsavedWork?.()?.token === token)
|
|
938
|
+
deps.clearUnsavedWork?.();
|
|
939
|
+
}
|
|
940
|
+
// A signed-out boot on a URL that names no document reopens the last
|
|
941
|
+
// document this browser made without an account on this app, the way a
|
|
942
|
+
// signed-in boot reopens the last document picked.
|
|
943
|
+
async function restoreUnsavedWork() {
|
|
944
|
+
if (deps.readUrlSelection())
|
|
945
|
+
return null;
|
|
946
|
+
const entry = deps.unsavedWork?.();
|
|
947
|
+
if (!entry || !deps.enterGuestMode || !deps.openDocumentByLink)
|
|
948
|
+
return null;
|
|
949
|
+
const openGuest = deps.openDocumentByLink;
|
|
950
|
+
const { token } = entry;
|
|
951
|
+
// The same path a reload of the document's URL takes: the token becomes
|
|
952
|
+
// the page's credential and the URL carries it, then the link opens.
|
|
953
|
+
deps.enterGuestMode(token);
|
|
954
|
+
try {
|
|
955
|
+
return await activate((onSnapshotProgress) => openByLink(openGuest, token, inMode({ ...withDefaults({}), onSnapshotProgress }, undefined)), null, 'skip');
|
|
956
|
+
}
|
|
957
|
+
catch (err) {
|
|
958
|
+
// The token did not open, so it is not this page's credential after
|
|
959
|
+
// all: the page is signed out again, and the stash is kept unless the
|
|
960
|
+
// link is gone for good, so the next boot tries it again.
|
|
961
|
+
deps.leaveGuestMode?.();
|
|
962
|
+
openError = null;
|
|
963
|
+
forgetUnsavedWorkIfGone(token, err);
|
|
964
|
+
emit();
|
|
965
|
+
return null;
|
|
966
|
+
}
|
|
967
|
+
}
|
|
688
968
|
function start() {
|
|
689
969
|
if (startPromise)
|
|
690
970
|
return startPromise;
|
|
@@ -699,12 +979,111 @@ export function createDocumentSession(deps) {
|
|
|
699
979
|
return startPromise;
|
|
700
980
|
}
|
|
701
981
|
function open(docId, options) {
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
982
|
+
if (options?.frozen === true) {
|
|
983
|
+
return Promise.reject(new BrassError(FROZEN_IN_SESSION, { status: 0 }));
|
|
984
|
+
}
|
|
985
|
+
const asked = options?.readOnly ?? options?.documentOptions?.readOnly;
|
|
986
|
+
return activate((onSnapshotProgress) => openById(docId, inMode({ ...withDefaults(options), onSnapshotProgress }, asked)), null, 'push', options?.branch ?? null);
|
|
987
|
+
}
|
|
988
|
+
// A switch the hosted history or branch surfaces make from the bound
|
|
989
|
+
// document (another branch, a version, a fork) opens in the mode that
|
|
990
|
+
// document was opened in, so a copy opened to read stays one and a copy
|
|
991
|
+
// reopened to edit stays one.
|
|
992
|
+
function openSibling(docId, options) {
|
|
993
|
+
const current = document;
|
|
994
|
+
const readOnly = current === null || current.id !== docId
|
|
995
|
+
? undefined
|
|
996
|
+
: readOnlyOpens.has(current)
|
|
997
|
+
? true
|
|
998
|
+
: current._openMode === 'edit'
|
|
999
|
+
? false
|
|
1000
|
+
: undefined;
|
|
1001
|
+
return open(docId, { ...options, ...(readOnly !== undefined ? { readOnly } : {}) });
|
|
1002
|
+
}
|
|
1003
|
+
// `reopen` on the bound document: the new copy replaces it the way a move's
|
|
1004
|
+
// reopen does, kept on screen until it binds and kept bound if the reopen
|
|
1005
|
+
// fails. The session holds a live copy, so a frozen one is the caller's to
|
|
1006
|
+
// open beside it.
|
|
1007
|
+
async function reopenBound(current, mode) {
|
|
1008
|
+
if (mode === 'frozen') {
|
|
1009
|
+
throw new BrassError(FROZEN_IN_SESSION, { status: 0 });
|
|
1010
|
+
}
|
|
1011
|
+
const id = current.id;
|
|
1012
|
+
if (id === null)
|
|
1013
|
+
throw new BrassError('No document is open', { status: 0 });
|
|
1014
|
+
const branch = urlBranch(current);
|
|
1015
|
+
const guestToken = deps.guestLinkToken?.();
|
|
1016
|
+
const openGuest = deps.openDocumentByLink;
|
|
1017
|
+
const readOnly = mode === 'read';
|
|
1018
|
+
const next = await activate((onSnapshotProgress) => guestToken && openGuest
|
|
1019
|
+
? openByLink(openGuest, guestToken, {
|
|
1020
|
+
...withDefaults(),
|
|
1021
|
+
readOnly,
|
|
1022
|
+
onSnapshotProgress,
|
|
1023
|
+
})
|
|
1024
|
+
: openById(id, {
|
|
1025
|
+
...withDefaults(),
|
|
1026
|
+
...(branch ? { branch } : {}),
|
|
1027
|
+
readOnly,
|
|
1028
|
+
onSnapshotProgress,
|
|
1029
|
+
}), documentName, 'skip', activeSpecifiedBranch, true, true, true);
|
|
1030
|
+
// A newer open, reopen or close took the session while this one joined,
|
|
1031
|
+
// and closed the copy it produced.
|
|
1032
|
+
if (document !== next) {
|
|
1033
|
+
throw new BrassError('The reopen was replaced by a later open of the session', {
|
|
1034
|
+
status: 0,
|
|
1035
|
+
});
|
|
1036
|
+
}
|
|
1037
|
+
return next;
|
|
1038
|
+
}
|
|
1039
|
+
// A create fails in two regimes, and only the later one reaches the
|
|
1040
|
+
// session. Once `activate` has the open, `abandonOpen` parks the failure on
|
|
1041
|
+
// `session.error` for a status-gated app to render. Everything before that
|
|
1042
|
+
// (minting the account-less document, resolving the destination) throws with
|
|
1043
|
+
// the returned promise as its only report, and the documented way to call
|
|
1044
|
+
// this from a button floats that promise, so the click reads as ignored.
|
|
1045
|
+
// Parking here under `abandonOpen`'s own condition gives both regimes one
|
|
1046
|
+
// surface. The generation check is what keeps a failure from clobbering a
|
|
1047
|
+
// newer open that started while this one was still resolving a destination.
|
|
1048
|
+
// The trial token this page last recorded an edit for, so a burst of
|
|
1049
|
+
// edits writes the marker once.
|
|
1050
|
+
let trialEditNoted = null;
|
|
1051
|
+
function noteTrialEdit(doc) {
|
|
1052
|
+
const token = deps.guestLinkToken?.();
|
|
1053
|
+
if (!token || doc.expiresAt === null || trialEditNoted === token)
|
|
1054
|
+
return;
|
|
1055
|
+
trialEditNoted = token;
|
|
1056
|
+
deps.markTrialEdited?.(token, doc.expiresAt);
|
|
1057
|
+
}
|
|
1058
|
+
// Whether New or Upload may replace the document on screen. A document
|
|
1059
|
+
// made without an account that this browser edited is kept only at its
|
|
1060
|
+
// link, so the visitor is asked first, and signing in is what keeps it.
|
|
1061
|
+
async function mayLeaveTrial() {
|
|
1062
|
+
const token = deps.guestLinkToken?.();
|
|
1063
|
+
const doc = document;
|
|
1064
|
+
if (!token || !doc || doc.expiresAt === null)
|
|
1065
|
+
return true;
|
|
1066
|
+
if (!deps.confirmReplaceTrial || !deps.trialEdited?.(token))
|
|
1067
|
+
return true;
|
|
1068
|
+
const answer = await deps.confirmReplaceTrial(doc.expiresAt);
|
|
1069
|
+
if (answer === 'sign-in')
|
|
1070
|
+
deps.signIn?.();
|
|
1071
|
+
return answer === 'leave';
|
|
706
1072
|
}
|
|
707
1073
|
async function create(options) {
|
|
1074
|
+
const generation = openGeneration;
|
|
1075
|
+
try {
|
|
1076
|
+
return await createUnguarded(options);
|
|
1077
|
+
}
|
|
1078
|
+
catch (err) {
|
|
1079
|
+
if (generation === openGeneration && document === null && openError === null) {
|
|
1080
|
+
openError = toDisplayError(err);
|
|
1081
|
+
emit();
|
|
1082
|
+
}
|
|
1083
|
+
throw err;
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
async function createUnguarded(options) {
|
|
708
1087
|
// Placement is expressed by context, never an org this layer names.
|
|
709
1088
|
// Create-in-place: a New doc anchors on the active one via `sibling_of`,
|
|
710
1089
|
// resolved server-side (its project, or loose in its org). With no active
|
|
@@ -723,29 +1102,45 @@ export function createDocumentSession(deps) {
|
|
|
723
1102
|
// one. The server owns the single-vs-multi decision: a `needs-*` outcome
|
|
724
1103
|
// is where we open the corresponding hosted surface and retry with the
|
|
725
1104
|
// opaque destination it returns, relayed verbatim.
|
|
1105
|
+
// No account: the document is minted without one and opened through
|
|
1106
|
+
// its link token, which the URL then carries so a reload and a sign-in
|
|
1107
|
+
// both find it again.
|
|
1108
|
+
if (withoutAccount() &&
|
|
1109
|
+
deps.mintAnonymousDocument &&
|
|
1110
|
+
deps.enterGuestMode &&
|
|
1111
|
+
deps.openDocumentByLink) {
|
|
1112
|
+
if (!(await mayLeaveTrial()))
|
|
1113
|
+
return null;
|
|
1114
|
+
const minted = await deps.mintAnonymousDocument(options?.name !== undefined ? { name: options.name } : {});
|
|
1115
|
+
const enterGuestMode = deps.enterGuestMode;
|
|
1116
|
+
const openGuest = deps.openDocumentByLink;
|
|
1117
|
+
accountFreeOpen = true;
|
|
1118
|
+
try {
|
|
1119
|
+
return await activate((onSnapshotProgress, _onUploadProgress, releasePrevious) => {
|
|
1120
|
+
releasePrevious();
|
|
1121
|
+
enterGuestMode(minted.linkToken);
|
|
1122
|
+
return openGuest(minted.linkToken, {
|
|
1123
|
+
...withDefaults(options?.documentOptions
|
|
1124
|
+
? { documentOptions: options.documentOptions }
|
|
1125
|
+
: undefined),
|
|
1126
|
+
onSnapshotProgress,
|
|
1127
|
+
});
|
|
1128
|
+
}, options?.name ?? null, 'skip');
|
|
1129
|
+
}
|
|
1130
|
+
finally {
|
|
1131
|
+
accountFreeOpen = false;
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
726
1134
|
let args = base;
|
|
727
1135
|
let knownName = options?.name ?? null;
|
|
728
|
-
|
|
729
|
-
for (;;) {
|
|
730
|
-
result = await deps.createDocumentId(args);
|
|
731
|
-
if (result.kind === 'created')
|
|
732
|
-
break;
|
|
733
|
-
if (result.kind === 'needs-organization') {
|
|
734
|
-
const acquired = await deps.acquireOrganization();
|
|
735
|
-
if (!acquired)
|
|
736
|
-
return null;
|
|
737
|
-
args = { ...args, destination: { organization_id: acquired.organization_id } };
|
|
738
|
-
continue;
|
|
739
|
-
}
|
|
740
|
-
// needs-destination
|
|
741
|
-
const destination = await deps.selectCreateDestination(options?.name !== undefined ? { name: options.name } : undefined);
|
|
742
|
-
if (destination === null)
|
|
743
|
-
return null;
|
|
1136
|
+
const result = await placeThroughHostedSurfaces(deps, (destination) => deps.createDocumentId(destination === undefined ? args : { ...args, destination }), options?.name, (chosen) => {
|
|
744
1137
|
// The dialog's final name wins on both the wire and the display: the
|
|
745
1138
|
// user saw and could edit it.
|
|
746
|
-
args = { ...args,
|
|
747
|
-
knownName =
|
|
748
|
-
}
|
|
1139
|
+
args = { ...args, name: chosen.name };
|
|
1140
|
+
knownName = chosen.name;
|
|
1141
|
+
});
|
|
1142
|
+
if (result === null)
|
|
1143
|
+
return null;
|
|
749
1144
|
const docId = result.docId;
|
|
750
1145
|
const openOptions = options?.documentOptions
|
|
751
1146
|
? { documentOptions: options.documentOptions }
|
|
@@ -754,20 +1149,66 @@ export function createDocumentSession(deps) {
|
|
|
754
1149
|
// with no name the server owns it (resolving the next free "Untitled N"),
|
|
755
1150
|
// and a null `knownName` is what lets `resolveActiveDocRecord` adopt the
|
|
756
1151
|
// stored name once the record binds.
|
|
757
|
-
return activate((onSnapshotProgress) =>
|
|
1152
|
+
return activate((onSnapshotProgress) => openById(docId, {
|
|
758
1153
|
...withDefaults(openOptions),
|
|
759
1154
|
onSnapshotProgress,
|
|
760
1155
|
}), knownName);
|
|
761
1156
|
}
|
|
1157
|
+
// The upload runs inside the open, so the app renders its loading surface
|
|
1158
|
+
// with the upload's progress rather than an unchanged screen. A failure
|
|
1159
|
+
// lands on `session.error` the way a failed create's does.
|
|
1160
|
+
async function pickAnonymousFile(choose, mint, enterGuestMode, openGuest) {
|
|
1161
|
+
const generation = openGeneration;
|
|
1162
|
+
let file;
|
|
1163
|
+
try {
|
|
1164
|
+
file = await choose();
|
|
1165
|
+
}
|
|
1166
|
+
catch (err) {
|
|
1167
|
+
// Before `activate` there is no open to abandon, so the refusal is
|
|
1168
|
+
// parked here under `create`'s condition.
|
|
1169
|
+
if (generation === openGeneration && document === null && openError === null) {
|
|
1170
|
+
openError = toDisplayError(err);
|
|
1171
|
+
emit();
|
|
1172
|
+
}
|
|
1173
|
+
throw err;
|
|
1174
|
+
}
|
|
1175
|
+
if (file === null)
|
|
1176
|
+
return null;
|
|
1177
|
+
accountFreeOpen = true;
|
|
1178
|
+
try {
|
|
1179
|
+
return await activate(async (onSnapshotProgress, onUploadProgress, releasePrevious) => {
|
|
1180
|
+
const minted = await mint({ file, onUploadProgress });
|
|
1181
|
+
releasePrevious();
|
|
1182
|
+
enterGuestMode(minted.linkToken);
|
|
1183
|
+
return openGuest(minted.linkToken, {
|
|
1184
|
+
...withDefaults(),
|
|
1185
|
+
onSnapshotProgress,
|
|
1186
|
+
});
|
|
1187
|
+
}, null, 'skip');
|
|
1188
|
+
}
|
|
1189
|
+
finally {
|
|
1190
|
+
accountFreeOpen = false;
|
|
1191
|
+
emit();
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
762
1194
|
async function pick() {
|
|
1195
|
+
if (withoutAccount() &&
|
|
1196
|
+
deps.chooseAnonymousFile &&
|
|
1197
|
+
deps.mintAnonymousDocumentFromFile &&
|
|
1198
|
+
deps.enterGuestMode &&
|
|
1199
|
+
deps.openDocumentByLink) {
|
|
1200
|
+
if (!(await mayLeaveTrial()))
|
|
1201
|
+
return null;
|
|
1202
|
+
return pickAnonymousFile(deps.chooseAnonymousFile, deps.mintAnonymousDocumentFromFile, deps.enterGuestMode, deps.openDocumentByLink);
|
|
1203
|
+
}
|
|
763
1204
|
const result = await deps.selectDocument();
|
|
764
1205
|
if (!result)
|
|
765
1206
|
return null;
|
|
766
|
-
const opts = {
|
|
1207
|
+
const opts = inMode({
|
|
767
1208
|
...withDefaults(),
|
|
768
1209
|
...(result.branch_id ? { branch: result.branch_id } : {}),
|
|
769
|
-
};
|
|
770
|
-
return activate((onSnapshotProgress) =>
|
|
1210
|
+
}, undefined);
|
|
1211
|
+
return activate((onSnapshotProgress) => openById(result.doc_id, { ...opts, onSnapshotProgress }), result.name, 'push', result.branch_id ?? null);
|
|
771
1212
|
}
|
|
772
1213
|
// Merge `defaultDocumentOptions` into the caller's options: caller values
|
|
773
1214
|
// win per-field, the default fills gaps, and nested `undo` is shallow-merged
|
|
@@ -849,11 +1290,11 @@ export function createDocumentSession(deps) {
|
|
|
849
1290
|
urlBranch(current) === sel.branchId) {
|
|
850
1291
|
return; // already showing this selection
|
|
851
1292
|
}
|
|
852
|
-
void activate((onSnapshotProgress) =>
|
|
1293
|
+
void activate((onSnapshotProgress) => openById(sel.docId, inMode({
|
|
853
1294
|
...withDefaults(),
|
|
854
1295
|
...(sel.branchId ? { branch: sel.branchId } : {}),
|
|
855
1296
|
onSnapshotProgress,
|
|
856
|
-
}, 'skip'), null, 'skip', sel.branchId).catch(() => {
|
|
1297
|
+
}, undefined), 'skip'), null, 'skip', sel.branchId).catch(() => {
|
|
857
1298
|
// Back / Forward to a doc that can't be opened parks the 'error' state
|
|
858
1299
|
// (or, warm, keeps the current doc); the URL the user navigated to is
|
|
859
1300
|
// left as-is, so the caught rejection needs no cleanup here.
|
|
@@ -877,7 +1318,14 @@ export function createDocumentSession(deps) {
|
|
|
877
1318
|
// (opened via `?brass_link=`) is never signed in, so the initial
|
|
878
1319
|
// resolution arrives here with `signedIn === false`; it must boot the
|
|
879
1320
|
// read-only link document rather than tear down to the sign-in surface.
|
|
1321
|
+
//
|
|
1322
|
+
// The first resolution to signed-out is the boot of a signed-out page, which
|
|
1323
|
+
// restores the unsaved work this browser made (`restoreUnsavedWork`); a
|
|
1324
|
+
// later sign-out lands on the sign-in surface.
|
|
1325
|
+
let authResolved = false;
|
|
880
1326
|
deps.onUserChange((signedIn) => {
|
|
1327
|
+
const boot = !authResolved;
|
|
1328
|
+
authResolved = true;
|
|
881
1329
|
if (signedIn || deps.guestLinkToken?.() != null) {
|
|
882
1330
|
if (!document)
|
|
883
1331
|
void start();
|
|
@@ -886,6 +1334,10 @@ export function createDocumentSession(deps) {
|
|
|
886
1334
|
}
|
|
887
1335
|
else {
|
|
888
1336
|
teardownForSignOut();
|
|
1337
|
+
// Deferred: a session made after `init` settled hears this resolution
|
|
1338
|
+
// while it is still being built.
|
|
1339
|
+
if (boot)
|
|
1340
|
+
queueMicrotask(() => void start());
|
|
889
1341
|
}
|
|
890
1342
|
});
|
|
891
1343
|
const session = {
|
|
@@ -899,7 +1351,17 @@ export function createDocumentSession(deps) {
|
|
|
899
1351
|
return status();
|
|
900
1352
|
},
|
|
901
1353
|
get error() {
|
|
902
|
-
|
|
1354
|
+
// Not gated on `status() === 'error'`. A signed-out visitor's failed
|
|
1355
|
+
// create is the case that gating loses: 'signed-out' outranks the error
|
|
1356
|
+
// in `status()` (the app must keep rendering its sign-in surface, which
|
|
1357
|
+
// is where the retry lives), so gating here left the try-before-signing-in
|
|
1358
|
+
// failure with no channel at all.
|
|
1359
|
+
// What makes ungating safe is that a parked error never coexists with a
|
|
1360
|
+
// bound document: `activate` clears it on entry and is the only path that
|
|
1361
|
+
// binds a non-null one, and `abandonOpen` parks only when there was no
|
|
1362
|
+
// document to fall back to. So this can never show a stale failure beside
|
|
1363
|
+
// a working document. A new write to `openError` has to hold that.
|
|
1364
|
+
return openError ?? deps.initFailure?.() ?? null;
|
|
903
1365
|
},
|
|
904
1366
|
get loadingMessage() {
|
|
905
1367
|
return loading ? loadingMessage : null;
|
|
@@ -919,19 +1381,38 @@ export function createDocumentSession(deps) {
|
|
|
919
1381
|
listener(session);
|
|
920
1382
|
}
|
|
921
1383
|
catch (err) {
|
|
922
|
-
|
|
1384
|
+
reportListenerThrow(err);
|
|
923
1385
|
}
|
|
924
1386
|
return () => {
|
|
925
1387
|
listeners.delete(listener);
|
|
926
1388
|
};
|
|
927
1389
|
},
|
|
928
1390
|
start,
|
|
929
|
-
open,
|
|
1391
|
+
open: ((docId, options) => docId === undefined ? pick() : open(docId, options)),
|
|
930
1392
|
create,
|
|
931
1393
|
pick,
|
|
932
1394
|
close,
|
|
933
1395
|
async run(commandId) {
|
|
934
1396
|
const command = allCommands().find((c) => c.id === commandId);
|
|
1397
|
+
// An app's own signed-out button may still run a withdrawn New or Open,
|
|
1398
|
+
// and the reader is owed the reason rather than a press that does
|
|
1399
|
+
// nothing.
|
|
1400
|
+
if (command &&
|
|
1401
|
+
command.group === 'session' &&
|
|
1402
|
+
!command.enabled &&
|
|
1403
|
+
withoutAccount() &&
|
|
1404
|
+
signedOutRefusal !== null) {
|
|
1405
|
+
// A parked error never sits beside a bound document, so a guest
|
|
1406
|
+
// reading one hears the refusal through `onError` instead.
|
|
1407
|
+
if (document === null) {
|
|
1408
|
+
openError = signedOutRefusal;
|
|
1409
|
+
emit();
|
|
1410
|
+
}
|
|
1411
|
+
else {
|
|
1412
|
+
deps.reportError(signedOutRefusal);
|
|
1413
|
+
}
|
|
1414
|
+
return;
|
|
1415
|
+
}
|
|
935
1416
|
if (command)
|
|
936
1417
|
await command.run();
|
|
937
1418
|
},
|
|
@@ -970,8 +1451,9 @@ export function createDocumentSession(deps) {
|
|
|
970
1451
|
}
|
|
971
1452
|
// Already-terminal open failure: reject now rather than wait for a bind
|
|
972
1453
|
// that isn't coming.
|
|
973
|
-
|
|
974
|
-
|
|
1454
|
+
const failure = session.error;
|
|
1455
|
+
if (status() === 'error' && failure) {
|
|
1456
|
+
return Promise.reject(failure);
|
|
975
1457
|
}
|
|
976
1458
|
return new Promise((resolve, reject) => {
|
|
977
1459
|
whenReadyPending.push({ resolve, reject });
|
|
@@ -989,6 +1471,7 @@ export function createDocumentSession(deps) {
|
|
|
989
1471
|
_clearLocalDelete(docId) {
|
|
990
1472
|
localDeletes.delete(docId);
|
|
991
1473
|
},
|
|
1474
|
+
_openSibling: openSibling,
|
|
992
1475
|
};
|
|
993
1476
|
return session;
|
|
994
1477
|
}
|