codex-overleaf-link 2.5.0 → 2.5.1

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.
Files changed (168) hide show
  1. package/README.md +38 -26
  2. package/README.zh-CN.md +39 -27
  3. package/extension/bootstrap/background.js +52 -9
  4. package/extension/bootstrap/manifest.template.json +1 -1
  5. package/extension/bootstrap/popup.html +1 -0
  6. package/extension/bootstrap/popupFallback.js +55 -0
  7. package/extension/bootstrap/updateStatus.js +1 -0
  8. package/extension/src/background.js +206 -53
  9. package/extension/src/backgroundBootstrapMigration.js +181 -0
  10. package/extension/src/backgroundSharedSessions.js +74 -14
  11. package/extension/src/backgroundUpdateCoordinator.js +118 -21
  12. package/extension/src/backgroundUpdateSafety.js +173 -0
  13. package/extension/src/content/activeTurnControl.js +8 -2
  14. package/extension/src/content/applyResultFormatters.js +46 -73
  15. package/extension/src/content/assetTransferBroker.js +72 -14
  16. package/extension/src/content/composerAttachments.js +93 -45
  17. package/extension/src/content/composerPanel.js +18 -0
  18. package/extension/src/content/contentRuntime.js +661 -676
  19. package/extension/src/content/diagnosticsController.js +122 -96
  20. package/extension/src/content/diagnosticsExport.js +19 -5
  21. package/extension/src/content/diagnosticsPanel.js +30 -0
  22. package/extension/src/content/directoryCleanupRecovery.js +54 -0
  23. package/extension/src/content/generated/content.bundle.js +6879 -2786
  24. package/extension/src/content/generated/content.bundle.meta.json +11 -3
  25. package/extension/src/content/globalPreferencesController.js +1 -1
  26. package/extension/src/content/legacyGlobalRegistry.js +6 -0
  27. package/extension/src/content/localSkillsPanel.js +102 -12
  28. package/extension/src/content/markdownText.js +5 -6
  29. package/extension/src/content/modelPicker.js +7 -4
  30. package/extension/src/content/nativeCompatibilityController.js +45 -20
  31. package/extension/src/content/otWarmMirror.js +4 -5
  32. package/extension/src/content/otWarmMirrorController.js +17 -20
  33. package/extension/src/content/pageBridgeClient.js +15 -2
  34. package/extension/src/content/panelMaintenance.js +40 -12
  35. package/extension/src/content/panelRenderer.js +6 -0
  36. package/extension/src/content/projectProviderSelection.js +13 -4
  37. package/extension/src/content/projectSessionCleanup.js +109 -62
  38. package/extension/src/content/projectSettingsCoordinator.js +39 -2
  39. package/extension/src/content/providerSettingsCoordinator.js +46 -8
  40. package/extension/src/content/providerSettingsDialog.js +71 -13
  41. package/extension/src/content/recentProjects.js +11 -5
  42. package/extension/src/content/runActivitySummary.js +40 -6
  43. package/extension/src/content/runCompletionMeta.js +344 -0
  44. package/extension/src/content/runController.js +37 -14
  45. package/extension/src/content/runFailureNotice.js +21 -12
  46. package/extension/src/content/runGuidanceController.js +72 -27
  47. package/extension/src/content/runGuidanceView.js +59 -10
  48. package/extension/src/content/runQueueScheduler.js +148 -42
  49. package/extension/src/content/runRecoveryActions.js +146 -0
  50. package/extension/src/content/runResultActions.js +8 -125
  51. package/extension/src/content/runReviewController.js +574 -0
  52. package/extension/src/content/runScrollLayout.js +21 -2
  53. package/extension/src/content/runTimelineView.js +146 -153
  54. package/extension/src/content/scopedPersistencePanelState.js +51 -6
  55. package/extension/src/content/selectionContextView.js +1 -1
  56. package/extension/src/content/sensitivePreflight.js +140 -0
  57. package/extension/src/content/sessionForkController.js +80 -17
  58. package/extension/src/content/sessionManager.js +1 -1
  59. package/extension/src/content/settingsPanel.js +7 -3
  60. package/extension/src/content/settingsWorkbench.js +5 -0
  61. package/extension/src/content/sharedSessionViewSync.js +45 -8
  62. package/extension/src/content/updateIdle.js +4 -1
  63. package/extension/src/content/updateNotice.js +60 -8
  64. package/extension/src/content/updateSafetyReceiver.js +58 -0
  65. package/extension/src/content/writebackController.js +53 -4
  66. package/extension/src/content/writebackOrchestrator.js +82 -73
  67. package/extension/src/content/writingStyleSettings.js +35 -11
  68. package/extension/src/page/binaryAssetUploader.js +136 -14
  69. package/extension/src/page/fileMover.js +256 -0
  70. package/extension/src/page/ownedDirectoryCleanup.js +94 -0
  71. package/extension/src/page/referenceProjects.js +1 -0
  72. package/extension/src/page/reviewContext.js +113 -0
  73. package/extension/src/page/saveState.js +6 -4
  74. package/extension/src/page/snapshotRouter.js +84 -39
  75. package/extension/src/page/textFileCreator.js +105 -25
  76. package/extension/src/page/trackedChangeCapture.js +12 -2
  77. package/extension/src/page/trackedChangeOwnership.js +19 -1
  78. package/extension/src/page/trackedChangeReplay.js +41 -21
  79. package/extension/src/page/trackedChangesLifecycle.js +217 -199
  80. package/extension/src/page/treeOperations.js +7 -10
  81. package/extension/src/page/updateSafetyProbe.js +41 -0
  82. package/extension/src/page/writebackReceiptJournal.js +2 -0
  83. package/extension/src/page/writebackRouter.js +80 -79
  84. package/extension/src/pageBridge.js +19 -3
  85. package/extension/src/popup.js +62 -10
  86. package/extension/src/shared/agentTranscript.js +23 -0
  87. package/extension/src/shared/auditRecords.js +2 -0
  88. package/extension/src/shared/compatibility.js +22 -6
  89. package/extension/src/shared/compileAdapter.js +79 -57
  90. package/extension/src/shared/composerAttachmentDraft.js +151 -0
  91. package/extension/src/shared/directoryCleanupProof.js +43 -0
  92. package/extension/src/shared/failureReasons.js +309 -3
  93. package/extension/src/shared/globalPreferences.js +30 -13
  94. package/extension/src/shared/governanceRules.js +4 -37
  95. package/extension/src/shared/i18n.js +298 -2
  96. package/extension/src/shared/managedUpdateProjection.js +2 -2
  97. package/extension/src/shared/nativeRequestIdentity.js +1 -0
  98. package/extension/src/shared/pageRpcContract.js +5 -4
  99. package/extension/src/shared/projectFiles.js +21 -44
  100. package/extension/src/shared/providerProfiles.js +5 -3
  101. package/extension/src/shared/runInputQueue.js +48 -5
  102. package/extension/src/shared/selectionContext.js +12 -3
  103. package/extension/src/shared/sessionState.js +72 -27
  104. package/extension/src/shared/settlementFacts.js +10 -2
  105. package/extension/src/shared/sharedSessionBridge.js +37 -14
  106. package/extension/src/shared/sharedSessionTransfer.js +140 -0
  107. package/extension/src/shared/staleGuard.js +118 -13
  108. package/extension/src/shared/storageDb.js +58 -26
  109. package/extension/src/shared/storageMigration.js +71 -111
  110. package/extension/src/shared/storageRunActions.js +209 -26
  111. package/extension/src/shared/subagentActivityModel.js +1 -1
  112. package/extension/src/shared/undoOperations.js +136 -13
  113. package/extension/src/shared/updateConsent.js +6 -4
  114. package/extension/src/shared/updateRuntimeIdentity.js +1 -1
  115. package/extension/src/shared/writebackIntent.js +16 -0
  116. package/extension/src/shared/writebackOperationEvidence.js +4 -15
  117. package/extension/src/shared/writebackSettlement.js +38 -38
  118. package/extension/styles/panel.css +19 -5
  119. package/native-host/src/anthropicBridgeRequest.js +4 -2
  120. package/native-host/src/anthropicBridgeResponse.js +6 -2
  121. package/native-host/src/anthropicMessagesBridge.js +24 -16
  122. package/native-host/src/bootstrapMigration.js +296 -0
  123. package/native-host/src/bootstrapMigrationContract.js +101 -0
  124. package/native-host/src/bootstrapUpdate.js +233 -0
  125. package/native-host/src/bootstrapUpdateWatchdog.js +44 -0
  126. package/native-host/src/chatBridgeResponse.js +10 -0
  127. package/native-host/src/chatCompletionsBridge.js +21 -15
  128. package/native-host/src/codexConfigPaths.js +106 -0
  129. package/native-host/src/codexHome.js +66 -73
  130. package/native-host/src/codexHomeScope.js +124 -0
  131. package/native-host/src/codexInheritedAuth.js +90 -0
  132. package/native-host/src/codexLocalSkills.js +301 -0
  133. package/native-host/src/codexPromptAssembly.js +31 -12
  134. package/native-host/src/codexProviderTest.js +24 -26
  135. package/native-host/src/codexSessionRunner.js +110 -183
  136. package/native-host/src/codexSessionTiming.js +18 -8
  137. package/native-host/src/codexSkillIsolation.js +170 -0
  138. package/native-host/src/codexThreadFork.js +19 -3
  139. package/native-host/src/index.js +19 -7
  140. package/native-host/src/managedInstall.js +110 -35
  141. package/native-host/src/managedInstallRecovery.js +140 -0
  142. package/native-host/src/managedRuntimeEvidence.js +57 -0
  143. package/native-host/src/mirrorWorkspace.js +92 -22
  144. package/native-host/src/nativeDoctor.js +15 -0
  145. package/native-host/src/nativeHelperLifecycle.js +21 -0
  146. package/native-host/src/nativeHostRegistration.js +33 -0
  147. package/native-host/src/nativeRunLifecycle.js +71 -0
  148. package/native-host/src/processStopBarrier.js +42 -7
  149. package/native-host/src/projectPathPlatform.js +30 -0
  150. package/native-host/src/providerHttp.js +80 -0
  151. package/native-host/src/providerProfile.js +4 -2
  152. package/native-host/src/providerReasoning.js +2 -7
  153. package/native-host/src/providerRuntime.js +1 -0
  154. package/native-host/src/providerStore.js +72 -14
  155. package/native-host/src/responsesPassthroughBridge.js +266 -52
  156. package/native-host/src/runtimeInstaller.js +21 -1
  157. package/native-host/src/selectionScope.js +36 -8
  158. package/native-host/src/subagentAdmission.js +26 -0
  159. package/native-host/src/subagentBroker.js +79 -66
  160. package/native-host/src/subagentTelemetry.js +19 -10
  161. package/native-host/src/subagentWorkspacePath.js +5 -2
  162. package/native-host/src/taskRunnerRuntime.js +77 -54
  163. package/native-host/src/tomlStatements.js +140 -0
  164. package/native-host/src/updateManager.js +297 -39
  165. package/native-host/src/writingStyleRuntime.js +57 -29
  166. package/native-host/src/writingStyleStore.js +73 -0
  167. package/package.json +1 -1
  168. package/scripts/install-managed.mjs +1 -0
