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