codex-overleaf-link 1.9.2 → 1.9.4
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/README.md +23 -23
- package/extension/bootstrap/background.js +23 -8
- package/extension/bootstrap/manifest.template.json +1 -1
- package/extension/bootstrap/popup.html +1 -0
- package/extension/bootstrap/popup.js +5 -1
- package/extension/bootstrap/updateStatus.js +78 -0
- package/extension/runtime-manifest.json +1 -0
- package/extension/src/background.js +20 -0
- package/extension/src/backgroundUpdateCoordinator.js +414 -0
- package/extension/src/content/contentRuntime.js +3 -0
- package/extension/src/content/updateNotice.js +248 -0
- package/extension/src/popup.js +268 -3
- package/extension/src/shared/compatibility.js +1 -1
- package/extension/src/shared/updateConsent.js +201 -0
- package/extension/styles/panel.css +146 -0
- package/native-host/src/updateManager.js +320 -37
- package/package.json +1 -1
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
(function initCodexOverleafUpdateCoordinator(root) {
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const UPDATE_STATE_KEY = 'codex-overleaf-managed-update-state-v1';
|
|
5
|
+
const CONSENT_STATE_KEY = 'codex-overleaf-update-consent-v1';
|
|
6
|
+
const CHECK_ALARM = 'codex-overleaf-consent-update-check';
|
|
7
|
+
const CHECK_INTERVAL_MINUTES = 24 * 60;
|
|
8
|
+
const SNOOZE_MS = 24 * 60 * 60 * 1000;
|
|
9
|
+
const CANDIDATE_MAX_AGE_MS = 5 * 60 * 1000;
|
|
10
|
+
const LEGACY_GUARD = Number.MAX_SAFE_INTEGER;
|
|
11
|
+
const OVERLEAF_MATCHES = [
|
|
12
|
+
'https://www.overleaf.com/project/*',
|
|
13
|
+
'https://overleaf.com/project/*'
|
|
14
|
+
];
|
|
15
|
+
const MESSAGE_TYPES = new Set([
|
|
16
|
+
'codex-overleaf/consent-update-get-state',
|
|
17
|
+
'codex-overleaf/consent-update-check',
|
|
18
|
+
'codex-overleaf/consent-update-install',
|
|
19
|
+
'codex-overleaf/consent-update-later'
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
const policy = root.CodexOverleafUpdateConsent;
|
|
23
|
+
let nativeBridge = null;
|
|
24
|
+
let initialized = false;
|
|
25
|
+
let policyTail = Promise.resolve();
|
|
26
|
+
|
|
27
|
+
function init(options = {}) {
|
|
28
|
+
if (initialized || !policy) return;
|
|
29
|
+
initialized = true;
|
|
30
|
+
nativeBridge = options.nativeBridge || root.CodexOverleafNativeBridge;
|
|
31
|
+
|
|
32
|
+
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|
33
|
+
if (!MESSAGE_TYPES.has(message?.type)) return undefined;
|
|
34
|
+
if (!isAllowedSender(sender)) {
|
|
35
|
+
sendResponse({ ok: false, error: { code: 'forbidden_sender', message: 'Update actions are limited to this extension and Overleaf project tabs.' } });
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
enqueuePolicyAction(() => handleMessage(message))
|
|
39
|
+
.then(result => sendResponse({ ok: true, result }))
|
|
40
|
+
.catch(error => sendResponse({ ok: false, error: safeError(error) }));
|
|
41
|
+
return true;
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
chrome.alarms?.onAlarm?.addListener(alarm => {
|
|
45
|
+
if (alarm?.name === CHECK_ALARM) {
|
|
46
|
+
void enqueuePolicyAction(() => checkOnly({ manual: false })).catch(() => {});
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
chrome.storage?.onChanged?.addListener((changes, area) => {
|
|
51
|
+
if (area !== 'local' || (!changes[UPDATE_STATE_KEY] && !changes[CONSENT_STATE_KEY])) return;
|
|
52
|
+
void handleObservedStateChange();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
void startup();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function startup() {
|
|
59
|
+
chrome.alarms?.create?.(CHECK_ALARM, {
|
|
60
|
+
delayInMinutes: 0.5,
|
|
61
|
+
periodInMinutes: CHECK_INTERVAL_MINUTES
|
|
62
|
+
});
|
|
63
|
+
await settleTerminalConsent();
|
|
64
|
+
await armLegacyGuard();
|
|
65
|
+
await publishView();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function handleMessage(message) {
|
|
69
|
+
switch (message.type) {
|
|
70
|
+
case 'codex-overleaf/consent-update-get-state':
|
|
71
|
+
return getView();
|
|
72
|
+
case 'codex-overleaf/consent-update-check':
|
|
73
|
+
return checkOnly({ manual: true });
|
|
74
|
+
case 'codex-overleaf/consent-update-install':
|
|
75
|
+
return installUpdate();
|
|
76
|
+
case 'codex-overleaf/consent-update-later':
|
|
77
|
+
return postponeUpdate();
|
|
78
|
+
default:
|
|
79
|
+
throw codedError('unknown_update_action', 'Unknown update action.');
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function checkOnly({ manual }) {
|
|
84
|
+
const [currentState, consent] = await Promise.all([getUpdateState(), getConsentState()]);
|
|
85
|
+
if (consent.authorizedVersion || policy.isExecutionState(currentState.state)) {
|
|
86
|
+
return getView();
|
|
87
|
+
}
|
|
88
|
+
await setUpdateState({
|
|
89
|
+
...currentState,
|
|
90
|
+
state: 'checking',
|
|
91
|
+
code: '',
|
|
92
|
+
message: '',
|
|
93
|
+
blocker: '',
|
|
94
|
+
blockers: []
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
const result = await requestNative('update.check', {
|
|
99
|
+
currentVersion: currentVersion(),
|
|
100
|
+
etag: currentState.etag || ''
|
|
101
|
+
});
|
|
102
|
+
const checkedAt = Date.now();
|
|
103
|
+
if (result.available) {
|
|
104
|
+
const nextConsent = result.latestVersion !== consent.snoozedVersion
|
|
105
|
+
? { ...consent, snoozedVersion: '', snoozedUntil: 0 }
|
|
106
|
+
: consent;
|
|
107
|
+
await setConsentState({
|
|
108
|
+
...nextConsent,
|
|
109
|
+
lastPromptedVersion: result.latestVersion,
|
|
110
|
+
lastPromptedAt: manual ? checkedAt : nextConsent.lastPromptedAt
|
|
111
|
+
});
|
|
112
|
+
await setUpdateState({
|
|
113
|
+
...currentState,
|
|
114
|
+
state: 'update_available',
|
|
115
|
+
managed: true,
|
|
116
|
+
currentVersion: result.currentVersion || currentVersion(),
|
|
117
|
+
latestVersion: result.latestVersion,
|
|
118
|
+
etag: result.etag || currentState.etag || '',
|
|
119
|
+
lastCheckedAt: checkedAt,
|
|
120
|
+
postponeUntil: LEGACY_GUARD,
|
|
121
|
+
code: '',
|
|
122
|
+
message: ''
|
|
123
|
+
});
|
|
124
|
+
} else if (result.reason === 'not_modified' &&
|
|
125
|
+
policy.compareStableVersions(currentState.latestVersion, currentVersion()) > 0) {
|
|
126
|
+
await setUpdateState({
|
|
127
|
+
...currentState,
|
|
128
|
+
state: 'update_available',
|
|
129
|
+
lastCheckedAt: checkedAt,
|
|
130
|
+
postponeUntil: LEGACY_GUARD,
|
|
131
|
+
code: '',
|
|
132
|
+
message: ''
|
|
133
|
+
});
|
|
134
|
+
} else {
|
|
135
|
+
await setUpdateState({
|
|
136
|
+
...currentState,
|
|
137
|
+
state: 'idle',
|
|
138
|
+
currentVersion: result.currentVersion || currentVersion(),
|
|
139
|
+
latestVersion: result.latestVersion || result.currentVersion || currentVersion(),
|
|
140
|
+
etag: result.etag || currentState.etag || '',
|
|
141
|
+
lastCheckedAt: checkedAt,
|
|
142
|
+
postponeUntil: LEGACY_GUARD,
|
|
143
|
+
transactionId: '',
|
|
144
|
+
code: '',
|
|
145
|
+
message: ''
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
return getView();
|
|
149
|
+
} catch (error) {
|
|
150
|
+
await setUpdateState({
|
|
151
|
+
...currentState,
|
|
152
|
+
state: 'failed',
|
|
153
|
+
postponeUntil: LEGACY_GUARD,
|
|
154
|
+
code: safeCode(error),
|
|
155
|
+
message: safeMessage(error)
|
|
156
|
+
});
|
|
157
|
+
throw error;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function installUpdate() {
|
|
162
|
+
let state = await getUpdateState();
|
|
163
|
+
let consent = await getConsentState();
|
|
164
|
+
const candidateStale = !state.lastCheckedAt || Date.now() - state.lastCheckedAt > CANDIDATE_MAX_AGE_MS;
|
|
165
|
+
if (state.state !== 'update_available' || candidateStale) {
|
|
166
|
+
await checkOnly({ manual: true });
|
|
167
|
+
state = await getUpdateState();
|
|
168
|
+
consent = await getConsentState();
|
|
169
|
+
}
|
|
170
|
+
if (state.state !== 'update_available' ||
|
|
171
|
+
policy.compareStableVersions(state.latestVersion, currentVersion()) <= 0) {
|
|
172
|
+
throw codedError('update_candidate_missing', 'No newer signed stable update is available.');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const authorizationId = crypto.randomUUID();
|
|
176
|
+
await requestNative('update.authorize', {
|
|
177
|
+
authorizationId,
|
|
178
|
+
targetVersion: state.latestVersion,
|
|
179
|
+
currentVersion: currentVersion()
|
|
180
|
+
});
|
|
181
|
+
await setConsentState({
|
|
182
|
+
...consent,
|
|
183
|
+
snoozedVersion: '',
|
|
184
|
+
snoozedUntil: 0,
|
|
185
|
+
authorizedVersion: state.latestVersion,
|
|
186
|
+
authorizationId,
|
|
187
|
+
authorizedAt: Date.now()
|
|
188
|
+
});
|
|
189
|
+
await setUpdateState({ ...state, postponeUntil: 0, code: '', message: '' });
|
|
190
|
+
|
|
191
|
+
try {
|
|
192
|
+
const result = await chrome.runtime.sendMessage({ type: 'codex-overleaf/update-check-now' });
|
|
193
|
+
if (result === undefined) {
|
|
194
|
+
throw codedError('update_executor_unavailable', 'The managed update executor is unavailable.');
|
|
195
|
+
}
|
|
196
|
+
return getView();
|
|
197
|
+
} catch (error) {
|
|
198
|
+
await bestEffortRevoke({
|
|
199
|
+
authorizationId,
|
|
200
|
+
targetVersion: state.latestVersion,
|
|
201
|
+
transactionId: ''
|
|
202
|
+
});
|
|
203
|
+
await setConsentState({
|
|
204
|
+
...consent,
|
|
205
|
+
authorizedVersion: '',
|
|
206
|
+
authorizationId: '',
|
|
207
|
+
authorizedAt: 0
|
|
208
|
+
});
|
|
209
|
+
await armLegacyGuard();
|
|
210
|
+
throw error;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function postponeUpdate() {
|
|
215
|
+
const [state, consent] = await Promise.all([getUpdateState(), getConsentState()]);
|
|
216
|
+
if (['applying', 'awaiting_health'].includes(state.state)) {
|
|
217
|
+
throw codedError('update_revoke_too_late', 'The update is already being installed.');
|
|
218
|
+
}
|
|
219
|
+
if (consent.authorizationId) {
|
|
220
|
+
try {
|
|
221
|
+
await requestNative('update.revoke', {
|
|
222
|
+
authorizationId: consent.authorizationId,
|
|
223
|
+
targetVersion: consent.authorizedVersion || state.latestVersion,
|
|
224
|
+
transactionId: state.transactionId || ''
|
|
225
|
+
});
|
|
226
|
+
} catch (error) {
|
|
227
|
+
if (error?.code === 'update_revoke_too_late') {
|
|
228
|
+
return getView();
|
|
229
|
+
}
|
|
230
|
+
throw error;
|
|
231
|
+
}
|
|
232
|
+
} else if (['staged', 'waiting_for_idle'].includes(state.state)) {
|
|
233
|
+
throw codedError('update_consent_mismatch', 'The staged update has no matching runtime authorization.');
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const snoozedUntil = Date.now() + SNOOZE_MS;
|
|
237
|
+
await setUpdateState({
|
|
238
|
+
...state,
|
|
239
|
+
state: 'update_available',
|
|
240
|
+
transactionId: '',
|
|
241
|
+
stagedAt: 0,
|
|
242
|
+
blocker: '',
|
|
243
|
+
blockers: [],
|
|
244
|
+
postponeUntil: LEGACY_GUARD,
|
|
245
|
+
code: '',
|
|
246
|
+
message: ''
|
|
247
|
+
});
|
|
248
|
+
await setConsentState({
|
|
249
|
+
...consent,
|
|
250
|
+
snoozedVersion: state.latestVersion,
|
|
251
|
+
snoozedUntil,
|
|
252
|
+
authorizedVersion: '',
|
|
253
|
+
authorizationId: '',
|
|
254
|
+
authorizedAt: 0
|
|
255
|
+
});
|
|
256
|
+
return getView();
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async function settleTerminalConsent() {
|
|
260
|
+
const [state, consent] = await Promise.all([getUpdateState(), getConsentState()]);
|
|
261
|
+
if (!policy.isTerminalState(state.state) || !consent.authorizationId) return;
|
|
262
|
+
await setConsentState({
|
|
263
|
+
...consent,
|
|
264
|
+
authorizedVersion: '',
|
|
265
|
+
authorizationId: '',
|
|
266
|
+
authorizedAt: 0
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function handleObservedStateChange() {
|
|
271
|
+
await settleTerminalConsent();
|
|
272
|
+
const [state, consent] = await Promise.all([getUpdateState(), getConsentState()]);
|
|
273
|
+
if (!consent.authorizationId && !policy.isExecutionState(state.state)) {
|
|
274
|
+
await armLegacyGuard();
|
|
275
|
+
}
|
|
276
|
+
await publishView();
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function armLegacyGuard() {
|
|
280
|
+
const [state, consent] = await Promise.all([getUpdateState(), getConsentState()]);
|
|
281
|
+
if (consent.authorizationId || state.postponeUntil === LEGACY_GUARD) return state;
|
|
282
|
+
return setUpdateState({ ...state, postponeUntil: LEGACY_GUARD });
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async function getView() {
|
|
286
|
+
const [state, consent] = await Promise.all([getUpdateState(), getConsentState()]);
|
|
287
|
+
return policy.deriveViewModel(state, consent, {
|
|
288
|
+
currentVersion: currentVersion(),
|
|
289
|
+
now: Date.now()
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async function publishView() {
|
|
294
|
+
const view = await getView();
|
|
295
|
+
await setBadge(view.badge);
|
|
296
|
+
const tabs = await chrome.tabs.query({ url: OVERLEAF_MATCHES }).catch(() => []);
|
|
297
|
+
await Promise.all((tabs || []).map(tab => {
|
|
298
|
+
if (!Number.isInteger(tab?.id)) return Promise.resolve();
|
|
299
|
+
return chrome.tabs.sendMessage(tab.id, {
|
|
300
|
+
type: 'codex-overleaf/consent-update-state',
|
|
301
|
+
view
|
|
302
|
+
}).catch(() => {});
|
|
303
|
+
}));
|
|
304
|
+
return view;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function setBadge(badge = {}) {
|
|
308
|
+
try {
|
|
309
|
+
await chrome.action.setBadgeText({ text: String(badge.text || '').slice(0, 4) });
|
|
310
|
+
if (badge.text) {
|
|
311
|
+
await chrome.action.setBadgeBackgroundColor({ color: badge.color || '#3578bd' });
|
|
312
|
+
}
|
|
313
|
+
} catch (_error) {
|
|
314
|
+
// Badge rendering is best-effort and never participates in update state.
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async function getUpdateState() {
|
|
319
|
+
const stored = await chrome.storage.local.get(UPDATE_STATE_KEY);
|
|
320
|
+
return policy.normalizeUpdateState(stored?.[UPDATE_STATE_KEY], currentVersion());
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function setUpdateState(value) {
|
|
324
|
+
const next = policy.normalizeUpdateState(value, currentVersion());
|
|
325
|
+
await chrome.storage.local.set({ [UPDATE_STATE_KEY]: next });
|
|
326
|
+
return next;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async function getConsentState() {
|
|
330
|
+
const stored = await chrome.storage.local.get(CONSENT_STATE_KEY);
|
|
331
|
+
return policy.normalizeConsentState(stored?.[CONSENT_STATE_KEY]);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function setConsentState(value) {
|
|
335
|
+
const next = policy.normalizeConsentState(value);
|
|
336
|
+
await chrome.storage.local.set({ [CONSENT_STATE_KEY]: next });
|
|
337
|
+
return next;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async function requestNative(method, params = {}) {
|
|
341
|
+
const response = await nativeBridge?.requestInternal?.({
|
|
342
|
+
id: crypto.randomUUID(),
|
|
343
|
+
method,
|
|
344
|
+
params
|
|
345
|
+
});
|
|
346
|
+
if (!response?.ok) {
|
|
347
|
+
throw codedError(
|
|
348
|
+
response?.error?.code || 'native_connection_failed',
|
|
349
|
+
response?.error?.message || 'Native Host update request failed.'
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
return response.result || {};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function bestEffortRevoke(params) {
|
|
356
|
+
try {
|
|
357
|
+
await requestNative('update.revoke', params);
|
|
358
|
+
} catch (_error) {
|
|
359
|
+
// Native authorization remains the hard gate if cleanup cannot connect.
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function enqueuePolicyAction(action) {
|
|
364
|
+
const result = policyTail.then(action, action);
|
|
365
|
+
policyTail = result.catch(() => {});
|
|
366
|
+
return result;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function isAllowedSender(sender) {
|
|
370
|
+
if (sender?.id && sender.id !== chrome.runtime.id) return false;
|
|
371
|
+
if (!sender?.tab) return true;
|
|
372
|
+
try {
|
|
373
|
+
const url = new URL(sender.tab.url || '');
|
|
374
|
+
return url.protocol === 'https:' &&
|
|
375
|
+
(url.hostname === 'www.overleaf.com' || url.hostname === 'overleaf.com') &&
|
|
376
|
+
url.pathname.startsWith('/project/');
|
|
377
|
+
} catch (_error) {
|
|
378
|
+
return false;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function currentVersion() {
|
|
383
|
+
return String(
|
|
384
|
+
root.CodexOverleafCompatibility?.BUILD_TARGET_VERSION ||
|
|
385
|
+
chrome.runtime.getManifest().version ||
|
|
386
|
+
''
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function codedError(code, message) {
|
|
391
|
+
const error = new Error(message);
|
|
392
|
+
error.code = code;
|
|
393
|
+
return error;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function safeError(error) {
|
|
397
|
+
return { code: safeCode(error), message: safeMessage(error) };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function safeCode(error) {
|
|
401
|
+
const code = String(error?.code || '');
|
|
402
|
+
return /^[a-z0-9_]{1,80}$/.test(code) ? code : 'update_failed';
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function safeMessage(error) {
|
|
406
|
+
return String(error?.message || 'Update failed.')
|
|
407
|
+
.replace(/(?:file:\/\/)?(?:[A-Za-z]:[\\/]|\/Users\/|\/home\/)[^\s]*/g, '[local path]')
|
|
408
|
+
.replace(/\s+/g, ' ')
|
|
409
|
+
.trim()
|
|
410
|
+
.slice(0, 300);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
root.CodexOverleafUpdateCoordinator = Object.freeze({ init });
|
|
414
|
+
})(globalThis);
|
|
@@ -778,6 +778,9 @@
|
|
|
778
778
|
storageKey = getProjectStorageKey(LEGACY_STORAGE_KEY, window.location.href);
|
|
779
779
|
state = normalizePanelState(await loadStoredState(), { restoreRunningRuns: true });
|
|
780
780
|
ensurePanelOpen();
|
|
781
|
+
root.CodexOverleafUpdateNotice?.mount?.(panel?.panelEl || panel, {
|
|
782
|
+
getLocale: () => state?.locale || 'en'
|
|
783
|
+
});
|
|
781
784
|
// v1.8.1: on a non-editor route the panel must NEVER first paint the
|
|
782
785
|
// per-project session UI (composer + probe line) and then swap — mount
|
|
783
786
|
// the dashboard shell synchronously; the account-scope resolution below
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
(function initCodexOverleafUpdateNotice(root) {
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
let notice = null;
|
|
5
|
+
let currentView = null;
|
|
6
|
+
let mountedPanel = null;
|
|
7
|
+
let actionInFlight = false;
|
|
8
|
+
let completionTimer = null;
|
|
9
|
+
let listenerInstalled = false;
|
|
10
|
+
let getLocale = () => '';
|
|
11
|
+
|
|
12
|
+
function mount(panelLike, options = {}) {
|
|
13
|
+
if (typeof options.getLocale === 'function') {
|
|
14
|
+
getLocale = options.getLocale;
|
|
15
|
+
}
|
|
16
|
+
const panel = panelLike?.panelEl || panelLike;
|
|
17
|
+
if (!panel) return;
|
|
18
|
+
if (mountedPanel === panel) {
|
|
19
|
+
render();
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
mountedPanel = panel;
|
|
23
|
+
notice?.remove();
|
|
24
|
+
notice = document.createElement('section');
|
|
25
|
+
notice.className = 'codex-update-notice';
|
|
26
|
+
notice.hidden = true;
|
|
27
|
+
notice.setAttribute('aria-label', tx('Stable update', '稳定版更新'));
|
|
28
|
+
const header = panel.querySelector('[data-panel-header]');
|
|
29
|
+
if (typeof header?.insertAdjacentElement === 'function') {
|
|
30
|
+
header.insertAdjacentElement('afterend', notice);
|
|
31
|
+
} else if (typeof header?.after === 'function') {
|
|
32
|
+
header.after(notice);
|
|
33
|
+
} else if (typeof panel.prepend === 'function') {
|
|
34
|
+
panel.prepend(notice);
|
|
35
|
+
} else {
|
|
36
|
+
panel.append?.(notice);
|
|
37
|
+
}
|
|
38
|
+
notice.addEventListener('click', handleClick);
|
|
39
|
+
|
|
40
|
+
if (!listenerInstalled) {
|
|
41
|
+
listenerInstalled = true;
|
|
42
|
+
chrome.runtime.onMessage.addListener(message => {
|
|
43
|
+
if (message?.type !== 'codex-overleaf/consent-update-state') return;
|
|
44
|
+
currentView = message.view;
|
|
45
|
+
render();
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
request('codex-overleaf/consent-update-get-state')
|
|
49
|
+
.then(view => {
|
|
50
|
+
currentView = view;
|
|
51
|
+
render();
|
|
52
|
+
})
|
|
53
|
+
.catch(() => {});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function handleClick(event) {
|
|
57
|
+
const action = event.target?.closest?.('[data-update-notice-action]')?.dataset?.updateNoticeAction;
|
|
58
|
+
if (!action || actionInFlight) return;
|
|
59
|
+
actionInFlight = true;
|
|
60
|
+
render();
|
|
61
|
+
try {
|
|
62
|
+
currentView = await request({
|
|
63
|
+
install: 'codex-overleaf/consent-update-install',
|
|
64
|
+
later: 'codex-overleaf/consent-update-later',
|
|
65
|
+
retry: 'codex-overleaf/consent-update-check'
|
|
66
|
+
}[action]);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
currentView = {
|
|
69
|
+
...(currentView || {}),
|
|
70
|
+
state: {
|
|
71
|
+
...(currentView?.state || {}),
|
|
72
|
+
state: 'failed',
|
|
73
|
+
code: error?.code || 'update_failed',
|
|
74
|
+
message: error?.message || tx('Update failed.', '更新失败。')
|
|
75
|
+
},
|
|
76
|
+
progress: { value: 0, determinate: true, phase: 'failed' },
|
|
77
|
+
showPanel: true
|
|
78
|
+
};
|
|
79
|
+
} finally {
|
|
80
|
+
actionInFlight = false;
|
|
81
|
+
render();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function request(type) {
|
|
86
|
+
if (!type) throw new Error('Unknown update action.');
|
|
87
|
+
const response = await chrome.runtime.sendMessage({ type });
|
|
88
|
+
if (!response?.ok) {
|
|
89
|
+
const error = new Error(response?.error?.message || tx('Update action failed.', '更新操作失败。'));
|
|
90
|
+
error.code = response?.error?.code || 'update_failed';
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
return response.result;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function render() {
|
|
97
|
+
if (!notice || !currentView) return;
|
|
98
|
+
clearTimeout(completionTimer);
|
|
99
|
+
if (!currentView.showPanel) {
|
|
100
|
+
notice.hidden = true;
|
|
101
|
+
notice.replaceChildren();
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const focusedAction = document.activeElement?.dataset?.updateNoticeAction || '';
|
|
106
|
+
const state = currentView.state || {};
|
|
107
|
+
const progress = currentView.progress || {};
|
|
108
|
+
const copy = getCopy(state);
|
|
109
|
+
notice.hidden = false;
|
|
110
|
+
notice.dataset.state = state.state || 'idle';
|
|
111
|
+
|
|
112
|
+
const body = document.createElement('div');
|
|
113
|
+
body.className = 'codex-update-notice-body';
|
|
114
|
+
const eyebrow = document.createElement('span');
|
|
115
|
+
eyebrow.className = 'codex-update-notice-eyebrow';
|
|
116
|
+
eyebrow.textContent = copy.eyebrow;
|
|
117
|
+
const title = document.createElement('strong');
|
|
118
|
+
title.textContent = copy.title;
|
|
119
|
+
const detail = document.createElement('span');
|
|
120
|
+
detail.className = 'codex-update-notice-detail';
|
|
121
|
+
detail.textContent = copy.detail;
|
|
122
|
+
body.append(eyebrow, title, detail);
|
|
123
|
+
|
|
124
|
+
if (!['update_available', 'failed', 'rolled_back'].includes(state.state)) {
|
|
125
|
+
const bar = document.createElement('div');
|
|
126
|
+
bar.className = 'codex-update-notice-progress' + (progress.determinate ? '' : ' is-indeterminate');
|
|
127
|
+
bar.setAttribute('role', 'progressbar');
|
|
128
|
+
bar.setAttribute('aria-valuemin', '0');
|
|
129
|
+
bar.setAttribute('aria-valuemax', '100');
|
|
130
|
+
bar.setAttribute('aria-valuetext', copy.detail);
|
|
131
|
+
if (progress.determinate) bar.setAttribute('aria-valuenow', String(progress.value || 0));
|
|
132
|
+
const fill = document.createElement('span');
|
|
133
|
+
fill.style.width = Math.max(0, Math.min(100, Number(progress.value || 0))) + '%';
|
|
134
|
+
bar.append(fill);
|
|
135
|
+
body.append(bar);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const actions = document.createElement('div');
|
|
139
|
+
actions.className = 'codex-update-notice-actions';
|
|
140
|
+
for (const action of visibleActions(state.state)) {
|
|
141
|
+
const button = document.createElement('button');
|
|
142
|
+
button.type = 'button';
|
|
143
|
+
button.dataset.updateNoticeAction = action.id;
|
|
144
|
+
button.className = action.primary ? 'is-primary' : '';
|
|
145
|
+
button.textContent = action.label;
|
|
146
|
+
button.disabled = actionInFlight;
|
|
147
|
+
actions.append(button);
|
|
148
|
+
}
|
|
149
|
+
notice.replaceChildren(body, actions);
|
|
150
|
+
if (focusedAction) {
|
|
151
|
+
notice.querySelector('[data-update-notice-action="' + focusedAction + '"]:not(:disabled)')?.focus();
|
|
152
|
+
}
|
|
153
|
+
if (state.state === 'committed') {
|
|
154
|
+
completionTimer = setTimeout(() => {
|
|
155
|
+
if (notice?.dataset.state === 'committed') notice.hidden = true;
|
|
156
|
+
}, 5000);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function visibleActions(state) {
|
|
161
|
+
if (actionInFlight) return [];
|
|
162
|
+
if (state === 'update_available') {
|
|
163
|
+
return [
|
|
164
|
+
{ id: 'later', label: tx('Later', '稍后'), primary: false },
|
|
165
|
+
{ id: 'install', label: tx('Update now', '立即更新'), primary: true }
|
|
166
|
+
];
|
|
167
|
+
}
|
|
168
|
+
if (['staged', 'waiting_for_idle'].includes(state)) {
|
|
169
|
+
return [{ id: 'later', label: tx('Later', '稍后'), primary: false }];
|
|
170
|
+
}
|
|
171
|
+
if (['failed', 'rolled_back'].includes(state)) {
|
|
172
|
+
return [{ id: 'retry', label: tx('Retry', '重试'), primary: true }];
|
|
173
|
+
}
|
|
174
|
+
return [];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function getCopy(state) {
|
|
178
|
+
const target = state.latestVersion ? 'v' + state.latestVersion : '';
|
|
179
|
+
const blocker = blockerCopy(state.blocker);
|
|
180
|
+
return {
|
|
181
|
+
update_available: {
|
|
182
|
+
eyebrow: tx('Update available', '发现新版本'),
|
|
183
|
+
title: target,
|
|
184
|
+
detail: tx('Signed stable update for the extension and Native Host.', '扩展与 Native Host 的签名稳定更新。')
|
|
185
|
+
},
|
|
186
|
+
downloading: {
|
|
187
|
+
eyebrow: tx('Updating Codex Overleaf Link', '正在更新 Codex Overleaf Link'),
|
|
188
|
+
title: target,
|
|
189
|
+
detail: tx('Downloading and verifying the coordinated update.', '正在下载并验证协调更新。')
|
|
190
|
+
},
|
|
191
|
+
staged: {
|
|
192
|
+
eyebrow: tx('Update ready', '更新已就绪'),
|
|
193
|
+
title: target,
|
|
194
|
+
detail: tx('Verified and ready to install at a safe point.', '已完成验证,将在安全时机安装。')
|
|
195
|
+
},
|
|
196
|
+
waiting_for_idle: {
|
|
197
|
+
eyebrow: tx('Waiting for a safe point', '正在等待安全时机'),
|
|
198
|
+
title: target,
|
|
199
|
+
detail: blocker
|
|
200
|
+
},
|
|
201
|
+
applying: {
|
|
202
|
+
eyebrow: tx('Installing update', '正在安装更新'),
|
|
203
|
+
title: target,
|
|
204
|
+
detail: tx('Installing extension and Native Host.', '正在安装扩展与 Native Host。')
|
|
205
|
+
},
|
|
206
|
+
awaiting_health: {
|
|
207
|
+
eyebrow: tx('Checking updated components', '正在检查更新后的组件'),
|
|
208
|
+
title: target,
|
|
209
|
+
detail: tx('Restarting and checking both components.', '正在重启并检查两个组件。')
|
|
210
|
+
},
|
|
211
|
+
committed: {
|
|
212
|
+
eyebrow: tx('Update complete', '更新完成'),
|
|
213
|
+
title: target,
|
|
214
|
+
detail: tx('Both components passed health confirmation.', '两个组件均已通过健康检查。')
|
|
215
|
+
},
|
|
216
|
+
rolled_back: {
|
|
217
|
+
eyebrow: tx('Previous version restored', '已恢复上一版本'),
|
|
218
|
+
title: state.currentVersion ? 'v' + state.currentVersion : '',
|
|
219
|
+
detail: tx('The replacement failed its health check.', '替换版本未通过健康检查。')
|
|
220
|
+
},
|
|
221
|
+
failed: {
|
|
222
|
+
eyebrow: tx('Update could not continue', '更新无法继续'),
|
|
223
|
+
title: target,
|
|
224
|
+
detail: state.message || tx('Review the update details and retry.', '请查看更新详情后重试。')
|
|
225
|
+
}
|
|
226
|
+
}[state.state] || { eyebrow: '', title: '', detail: '' };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function blockerCopy(value) {
|
|
230
|
+
return {
|
|
231
|
+
unsaved: tx('Overleaf has not confirmed that this document is saved.', 'Overleaf 尚未确认当前文档已保存。'),
|
|
232
|
+
active_run: tx('A Codex task is still running.', 'Codex 任务仍在运行。'),
|
|
233
|
+
cancelling: tx('Cancellation is still settling.', '取消操作仍在收尾。'),
|
|
234
|
+
storage_write: tx('Extension state is still being saved.', '扩展状态仍在保存。'),
|
|
235
|
+
review_action: tx('Accept or Undo is still running.', '接受或撤销操作仍在进行。'),
|
|
236
|
+
dialog_open: tx('A confirmation dialog is open.', '确认对话框仍处于打开状态。'),
|
|
237
|
+
native_project_locked: tx('Native Host still holds a project lock.', 'Native Host 仍持有项目锁。'),
|
|
238
|
+
native_run_active: tx('Native Host is still processing a task.', 'Native Host 仍在处理任务。')
|
|
239
|
+
}[value] || tx('Waiting until every Overleaf tab is saved and idle.', '正在等待所有 Overleaf 标签页完成保存并进入空闲状态。');
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function tx(english, chinese) {
|
|
243
|
+
const locale = String(getLocale() || document.documentElement?.lang || 'en');
|
|
244
|
+
return /^zh\b/i.test(locale) ? chinese : english;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
root.CodexOverleafUpdateNotice = Object.freeze({ mount });
|
|
248
|
+
})(window);
|