@@ -1,11 +1,13 @@
1
1
  (function (root, factory) {
2
- const api = factory();
2
+ const api = factory(typeof module === 'object' && module.exports
3
+ ? require('./shared/sharedSessionTransfer') : root.CodexOverleafSharedSessionTransfer);
3
4
  if (typeof module === 'object' && module.exports) module.exports = api;
4
5
  if (root) root.CodexOverleafSharedSessionsBackground = api;
5
- })(typeof globalThis !== 'undefined' ? globalThis : this, function () {
6
+ })(typeof globalThis !== 'undefined' ? globalThis : this, function (Transfer) {
6
7
  'use strict';
7
8
  const MESSAGE = 'codex-overleaf/shared-sessions-v1';
8
9
  const LOCK_PORT = 'codex-overleaf/shared-session-lock-v1';
10
+ const REVIEW_LEASE_PREFIX = 'codex-overleaf-review-lease-v1:';
9
11
  const CHANGE_KEY = 'codex-overleaf-shared-sessions-changed-v1';
10
12
  const HOSTS = ['overleaf.com', 'www.overleaf.com', 'cn.overleaf.com'];
11
13
  const META_PREFIX = 'codex-overleaf-scoped-persistence-v1:';
@@ -20,10 +22,11 @@
20
22
  return value;
21
23
  }
22
24
  function scope(input) {
23
- return { accountScopeId: part(input?.accountScopeId), projectId: typeof input?.projectId === 'string' ? input.projectId : '' };
25
+ return { accountScopeId: part(input?.accountScopeId), projectId: typeof input?.projectId === 'string' ? input.projectId : '', ...(input?.runId ? { runId: part(input.runId) } : {}) };
24
26
  }
25
27
  function keyFor(kind, value) {
26
- return kind === 'preferences' ? 'preferences'
28
+ return kind === 'review' ? 'review:' + JSON.stringify([value.accountScopeId, part(value.projectId), part(value.runId)])
29
+ : kind === 'preferences' ? 'preferences'
27
30
  : 'state:' + encodeURIComponent(value.accountScopeId);
28
31
  }
29
32
  function metaKey(value) {
@@ -137,7 +140,7 @@
137
140
  const stored = item.value;
138
141
  if (value.projectId && stored.projectId !== value.projectId) { item.continue(); return; }
139
142
  const record = { ...stored.record, sharedRevision: stored.revision };
140
- const size = JSON.stringify(record).length * 2;
143
+ const size = Transfer.byteLength(JSON.stringify(record));
141
144
  if (result.length && (result.length >= 8 || bytes + size > MAX_BYTES)) {
142
145
  resolve({ records: result, next: last }); return;
143
146
  }
@@ -162,10 +165,9 @@
162
165
  appliedOperations: [], trackedChangeCaptures: [], undoStatus: '' })) };
