Line data Source code
1 : import * as React from 'react';
2 :
3 : import { AppLoadingStatus } from './app_loading_status.js';
4 : import { OPEN_LINK_SESSION, useRouteContext, useRouterNavigate } from '../router/router.js';
5 : import { useDashQLCoreSetup } from '../../runtime/core_provider.js';
6 : import { useLogger } from '../../platform/logger/logger_provider.js';
7 : import { createTrace } from '../../platform/logger/trace_context.js';
8 : import { stringifyError } from '../../platform/logger/logger.js';
9 : import { usePlatformEventListener } from '../../platform/events/event_listener_provider.js';
10 : import { SETUP_SESSION, SETUP_SESSION_URL, SetupEventVariant } from '../../platform/events/event.js';
11 : import { AppHost, getAppHost } from '../../platform/native_globals.js';
12 : import { useHttpClient } from '../../platform/http/http_client_provider.js';
13 : import { readSessionBundleFromZip } from '../../session/persistence/session_import.js';
14 : import { useSessionImport } from '../../session/persistence/session_import_provider.js';
15 : import {
16 : readSessionBundleFromHttp,
17 : type HttpSessionLoadProgress,
18 : type HttpSessionLoadResult,
19 : } from '../../session/persistence/http_session_bundle.js';
20 : import { SessionImportCard } from '../../session/persistence/session_import_card.js';
21 :
22 1 : const LOG_CTX = "app_loader";
23 :
24 : /// A shared session that loads from its URL
25 : interface RemoteSessionLoad {
26 : /// The URL of the session
27 : sourceUrl: string;
28 : /// The progress of the load
29 : progress: HttpSessionLoadProgress;
30 : }
31 :
32 : /// A loaded shared session that waits for the user to import it
33 : interface LoadedSharedSession {
34 : /// The loaded session
35 : result: HttpSessionLoadResult;
36 : /// The registered session with the same id, if any
37 : conflict: { displayLocation: string; isNative: boolean } | null;
38 : }
39 :
40 : /// Import the sessions that the platform hands to the app.
41 : ///
42 : /// A deep link or an opened `.dashql` file carries the session itself, it is imported right away. A
43 : /// shared URL is loaded while the app sets up and then shown in a card until the user imports it.
44 : /// Imports wait for `setupDone` since they use the restored registries. The import of a session that
45 : /// the app was started with is stored in `initialImport`, the setup doesn't open another session
46 : /// when it opens one.
47 : ///
48 : /// Returns the card of a shared session, null if there is none.
49 1 : export function useSharedSessionImport(
50 1 : setupDone: Promise<void>,
51 1 : initialImport: React.RefObject<Promise<boolean> | null>,
52 1 : ): React.ReactElement | null {
53 1 : const logger = useLogger();
54 1 : const navigate = useRouterNavigate();
55 1 : const routeContext = useRouteContext();
56 1 : const setupCore = useDashQLCoreSetup();
57 1 : const appEvents = usePlatformEventListener();
58 1 : const sessionImport = useSessionImport();
59 1 : const httpClient = useHttpClient();
60 :
61 1 : const [remoteLoad, setRemoteLoad] = React.useState<RemoteSessionLoad | null>(null);
62 1 : const [loaded, setLoaded] = React.useState<LoadedSharedSession | null>(null);
63 1 : const [importBusy, setImportBusy] = React.useState(false);
64 1 : const remoteAbortRef = React.useRef<AbortController | null>(null);
65 1 : React.useEffect(() => () => remoteAbortRef.current?.abort(), []);
66 :
67 : // Returns true if the event opened a session
68 1 : const consumeSetupEvent = React.useEffectEvent(async (data: SetupEventVariant): Promise<boolean> => {
69 : // Start trace for setup event handling
70 1 : const traced = logger.withTrace(createTrace());
71 1 : traced.debug("Consuming setup event", { "event_type": String(data.type) }, LOG_CTX);
72 :
73 1 : let remoteAbort: AbortController | null = null;
74 1 : let remoteResult: Promise<HttpSessionLoadResult> | null = null;
75 1 : if (data.type === SETUP_SESSION_URL) {
76 0 : remoteAbortRef.current?.abort();
77 0 : remoteAbort = new AbortController();
78 0 : remoteAbortRef.current = remoteAbort;
79 0 : setRemoteLoad({ sourceUrl: data.value, progress: { phase: 'preparing' } });
80 0 : setLoaded(null);
81 0 : traced.info('Loading shared session', { sourceUrl: data.value }, LOG_CTX);
82 0 : remoteResult = readSessionBundleFromHttp(
83 0 : new URL(data.value),
84 0 : httpClient,
85 0 : remoteAbort.signal,
86 0 : progress => {
87 0 : if (remoteAbortRef.current === remoteAbort) {
88 0 : setRemoteLoad({ sourceUrl: data.value, progress });
89 : }
90 0 : },
91 0 : );
92 0 : }
93 :
94 : // Remote session I/O can proceed while the application initializes. Import and conflict
95 : // detection still wait for setup because they use the initialized storage registry.
96 1 : const setup = Promise.all([setupCore("app_setup"), setupDone]);
97 1 : const result = remoteResult == null ? null : await remoteResult;
98 0 : await setup;
99 1 : remoteAbort?.signal.throwIfAborted();
100 1 : if (data.type === SETUP_SESSION) {
101 1 : const zipBlob = new Blob([data.value.buffer as ArrayBuffer], { type: 'application/zip' });
102 1 : const bundle = await readSessionBundleFromZip(zipBlob);
103 1 : const sessionId = await sessionImport.importPortableBundle(bundle, {
104 1 : presentation: { mode: 'centered' },
105 1 : });
106 1 : if (sessionId != null) {
107 1 : traced.debug("Finished link setup", { sessionId }, LOG_CTX);
108 1 : navigate({ type: OPEN_LINK_SESSION, value: sessionId });
109 1 : return true;
110 1 : }
111 1 : } else if (data.type === SETUP_SESSION_URL && remoteAbortRef.current === remoteAbort) {
112 0 : traced.info('Loaded shared session', {
113 0 : sessionId: result!.bundle.session.sessionId,
114 0 : loadedScriptCount: String(result!.loadedScriptCount),
115 0 : listedScriptCount: String(result!.listedScriptCount),
116 0 : incomplete: String(result!.incomplete),
117 0 : }, LOG_CTX);
118 0 : const conflict = await sessionImport.findPortableBundleConflict(result!.bundle);
119 0 : remoteAbort!.signal.throwIfAborted();
120 0 : if (remoteAbortRef.current !== remoteAbort) return false;
121 0 : traced.info('Checked shared session import conflict', {
122 0 : sessionId: result!.bundle.session.sessionId,
123 0 : conflict: String(conflict != null),
124 0 : existingLocation: conflict?.displayLocation ?? '',
125 0 : }, LOG_CTX);
126 0 : remoteAbortRef.current = null;
127 0 : setRemoteLoad(null);
128 0 : setLoaded({ result: result!, conflict });
129 0 : }
130 0 : return false;
131 1 : });
132 :
133 1 : const handleSetupEvent = React.useEffectEvent((data: SetupEventVariant) => {
134 1 : const handling = consumeSetupEvent(data).catch(error => {
135 0 : if ((error as Error)?.name === 'AbortError') return false;
136 0 : remoteAbortRef.current = null;
137 0 : setRemoteLoad(null);
138 0 : logger.error('Failed to open shared session', { error: stringifyError(error) }, LOG_CTX);
139 0 : return false;
140 1 : });
141 : // Normal startup must not replace the route selected by an inline session import
142 1 : if (data.type === SETUP_SESSION && routeContext.appLoadingStatus === AppLoadingStatus.NOT_STARTED) {
143 1 : initialImport.current = handling;
144 : }
145 : void handling;
146 1 : });
147 :
148 : // Effect Events are non-reactive. Depending on handleSetupEvent would resubscribe after every
149 : // render even though it always reads the latest callback state.
150 1 : React.useEffect(() => {
151 1 : appEvents.subscribeSetupEvents(handleSetupEvent);
152 1 : return () => appEvents.unsubscribeSetupEvents(handleSetupEvent);
153 1 : }, [appEvents]);
154 :
155 1 : const cancelRemoteLoad = React.useCallback(() => {
156 0 : remoteAbortRef.current?.abort();
157 0 : remoteAbortRef.current = null;
158 0 : setRemoteLoad(null);
159 1 : }, []);
160 :
161 1 : const importLoaded = React.useCallback((choice?: 'replace' | 'create-new') => {
162 0 : if (loaded == null || importBusy) return;
163 0 : setImportBusy(true);
164 0 : const { result } = loaded;
165 0 : const importChoice = choice ?? 'import';
166 0 : logger.info('Importing shared session', {
167 0 : sessionId: result.bundle.session.sessionId,
168 0 : choice: importChoice,
169 0 : }, LOG_CTX);
170 0 : void sessionImport.importPortableBundleWithChoice(result.bundle, choice ?? 'create-new').then(sessionId => {
171 : // The user cancelled the import while choosing connections
172 0 : if (sessionId == null) return;
173 0 : logger.info('Imported shared session', {
174 0 : sourceSessionId: result.bundle.session.sessionId,
175 0 : sessionId,
176 0 : choice: importChoice,
177 0 : }, LOG_CTX);
178 0 : setLoaded(null);
179 0 : navigate({ type: OPEN_LINK_SESSION, value: sessionId });
180 0 : }).catch(error => {
181 0 : logger.error('Failed to import loaded session', { error: stringifyError(error) }, LOG_CTX);
182 0 : }).finally(() => setImportBusy(false));
183 1 : }, [loaded, importBusy, logger, navigate, sessionImport]);
184 :
185 1 : const cancelLoaded = React.useCallback(() => {
186 0 : if (importBusy) return;
187 0 : setLoaded(null);
188 1 : }, [importBusy]);
189 :
190 1 : if (remoteLoad != null) {
191 1 : return <SessionImportCard
192 1 : phase="loading"
193 1 : sourceUrl={remoteLoad.sourceUrl}
194 1 : progress={remoteLoad.progress}
195 1 : onClose={cancelRemoteLoad}
196 1 : />;
197 : }
198 1 : if (loaded != null) {
199 1 : return <SessionImportCard
200 1 : phase="ready"
201 1 : result={loaded.result}
202 1 : conflictLocation={loaded.conflict?.displayLocation ?? null}
203 1 : conflictIsNative={getAppHost() === AppHost.ELECTRON && (loaded.conflict?.isNative ?? false)}
204 1 : busy={importBusy}
205 1 : onImport={() => importLoaded()}
206 1 : onReplace={() => importLoaded('replace')}
207 1 : onCreateNew={() => importLoaded('create-new')}
208 1 : onClose={cancelLoaded}
209 1 : />;
210 : }
211 1 : return null;
212 1 : }
|