codex-overleaf-link 2.5.0 → 2.6.0

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 (189) hide show
  1. package/README.md +155 -392
  2. package/README.zh-CN.md +154 -393
  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 +283 -55
  9. package/extension/src/backgroundBootstrapMigration.js +181 -0
  10. package/extension/src/backgroundSharedSessions.js +129 -16
  11. package/extension/src/backgroundUpdateCoordinator.js +242 -29
  12. package/extension/src/backgroundUpdateSafety.js +173 -0
  13. package/extension/src/content/activeTurnControl.js +171 -8
  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 +104 -47
  17. package/extension/src/content/composerPanel.js +110 -12
  18. package/extension/src/content/contentRuntime.js +1555 -926
  19. package/extension/src/content/contextTray.js +8 -2
  20. package/extension/src/content/contextUsageView.js +267 -0
  21. package/extension/src/content/diagnosticsController.js +122 -96
  22. package/extension/src/content/diagnosticsExport.js +19 -5
  23. package/extension/src/content/diagnosticsPanel.js +48 -0
  24. package/extension/src/content/directoryCleanupRecovery.js +54 -0
  25. package/extension/src/content/generated/content.bundle.js +11077 -3412
  26. package/extension/src/content/generated/content.bundle.meta.json +14 -3
  27. package/extension/src/content/globalPreferencesController.js +1 -1
  28. package/extension/src/content/legacyGlobalRegistry.js +10 -0
  29. package/extension/src/content/localSkillsPanel.js +104 -14
  30. package/extension/src/content/markdownDomRenderer.js +5 -3
  31. package/extension/src/content/markdownText.js +47 -11
  32. package/extension/src/content/modelPicker.js +8 -4
  33. package/extension/src/content/nativeCompatibilityController.js +74 -30
  34. package/extension/src/content/otWarmMirror.js +5 -6
  35. package/extension/src/content/otWarmMirrorController.js +17 -20
  36. package/extension/src/content/pageBridgeClient.js +17 -4
  37. package/extension/src/content/panelMaintenance.js +46 -14
  38. package/extension/src/content/panelRenderer.js +57 -27
  39. package/extension/src/content/pendingInputView.js +1 -1
  40. package/extension/src/content/projectProviderSelection.js +13 -4
  41. package/extension/src/content/projectSessionCleanup.js +109 -62
  42. package/extension/src/content/projectSettingsCoordinator.js +67 -6
  43. package/extension/src/content/providerSettingsCoordinator.js +46 -8
  44. package/extension/src/content/providerSettingsDialog.js +73 -14
  45. package/extension/src/content/recentProjects.js +15 -8
  46. package/extension/src/content/runActivitySummary.js +160 -18
  47. package/extension/src/content/runCompletionMeta.js +532 -0
  48. package/extension/src/content/runController.js +39 -15
  49. package/extension/src/content/runFailureNotice.js +29 -13
  50. package/extension/src/content/runGuidanceController.js +72 -27
  51. package/extension/src/content/runGuidanceView.js +59 -10
  52. package/extension/src/content/runPresence.js +18 -8
  53. package/extension/src/content/runQueueScheduler.js +157 -47
  54. package/extension/src/content/runRecoveryActions.js +147 -0
  55. package/extension/src/content/runResultActions.js +28 -143
  56. package/extension/src/content/runReviewController.js +633 -0
  57. package/extension/src/content/runScrollLayout.js +21 -2
  58. package/extension/src/content/runTimelineView.js +367 -175
  59. package/extension/src/content/scopedPersistencePanelState.js +134 -20
  60. package/extension/src/content/selectionContextView.js +1 -1
  61. package/extension/src/content/sensitivePreflight.js +153 -0
  62. package/extension/src/content/sessionForkController.js +80 -17
  63. package/extension/src/content/sessionManager.js +50 -4
  64. package/extension/src/content/sessionMenuView.js +54 -18
  65. package/extension/src/content/sessionPersistence.js +25 -5
  66. package/extension/src/content/settingsPanel.js +22 -5
  67. package/extension/src/content/settingsWorkbench.js +132 -89
  68. package/extension/src/content/sharedSessionViewSync.js +49 -11
  69. package/extension/src/content/subagentActivityView.js +23 -7
  70. package/extension/src/content/updateIdle.js +4 -1
  71. package/extension/src/content/updateNotice.js +118 -12
  72. package/extension/src/content/updateSafetyReceiver.js +58 -0
  73. package/extension/src/content/writebackController.js +53 -4
  74. package/extension/src/content/writebackOrchestrator.js +116 -94
  75. package/extension/src/content/writingStyleSettings.js +35 -11
  76. package/extension/src/page/binaryAssetUploader.js +136 -14
  77. package/extension/src/page/compileBridge.js +193 -180
  78. package/extension/src/page/fileMover.js +256 -0
  79. package/extension/src/page/overleafCapabilities.js +4 -1
  80. package/extension/src/page/ownedDirectoryCleanup.js +94 -0
  81. package/extension/src/page/referenceProjects.js +1 -0
  82. package/extension/src/page/reviewContext.js +113 -0
  83. package/extension/src/page/saveState.js +6 -4
  84. package/extension/src/page/snapshotRouter.js +84 -39
  85. package/extension/src/page/textCoordinates.js +17 -1
  86. package/extension/src/page/textFileCreator.js +114 -26
  87. package/extension/src/page/trackedChangeCapture.js +12 -2
  88. package/extension/src/page/trackedChangeOwnership.js +19 -1
  89. package/extension/src/page/trackedChangeReplay.js +41 -21
  90. package/extension/src/page/trackedChangesLifecycle.js +217 -199
  91. package/extension/src/page/treeOperations.js +7 -10
  92. package/extension/src/page/updateSafetyProbe.js +41 -0
  93. package/extension/src/page/writebackReceiptJournal.js +2 -0
  94. package/extension/src/page/writebackRouter.js +103 -155
  95. package/extension/src/pageBridge.js +126 -69
  96. package/extension/src/popup.js +62 -10
  97. package/extension/src/shared/agentTranscript.js +373 -17
  98. package/extension/src/shared/auditRecords.js +4 -0
  99. package/extension/src/shared/compatibility.js +22 -6
  100. package/extension/src/shared/compileAdapter.js +129 -66
  101. package/extension/src/shared/composerAttachmentDraft.js +156 -0
  102. package/extension/src/shared/contextUsage.js +125 -0
  103. package/extension/src/shared/directoryCleanupProof.js +43 -0
  104. package/extension/src/shared/failureReasons.js +349 -4
  105. package/extension/src/shared/globalPreferences.js +30 -13
  106. package/extension/src/shared/governanceRules.js +4 -37
  107. package/extension/src/shared/i18n.js +590 -24
  108. package/extension/src/shared/lineReferences.js +38 -31
  109. package/extension/src/shared/managedUpdateProjection.js +2 -2
  110. package/extension/src/shared/nativeRequestIdentity.js +1 -0
  111. package/extension/src/shared/pageRpcContract.js +5 -4
  112. package/extension/src/shared/panelIcons.js +39 -0
  113. package/extension/src/shared/projectFiles.js +21 -44
  114. package/extension/src/shared/providerProfiles.js +5 -3
  115. package/extension/src/shared/reviewing.js +130 -1
  116. package/extension/src/shared/runActivityModel.js +70 -4
  117. package/extension/src/shared/runExecutionSnapshot.js +13 -0
  118. package/extension/src/shared/runInputQueue.js +48 -5
  119. package/extension/src/shared/selectionContext.js +12 -3
  120. package/extension/src/shared/sessionState.js +132 -28
  121. package/extension/src/shared/sessionTitle.js +14 -1
  122. package/extension/src/shared/settlementFacts.js +10 -2
  123. package/extension/src/shared/sharedSessionBridge.js +118 -27
  124. package/extension/src/shared/sharedSessionTransfer.js +140 -0
  125. package/extension/src/shared/staleGuard.js +166 -52
  126. package/extension/src/shared/storageDb.js +90 -26
  127. package/extension/src/shared/storageMigration.js +78 -112
  128. package/extension/src/shared/storageRunActions.js +267 -30
  129. package/extension/src/shared/subagentActivityModel.js +1 -1
  130. package/extension/src/shared/undoOperations.js +253 -32
  131. package/extension/src/shared/updateConsent.js +6 -4
  132. package/extension/src/shared/updateRuntimeIdentity.js +17 -3
  133. package/extension/src/shared/writebackIntent.js +16 -0
  134. package/extension/src/shared/writebackOperationEvidence.js +4 -15
  135. package/extension/src/shared/writebackSettlement.js +58 -40
  136. package/extension/styles/panel.css +1123 -493
  137. package/native-host/src/anthropicBridgeRequest.js +4 -2
  138. package/native-host/src/anthropicBridgeResponse.js +6 -2
  139. package/native-host/src/anthropicMessagesBridge.js +24 -16
  140. package/native-host/src/bootstrapMigration.js +296 -0
  141. package/native-host/src/bootstrapMigrationContract.js +101 -0
  142. package/native-host/src/bootstrapUpdate.js +269 -0
  143. package/native-host/src/bootstrapUpdateWatchdog.js +44 -0
  144. package/native-host/src/chatBridgeRequest.js +60 -6
  145. package/native-host/src/chatBridgeResponse.js +20 -1
  146. package/native-host/src/chatCompletionsBridge.js +48 -15
  147. package/native-host/src/codexConfigPaths.js +106 -0
  148. package/native-host/src/codexHome.js +66 -73
  149. package/native-host/src/codexHomeScope.js +124 -0
  150. package/native-host/src/codexInheritedAuth.js +90 -0
  151. package/native-host/src/codexLocalSkills.js +301 -0
  152. package/native-host/src/codexPromptAssembly.js +114 -127
  153. package/native-host/src/codexProviderTest.js +24 -26
  154. package/native-host/src/codexSessionRunner.js +234 -198
  155. package/native-host/src/codexSessionTiming.js +18 -8
  156. package/native-host/src/codexSkillIsolation.js +170 -0
  157. package/native-host/src/codexThreadFork.js +19 -3
  158. package/native-host/src/index.js +19 -7
  159. package/native-host/src/managedInstall.js +110 -35
  160. package/native-host/src/managedInstallRecovery.js +140 -0
  161. package/native-host/src/managedRuntimeEvidence.js +57 -0
  162. package/native-host/src/mirrorWorkspace.js +92 -22
  163. package/native-host/src/nativeDoctor.js +15 -0
  164. package/native-host/src/nativeHelperLifecycle.js +21 -0
  165. package/native-host/src/nativeHostRegistration.js +33 -0
  166. package/native-host/src/nativeRunLifecycle.js +71 -0
  167. package/native-host/src/processStopBarrier.js +42 -7
  168. package/native-host/src/projectPathPlatform.js +30 -0
  169. package/native-host/src/providerContextError.js +20 -0
  170. package/native-host/src/providerHttp.js +80 -0
  171. package/native-host/src/providerProfile.js +4 -2
  172. package/native-host/src/providerReasoning.js +2 -7
  173. package/native-host/src/providerRuntime.js +1 -0
  174. package/native-host/src/providerStore.js +72 -14
  175. package/native-host/src/responsesPassthroughBridge.js +266 -52
  176. package/native-host/src/runtimeInstaller.js +21 -1
  177. package/native-host/src/selectionScope.js +36 -8
  178. package/native-host/src/subagentAdmission.js +26 -0
  179. package/native-host/src/subagentBroker.js +79 -66
  180. package/native-host/src/subagentTelemetry.js +19 -10
  181. package/native-host/src/subagentWorkspacePath.js +5 -2
  182. package/native-host/src/taskRunnerRuntime.js +92 -57
  183. package/native-host/src/textPatch.js +473 -377
  184. package/native-host/src/tomlStatements.js +140 -0
  185. package/native-host/src/updateManager.js +409 -46
  186. package/native-host/src/writingStyleRuntime.js +57 -29
  187. package/native-host/src/writingStyleStore.js +73 -0
  188. package/package.json +5 -8
  189. package/scripts/install-managed.mjs +1 -0