163
166
  }
164
167
  async function writeMany(message, value, importing, origin, sender) {
165
- const entries = (importing ? [{ record: message.record }] : message.entries || [])
166
- .map(entry => ({ record: clone(entry.record), base: clone(entry.base) }));
167
- if (JSON.stringify(entries).length * 2 > MAX_BYTES * 3)
168
- throw failure('shared_session_too_large', 'The session batch exceeds the shared storage transfer limit.');
168
+ // Reject a lost owner before doing any bounded decompression work.
169
+ ensureLock(message, value, sender);
170
+ const entries = (await Transfer.decode(message)).map(entry => ({ record: clone(entry.record), base: clone(entry.base) }));
169
171
  for (const { record, base } of entries) {
170
172
  if (!record || record.accountScopeId !== value.accountScopeId || record.projectId !== value.projectId)
171
173
  throw failure('shared_store_scope_mismatch', 'The session does not belong to the captured account and project.');
@@ -197,6 +199,14 @@
197
199
  putAt(entry.record, entry.base);
198
200
  }
199
201
  };
202
+ function appendResult(record, revision) {
203
+ // Merge may grow beyond either input. Reject inside this transaction,
204
+ // before returning success or committing any sibling record.
205
+ Transfer.validateRecord(record);
206
+ const result = { ...record, sharedRevision: revision };
207
+ Transfer.validateResponse([...results, result]);
208
+ results.push(result);
209
+ }
200
210
  function putAt(candidate, base, copy = false) {
201
211
  const key = [value.accountScopeId, candidate.id];
202
212
  const tombstone = deleted.get(key);
@@ -214,7 +224,7 @@
214
224
  let next = candidate;
215
225
  if (importing && existing) {
216
226
  if (equal(existing.record, candidate) || copy) {
217
- results.push({ ...existing.record, sharedRevision: existing.revision }); return;
227
+ appendResult(existing.record, existing.revision); return;
218
228
  }
219
229
  putAt(recoveryCopy(candidate, origin), null, true); return;
220
230
  }
@@ -227,9 +237,9 @@
227
237
  throw failure('shared_session_deleted', 'The session no longer exists.');
228
238
  }
229
239
  const revision = (existing?.revision || 0) + 1;
240
+ appendResult(next, revision);
230
241
  store.put({ accountScopeId: value.accountScopeId, projectId: value.projectId,
231
242
  id: next.id, record: next, revision });
232
- results.push({ ...next, sharedRevision: revision });
233
243
  } catch (error) { abort(error); }
234
244
  };
235
245
  };
@@ -264,9 +274,41 @@
264
274
  }
265
275
  await done;
266
276
  }
