Line data Source code
1 : import * as React from 'react';
2 :
3 : import * as baseStyles from '../ui/foundations/banner_page.module.css';
4 : import * as styles from './app_ownership_gate.module.css';
5 :
6 : import { AppOwnership, AppOwnershipTimeoutError, getAppOwnershipEnvironment, type AppOwnershipEnvironment } from '../platform/crosstab/app_ownership.js';
7 : import { APP_HAND_OVER_CTX, APP_RESTART_CTX, reloadTab, type AppHandOverHook } from '../platform/crosstab/app_hand_over.js';
8 : import { Button, ButtonVariant } from '../ui/foundations/button.js';
9 : import { ParticleFlowBackground } from '../ui/particle_flow/particle_flow_background.js';
10 : import { CompactNavBar } from './navbar.js';
11 : import { useLogger } from '../platform/logger/logger_provider.js';
12 : import { stringifyError } from '../platform/logger/logger.js';
13 :
14 1 : const LOG_CTX = 'app_ownership';
15 :
16 : /// How long a tab waits for the owner to hand over the app
17 1 : const TAKE_OVER_TIMEOUT_MS = 10_000;
18 : /// How long the owner waits for its writers to stop before it hands over anyway
19 1 : const HAND_OVER_TIMEOUT_MS = 5_000;
20 :
21 1 : enum GateStatus {
22 : /// Waiting for the lock
23 1 : ACQUIRING,
24 : /// The tab owns the app
25 1 : OWNER,
26 : /// The tab stops its writers for another tab
27 1 : HANDING_OVER,
28 : /// Another tab owns the app
29 1 : ELSEWHERE,
30 : /// The tab asked the owner for the app
31 1 : TAKING_OVER,
32 : /// The owner did not hand over the app
33 1 : UNRESPONSIVE,
34 1 : }
35 :
36 : interface Props {
37 : children: React.ReactElement;
38 : /// The tabs of the app, defaults to the Web Locks and broadcast channels of the browser
39 : environment?: AppOwnershipEnvironment | null;
40 : /// Leave the app after the writers stopped, defaults to a reload of the tab.
41 : /// A restart of the app leaves it the same way, the reloaded tab acquires the app again.
42 : leave?: () => Promise<void>;
43 : }
44 :
45 : /// Render the app only in the tab that owns it, see `AppOwnership`
46 1 : export const AppOwnershipGate: React.FC<Props> = (props: Props) => {
47 1 : const logger = useLogger();
48 1 : const environment = React.useMemo(() => props.environment !== undefined ? props.environment : getAppOwnershipEnvironment(), [props.environment]);
49 1 : const [status, setStatus] = React.useState<GateStatus>(environment == null ? GateStatus.OWNER : GateStatus.ACQUIRING);
50 1 : const ownership = React.useRef<AppOwnership | null>(null);
51 :
52 : // The hooks of the writers that are stopped before a handoff
53 1 : const hooks = React.useRef(new Set<AppHandOverHook>());
54 1 : const register = React.useCallback((hook: AppHandOverHook) => {
55 1 : hooks.current.add(hook);
56 1 : return () => { hooks.current.delete(hook); };
57 1 : }, []);
58 :
59 1 : const leave = props.leave ?? reloadTab;
60 1 : const leaveRef = React.useRef(leave);
61 1 : leaveRef.current = leave;
62 : // Stop the writers, e.g. the storage and the embedded database, which cancels its running queries
63 1 : const stopWriters = React.useCallback(async () => {
64 1 : let timer: ReturnType<typeof setTimeout> | undefined;
65 1 : const timeout = new Promise<'timeout'>(resolve => { timer = setTimeout(() => resolve('timeout'), HAND_OVER_TIMEOUT_MS); });
66 1 : const stopped = Promise.all([...hooks.current].map(hook => hook().catch(error => {
67 0 : logger.error("Failed to stop a storage writer", { error: stringifyError(error) }, LOG_CTX);
68 1 : })));
69 1 : if (await Promise.race([stopped, timeout]) === 'timeout') {
70 0 : logger.warn("Storage writers did not stop in time", {}, LOG_CTX);
71 : }
72 1 : clearTimeout(timer);
73 1 : }, [logger]);
74 1 : const handOver = React.useCallback(async () => {
75 1 : logger.info("Handing over the app to another tab", {}, LOG_CTX);
76 1 : setStatus(GateStatus.HANDING_OVER);
77 1 : await stopWriters();
78 1 : ownership.current = null;
79 1 : await leaveRef.current();
80 1 : setStatus(GateStatus.ELSEWHERE);
81 1 : }, [logger, stopWriters]);
82 1 : const restart = React.useCallback(async () => {
83 1 : logger.info("Restarting the app", {}, LOG_CTX);
84 1 : await stopWriters();
85 1 : await leaveRef.current();
86 1 : }, [logger, stopWriters]);
87 :
88 1 : React.useEffect(() => {
89 1 : if (environment == null) {
90 1 : logger.warn("Web Locks are unavailable, tabs of the app are not coordinated", {}, LOG_CTX);
91 1 : return;
92 1 : }
93 1 : let cancelled = false;
94 1 : AppOwnership.tryAcquire(environment, handOver).then(acquired => {
95 1 : if (cancelled) {
96 1 : acquired?.dispose();
97 1 : return;
98 1 : }
99 1 : ownership.current = acquired;
100 1 : setStatus(acquired != null ? GateStatus.OWNER : GateStatus.ELSEWHERE);
101 1 : }, error => {
102 0 : logger.error("Failed to acquire the app", { error: stringifyError(error) }, LOG_CTX);
103 1 : });
104 1 : return () => {
105 1 : cancelled = true;
106 1 : ownership.current?.dispose();
107 1 : ownership.current = null;
108 1 : };
109 1 : }, [environment, handOver, logger]);
110 :
111 1 : const takeOver = React.useCallback(async () => {
112 1 : if (environment == null) {
113 0 : return;
114 : }
115 1 : setStatus(GateStatus.TAKING_OVER);
116 1 : try {
117 1 : ownership.current = await AppOwnership.takeOver(environment, handOver, TAKE_OVER_TIMEOUT_MS);
118 1 : logger.info("Took over the app from another tab", {}, LOG_CTX);
119 1 : setStatus(GateStatus.OWNER);
120 1 : } catch (error) {
121 0 : if (!(error instanceof AppOwnershipTimeoutError)) {
122 0 : logger.error("Failed to take over the app", { error: stringifyError(error) }, LOG_CTX);
123 : }
124 0 : setStatus(GateStatus.UNRESPONSIVE);
125 0 : }
126 1 : }, [environment, handOver, logger]);
127 :
128 1 : switch (status) {
129 1 : case GateStatus.ACQUIRING:
130 1 : return null;
131 1 : case GateStatus.OWNER:
132 1 : return (
133 1 : <APP_HAND_OVER_CTX.Provider value={register}>
134 1 : <APP_RESTART_CTX.Provider value={restart}>{props.children}</APP_RESTART_CTX.Provider>
135 1 : </APP_HAND_OVER_CTX.Provider>
136 : );
137 1 : case GateStatus.HANDING_OVER:
138 : // The app stays mounted until its writers stopped, the page blocks further edits
139 1 : return (
140 1 : <APP_HAND_OVER_CTX.Provider value={register}>
141 1 : {props.children}
142 1 : <div className={styles.overlay}>
143 1 : <AppElsewherePage status={status} />
144 1 : </div>
145 1 : </APP_HAND_OVER_CTX.Provider>
146 : );
147 1 : default:
148 1 : return <AppElsewherePage status={status} onTakeOver={takeOver} />;
149 1 : }
150 1 : };
151 :
152 1 : function AppElsewherePage(props: { status: GateStatus; onTakeOver?: () => void }) {
153 1 : let description: string;
154 1 : switch (props.status) {
155 0 : case GateStatus.HANDING_OVER:
156 0 : description = 'DashQL is moving to another tab.';
157 0 : break;
158 0 : case GateStatus.UNRESPONSIVE:
159 0 : description = 'The other tab did not respond. Close it and try again.';
160 0 : break;
161 1 : default:
162 1 : description = 'DashQL is open in another tab. Only one tab can use the app at a time, so that tabs don\'t overwrite each other\'s changes.';
163 : break;
164 1 : }
165 1 : return (
166 1 : <div className={`${baseStyles.page} ${styles.page}`} data-electron-drag-region data-testid="app-elsewhere">
167 1 : <ParticleFlowBackground />
168 : {/* The internals need the storage, which another tab owns */}
169 1 : <CompactNavBar showInternals={false} />
170 1 : <div className={`${baseStyles.banner_and_content_container} ${styles.foreground}`} data-electron-drag-region>
171 1 : <div className={baseStyles.content_container} data-electron-drag-region>
172 1 : <div className={`${baseStyles.card} ${styles.card}`}>
173 1 : <div className={baseStyles.card_header} data-electron-drag-region>
174 1 : <div className={baseStyles.card_header_left_container}>
175 1 : <div className={baseStyles.card_header_left_title}>
176 : Open in Another Tab
177 1 : </div>
178 1 : </div>
179 1 : </div>
180 1 : <div className={baseStyles.card_section}>
181 1 : <div className={`${baseStyles.section_entries} ${styles.description}`}>
182 1 : {description}
183 1 : </div>
184 1 : {props.onTakeOver && (
185 1 : <div className={baseStyles.card_actions}>
186 1 : <div className={baseStyles.card_actions_right}>
187 1 : <Button
188 1 : variant={ButtonVariant.Primary}
189 1 : data-testid="app-take-over"
190 1 : onClick={props.onTakeOver}
191 1 : disabled={props.status === GateStatus.TAKING_OVER}
192 1 : >
193 : Use Here
194 1 : </Button>
195 1 : </div>
196 1 : </div>
197 : )}
198 1 : </div>
199 1 : </div>
200 1 : </div>
201 1 : </div>
202 1 : </div>
203 : );
204 1 : }
|