codex-overleaf-link 2.5.1 → 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 (93) hide show
  1. package/README.md +149 -398
  2. package/README.zh-CN.md +153 -404
  3. package/extension/bootstrap/manifest.template.json +1 -1
  4. package/extension/src/background.js +78 -3
  5. package/extension/src/backgroundSharedSessions.js +55 -2
  6. package/extension/src/backgroundUpdateCoordinator.js +128 -12
  7. package/extension/src/content/activeTurnControl.js +164 -7
  8. package/extension/src/content/composerAttachments.js +11 -2
  9. package/extension/src/content/composerPanel.js +92 -12
  10. package/extension/src/content/contentRuntime.js +920 -276
  11. package/extension/src/content/contextTray.js +8 -2
  12. package/extension/src/content/contextUsageView.js +267 -0
  13. package/extension/src/content/diagnosticsPanel.js +18 -0
  14. package/extension/src/content/generated/content.bundle.js +4496 -924
  15. package/extension/src/content/generated/content.bundle.meta.json +6 -3
  16. package/extension/src/content/legacyGlobalRegistry.js +4 -0
  17. package/extension/src/content/localSkillsPanel.js +2 -2
  18. package/extension/src/content/markdownDomRenderer.js +5 -3
  19. package/extension/src/content/markdownText.js +42 -5
  20. package/extension/src/content/modelPicker.js +1 -0
  21. package/extension/src/content/nativeCompatibilityController.js +29 -10
  22. package/extension/src/content/otWarmMirror.js +1 -1
  23. package/extension/src/content/pageBridgeClient.js +2 -2
  24. package/extension/src/content/panelMaintenance.js +6 -2
  25. package/extension/src/content/panelRenderer.js +51 -27
  26. package/extension/src/content/pendingInputView.js +1 -1
  27. package/extension/src/content/projectSettingsCoordinator.js +30 -6
  28. package/extension/src/content/providerSettingsDialog.js +2 -1
  29. package/extension/src/content/recentProjects.js +4 -3
  30. package/extension/src/content/runActivitySummary.js +122 -14
  31. package/extension/src/content/runCompletionMeta.js +194 -6
  32. package/extension/src/content/runController.js +2 -1
  33. package/extension/src/content/runFailureNotice.js +8 -1
  34. package/extension/src/content/runPresence.js +18 -8
  35. package/extension/src/content/runQueueScheduler.js +9 -5
  36. package/extension/src/content/runRecoveryActions.js +2 -1
  37. package/extension/src/content/runResultActions.js +22 -20
  38. package/extension/src/content/runReviewController.js +94 -35
  39. package/extension/src/content/runTimelineView.js +224 -25
  40. package/extension/src/content/scopedPersistencePanelState.js +92 -23
  41. package/extension/src/content/sensitivePreflight.js +14 -1
  42. package/extension/src/content/sessionManager.js +49 -3
  43. package/extension/src/content/sessionMenuView.js +54 -18
  44. package/extension/src/content/sessionPersistence.js +25 -5
  45. package/extension/src/content/settingsPanel.js +15 -2
  46. package/extension/src/content/settingsWorkbench.js +127 -89
  47. package/extension/src/content/sharedSessionViewSync.js +4 -3
  48. package/extension/src/content/subagentActivityView.js +23 -7
  49. package/extension/src/content/updateNotice.js +60 -6
  50. package/extension/src/content/writebackOrchestrator.js +35 -22
  51. package/extension/src/page/compileBridge.js +193 -180
  52. package/extension/src/page/overleafCapabilities.js +4 -1
  53. package/extension/src/page/textCoordinates.js +17 -1
  54. package/extension/src/page/textFileCreator.js +9 -1
  55. package/extension/src/page/writebackRouter.js +24 -77
  56. package/extension/src/pageBridge.js +107 -66
  57. package/extension/src/popup.js +1 -1
  58. package/extension/src/shared/agentTranscript.js +350 -17
  59. package/extension/src/shared/auditRecords.js +2 -0
  60. package/extension/src/shared/compatibility.js +1 -1
  61. package/extension/src/shared/compileAdapter.js +58 -17
  62. package/extension/src/shared/composerAttachmentDraft.js +6 -1
  63. package/extension/src/shared/contextUsage.js +125 -0
  64. package/extension/src/shared/failureReasons.js +42 -3
  65. package/extension/src/shared/i18n.js +299 -29
  66. package/extension/src/shared/lineReferences.js +38 -31
  67. package/extension/src/shared/pageRpcContract.js +1 -1
  68. package/extension/src/shared/panelIcons.js +39 -0
  69. package/extension/src/shared/reviewing.js +130 -1
  70. package/extension/src/shared/runActivityModel.js +70 -4
  71. package/extension/src/shared/runExecutionSnapshot.js +13 -0
  72. package/extension/src/shared/sessionState.js +63 -4
  73. package/extension/src/shared/sessionTitle.js +14 -1
  74. package/extension/src/shared/sharedSessionBridge.js +81 -13
  75. package/extension/src/shared/staleGuard.js +72 -63
  76. package/extension/src/shared/storageDb.js +35 -3
  77. package/extension/src/shared/storageMigration.js +7 -1
  78. package/extension/src/shared/storageRunActions.js +62 -8
  79. package/extension/src/shared/undoOperations.js +126 -28
  80. package/extension/src/shared/updateRuntimeIdentity.js +16 -2
  81. package/extension/src/shared/writebackSettlement.js +21 -3
  82. package/extension/styles/panel.css +1106 -490
  83. package/native-host/src/bootstrapUpdate.js +52 -16
  84. package/native-host/src/chatBridgeRequest.js +60 -6
  85. package/native-host/src/chatBridgeResponse.js +10 -1
  86. package/native-host/src/chatCompletionsBridge.js +29 -2
  87. package/native-host/src/codexPromptAssembly.js +86 -118
  88. package/native-host/src/codexSessionRunner.js +126 -17
  89. package/native-host/src/providerContextError.js +20 -0
  90. package/native-host/src/taskRunnerRuntime.js +18 -6
  91. package/native-host/src/textPatch.js +473 -377
  92. package/native-host/src/updateManager.js +120 -15
  93. package/package.json +5 -8
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Codex Overleaf Link",
4
- "version": "2.5.1",
4
+ "version": "2.6.0",
5
5
  "description": "Connect the active Overleaf project to a local Codex bridge.",