277
+ const reviewLeaseKey = (key, token) => REVIEW_LEASE_PREFIX + key + ':' + token;
278
+ async function activeReviewLease(key) {
279
+ const prefix = REVIEW_LEASE_PREFIX + key + ':', records = await chromeApi.storage.local.get(null);
280
+ const active = [], expired = [];
281
+ for (const [id, lease] of Object.entries(records)) {
282
+ if (!id.startsWith(prefix)) continue;
283
+ const valid = lease && typeof lease.token === 'string' && lease.token.length > 0 && lease.token.length <= 256
284
+ && id === reviewLeaseKey(key, lease.token) && Number.isSafeInteger(lease.deadlineAt) && lease.deadlineAt > 0;
285
+ if (!valid) throw failure('review_lease_invalid', 'Undo ownership storage could not be verified.');
286
+ if (lease.deadlineAt > Date.now()) active.push(lease); else expired.push(id);
287
+ }
288
+ // Immutable token rows can only be removed by their exact expired key.
289
+ await Promise.all(expired.map(id => chromeApi.storage.local.remove(id).catch(() => {})));
290
+ return active.sort((a, b) => b.deadlineAt - a.deadlineAt)[0];
291
+ }
267
292
  async function handle(message, sender) {
268
293
  const origin = senderOrigin(sender), value = scope(message.scope);
269
294
  const method = message.method;
295
+ if (method === 'review-dispatched') {
296
+ const key = keyFor('review', { ...value, runId: part(message.runId) }), held = locks.get(key);
297
+ if (!held || held.disconnected || held.token !== message.reviewToken || held.tabId !== sender.tab.id)
298
+ throw failure('storage_lock_lost', 'The shared Undo owner was lost before dispatch.');
299
+ const deadlineAt = held.deadlineAt || (held.deadlineAt = Date.now() + 120000);
300
+ held.inFlight += 1;
301
+ try {
302
+ await chromeApi.storage.local.set({ [reviewLeaseKey(key, held.token)]: { token: held.token, deadlineAt } });
303
+ if (held.disconnected) throw failure('storage_lock_lost', 'The shared Undo owner disconnected before dispatch.');
304
+ return { deadlineAt };
305
+ } finally { held.inFlight -= 1; if (held.disconnected && !held.inFlight) held.release(); }
306
+ }
307
+ if (method === 'review-owner') {
308
+ const key = keyFor('review', { ...value, runId: part(message.runId) });
309
+ const lease = await activeReviewLease(key);
310
+ return { busy: locks.has(key) || lease?.deadlineAt > Date.now(), deadlineAt: lease?.deadlineAt || 0 };
311
+ }
270
312
  if (method === 'list') return list(value, typeof message.after === 'string' ? message.after : '');
271
313
  if (method === 'get') {
272
314
  const record = await readOne('sessions', [value.accountScopeId, part(message.id)]);
@@ -284,7 +326,7 @@
284
326
  tx.objectStore('migrations').put({ ...value, origin, complete: true, at: Date.now() });
285
327
  await done; return { complete: true };
286
328
  }
287
- if (method === 'put' || method === 'import') {
329
+ if (method === 'put' || method === 'put-compressed' || method === 'import') {
288
330
  const result = await writeMany(message, value, method === 'import', origin, sender);
289
331
  if (result.length) await notify(value, message.clientId);
290
332
  return result;
@@ -327,12 +369,30 @@
327
369
  queues.delete(key);
328
370
  }
329
371
  port.onMessage.addListener(message => {
372
+ if (message?.type === 'release-review') {
373
+ const held = locks.get(heldKey);
374
+ if (held?.port !== port || held.token !== message.token || !heldKey.startsWith('review:')) return;
375
+ held.inFlight += 1;
376
+ void chromeApi.storage.local.remove(reviewLeaseKey(heldKey, held.token)).then(() => {
377
+ held.inFlight -= 1; held.release(); if (!closed) port.postMessage({ type: 'released' });
378
+ }, () => { held.inFlight -= 1; port.disconnect(); if (held.disconnected && !held.inFlight) held.release(); });
379
+ return;
380
+ }
330
381
  if (message?.type === 'ping') { port.postMessage({ type: 'pong' }); return; }
331
382
  if (message?.type !== 'acquire' || job) return;
332
383
  try {
333
- const value = scope(message.scope), kind = message.kind === 'preferences' ? 'preferences' : 'state';
384
+ const value = scope(message.scope), kind = message.kind === 'review' ? 'review' : message.kind === 'preferences' ? 'preferences' : 'state';
334
385
  heldKey = keyFor(kind, value);
335
- job = { port, tabId: port.sender.tab.id, closed: () => closed, grant: token => port.postMessage({ type: 'granted', token }) };
386
+ if (kind === 'review' && locks.has(heldKey)) { port.postMessage({ type: 'busy' }); return; }
387
+ job = { port, tabId: port.sender.tab.id, closed: () => closed, grant: token => {
388
+ if (kind !== 'review') { port.postMessage({ type: 'granted', token }); return; }
389
+ const held = locks.get(heldKey); held.inFlight += 1;
390
+ void (async () => {
391
+ const existing = await activeReviewLease(heldKey);
392
+ if (existing?.deadlineAt > Date.now()) { locks.get(heldKey)?.release(); if (!closed) port.postMessage({ type: 'busy' }); return; }
393
+ if (!closed) port.postMessage({ type: 'granted', token });
394
+ })().catch(() => port.disconnect()).finally(() => { held.inFlight -= 1; if (held.disconnected && !held.inFlight) held.release(); });
395
+ } };
336
396
  if (!queues.has(heldKey)) queues.set(heldKey, []);
337
397
  queues.get(heldKey).push(job);
338
398
  pump(heldKey);
@@ -98,6 +98,7 @@
98
98
  }
99
99
 
100
100
  async function startup() {
101
+ if (!await waitForBootstrapOwnership()) return;
101
102
  chrome.alarms?.create?.(CHECK_ALARM, {
102
103
  delayInMinutes: 0.5,
103
104
  periodInMinutes: CHECK_INTERVAL_MINUTES
@@ -170,15 +171,14 @@
170
171
  operation.phase = 'checking';
171
172
  await setUpdateState({ ...currentState, state: 'checking', operationId: operation.id,
172
173
  cancelRequested: false, initiatedBy: manual ? 'manual' : 'automatic',
173
- code: '', message: '', blocker: '', blockers: [], postponeUntil: LEGACY_GUARD }, { operation });
174
+ code: '', message: '', blocker: '', blockers: [], cooldownUntil: 0, postponeUntil: LEGACY_GUARD }, { operation });
174
175
  try {
175
176
  const result = await withTimeout(requestNative('update.check', {
176
177
  operationId: operation.id, currentVersion: currentVersion(), etag: currentState.etag || ''
177
178
  }), CHECK_REQUEST_TIMEOUT_MS, codedError('update_check_timeout', 'The managed update check did not complete in time.'));
178
179
  assertOperation(operation);
179
180
  const checkedAt = Date.now();
180
- if (result.available || (result.reason === 'not_modified'
181
- && policy.compareStableVersions(currentState.latestVersion, currentVersion()) > 0)) {
181
+ if (result.available) {
182
182
  const latestVersion = result.latestVersion || currentState.latestVersion;
183
183
  await setConsentState({ ...consent,
184
184
  ...(latestVersion !== consent.snoozedVersion ? { snoozedVersion: '', snoozedUntil: 0 } : {}),
@@ -188,22 +188,30 @@
188
188
  await setUpdateState({ ...currentState, state: 'update_available', operationId: operation.id,
189
189
  currentVersion: result.currentVersion || currentVersion(), latestVersion,
190
190
  managed: true, etag: result.etag || currentState.etag || '', lastCheckedAt: checkedAt,
191
- postponeUntil: LEGACY_GUARD, code: '', message: '', cancelRequested: false
191
+ postponeUntil: LEGACY_GUARD, code: '', message: '', cooldownUntil: 0, manualReason: {}, cancelRequested: false
192
192
  }, { operation });
193
- } else if (result.reason === 'manual_install_required'
194
- && policy.compareStableVersions(result.latestVersion, currentVersion()) > 0) {
193
+ } else if ((result.reason === 'manual_install_required'
194
+ && policy.compareStableVersions(result.latestVersion, currentVersion()) > 0)
195
+ || (result.reason === 'prerelease_promotion_requires_manual_update'
196
+ && result.latestVersion === (result.currentVersion || currentVersion()))) {
195
197
  await setUpdateState({ ...currentState, state: 'manual_install_required', operationId: operation.id,
196
198
  currentVersion: result.currentVersion || currentVersion(), latestVersion: result.latestVersion,
197
199
  managed: true, etag: result.etag || currentState.etag || '', lastCheckedAt: checkedAt,
198
- postponeUntil: LEGACY_GUARD, code: 'update_bootstrap_upgrade_required', message: '',
200
+ postponeUntil: LEGACY_GUARD, code: result.reason === 'prerelease_promotion_requires_manual_update'
201
+ ? result.reason : 'update_bootstrap_upgrade_required', message: '', cooldownUntil: 0,
199
202
  manualReason: result.manualInstall?.reason || {}, cancelRequested: false
200
203
  }, { operation });
201
204
  } else {
202
205
  await setUpdateState({ ...currentState, state: 'idle', operationId: operation.id,
203
206
  currentVersion: result.currentVersion || currentVersion(),
204
207
  latestVersion: result.latestVersion || result.currentVersion || currentVersion(),
205
- etag: result.etag || currentState.etag || '', lastCheckedAt: checkedAt,
206
- postponeUntil: LEGACY_GUARD, transactionId: '', code: '', message: '', cancelRequested: false
208
+ // A 304 without a verified Native candidate is a cache miss. Drop
209
+ // its validator so the next normal check can obtain fresh bytes.
210
+ etag: result.reason === 'not_modified' ? '' : result.etag || currentState.etag || '', lastCheckedAt: checkedAt,
211
+ postponeUntil: LEGACY_GUARD, transactionId: '',
212
+ code: result.reason === 'cooldown' ? 'update_cooldown' : '',
213
+ cooldownUntil: result.reason === 'cooldown' ? Number(result.cooldownUntil || 0) : 0,
214
+ manualReason: {}, message: '', cancelRequested: false
207
215
  }, { operation });
208
216
  }
209
217
  return getView();
@@ -557,6 +565,7 @@
557
565
  function reconcileRecoveryState() {
558
566
  if (recoveryPromise) return recoveryPromise;
559
567
  recoveryPromise = (async () => {
568
+ if (!await waitForBootstrapOwnership()) return getUpdateState();
560
569
  const state = await getUpdateState();
561
570
  if (state.recoveryPending) return reconcileExpiredPhase();
562
571
  if (state.cancelRequested) return postponeUpdate();
@@ -584,7 +593,9 @@
584
593
 
585
594
 
586
595
  async function reconcileExpiredPhase() {
587
- const state = await getUpdateState();
596
+ const recoveryOwner = await captureRecoveryOwner();
597
+ const state = recoveryOwner.state;
598
+ const recoveryOptions = { observed: true, recoveryOwner };
588
599
  if (!state.recoveryPending && (!state.deadlineAt || Date.now() <= state.deadlineAt
589
600
  || policy.isTerminalState(state.state))) return state;
590
601
  if (['checking', 'downloading'].includes(state.state)) {
@@ -597,32 +608,38 @@
597
608
  try {
598
609
  let status = await withTimeout(requestNative('update.status'), 10000,
599
610
  codedError('update_status_timeout', 'Update recovery status is unavailable.'));
611
+ await assertRecoveryOwner(recoveryOwner);
600
612
  let transaction = status.transaction;
601
613
  if (state.transactionId && transaction?.id !== state.transactionId) {
602
614
  throw codedError('update_transaction_mismatch', 'The update recovery transaction could not be matched.');
603
615
  }
616
+ if (transaction?.state === 'superseded') {
617
+ return settleSupersededTransaction(status, recoveryOwner);
618
+ }
604
619
  if (transaction?.state === 'staged' || (!transaction && status.installedAligned
605
620
  && status.activeVersion === state.currentVersion)) {
606
621
  if (state.operationId) await requestNative('update.cancel', { operationId: state.operationId });
622
+ await assertRecoveryOwner(recoveryOwner);
607
623
  const consent = await getConsentState();
608
624
  const authorizationId = consent.authorizationId || consent.revokingAuthorizationId;
609
625
  const targetVersion = consent.authorizedVersion || consent.revokingVersion || state.latestVersion;
626
+ await assertRecoveryOwner(recoveryOwner);
610
627
  if (authorizationId) await requestNative('update.revoke', { authorizationId, targetVersion });
611
- await setConsentState(revocation.clear({ ...consent,
612
- authorizedVersion: '', authorizationId: '', authorizedAt: 0
613
- }));
614
- return setUpdateState({ state: 'failed', recoveryPending: false, cancelRequested: false,
628
+ const completed = await setUpdateAndConsentState({ state: 'failed', recoveryPending: false, cancelRequested: false,
615
629
  operationId: '', code: 'update_not_applied', message: 'The update was stopped before installation. Retry when ready.'
616
- }, { observed: true });
630
+ }, revocation.clear({ ...consent, authorizedVersion: '', authorizationId: '', authorizedAt: 0 }), recoveryOptions);
631
+ return completed.updateState;
617
632
  }
618
633
  if (transaction?.state === 'awaiting_health') {
619
634
  const rolledBack = await requestNative('update.rollback', {
620
635
  transactionId: transaction.id, expectedState: 'awaiting_health', reasonCode: 'update_health_timeout'
621
636
  });
637
+ await assertRecoveryOwner(recoveryOwner);
622
638
  if (rolledBack.state !== 'rolled_back' || rolledBack.version !== transaction.sourceVersion) {
623
639
  throw codedError('update_rollback_unconfirmed', 'Rollback returned incomplete evidence.');
624
640
  }
625
641
  status = await requestNative('update.status');
642
+ await assertRecoveryOwner(recoveryOwner);
626
643
  transaction = status.transaction;
627
644
  }
628
645
  if (transaction?.state === 'committed' && status.installedAligned
@@ -630,7 +647,7 @@
630
647
  return setUpdateState({ state: 'committed', currentVersion: transaction.targetVersion,
631
648
  latestVersion: transaction.targetVersion, transactionId: transaction.id,
632
649
  recoveryPending: false, cancelRequested: false, code: '', message: ''
633
- }, { observed: true });
650
+ }, recoveryOptions);
634
651
  }
635
652
  if (transaction?.state === 'rolled_back' && status.installedAligned
636
653
  && status.activeVersion === transaction.sourceVersion
@@ -638,22 +655,88 @@
638
655
  const restored = await setUpdateState({ state: 'rolled_back', currentVersion: transaction.sourceVersion,
639
656
  transactionId: transaction.id, recoveryPending: false, cancelRequested: false,
640
657
  code: transaction.reasonCode || 'update_rolled_back', message: 'The previous version has been restored.'
641
- }, { observed: true });
658
+ }, recoveryOptions);
642
659
  setTimeout(() => chrome.runtime.reload(), 0);
643
660
  return restored;
644
661
  }
645
662
  throw codedError('update_recovery_pending', 'The installed update result has not been confirmed.');
646
663
  } catch (error) {
664
+ if (error.code === 'update_recovery_superseded') return getUpdateState();
647
665
  return setUpdateState({ state: 'failed', recoveryPending: true, cancelRequested: false,
648
666
  code: safeCode(error), message: safeMessage(error)
649
- }, { observed: true });
667
+ }, recoveryOptions).catch(writeError => {
668
+ if (writeError.code === 'update_recovery_superseded') return getUpdateState();
669
+ throw writeError;
670
+ });
650
671
  }
651
672
  }
652
673
  return state;
653
674
  }
654
675
 
676
+ async function captureRecoveryOwner() {
677
+ const generation = operationGeneration;
678
+ const [state, consent] = await Promise.all([getUpdateState(), getConsentState()]);
679
+ return { generation, state, consent };
680
+ }
681
+
682
+ function matchesRecoveryOwner(owner, state, consent) {
683
+ return owner && owner.generation === operationGeneration
684
+ && ['state', 'transactionId', 'operationId', 'currentVersion', 'latestVersion',
685
+ 'recoveryPending', 'cancelRequested', 'lastCheckedAt', 'phaseStartedAt']
686
+ .every(key => state[key] === owner.state[key])
687
+ && ['authorizationId', 'authorizedVersion', 'revokingAuthorizationId', 'revokingVersion']
688
+ .every(key => consent[key] === owner.consent[key]);
689
+ }
690
+
691
+ async function assertRecoveryOwner(owner) {
692
+ const [state, consent] = await Promise.all([getUpdateState(), getConsentState()]);
693
+ assertRecoverySnapshot(owner, state, consent);
694
+ }
695
+
696
+ function assertRecoverySnapshot(owner, state, consent) {
697
+ if (owner && !matchesRecoveryOwner(owner, state, consent)) {
698
+ throw codedError('update_recovery_superseded', 'A newer update state replaced this recovery request.');
699
+ }
700
+ }
701
+
702
+ // Both startup recovery and the coordinator retire the same Native journal
703
+ // through this queue. The snapshot was captured before requesting status.
704
+ function settleSupersededTransaction(status, owner) {
705
+ return enqueueStateWrite(async () => {
706
+ const [previous, consent] = await Promise.all([getUpdateState(), getConsentState()]);
707
+ if (!matchesRecoveryOwner(owner, previous, consent)) return previous;
708
+ const transaction = status?.transaction;
709
+ const sameTargetReinstall = transaction?.reasonCode === 'update_reinstalled'
710
+ && status.activeVersion === transaction.targetVersion
711
+ && status.reinstalledPair?.extensionVersion === status.activeVersion
712
+ && status.reinstalledPair?.nativeVersion === status.activeVersion
713
+ && /^[0-9a-f]{64}$/.test(status.reinstalledPair.extensionSha256 || '')
714
+ && /^[0-9a-f]{64}$/.test(status.reinstalledPair.nativeSha256 || '');
715
+ if (activeOperation || transaction?.state !== 'superseded' || !transaction.id
716
+ || (previous.transactionId && previous.transactionId !== transaction.id)
717
+ || status.installedAligned !== true || status.activeVersion !== transaction.supersededByVersion
718
+ || (!sameTargetReinstall && policy.compareStableVersions(status.activeVersion, transaction.targetVersion) <= 0)
719
+ || policy.compareStableVersions(status.activeVersion, transaction.sourceVersion) <= 0
720
+ || ['authorized', 'bound'].includes(status.authorization?.state)
721
+ || (consent.authorizedVersion && consent.authorizedVersion !== transaction.targetVersion)
722
+ || (consent.revokingVersion && consent.revokingVersion !== transaction.targetVersion)) return previous;
723
+ const next = projectUpdateState(previous, { state: 'idle', currentVersion: status.activeVersion,
724
+ latestVersion: status.activeVersion, transactionId: '', operationId: '', recoveryPending: false,
725
+ cancelRequested: false, blocker: '', blockers: [], code: '', message: '', manualReason: {}, cooldownUntil: 0
726
+ }, { observed: true });
727
+ // Invalidate older awaits before yielding to storage, including recovery
728
+ // responses that describe this same retired transaction.
729
+ operationGeneration += 1;
730
+ await chrome.storage.local.set({ [UPDATE_STATE_KEY]: next,
731
+ [CONSENT_STATE_KEY]: policy.normalizeConsentState({}), [UPDATE_RELOAD_TABS_KEY]: [] });
732
+ runtimeStatus = null;
733
+ return next;
734
+ });
735
+ }
736
+
655
737
 
656
738
  async function handleObservedStateChange() {
739
+ if (!await waitForBootstrapOwnership()) return;
657
740
  await settleTerminalConsent();
658
741
  const [state, consent] = await Promise.all([getUpdateState(), getConsentState()]);
659
742
  if (!consent.authorizationId && !policy.isExecutionState(state.state)) {
@@ -784,9 +867,11 @@
784
867
  function setUpdateState(value, options = {}) {
785
868
  return enqueueStateWrite(async () => {
786
869
  assertWriteOwner(options);
787
- const stored = await chrome.storage.local.get(UPDATE_STATE_KEY);
870
+ const stored = await chrome.storage.local.get(options.recoveryOwner
871
+ ? [UPDATE_STATE_KEY, CONSENT_STATE_KEY] : UPDATE_STATE_KEY);
788
872
  assertWriteOwner(options);
789
873
  const previous = policy.normalizeUpdateState(stored?.[UPDATE_STATE_KEY], currentVersion());
874
+ assertRecoverySnapshot(options.recoveryOwner, previous, policy.normalizeConsentState(stored?.[CONSENT_STATE_KEY]));
790
875
  const next = projectUpdateState(previous, value, options);
791
876
  await chrome.storage.local.set({ [UPDATE_STATE_KEY]: next });
792
877
  return next;
@@ -844,6 +929,7 @@
844
929
  const stored = await chrome.storage.local.get([UPDATE_STATE_KEY, CONSENT_STATE_KEY]);
845
930
  assertWriteOwner(options);
846
931
  const previous = policy.normalizeUpdateState(stored?.[UPDATE_STATE_KEY], currentVersion());
932
+ assertRecoverySnapshot(options.recoveryOwner, previous, policy.normalizeConsentState(stored?.[CONSENT_STATE_KEY]));
847
933
  const updateState = projectUpdateState(previous, updateValue, options);
848
934
  const consentState = policy.normalizeConsentState(consentValue);
849
935
  await chrome.storage.local.set({ [UPDATE_STATE_KEY]: updateState, [CONSENT_STATE_KEY]: consentState });
@@ -869,12 +955,20 @@
869
955
 
870
956
  function enqueuePolicyAction(action) {
871
957
  const generation = operationGeneration;
872
- const guarded = () => generation !== operationGeneration || cancellationPromise ? getView() : action();
958
+ const guarded = async () => !await waitForBootstrapOwnership()
959
+ || generation !== operationGeneration || cancellationPromise ? getView() : action();
873
960
  const result = policyTail.then(guarded, guarded);
874
961
  policyTail = result.catch(() => {});
875
962
  return result;
876
963
  }
877
964
 
965
+ async function waitForBootstrapOwnership() {
966
+ const migration = await root.CodexOverleafBootstrapMigration?.ready?.();
967
+ if (migration?.continueInitialization === false) return false;
968
+ await root.__CODEX_OVERLEAF_BOOTSTRAP_READY__;
969
+ return true;
970
+ }
971
+
878
972
  function beginOperation(id) {
879
973
  const operation = { id: id || crypto.randomUUID(), generation: ++operationGeneration, phase: 'checking' };
880
974
  activeOperation = operation;
@@ -894,6 +988,9 @@
894
988
  }
895
989
 
896
990
  function assertWriteOwner(options) {
991
+ if (options.recoveryOwner && options.recoveryOwner.generation !== operationGeneration) {
992
+ throw codedError('update_recovery_superseded', 'A newer update state replaced this recovery request.');
993
+ }
897
994
  if (options.operation) assertOperation(options.operation);
898
995
  if (options.generation !== undefined && options.generation !== operationGeneration) {
899
996
  throw codedError('update_network_cancelled', 'The update request was superseded.');
@@ -970,5 +1067,5 @@
970
1067
  .slice(0, 300);
971
1068
  }
972
1069
 
973
- root.CodexOverleafUpdateCoordinator = Object.freeze({ init });
1070
+ root.CodexOverleafUpdateCoordinator = Object.freeze({ init, captureRecoveryOwner, settleSupersededTransaction });
974
1071
  })(globalThis);
@@ -0,0 +1,173 @@
1
+ (function installBackgroundUpdateSafety(root) {
2
+ 'use strict';
3
+ const blocked = () => ({ idle: false, saved: false, blockers: ['tab_probe_unavailable'] });
4
+ // Chrome storage serializes object keys independently of insertion order.
5
+ // Preserve every key/value and array position when comparing readback.
6
+ function sameStored(left, right) {
7
+ if (left === right) return true;
8
+ if (!left || !right || typeof left !== 'object' || typeof right !== 'object') return false;
9
+ if (Array.isArray(left) || Array.isArray(right)) {
10
+ return Array.isArray(left) && Array.isArray(right) && left.length === right.length
11
+ && left.every((value, index) => sameStored(value, right[index]));
12
+ }
13
+ const leftKeys = Object.keys(left), rightKeys = Object.keys(right);
14
+ return leftKeys.length === rightKeys.length
15
+ && leftKeys.every(key => Object.prototype.hasOwnProperty.call(right, key) && sameStored(left[key], right[key]));
16
+ }
17
+ function create(options) {
18
+ const bindings = new Map();
19
+ const tabsKey = 'codex-overleaf-managed-update-tabs-v1';
20
+ const guardKey = 'codex-overleaf-managed-update-guarded-tabs-v1';
21
+ async function ownerMatches(binding) {
22
+ if (!options.isExecutionLocked()) return false;
23
+ const response = await options.request({ id: crypto.randomUUID(), method: 'update.status',
24
+ params: { bootstrapRevision: String(root.__CODEX_OVERLEAF_BOOTSTRAP_REVISION__ || '') } });
25
+ const tx = response?.ok && response.result?.transaction;
26
+ return tx?.id === binding.transactionId && tx.targetVersion === binding.targetVersion
27
+ && tx.sourceVersion === binding.sourceVersion && tx.state === binding.expectedState;
28
+ }
29
+ async function snapshot(tabId, documentId) {
30
+ const results = await chrome.scripting.executeScript({ target: documentId ? { tabId, documentIds: [documentId] }
31
+ : { tabId, frameIds: [0] }, world: 'ISOLATED', func: () => ({ url: location.href }) });
32
+ const item = results?.length === 1 && results[0];
33
+ return item?.frameId === 0 && item.documentId && typeof item.result?.url === 'string'
34
+ ? { documentId: item.documentId, url: item.result.url } : null;
35
+ }
36
+ async function saveProbe(message, sender) {
37
+ const binding = bindings.get(sender?.tab?.id);
38
+ if (!binding || sender.id !== chrome.runtime.id || sender.frameId !== 0 || sender.documentId !== binding.documentId
39
+ || sender.url !== binding.url || message.token !== binding.token || message.transactionId !== binding.transactionId
40
+ || message.nonce !== binding.nonce || !await ownerMatches(binding)) return { ok: false };
41
+ const before = await snapshot(binding.tabId, binding.documentId);
42
+ if (before?.url !== binding.url) return { ok: false };
43
+ const results = await chrome.scripting.executeScript({ target: { tabId: binding.tabId, documentIds: [binding.documentId] },
44
+ world: 'MAIN', func: url => globalThis.CodexOverleafUpdateSafetyProbe?.probe(url), args: [binding.url] });
45
+ const after = await snapshot(binding.tabId, binding.documentId);
46
+ if (after?.url !== binding.url || message.nonce !== binding.nonce || !await ownerMatches(binding)
47
+ || results?.length !== 1 || results[0].documentId !== binding.documentId || results[0].frameId !== 0) return { ok: false };
48
+ return { ok: true, nonce: message.nonce, result: results[0].result };
49
+ }
50
+ chrome.runtime.onMessage.addListener((message, sender, respond) => {
51
+ if (message?.type !== 'codex-overleaf/migration-save-probe') return undefined;
52
+ saveProbe(message, sender).then(respond).catch(() => respond({ ok: false }));
53
+ return true;
54
+ });
55
+ async function probe(tab, owner) {
56
+ try {
57
+ const current = await snapshot(tab.id);
58
+ if (!current || current.url !== tab.url) return blocked();
59
+ let binding = bindings.get(tab.id);
60
+ if (binding && (binding.documentId !== current.documentId || binding.url !== current.url
61
+ || binding.transactionId !== owner.transactionId)) return blocked();
62
+ if (!binding) {
63
+ binding = { ...owner, ...current, tabId: tab.id, token: crypto.randomUUID(), nonce: '' };
64
+ if (!await ownerMatches(binding)) return blocked();
65
+ bindings.set(tab.id, binding);
66
+ }
67
+ binding.expectedState = owner.expectedState;
68
+ if (!binding.installed) {
69
+ const target = { tabId: tab.id, documentIds: [binding.documentId] };
70
+ await chrome.scripting.executeScript({ target, world: 'MAIN',
71
+ files: ['runtime/src/page/saveState.js', 'runtime/src/page/updateSafetyProbe.js'] });
72
+ await chrome.scripting.executeScript({ target, world: 'ISOLATED',
73
+ files: ['runtime/src/shared/pageRpcContract.js', 'runtime/src/content/updateIdle.js', 'runtime/src/content/updateSafetyReceiver.js'] });
74
+ const installed = await chrome.scripting.executeScript({ target, world: 'ISOLATED',
75
+ func: value => globalThis.CodexOverleafUpdateSafetyReceiver?.install(value, {
76
+ UpdateIdle: globalThis.CodexOverleafUpdateIdle, PageRpcContract: globalThis.CodexOverleafPageRpcContract
77
+ }), args: [binding] });
78
+ if (installed?.length !== 1 || installed[0].documentId !== binding.documentId
79
+ || installed[0].result?.ok !== true || installed[0].result.url !== binding.url) return blocked();
80
+ binding.installed = true;
81
+ }
82
+ if (!await ownerMatches(binding)) return blocked();
83
+ binding.nonce = crypto.randomUUID();
84
+ const nonce = binding.nonce;
85
+ let timer;
86
+ const response = await Promise.race([
87
+ chrome.tabs.sendMessage(tab.id, { type: 'codex-overleaf/migration-idle-probe', ...binding },
88
+ { documentId: binding.documentId }),
89
+ new Promise(resolve => { timer = setTimeout(() => resolve(null), 3500); })
90
+ ]).finally(() => clearTimeout(timer));
91
+ const after = await snapshot(tab.id, binding.documentId);
92
+ if (!response?.safetyOnly || response.token !== binding.token || response.transactionId !== binding.transactionId
93
+ || response.targetVersion !== binding.targetVersion || response.nonce !== nonce || response.url !== binding.url
94
+ || after?.url !== binding.url || !await ownerMatches(binding)) return blocked();
95
+ return response;
96
+ } catch (_) { return blocked(); }
97
+ }
98
+ async function protectReloadTabs(transaction) {
99
+ const owner = { transactionId: transaction.id, sourceVersion: transaction.sourceVersion,
100
+ targetVersion: transaction.targetVersion, expectedState: 'bootstrap_pending' };
101
+ if (!await ownerMatches(owner)) return false;
102
+ const stored = await chrome.storage.local.get([tabsKey, guardKey]);
103
+ if (stored[guardKey]?.transactionId === transaction.id && !stored[tabsKey]?.length) return true;
104
+ if (stored[guardKey]?.state === 'guarded' && stored[guardKey].transactionId !== transaction.id) {
105
+ const open = new Set((await chrome.tabs.query({})).map(tab => tab.id));
106
+ for (const entry of stored[guardKey].entries || []) {
107
+ if (!open.has(entry.tabId)) continue;
108
+ const current = await snapshot(entry.tabId).catch(() => null);
109
+ if (!entry.documentId || !current || current.documentId === entry.documentId) return false;
110
+ }
111
+ }
112
+ const entries = await Promise.all((stored[tabsKey] || []).map(async tabId => ({ tabId,
113
+ ...await snapshot(tabId).catch(() => null) })));
114
+ const guard = { transactionId: transaction.id, sourceVersion: transaction.sourceVersion,
115
+ targetVersion: transaction.targetVersion, state: 'guarded', entries };
116
+ const current = await chrome.storage.local.get([tabsKey, guardKey]);
117
+ if (!sameStored(current, stored) || !await ownerMatches(owner)) return false;
118
+ await chrome.storage.local.set({ [guardKey]: guard, [tabsKey]: [] });
119
+ const readback = await chrome.storage.local.get([tabsKey, guardKey]);
120
+ return sameStored(readback[guardKey], guard) && readback[tabsKey]?.length === 0;
121
+ }
122
+ async function releaseReloadTabs(transaction, expectedState = 'bootstrap_pending') {
123
+ const owner = { transactionId: transaction.id, sourceVersion: transaction.sourceVersion,
124
+ targetVersion: transaction.targetVersion, expectedState };
125
+ if (!await ownerMatches(owner)) return false;
126
+ const stored = await chrome.storage.local.get([tabsKey, guardKey]);
127
+ const guard = stored[guardKey];
128
+ if (guard?.transactionId !== transaction.id || guard.state !== 'guarded' || stored[tabsKey]?.length) return false;
129
+ const open = new Set((await chrome.tabs.query({})).map(tab => tab.id));
130
+ const ids = [];
131
+ for (const entry of guard.entries) {
132
+ if (!open.has(entry.tabId)) continue;
133
+ if (!entry.documentId || !entry.url) return false;
134
+ const current = await snapshot(entry.tabId, entry.documentId).catch(() => null);
135
+ if (current?.documentId !== entry.documentId || current?.url !== entry.url) return false;
136
+ ids.push(entry.tabId);
137
+ }
138
+ const again = await chrome.storage.local.get([tabsKey, guardKey]);
139
+ if (!sameStored(again[guardKey], guard) || again[tabsKey]?.length || !await ownerMatches(owner)) return false;
140
+ await chrome.storage.local.set({ [tabsKey]: ids, [guardKey]: { ...guard, state: 'ready' } });
141
+ return true;
142
+ }
143
+ async function revalidate(tabs, owner) {
144
+ if (!await ownerMatches(owner)) return false;
145
+ for (const tab of tabs) {
146
+ const binding = bindings.get(tab.id);
147
+ if (!binding || binding.transactionId !== owner.transactionId) continue;
148
+ if ((await probe(tab, owner)).idle !== true) return false;
149
+ }
150
+ return ownerMatches(owner);
151
+ }
152
+ async function hasReplacedReloadDocument(transaction) {
153
+ const owner = { transactionId: transaction.id, sourceVersion: transaction.sourceVersion,
154
+ targetVersion: transaction.targetVersion, expectedState: 'rolled_back' };
155
+ if (!await ownerMatches(owner)) return false;
156
+ const stored = await chrome.storage.local.get([tabsKey, guardKey]);
157
+ const guard = stored[guardKey];
158
+ if (guard?.transactionId !== transaction.id || guard.state !== 'guarded' || stored[tabsKey]?.length) return false;
159
+ const open = new Set((await chrome.tabs.query({})).map(tab => tab.id));
160
+ for (const entry of guard.entries) {
161
+ if (!open.has(entry.tabId) || !entry.documentId) continue;
162
+ const current = await snapshot(entry.tabId).catch(() => null);
163
+ if (current && current.documentId !== entry.documentId) {
164
+ const again = await chrome.storage.local.get([tabsKey, guardKey]);
165
+ return sameStored(again, stored) && await ownerMatches(owner);
166
+ }
167
+ }
168
+ return false;
169
+ }
170
+ return Object.freeze({ probe, protectReloadTabs, releaseReloadTabs, revalidate, hasReplacedReloadDocument });
171
+ }
172
+ root.CodexOverleafBackgroundUpdateSafety = Object.freeze({ create });
173
+ })(globalThis);