@@ -37,8 +37,11 @@
37
37
  'codex-overleaf/consent-update-cancel',
38
38
  'codex-overleaf/consent-update-recover',
39
39
  'codex-overleaf/consent-update-dismiss',
40
- 'codex-overleaf/consent-update-reload'
40
+ 'codex-overleaf/consent-update-reload',
41
+ 'codex-overleaf/consent-update-rollback'
41
42
  ]);
43
+ const USER_ROLLBACK_REASON = 'update_user_rollback';
44
+ const RECOVERABLE_TRANSACTION_STATES = new Set(['awaiting_health', 'bootstrap_pending', 'bootstrap_rolling_back']);
42
45
 
43
46
  const policy = root.CodexOverleafUpdateConsent;
44
47
  const revocation = root.CodexOverleafUpdateRevocation;
@@ -98,6 +101,7 @@
98
101
  }
99
102
 
100
103
  async function startup() {
104
+ if (!await waitForBootstrapOwnership()) return;
101
105
  chrome.alarms?.create?.(CHECK_ALARM, {
102
106
  delayInMinutes: 0.5,
103
107
  periodInMinutes: CHECK_INTERVAL_MINUTES
@@ -155,6 +159,8 @@
155
159
  return dismissCompletedUpdate();
156
160
  case 'codex-overleaf/consent-update-reload':
157
161
  return reloadInstalledRuntime();
162
+ case 'codex-overleaf/consent-update-rollback':
163
+ return restorePreviousVersion();
158
164
  default:
159
165
  throw codedError('unknown_update_action', 'Unknown update action.');
160
166
  }
@@ -170,40 +176,50 @@
170
176
  operation.phase = 'checking';
171
177
  await setUpdateState({ ...currentState, state: 'checking', operationId: operation.id,
172
178
  cancelRequested: false, initiatedBy: manual ? 'manual' : 'automatic',
173
- code: '', message: '', blocker: '', blockers: [], postponeUntil: LEGACY_GUARD }, { operation });
179
+ code: '', message: '', blocker: '', blockers: [], cooldownUntil: 0, postponeUntil: LEGACY_GUARD }, { operation });
174
180
  try {
175
181
  const result = await withTimeout(requestNative('update.check', {
176
- operationId: operation.id, currentVersion: currentVersion(), etag: currentState.etag || ''
182
+ operationId: operation.id, currentVersion: currentVersion(), etag: currentState.etag || '', manual: manual === true
177
183
  }), CHECK_REQUEST_TIMEOUT_MS, codedError('update_check_timeout', 'The managed update check did not complete in time.'));
178
184
  assertOperation(operation);
179
185
  const checkedAt = Date.now();
180
- if (result.available || (result.reason === 'not_modified'
181
- && policy.compareStableVersions(currentState.latestVersion, currentVersion()) > 0)) {
186
+ if (result.available) {
182
187
  const latestVersion = result.latestVersion || currentState.latestVersion;
183
188
  await setConsentState({ ...consent,
184
- ...(latestVersion !== consent.snoozedVersion ? { snoozedVersion: '', snoozedUntil: 0 } : {}),
189
+ ...(manual || latestVersion !== consent.snoozedVersion ? { snoozedVersion: '', snoozedUntil: 0 } : {}),
185
190
  lastPromptedVersion: latestVersion,
186
191
  lastPromptedAt: manual ? checkedAt : consent.lastPromptedAt
187
192
  }, { operation });
188
193
  await setUpdateState({ ...currentState, state: 'update_available', operationId: operation.id,
189
194
  currentVersion: result.currentVersion || currentVersion(), latestVersion,
190
195
  managed: true, etag: result.etag || currentState.etag || '', lastCheckedAt: checkedAt,
191
- postponeUntil: LEGACY_GUARD, code: '', message: '', cancelRequested: false
196
+ postponeUntil: LEGACY_GUARD, code: '', message: '', cooldownUntil: 0, manualReason: {}, cancelRequested: false
192
197
  }, { operation });
193
- } else if (result.reason === 'manual_install_required'
194
- && policy.compareStableVersions(result.latestVersion, currentVersion()) > 0) {
198
+ } else if ((result.reason === 'manual_install_required'
199
+ && policy.compareStableVersions(result.latestVersion, currentVersion()) > 0)
200
+ || (result.reason === 'prerelease_promotion_requires_manual_update'
201
+ && result.latestVersion === (result.currentVersion || currentVersion()))) {
195
202
  await setUpdateState({ ...currentState, state: 'manual_install_required', operationId: operation.id,
196
203
  currentVersion: result.currentVersion || currentVersion(), latestVersion: result.latestVersion,
197
204
  managed: true, etag: result.etag || currentState.etag || '', lastCheckedAt: checkedAt,
198
- postponeUntil: LEGACY_GUARD, code: 'update_bootstrap_upgrade_required', message: '',
205
+ postponeUntil: LEGACY_GUARD, code: result.reason === 'prerelease_promotion_requires_manual_update'
206
+ ? result.reason : result.code === 'update_bootstrap_source_mismatch'
207
+ ? result.code : 'update_bootstrap_upgrade_required', message: '', cooldownUntil: 0,
199
208
  manualReason: result.manualInstall?.reason || {}, cancelRequested: false
200
209
  }, { operation });
201
210
  } else {
202
211
  await setUpdateState({ ...currentState, state: 'idle', operationId: operation.id,
203
212
  currentVersion: result.currentVersion || currentVersion(),
204
213
  latestVersion: result.latestVersion || result.currentVersion || currentVersion(),
205
- etag: result.etag || currentState.etag || '', lastCheckedAt: checkedAt,
206
- postponeUntil: LEGACY_GUARD, transactionId: '', code: '', message: '', cancelRequested: false
214
+ // A 304 without a verified Native candidate is a cache miss. Drop
215
+ // its validator so the next normal check can obtain fresh bytes.
216
+ // A skipped version also needs fresh bytes when checked explicitly.
217
+ etag: ['not_modified', 'cooldown'].includes(result.reason) ? '' : result.etag || currentState.etag || '', lastCheckedAt: checkedAt,
218
+ postponeUntil: LEGACY_GUARD, transactionId: '',
219
+ code: result.reason === 'cooldown'
220
+ ? (result.cooldownReason === USER_ROLLBACK_REASON ? 'update_skipped_after_rollback' : 'update_cooldown') : '',
221
+ cooldownUntil: result.reason === 'cooldown' ? Number(result.cooldownUntil || 0) : 0,
222
+ manualReason: {}, message: '', cancelRequested: false
207
223
  }, { operation });
208
224
  }
209
225
  return getView();
@@ -500,7 +516,7 @@
500
516
  async function cancelUpdateCore() {
501
517
  const [state, consent] = await Promise.all([getUpdateState(), getConsentState()]);
502
518
  if (['applying', 'awaiting_health', 'rolling_back'].includes(state.state)
503
- || activeOperation?.phase === 'applying' || state.recoveryPending) {
519
+ || ['applying', 'rolling_back'].includes(activeOperation?.phase) || state.recoveryPending) {
504
520
  throw codedError('update_revoke_too_late', 'The update is being installed or recovered.');
505
521
  }
506
522
  const operation = activeOperation;
@@ -555,8 +571,10 @@
555
571
 
556
572
 
557
573
  function reconcileRecoveryState() {
574
+ if (activeOperation?.phase === 'rolling_back') return getUpdateState();
558
575
  if (recoveryPromise) return recoveryPromise;
559
576
  recoveryPromise = (async () => {
577
+ if (!await waitForBootstrapOwnership()) return getUpdateState();
560
578
  const state = await getUpdateState();
561
579
  if (state.recoveryPending) return reconcileExpiredPhase();
562
580
  if (state.cancelRequested) return postponeUpdate();
@@ -584,7 +602,9 @@
584
602
 
585
603
 
586
604
  async function reconcileExpiredPhase() {
587
- const state = await getUpdateState();
605
+ const recoveryOwner = await captureRecoveryOwner();
606
+ const state = recoveryOwner.state;
607
+ const recoveryOptions = { observed: true, recoveryOwner };
588
608
  if (!state.recoveryPending && (!state.deadlineAt || Date.now() <= state.deadlineAt
589
609
  || policy.isTerminalState(state.state))) return state;
590
610
  if (['checking', 'downloading'].includes(state.state)) {
@@ -597,32 +617,38 @@
597
617
  try {
598
618
  let status = await withTimeout(requestNative('update.status'), 10000,
599
619
  codedError('update_status_timeout', 'Update recovery status is unavailable.'));
620
+ await assertRecoveryOwner(recoveryOwner);
600
621
  let transaction = status.transaction;
601
622
  if (state.transactionId && transaction?.id !== state.transactionId) {
602
623
  throw codedError('update_transaction_mismatch', 'The update recovery transaction could not be matched.');
603
624
  }
625
+ if (transaction?.state === 'superseded') {
626
+ return settleSupersededTransaction(status, recoveryOwner);
627
+ }
604
628
  if (transaction?.state === 'staged' || (!transaction && status.installedAligned
605
629
  && status.activeVersion === state.currentVersion)) {
606
630
  if (state.operationId) await requestNative('update.cancel', { operationId: state.operationId });
631
+ await assertRecoveryOwner(recoveryOwner);
607
632
  const consent = await getConsentState();
608
633
  const authorizationId = consent.authorizationId || consent.revokingAuthorizationId;
609
634
  const targetVersion = consent.authorizedVersion || consent.revokingVersion || state.latestVersion;
635
+ await assertRecoveryOwner(recoveryOwner);
610
636
  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,
637
+ const completed = await setUpdateAndConsentState({ state: 'failed', recoveryPending: false, cancelRequested: false,
615
638
  operationId: '', code: 'update_not_applied', message: 'The update was stopped before installation. Retry when ready.'
616
- }, { observed: true });
639
+ }, revocation.clear({ ...consent, authorizedVersion: '', authorizationId: '', authorizedAt: 0 }), recoveryOptions);
640
+ return completed.updateState;
617
641
  }
618
642
  if (transaction?.state === 'awaiting_health') {
619
643
  const rolledBack = await requestNative('update.rollback', {
620
644
  transactionId: transaction.id, expectedState: 'awaiting_health', reasonCode: 'update_health_timeout'
621
645
  });
646
+ await assertRecoveryOwner(recoveryOwner);
622
647
  if (rolledBack.state !== 'rolled_back' || rolledBack.version !== transaction.sourceVersion) {
623
648
  throw codedError('update_rollback_unconfirmed', 'Rollback returned incomplete evidence.');
624
649
  }
625
650
  status = await requestNative('update.status');
651
+ await assertRecoveryOwner(recoveryOwner);
626
652
  transaction = status.transaction;
627
653
  }
628
654
  if (transaction?.state === 'committed' && status.installedAligned
@@ -630,30 +656,121 @@
630
656
  return setUpdateState({ state: 'committed', currentVersion: transaction.targetVersion,
631
657
  latestVersion: transaction.targetVersion, transactionId: transaction.id,
632
658
  recoveryPending: false, cancelRequested: false, code: '', message: ''
633
- }, { observed: true });
659
+ }, recoveryOptions);
634
660
  }
635
661
  if (transaction?.state === 'rolled_back' && status.installedAligned
636
662
  && status.activeVersion === transaction.sourceVersion
637
663
  && (!state.transactionId || transaction.id === state.transactionId)) {
638
- const restored = await setUpdateState({ state: 'rolled_back', currentVersion: transaction.sourceVersion,
664
+ const userRestore = transaction.reasonCode === USER_ROLLBACK_REASON;
665
+ if ((userRestore || state.code === 'update_rollback_unconfirmed')
666
+ && !root.CodexOverleafUpdateRuntimeIdentity?.verifiedRollback(status)) {
667
+ throw codedError('update_rollback_unconfirmed', 'Rollback returned incomplete evidence.');
668
+ }
669
+ if (userRestore) {
670
+ const pending = (await chrome.storage.local.get(MANUAL_RELOAD_TABS_KEY))?.[MANUAL_RELOAD_TABS_KEY];
671
+ const tabs = await chrome.tabs.query({ url: OVERLEAF_MATCHES });
672
+ await assertRecoveryOwner(recoveryOwner);
673
+ await chrome.storage.local.set({ [UPDATE_RELOAD_TABS_KEY]: [],
674
+ [MANUAL_RELOAD_TABS_KEY]: pending?.targetVersion === transaction.sourceVersion ? pending : {
675
+ targetVersion: transaction.sourceVersion, tabIds: tabs.filter(isUsableEditorTab).map(tab => tab.id)
676
+ } });
677
+ }
678
+ const value = { state: 'rolled_back', currentVersion: transaction.sourceVersion,
639
679
  transactionId: transaction.id, recoveryPending: false, cancelRequested: false,
640
680
  code: transaction.reasonCode || 'update_rolled_back', message: 'The previous version has been restored.'
641
- }, { observed: true });
681
+ };
682
+ const restored = userRestore
683
+ ? (await setUpdateAndConsentState({ ...value, latestVersion: transaction.targetVersion,
684
+ postponeUntil: LEGACY_GUARD, etag: '', cooldownUntil: 0
685
+ }, policy.normalizeConsentState({ snoozedVersion: transaction.targetVersion,
686
+ snoozedUntil: Date.now() + SNOOZE_MS }), recoveryOptions)).updateState
687
+ : await setUpdateState(value, recoveryOptions);
688
+ runtimeStatus = null;
689
+ if (userRestore) {
690
+ try { await requireReloadSafePoint(); } catch (_error) { return restored; }
691
+ }
692
+ if (recoveryOwner.generation !== operationGeneration) return getUpdateState();
642
693
  setTimeout(() => chrome.runtime.reload(), 0);
643
694
  return restored;
644
695
  }
645
696
  throw codedError('update_recovery_pending', 'The installed update result has not been confirmed.');
646
697
  } catch (error) {
698
+ if (error.code === 'update_recovery_superseded') return getUpdateState();
647
699
  return setUpdateState({ state: 'failed', recoveryPending: true, cancelRequested: false,
648
700
  code: safeCode(error), message: safeMessage(error)
649
- }, { observed: true });
701
+ }, recoveryOptions).catch(writeError => {
702
+ if (writeError.code === 'update_recovery_superseded') return getUpdateState();
703
+ throw writeError;
704
+ });
650
705
  }
651
706
  }
652
707
  return state;
653
708
  }