6
6
  "icons": {
7
7
  "16": "assets/icons/icon16.png",
@@ -189,6 +189,17 @@ if (!globalThis.CodexOverleafBackgroundUpdateSafety) {
189
189
  .catch(error => sendResponse({ ok: false, journals: [], error: getErrorMessage(error, 'Journal read failed.') }));
190
190
  return true;
191
191
  }
192
+ if (message?.type === 'codex-overleaf/run-journal/known') {
193
+ if (!isAllowedOverleafSender(sender)) {
194
+ sendResponse({ ok: false, known: [] });
195
+ return undefined;
196
+ }
197
+ knownRunJournals(Array.isArray(message.requestIds) ? message.requestIds.map(String).slice(0, 64) : [],
198
+ Array.isArray(message.owners) ? message.owners.slice(0, 64) : [])
199
+ .then(result => sendResponse({ ok: true, ...result }))
200
+ .catch(error => sendResponse({ ok: false, known: [], error: getErrorMessage(error, 'Journal read failed.') }));
201
+ return true;
202
+ }
192
203
  if (message?.type === 'codex-overleaf/run-journal/ack') {
193
204
  if (!isAllowedOverleafSender(sender)) {
194
205
  sendResponse({ ok: false });
@@ -266,6 +277,9 @@ if (!globalThis.CodexOverleafBackgroundUpdateSafety) {
266
277
  return;
267
278
  }
268
279
  if (message.type === 'release') {
280
+ const current = ownerBindings.get(portConnection);
281
+ if (!current || ['requestId', 'projectKey', 'clientRunId', 'sessionId'].some(key => current[key] !== message[key])
282
+ || (current.browserOnly && current.ownerToken !== message.ownerToken)) return;
269
283
  ownerBindings.delete(portConnection);
270
284
  clearTimeout(ownerCancelTimers.get(requestId));
271
285
  ownerCancelTimers.delete(requestId);
@@ -278,9 +292,20 @@ if (!globalThis.CodexOverleafBackgroundUpdateSafety) {
278
292
  documentId: String(portConnection.sender?.documentId || ''),
279
293
  tabId: portConnection.sender?.tab?.id,
280
294
  clientRunId: String(message.clientRunId || ''),
281
- sessionId: String(message.sessionId || '')
295
+ sessionId: String(message.sessionId || ''),
296
+ browserOnly: message.browserOnly === true,
297
+ ownerToken: String(message.ownerToken || '')
282
298
  };
299
+ if (binding.browserOnly && (!binding.projectKey || !binding.clientRunId || !binding.sessionId || !binding.ownerToken)) return;
300
+ if (binding.browserOnly && managedUpdateExecutionLocked) {
301
+ try { portConnection.postMessage?.({ type: 'bound', ...binding, ok: false }); } catch (_error) { /* port disconnected */ }
302
+ return;
303
+ }
283
304
  ownerBindings.set(portConnection, binding);
305
+ if (binding.browserOnly) {
306
+ try { portConnection.postMessage?.({ type: 'bound', ...binding }); } catch (_error) { /* port disconnected */ }
307
+ return;
308
+ }
284
309
  restoreRunJournal(binding).then(journal => {
285
310
  if (ownerBindings.get(portConnection) !== binding || !journal || runJournals.get(requestId) !== journal) return;
286
311
  clearTimeout(ownerCancelTimers.get(requestId));
@@ -295,7 +320,7 @@ if (!globalThis.CodexOverleafBackgroundUpdateSafety) {
295
320
  portConnection.onDisconnect.addListener(() => {
296
321
  const binding = ownerBindings.get(portConnection);
297
322
  ownerBindings.delete(portConnection);
298
- if (!binding?.requestId) {
323
+ if (!binding?.requestId || binding.browserOnly) {
299
324
  return;
300
325
  }
301
326
  const timer = setTimeout(() => interruptOwnerlessRun(binding), 150);
@@ -395,7 +420,8 @@ if (!globalThis.CodexOverleafBackgroundUpdateSafety) {
395
420
  payload = { ...payload, params: { ...payload.params,
396
421
  bootstrapRevision: String(globalThis.__CODEX_OVERLEAF_BOOTSTRAP_REVISION__ || '') } };
397
422
  }
398
- if (managedBootstrapRuntime && payload?.method === 'update.apply') {
423
+ if (managedBootstrapRuntime && (payload?.method === 'update.apply'
424
+ || (payload?.method === 'update.rollback' && payload?.params?.reasonCode === 'update_user_rollback'))) {
399
425
  const gate = await verifyManagedUpdateSafety();
400
426
  if (!gate.ok) {
401
427
  return gate;
@@ -450,6 +476,7 @@ if (!globalThis.CodexOverleafBackgroundUpdateSafety) {
450
476
  }
451
477
  const pendingState = globalThis.CodexOverleafNativeBridge?.getPendingState?.() || {};
452
478
  const backgroundBlockers = Number(pendingState.executionRequests || 0) > 0
479
+ || Array.from(ownerBindings.values()).some(binding => binding.browserOnly)
453
480
  ? ['background_execution_pending']
454
481
  : [];
455
482
  const blockers = [
@@ -995,6 +1022,9 @@ if (!globalThis.CodexOverleafBackgroundUpdateSafety) {
995
1022
  clientRunId: String(params.clientRunId || ''),
996
1023
  sessionId: String(params.clientSessionId || ''),
997
1024
  task: sanitizeJournalString(params.task).slice(0, 12000),
1025
+ ...(params.operation === 'compact' ? {
1026
+ operation: 'compact', threadId: String(params.threadId || '').slice(0, 160)
1027
+ } : {}),
998
1028
  mode: String(params.mode || ''),
999
1029
  model: String(params.model || ''),
1000
1030
  ownerTabId: pendingRequest.tabId,
@@ -1176,6 +1206,51 @@ if (!globalThis.CodexOverleafBackgroundUpdateSafety) {
1176
1206
  return entries.slice(-16).map(([_key, value]) => value);
1177
1207
  }
1178
1208
 
1209
+ // Browser-only retries outlive their original Native journal. Protect only
1210
+ // a live port whose request/project/session/run matches the queried record.
1211
+ async function knownRunJournals(requestIds, owners = []) {
1212
+ await Promise.allSettled(Array.from(journalLoads.values(), load => load.promise));
1213
+ await Promise.allSettled(Array.from(journalWrites.values()));
1214
+ const storage = chrome.storage?.session;
1215
+ if (!storage) throw new Error('Run journal storage is unavailable.');
1216
+ const values = requestIds.length ? await storage.get(requestIds.map(id => RUN_JOURNAL_PREFIX + id)) : {};
1217
+ // A persisted startup snapshot may lag behind its owner's Native dispatch.
1218
+ // Keep that same project/session/run alive through the request-id transition.
1219
+ const matches = (candidate, binding) => (candidate?.preDispatch === true
1220
+ ? ['projectKey', 'clientRunId', 'sessionId'] : ['requestId', 'projectKey', 'clientRunId', 'sessionId'])
1221
+ .every(key => Boolean(candidate?.[key]) && candidate[key] === binding?.[key]);
1222
+ const known = requestIds.filter(id => runJournals.has(id) || Boolean(values?.[RUN_JOURNAL_PREFIX + id])
1223
+ || owners.some(candidate => candidate?.requestId === id && Array.from(ownerBindings.values()).some(binding => matches(candidate, binding))));
1224
+ const missing = owners.filter(candidate => requestIds.includes(candidate?.requestId) && !known.includes(candidate.requestId));
1225
+ if (!missing.length) return { known, ownersChecked: true, preDispatchChecked: true };
1226
+ // A worker restart drops ports before hidden pages can process reconnect
1227
+ // timers. The page's existing health receiver can still report its binding.
1228
+ const unknown = () => ({ known, ownersChecked: false, preDispatchChecked: false, retryAfterMs: 1000 });
1229
+ const tabs = await chrome.tabs.query({ url: MANAGED_OVERLEAF_MATCHES }).catch(() => null);
1230
+ if (!Array.isArray(tabs)) return unknown();
1231
+ const projects = new Set(missing.map(candidate => candidate.projectKey));
1232
+ const relevant = tabs.filter(tab => isManagedEditorTab(tab) && !tab.discarded && tab.status !== 'unloaded'
1233
+ && projects.has(new URL(tab.url).pathname.split('/')[2]));
1234
+ const responses = await Promise.all(relevant.map(async tab => {
1235
+ let timer;
1236
+ try {
1237
+ return await Promise.race([
1238
+ chrome.tabs.sendMessage(tab.id, { type: 'codex-overleaf/runtime-health-probe' }, { frameId: 0 }),
1239
+ new Promise(resolve => { timer = setTimeout(() => resolve(null), 1500); })
1240
+ ]);
1241
+ } catch (_error) { return null; }
1242
+ finally { clearTimeout(timer); }
1243
+ }));
1244
+ const needsStartupInventory = missing.some(candidate => candidate.preDispatch === true);
1245
+ if (responses.some(response => response?.ok !== true || ![1, 2].includes(response.runOwnerProtocol)
1246
+ || (needsStartupInventory && response.runOwnerProtocol !== 2) || !Array.isArray(response.runOwners))) return unknown();
1247
+ for (const candidate of missing) {
1248
+ if (responses.some(response => response.runOwners.some(binding => matches(candidate, binding)))
1249
+ || Array.from(ownerBindings.values()).some(binding => matches(candidate, binding))) known.push(candidate.requestId);
1250
+ }
1251
+ return { known, ownersChecked: true, preDispatchChecked: true };
1252
+ }
1253
+
1179
1254
  function pruneInMemoryRunJournals() {
1180
1255
  if (runJournals.size <= 16) {
1181
1256
  return;
@@ -16,6 +16,7 @@
16
16
  const clone = value => value === undefined ? undefined : JSON.parse(JSON.stringify(value));
17
17
  const equal = (a, b) => JSON.stringify(a) === JSON.stringify(b);
18
18
  const failure = (code, message) => Object.assign(new Error(message), { code });
19
+ const conflict = () => failure('shared_session_conflict', 'The same session was edited in another tab. Refresh its history before retrying; the local draft was kept.');
19
20
  function part(value) {
20
21
  if (typeof value !== 'string' || !value.trim() || value.length > 256 || /[\0\r\n]/.test(value))
21
22
  throw failure('shared_store_scope_invalid', 'The shared session identity is invalid.');
@@ -71,7 +72,59 @@
71
72
  }
72
73
  return result;
73
74
  }
74
- throw failure('shared_session_conflict', 'The same session was edited in another tab. Refresh its history before retrying; the local draft was kept.');
75
+ throw conflict();
76
+ }
77
+
78
+ function titleValue(record) {
79
+ return { title: record.title || '', titleSource: record.titleSource === 'manual' ? 'manual' : 'auto' };
80
+ }
81
+ function manualTitleChange(base, next) {
82
+ return next.titleSource === 'manual' ? next.title !== base.title : base.titleSource === 'manual';
83
+ }
84
+ function mergeTitle(base, current, incoming) {
85
+ const before = titleValue(base), stored = titleValue(current), proposed = titleValue(incoming);
86
+ if (equal(proposed, before)) return stored;
87
+ if (equal(stored, before) || equal(stored, proposed)) return proposed;
88
+ // Auto derivations (including legacy normalization) carry no rename intent.
89
+ if (!manualTitleChange(before, proposed)) return stored;
90
+ if (!manualTitleChange(before, stored)) return proposed;
91
+ // The pair is one intent; merging its scalar members separately could
92
+ // combine a peer rename with this page's reset to auto.
93
+ throw conflict();
94
+ }
95
+ function draftConsumption(record) {
96
+ const marker = record.draftConsumption;
97
+ return record.task === '' && typeof marker?.task === 'string' && marker.task
98
+ && typeof marker.runId === 'string' && marker.runId
99
+ && (record.runs || []).some(run => run.id === marker.runId && !run.forkSnapshot
100
+ && run.runProjectId === record.projectId) ? marker : null;
101
+ }
102
+ function mergeSession(base, current, incoming) {
103
+ const currentConsumption = draftConsumption(current), incomingConsumption = draftConsumption(incoming);
104
+ const consumed = marker => marker && marker.task === base.task && !equal(marker, base.draftConsumption);
105
+ let task;
106
+ if (equal(incoming.task, base.task)) task = current.task;
107
+ else if (equal(current.task, base.task) || equal(current.task, incoming.task)) task = incoming.task;
108
+ else if (consumed(incomingConsumption)) task = current.task;
109
+ else if (consumed(currentConsumption)) task = incoming.task;
110
+ else task = mergeValue(base.task, current.task, incoming.task, 'task');
111
+
112
+ const result = { ...mergeTitle(base, current, incoming) };
113
+ if (task !== undefined) result.task = clone(task);
114
+ // Summaries are derived metadata, never a second draft edit. Prefer the
115
+ // stored summary when both sides have the winning task (including legacy forms).
116
+ const taskOwner = equal(task, current.task) ? current : incoming;
117
+ if (taskOwner.safeTaskSummary !== undefined) result.safeTaskSummary = clone(taskOwner.safeTaskSummary);
118
+ if (task === '' && (incomingConsumption || currentConsumption))
119
+ result.draftConsumption = clone(consumed(incomingConsumption)
120
+ ? incomingConsumption : currentConsumption || incomingConsumption);
121
+ const derived = new Set(['title', 'titleSource', 'task', 'safeTaskSummary', 'draftConsumption', 'sharedRevision']);
122
+ for (const key of new Set([...Object.keys(base), ...Object.keys(current), ...Object.keys(incoming)])) {
123
+ if (derived.has(key)) continue;
124
+ const value = mergeValue(base[key], current[key], incoming[key], key);
125
+ if (value !== undefined) Object.defineProperty(result, key, { value, enumerable: true, configurable: true, writable: true });
126
+ }
127
+ return result;
75
128
  }
76
129
 
77
130
  function createService(options = {}) {
@@ -232,7 +285,7 @@
232
285
  if (!base) throw failure('shared_session_conflict', 'Reload the session before updating existing history.');
233
286
  // Keep the established first-durable-review-decision policy.
234
287
  const reviewed = options.mergeSessionReviewState?.(candidate, existing.record, message.reviewRunIds || []) || candidate;
235
- next = mergeValue(base, existing.record, reviewed);
288
+ next = mergeSession(base, existing.record, reviewed);
236
289
  } else if (base && !importing) {
237
290
  throw failure('shared_session_deleted', 'The session no longer exists.');
238
291
  }
@@ -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;
@@ -156,6 +159,8 @@
156
159
  return dismissCompletedUpdate();
157
160
  case 'codex-overleaf/consent-update-reload':
158
161
  return reloadInstalledRuntime();
162
+ case 'codex-overleaf/consent-update-rollback':
163
+ return restorePreviousVersion();
159
164
  default:
160
165
  throw codedError('unknown_update_action', 'Unknown update action.');
161
166
  }
@@ -174,14 +179,14 @@
174
179
  code: '', message: '', blocker: '', blockers: [], cooldownUntil: 0, postponeUntil: LEGACY_GUARD }, { operation });
175
180
  try {
176
181
  const result = await withTimeout(requestNative('update.check', {
177
- operationId: operation.id, currentVersion: currentVersion(), etag: currentState.etag || ''
182
+ operationId: operation.id, currentVersion: currentVersion(), etag: currentState.etag || '', manual: manual === true
178
183
  }), CHECK_REQUEST_TIMEOUT_MS, codedError('update_check_timeout', 'The managed update check did not complete in time.'));
179
184
  assertOperation(operation);
180
185
  const checkedAt = Date.now();
181
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 });
@@ -198,7 +203,8 @@
198
203
  currentVersion: result.currentVersion || currentVersion(), latestVersion: result.latestVersion,
199
204
  managed: true, etag: result.etag || currentState.etag || '', lastCheckedAt: checkedAt,
200
205
  postponeUntil: LEGACY_GUARD, code: result.reason === 'prerelease_promotion_requires_manual_update'
201
- ? result.reason : 'update_bootstrap_upgrade_required', message: '', cooldownUntil: 0,
206
+ ? result.reason : result.code === 'update_bootstrap_source_mismatch'
207
+ ? result.code : 'update_bootstrap_upgrade_required', message: '', cooldownUntil: 0,
202
208
  manualReason: result.manualInstall?.reason || {}, cancelRequested: false
203
209
  }, { operation });
204
210
  } else {
@@ -207,9 +213,11 @@
207
213
  latestVersion: result.latestVersion || result.currentVersion || currentVersion(),
208
214
  // A 304 without a verified Native candidate is a cache miss. Drop
209
215
  // its validator so the next normal check can obtain fresh bytes.
210
- etag: result.reason === 'not_modified' ? '' : result.etag || currentState.etag || '', lastCheckedAt: checkedAt,
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,
211
218
  postponeUntil: LEGACY_GUARD, transactionId: '',
212
- code: result.reason === 'cooldown' ? 'update_cooldown' : '',
219
+ code: result.reason === 'cooldown'
220
+ ? (result.cooldownReason === USER_ROLLBACK_REASON ? 'update_skipped_after_rollback' : 'update_cooldown') : '',
213
221
  cooldownUntil: result.reason === 'cooldown' ? Number(result.cooldownUntil || 0) : 0,
214
222
  manualReason: {}, message: '', cancelRequested: false
215
223
  }, { operation });
@@ -508,7 +516,7 @@
508
516
  async function cancelUpdateCore() {
509
517
  const [state, consent] = await Promise.all([getUpdateState(), getConsentState()]);
510
518
  if (['applying', 'awaiting_health', 'rolling_back'].includes(state.state)
511
- || activeOperation?.phase === 'applying' || state.recoveryPending) {
519
+ || ['applying', 'rolling_back'].includes(activeOperation?.phase) || state.recoveryPending) {
512
520
  throw codedError('update_revoke_too_late', 'The update is being installed or recovered.');
513
521
  }
514
522
  const operation = activeOperation;
@@ -563,6 +571,7 @@
563
571
 
564
572
 
565
573
  function reconcileRecoveryState() {
574
+ if (activeOperation?.phase === 'rolling_back') return getUpdateState();
566
575
  if (recoveryPromise) return recoveryPromise;
567
576
  recoveryPromise = (async () => {
568
577
  if (!await waitForBootstrapOwnership()) return getUpdateState();
@@ -652,10 +661,35 @@
652
661
  if (transaction?.state === 'rolled_back' && status.installedAligned
653
662
  && status.activeVersion === transaction.sourceVersion
654
663
  && (!state.transactionId || transaction.id === state.transactionId)) {
655
- 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,
656
679
  transactionId: transaction.id, recoveryPending: false, cancelRequested: false,
657
680
  code: transaction.reasonCode || 'update_rolled_back', message: 'The previous version has been restored.'
658
- }, recoveryOptions);
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();
659
693
  setTimeout(() => chrome.runtime.reload(), 0);
660
694
  return restored;
661
695
  }
@@ -765,9 +799,15 @@
765
799
  now: Date.now()
766
800
  });
767
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) };
768
808
  const pending = (await chrome.storage.local.get(MANUAL_RELOAD_TABS_KEY))?.[MANUAL_RELOAD_TABS_KEY];
769
809
  const pagesPending = runtime.state === 'aligned' && pending?.targetVersion === runtime.installedVersion && pending.tabIds?.length;
770
- if (runtime.state === 'reload_required' || pagesPending) {
810
+ if (!state.recoveryPending && (runtime.state === 'reload_required' || pagesPending)) {
771
811
  view.showPanel = true;
772
812
  view.state = { ...state, state: pagesPending ? 'reload_tabs_required' : 'reload_required',
773
813
  currentVersion: runtime.extensionVersion, latestVersion: runtime.installedVersion,
@@ -784,9 +824,9 @@
784
824
  runtimeStatusRead = withTimeout(requestNative('update.status'), 4000,
785
825
  codedError('update_status_timeout', 'Installed version inspection timed out.')).then(status => {
786
826
  runtimeStatusAt = Date.now();
787
- runtimeStatus = root.CodexOverleafUpdateRuntimeIdentity.inspectInstalledRuntime(status, {
827
+ runtimeStatus = { ...root.CodexOverleafUpdateRuntimeIdentity.inspectInstalledRuntime(status, {
788
828
  extensionVersion: chrome.runtime.getManifest().version, runtimeVersion: currentVersion()
789
- });
829
+ }), rollbackAvailable: status.rollbackAvailable || null };
790
830
  return runtimeStatus;
791
831
  }).catch(() => ({ state: 'unknown' })).finally(() => { runtimeStatusRead = null; });
792
832
  return runtimeStatusRead;
@@ -808,6 +848,9 @@
808
848
  }
809
849
 
810
850
  async function reloadInstalledRuntime() {
851
+ if ((await getUpdateState()).recoveryPending) {
852
+ throw codedError('update_rollback_unconfirmed', 'Confirm update recovery before reloading.');
853
+ }
811
854
  const runtime = await readRuntimeStatus(true);
812
855
  if (runtime.state === 'aligned') { await finishManualRuntimeReload(); return getView(); }
813
856
  if (runtime.state !== 'reload_required') throw codedError('update_reload_unavailable', 'No verified installed update is awaiting reload.');
@@ -819,7 +862,80 @@
819
862
  return getView();
820
863
  }
821
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
+
822
937
  async function finishManualRuntimeReload() {
938
+ if ((await getUpdateState()).recoveryPending) return;
823
939
  const pending = (await chrome.storage.local.get(MANUAL_RELOAD_TABS_KEY))?.[MANUAL_RELOAD_TABS_KEY];
824
940
  if (!pending || !Array.isArray(pending.tabIds)) return;
825
941
  const runtime = await readRuntimeStatus(true);