654
709
 
710
+ async function captureRecoveryOwner() {
711
+ const generation = operationGeneration;
712
+ const [state, consent] = await Promise.all([getUpdateState(), getConsentState()]);
713
+ return { generation, state, consent };
714
+ }
715
+
716
+ function matchesRecoveryOwner(owner, state, consent) {
717
+ return owner && owner.generation === operationGeneration
718
+ && ['state', 'transactionId', 'operationId', 'currentVersion', 'latestVersion',
719
+ 'recoveryPending', 'cancelRequested', 'lastCheckedAt', 'phaseStartedAt']
720
+ .every(key => state[key] === owner.state[key])
721
+ && ['authorizationId', 'authorizedVersion', 'revokingAuthorizationId', 'revokingVersion']
722
+ .every(key => consent[key] === owner.consent[key]);
723
+ }
724
+
725
+ async function assertRecoveryOwner(owner) {
726
+ const [state, consent] = await Promise.all([getUpdateState(), getConsentState()]);
727
+ assertRecoverySnapshot(owner, state, consent);
728
+ }
729
+
730
+ function assertRecoverySnapshot(owner, state, consent) {
731
+ if (owner && !matchesRecoveryOwner(owner, state, consent)) {
732
+ throw codedError('update_recovery_superseded', 'A newer update state replaced this recovery request.');
733
+ }
734
+ }
735
+
736
+ // Both startup recovery and the coordinator retire the same Native journal
737
+ // through this queue. The snapshot was captured before requesting status.
738
+ function settleSupersededTransaction(status, owner) {
739
+ return enqueueStateWrite(async () => {
740
+ const [previous, consent] = await Promise.all([getUpdateState(), getConsentState()]);
741
+ if (!matchesRecoveryOwner(owner, previous, consent)) return previous;
742
+ const transaction = status?.transaction;
743
+ const sameTargetReinstall = transaction?.reasonCode === 'update_reinstalled'
744
+ && status.activeVersion === transaction.targetVersion
745
+ && status.reinstalledPair?.extensionVersion === status.activeVersion
746
+ && status.reinstalledPair?.nativeVersion === status.activeVersion
747
+ && /^[0-9a-f]{64}$/.test(status.reinstalledPair.extensionSha256 || '')
748
+ && /^[0-9a-f]{64}$/.test(status.reinstalledPair.nativeSha256 || '');
749
+ if (activeOperation || transaction?.state !== 'superseded' || !transaction.id
750
+ || (previous.transactionId && previous.transactionId !== transaction.id)
751
+ || status.installedAligned !== true || status.activeVersion !== transaction.supersededByVersion
752
+ || (!sameTargetReinstall && policy.compareStableVersions(status.activeVersion, transaction.targetVersion) <= 0)
753
+ || policy.compareStableVersions(status.activeVersion, transaction.sourceVersion) <= 0
754
+ || ['authorized', 'bound'].includes(status.authorization?.state)
755
+ || (consent.authorizedVersion && consent.authorizedVersion !== transaction.targetVersion)
756
+ || (consent.revokingVersion && consent.revokingVersion !== transaction.targetVersion)) return previous;
757
+ const next = projectUpdateState(previous, { state: 'idle', currentVersion: status.activeVersion,
758
+ latestVersion: status.activeVersion, transactionId: '', operationId: '', recoveryPending: false,
759
+ cancelRequested: false, blocker: '', blockers: [], code: '', message: '', manualReason: {}, cooldownUntil: 0
760
+ }, { observed: true });
761
+ // Invalidate older awaits before yielding to storage, including recovery
762
+ // responses that describe this same retired transaction.
763
+ operationGeneration += 1;
764
+ await chrome.storage.local.set({ [UPDATE_STATE_KEY]: next,
765
+ [CONSENT_STATE_KEY]: policy.normalizeConsentState({}), [UPDATE_RELOAD_TABS_KEY]: [] });
766
+ runtimeStatus = null;
767
+ return next;
768
+ });
769
+ }
770
+
655
771
 
656
772
  async function handleObservedStateChange() {
773
+ if (!await waitForBootstrapOwnership()) return;
657
774
  await settleTerminalConsent();
658
775
  const [state, consent] = await Promise.all([getUpdateState(), getConsentState()]);
659
776
  if (!consent.authorizationId && !policy.isExecutionState(state.state)) {
@@ -682,9 +799,15 @@
682
799
  now: Date.now()
683
800
  });
684
801
  view.runtime = runtime;
802
+ // Native decides availability: a committed pair with its source still on
803
+ // disk, or any unsettled installation that recovery must finish anyway.
804
+ view.restorePrevious = !state.cancelRequested && !activeOperation
805
+ && (runtime.rollbackAvailable?.sourceVersion || (state.recoveryPending && state.transactionId))
806
+ ? { version: runtime.rollbackAvailable?.sourceVersion || '' } : null;
807
+ view.actions = { ...view.actions, restorePrevious: Boolean(view.restorePrevious) };
685
808
  const pending = (await chrome.storage.local.get(MANUAL_RELOAD_TABS_KEY))?.[MANUAL_RELOAD_TABS_KEY];
686
809
  const pagesPending = runtime.state === 'aligned' && pending?.targetVersion === runtime.installedVersion && pending.tabIds?.length;
687
- if (runtime.state === 'reload_required' || pagesPending) {
810
+ if (!state.recoveryPending && (runtime.state === 'reload_required' || pagesPending)) {
688
811
  view.showPanel = true;
689
812
  view.state = { ...state, state: pagesPending ? 'reload_tabs_required' : 'reload_required',
690
813
  currentVersion: runtime.extensionVersion, latestVersion: runtime.installedVersion,
@@ -701,9 +824,9 @@
701
824
  runtimeStatusRead = withTimeout(requestNative('update.status'), 4000,
702
825
  codedError('update_status_timeout', 'Installed version inspection timed out.')).then(status => {
703
826
  runtimeStatusAt = Date.now();
704
- runtimeStatus = root.CodexOverleafUpdateRuntimeIdentity.inspectInstalledRuntime(status, {
827
+ runtimeStatus = { ...root.CodexOverleafUpdateRuntimeIdentity.inspectInstalledRuntime(status, {
705
828
  extensionVersion: chrome.runtime.getManifest().version, runtimeVersion: currentVersion()
706
- });
829
+ }), rollbackAvailable: status.rollbackAvailable || null };
707
830
  return runtimeStatus;
708
831
  }).catch(() => ({ state: 'unknown' })).finally(() => { runtimeStatusRead = null; });
709
832
  return runtimeStatusRead;
@@ -725,6 +848,9 @@
725
848
  }
726
849
 
727
850
  async function reloadInstalledRuntime() {
851
+ if ((await getUpdateState()).recoveryPending) {
852
+ throw codedError('update_rollback_unconfirmed', 'Confirm update recovery before reloading.');
853
+ }
728
854
  const runtime = await readRuntimeStatus(true);
729
855
  if (runtime.state === 'aligned') { await finishManualRuntimeReload(); return getView(); }
730
856
  if (runtime.state !== 'reload_required') throw codedError('update_reload_unavailable', 'No verified installed update is awaiting reload.');
@@ -736,7 +862,80 @@
736
862
  return getView();
737
863
  }
738
864
 
865
+ // Returns a committed update, or an update whose installation or recovery is
866
+ // unsettled, to the source pair Native still holds. Native owns the decision;
867
+ // the browser only supplies the transaction it observed and reloads after an
868
+ // exact readback.
869
+ async function restorePreviousVersion() {
870
+ if (activeOperation || cancellationPromise) {
871
+ throw codedError('update_transaction_in_progress', 'Another update operation is in progress.');
872
+ }
873
+ const operation = beginOperation();
874
+ operation.phase = 'rolling_back';
875
+ try {
876
+ const status = await requestNative('update.status');
877
+ assertOperation(operation);
878
+ const transaction = status.transaction;
879
+ const committed = transaction?.state === 'committed' && status.rollbackAvailable?.transactionId === transaction.id;
880
+ if (!transaction?.id || (!committed && !RECOVERABLE_TRANSACTION_STATES.has(transaction.state))) {
881
+ throw codedError('update_rollback_unavailable', 'No installed update can be restored to its previous version.');
882
+ }
883
+ // Page refresh happens after restoration, so it waits for the same saved
884
+ // and idle point as a reload of an installed update.
885
+ const tabs = await requireReloadSafePoint();
886
+ assertOperation(operation);
887
+ const generation = operation.generation;
888
+ clearIdleRetryTimer();
889
+ await setUpdateState({ state: 'rolling_back', transactionId: transaction.id,
890
+ currentVersion: transaction.targetVersion, latestVersion: transaction.targetVersion,
891
+ recoveryPending: false, cancelRequested: false, operationId: '', code: '', message: ''
892
+ }, { observed: true, generation });
893
+ try {
894
+ // Old source runtimes consume this key through their saved/idle check.
895
+ // The fixed Bootstrap's legacy list would refresh tabs unconditionally.
896
+ await chrome.storage.local.set({ [UPDATE_RELOAD_TABS_KEY]: [],
897
+ [MANUAL_RELOAD_TABS_KEY]: { targetVersion: transaction.sourceVersion, tabIds: tabs.map(tab => tab.id) } });
898
+ assertOperation(operation);
899
+ const rolledBack = await requestNative('update.rollback', { transactionId: transaction.id,
900
+ expectedState: transaction.state, reasonCode: USER_ROLLBACK_REASON });
901
+ assertOperation(operation);
902
+ const readback = await requestNative('update.status');
903
+ assertOperation(operation);
904
+ if (rolledBack.transactionId !== transaction.id || rolledBack.state !== 'rolled_back'
905
+ || rolledBack.version !== transaction.sourceVersion || readback.transaction?.id !== transaction.id
906
+ || readback.transaction.sourceVersion !== transaction.sourceVersion
907
+ || readback.transaction.targetVersion !== transaction.targetVersion
908
+ || !root.CodexOverleafUpdateRuntimeIdentity?.verifiedRollback({ ...readback,
909
+ transaction: { ...readback.transaction, bootstrapRevision: transaction.bootstrapRevision } })
910
+ || !['extensionVersion', 'nativeVersion', 'extensionSha256', 'nativeSha256', 'bootstrapSha256']
911
+ .every(key => readback.restoredPair[key] === rolledBack.restoredPair?.[key])) {
912
+ throw codedError('update_rollback_unconfirmed', 'Rollback returned incomplete evidence.');
913
+ }
914
+ } catch (error) {
915
+ await setUpdateState({ state: 'failed', transactionId: transaction.id, recoveryPending: true,
916
+ cancelRequested: false, code: safeCode(error), message: safeMessage(error)
917
+ }, { observed: true, generation }).catch(() => {});
918
+ throw error;
919
+ }
920
+ await setUpdateAndConsentState({ state: 'rolled_back', transactionId: transaction.id,
921
+ currentVersion: transaction.sourceVersion, latestVersion: transaction.targetVersion,
922
+ recoveryPending: false, cancelRequested: false, blocker: '', blockers: [],
923
+ postponeUntil: LEGACY_GUARD, etag: '', cooldownUntil: 0,
924
+ code: USER_ROLLBACK_REASON, message: 'You restored the previous version.'
925
+ }, policy.normalizeConsentState({ snoozedVersion: transaction.targetVersion,
926
+ snoozedUntil: Date.now() + SNOOZE_MS }), { observed: true, generation });
927
+ runtimeStatus = null;
928
+ // The disk restore is complete even if new work now prevents a reload.
929
+ // Keep the existing Reload action available without attempting rollback again.
930
+ try { await requireReloadSafePoint(); } catch (_error) { return getView(); }
931
+ assertOperation(operation);
932
+ setTimeout(() => chrome.runtime.reload(), 0);
933
+ return getView();
934
+ } finally { finishOperation(operation); }
935
+ }
936
+
739
937
  async function finishManualRuntimeReload() {
938
+ if ((await getUpdateState()).recoveryPending) return;
740
939
  const pending = (await chrome.storage.local.get(MANUAL_RELOAD_TABS_KEY))?.[MANUAL_RELOAD_TABS_KEY];
741
940
  if (!pending || !Array.isArray(pending.tabIds)) return;
742
941
  const runtime = await readRuntimeStatus(true);
@@ -784,9 +983,11 @@
784
983
  function setUpdateState(value, options = {}) {
785
984
  return enqueueStateWrite(async () => {
786
985
  assertWriteOwner(options);
787
- const stored = await chrome.storage.local.get(UPDATE_STATE_KEY);
986
+ const stored = await chrome.storage.local.get(options.recoveryOwner
987
+ ? [UPDATE_STATE_KEY, CONSENT_STATE_KEY] : UPDATE_STATE_KEY);
788
988
  assertWriteOwner(options);
789
989
  const previous = policy.normalizeUpdateState(stored?.[UPDATE_STATE_KEY], currentVersion());
990
+ assertRecoverySnapshot(options.recoveryOwner, previous, policy.normalizeConsentState(stored?.[CONSENT_STATE_KEY]));
790
991
  const next = projectUpdateState(previous, value, options);
791
992
  await chrome.storage.local.set({ [UPDATE_STATE_KEY]: next });
792
993
  return next;
@@ -844,6 +1045,7 @@
844
1045
  const stored = await chrome.storage.local.get([UPDATE_STATE_KEY, CONSENT_STATE_KEY]);
845
1046
  assertWriteOwner(options);
846
1047
  const previous = policy.normalizeUpdateState(stored?.[UPDATE_STATE_KEY], currentVersion());
1048
+ assertRecoverySnapshot(options.recoveryOwner, previous, policy.normalizeConsentState(stored?.[CONSENT_STATE_KEY]));
847
1049
  const updateState = projectUpdateState(previous, updateValue, options);
848
1050
  const consentState = policy.normalizeConsentState(consentValue);
849
1051
  await chrome.storage.local.set({ [UPDATE_STATE_KEY]: updateState, [CONSENT_STATE_KEY]: consentState });
@@ -869,12 +1071,20 @@
869
1071
 
870
1072
  function enqueuePolicyAction(action) {
871
1073
  const generation = operationGeneration;
872
- const guarded = () => generation !== operationGeneration || cancellationPromise ? getView() : action();
1074
+ const guarded = async () => !await waitForBootstrapOwnership()
1075
+ || generation !== operationGeneration || cancellationPromise ? getView() : action();
873
1076
  const result = policyTail.then(guarded, guarded);
874
1077
  policyTail = result.catch(() => {});
875
1078
  return result;
876
1079
  }
877
1080
 
1081
+ async function waitForBootstrapOwnership() {
1082
+ const migration = await root.CodexOverleafBootstrapMigration?.ready?.();
1083
+ if (migration?.continueInitialization === false) return false;
1084
+ await root.__CODEX_OVERLEAF_BOOTSTRAP_READY__;
1085
+ return true;
1086
+ }
1087
+
878
1088
  function beginOperation(id) {
879
1089
  const operation = { id: id || crypto.randomUUID(), generation: ++operationGeneration, phase: 'checking' };
880
1090
  activeOperation = operation;
@@ -894,6 +1104,9 @@
894
1104
  }
895
1105
 
896
1106
  function assertWriteOwner(options) {
1107
+ if (options.recoveryOwner && options.recoveryOwner.generation !== operationGeneration) {
1108
+ throw codedError('update_recovery_superseded', 'A newer update state replaced this recovery request.');
1109
+ }
897
1110
  if (options.operation) assertOperation(options.operation);
898
1111
  if (options.generation !== undefined && options.generation !== operationGeneration) {
899
1112
  throw codedError('update_network_cancelled', 'The update request was superseded.');
@@ -970,5 +1183,5 @@
970
1183
  .slice(0, 300);
971
1184
  }
972
1185
 
973
- root.CodexOverleafUpdateCoordinator = Object.freeze({ init });
1186
+ root.CodexOverleafUpdateCoordinator = Object.freeze({ init, captureRecoveryOwner, settleSupersededTransaction });
974
1187
  })(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);