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
@@ -4,11 +4,39 @@
4
4
  })(typeof globalThis !== 'undefined' ? globalThis : this, function () {
5
5
  'use strict';
6
6
 
7
+ const sameScope = (a, b) => a.accountScopeId === b.accountScopeId
8
+ && a.projectId === b.projectId && a.generation === b.generation;
9
+
10
+ function applySnapshot(options, sessions) {
11
+ const previous = options.getState(), scope = options.getScope();
12
+ const next = options.normalize({ ...previous, sessions }, { restoreRunningRuns: false });
13
+ const unchangedSnapshot = JSON.stringify(next);
14
+ options.setState(next);
15
+ try {
16
+ options.render();
17
+ } catch (error) {
18
+ // A callback may replace state, edit it in place or navigate. Restore only
19
+ // the unchanged view owned by this failed passive application.
20
+ let unchanged = false;
21
+ try {
22
+ unchanged = options.getState() === next && sameScope(scope, options.getScope())
23
+ && JSON.stringify(options.getState()) === unchangedSnapshot;
24
+ } catch (_error) { /* Unknown state must not overwrite a newer edit. */ }
25
+ if (unchanged) {
26
+ options.setState(previous);
27
+ // A second render can also fail. The old merge base still protects
28
+ // subsequent saves, including values from partially restored controls.
29
+ try { options.render(); } catch (_error) { /* Report the first failure. */ }
30
+ }
31
+ throw error;
32
+ }
33
+ }
34
+
7
35
  function install(options) {
8
- const { Bridge, StorageDb, getScope, getState, isBusy, apply, notice } = options;
36
+ const { Bridge, StorageDb, getScope, getState, isBusy, notice } = options;
37
+ const apply = options.apply || (sessions => applySnapshot(options, sessions));
9
38
  let saving = 0, version = 0, pending = false, timer = null, refreshing = false, stopped = false;
10
- const sameScope = (a, b) => a.accountScopeId === b.accountScopeId
11
- && a.projectId === b.projectId && a.generation === b.generation;
39
+ let retryCount = 0;
12
40
  const signature = state => JSON.stringify([state.activeSessionId,
13
41
  (state.sessions || []).map(session => [session.id, session.updatedAt, session.task])]);
14
42
  function schedule(delay = 250) {
@@ -21,9 +49,10 @@
21
49
  if (!scope.projectId || !scope.accountScopeId) { pending = false; return; }
22
50
  if (saving || isBusy()) { schedule(1000); return; }
23
51
  const local = getState();
24
- // A draft or a not-yet-persisted edit always takes priority over passive
25
- // history refresh. It remains on screen until the user saves/sends it.
26
- if (local.task || (local.sessions || []).some(session => StorageDb.hasUnpersistedSharedSession(session))) {
52
+ // A locally edited draft or a not-yet-persisted edit takes priority over
53
+ // passive history refresh. Callers may distinguish imported shared drafts.
54
+ if ((options.hasLocalDraft ? options.hasLocalDraft(local) : Boolean(local.task))
55
+ || (local.sessions || []).some(session => StorageDb.hasUnpersistedSharedSession(session))) {
27
56
  schedule(1500); return;
28
57
  }
29
58
  const capturedVersion = version, capturedSignature = signature(local);
@@ -37,12 +66,21 @@
37
66
  const ids = new Set(records.map(record => record.id));
38
67
  const newLocal = (local.sessions || []).filter(session =>
39
68
  !ids.has(session.id) && !StorageDb.wasSharedSessionStored(session.id));
40
- // The data has now been adopted by this view; future writes may use it
41
- // as a merge baseline. Snapshot reads alone never advance the baseline.
42
- StorageDb.adoptSharedSessionBaseline(records);
69
+ // Rendering may fail after only part of the composer was updated.
70
+ // Keep the prior merge base until the complete view accepts the data.
43
71
  apply([...records, ...newLocal]);
72
+ if (stopped || !sameScope(scope, getScope())) return;
73
+ if (saving || version !== capturedVersion) { pending = true; return; }
74
+ StorageDb.adoptSharedSessionBaseline(records);
75
+ retryCount = 0;
44
76
  } catch (error) {
45
77
  notice?.(error);
78
+ // One retry can recover transient DOM failures. Local edits, navigation
79
+ // and persistent failures stop it; another notification starts afresh.
80
+ if (!stopped && sameScope(scope, getScope()) && version === capturedVersion
81
+ && signature(getState()) === capturedSignature && retryCount < 1) {
82
+ retryCount++; pending = true;
83
+ }
46
84
  } finally {
47
85
  refreshing = false;
48
86
  schedule(1000);
@@ -51,7 +89,7 @@
51
89
  const unsubscribe = Bridge.subscribe(change => {
52
90
  const scope = getScope();
53
91
  if (!change.projectId || change.projectId === scope.projectId) {
54
- pending = true; schedule();
92
+ retryCount = 0; pending = true; schedule();
55
93
  }
56
94
  });
57
95
  return {
@@ -68,5 +106,5 @@
68
106
  }
69
107
  };
70
108
  }
71
- return { install };
109
+ return { install, applySnapshot };
72
110
  });
@@ -3,18 +3,31 @@
3
3
  })(typeof globalThis !== 'undefined' ? globalThis : window, function subagentActivityViewFactory(Agents, Activity) {
4
4
  'use strict';
5
5
  function create(deps) {
6
- const { tx, getPanel, plain, make, icon, formatElapsed, renderBlocks } = deps;
6
+ const { tx, getPanel, plain, plainThought, make, icon, formatElapsed, renderBlocks, glyph = () => '' } = deps;
7
+ const withGlyph = (node, name, label) => {
8
+ node.insertAdjacentHTML('beforeend', glyph(name));
9
+ const text = make('span', ''); text.textContent = label; node.append(text);
10
+ return node;
11
+ };
7
12
  let viewer = null;
8
13
  function statusLabel(status) {
9
14
  return ({ pending: tx('Starting', '启动中'), running: tx('Running', '运行中'),
10
15
  completed: tx('Completed', '已完成'), failed: tx('Failed', '失败'),
11
16
  cancelled: tx('Stopped', '已停止'), unknown: tx('Status unconfirmed', '状态未确认') })[status] || '';
12
17
  }
18
+ // A row preview is one plain line: a final message keeps its words, not its
19
+ // Markdown ("- `main.tex` is …" reads "main.tex is …").
20
+ function plainMessage(value) {
21
+ return value.replace(/^\s*(?:#{1,6}\s+|>\s?|[-*+]\s+|\d+[.)]\s+)/gm, '')
22
+ .replace(/!?\[([^\]]*)\]\([^)\s]*\)/g, '$1').replace(/`([^`\n]+)`/g, '$1')
23
+ .replace(/(\*\*|__)(?=\S)([\s\S]*?\S)\1/g, '$2').replace(/\s+/g, ' ').trim();
24
+ }
13
25
  function latest(agent) {
14
26
  const blocks = Activity.project({ status: agent.status, events: agent.events || [] }).blocks;
15
27
  const last = blocks.at(-1);
16
28
  const item = last?.kind === 'exploreGroup' ? last.items.at(-1) : last;
17
- return plain(item?.meta?.target || item?.meta?.command || item?.event?.title || agent.task).slice(0, 180);
29
+ const text = plain(item?.meta?.target || item?.meta?.command || item?.event?.title || agent.task);
30
+ return (item?.kind === 'message' ? plainMessage(text) : text).slice(0, 180);
18
31
  }
19
32
  function elapsed(agent, run) {
20
33
  const duration = Agents.elapsedMs(agent, run, Date.now());
@@ -42,9 +55,9 @@
42
55
  shell.setAttribute('aria-label', tx('Subagent conversation', '子代理对话'));
43
56
  const header = make('header', 'codex-subagent-view-head');
44
57
  const back = make('button', 'codex-subagent-back');
45
- back.type = 'button'; back.textContent = tx('Back to main task', '返回主任务');
58
+ back.type = 'button'; withGlyph(back, 'chevronLeft', tx('Back to main task', '返回主任务'));
46
59
  back.addEventListener('click', close);
47
- const readonly = make('span', 'codex-subagent-readonly'); readonly.textContent = tx('Read only', '只读');
60
+ const readonly = withGlyph(make('span', 'codex-subagent-readonly'), 'lock', tx('Read only', '只读'));
48
61
  header.append(back, readonly);
49
62
  const parent = make('div', 'codex-subagent-parent');
50
63
  const parentLabel = make('span', 'codex-subagent-parent-label');
@@ -112,7 +125,7 @@
112
125
  const mark = icon('agent'), copy = make('span', 'run-subagent-copy');
113
126
  const name = make('span', 'run-subagent-name'), activity = make('span', 'run-subagent-activity');
114
127
  copy.append(name, activity);
115
- const tail = make('span', 'run-subagent-tail'), status = make('span', ''), time = make('span', 'run-subagent-time');
128
+ const tail = make('span', 'run-subagent-tail'), status = make('span', 'run-subagent-status'), time = make('span', 'run-subagent-time');
116
129
  tail.append(status, time);
117
130
  el.append(mark, copy, tail);
118
131
  el.addEventListener('click', () => open(card, agent.key, el));
@@ -154,15 +167,17 @@
154
167
  const childStatus = Agents.effectiveStatus(agent, card.run);
155
168
  viewer.title.textContent = plain(agent.title || agent.jobId || agent.threadId);
156
169
  viewer.meta.textContent = [statusLabel(childStatus), elapsed(agent, card.run), agent.model, agent.reasoningEffort].filter(Boolean).join(' · ');
170
+ viewer.meta.dataset.state = childStatus;
157
171
  viewer.prompt.textContent = plain(agent.task);
158
172
  viewer.assignment.hidden = !viewer.prompt.textContent;
159
173
  const parent = Activity.project(card.run), last = parent.blocks.at(-1);
160
174
  const lastItem = last?.kind === 'exploreGroup' ? last.items.at(-1) : last;
161
- const parentWork = plain(lastItem?.meta?.target || lastItem?.meta?.command || lastItem?.event?.title).slice(0, 150);
175
+ const parentWork = plainThought(plain(lastItem?.meta?.target || lastItem?.meta?.command || lastItem?.event?.title)).slice(0, 150);
162
176
  viewer.parentLabel.textContent = tx('Main agent · ', '主 agent · ') + (card.run.status === 'running'
163
177
  ? parentWork || tx('Working', '处理中') : tx('Run ended', '本轮已结束'));
164
178
  const running = (card.run.subagents || []).filter(item => ['pending', 'running'].includes(Agents.effectiveStatus(item, card.run))).length;
165
179
  viewer.parentCount.textContent = tx(running + ' subagents active', running + ' 个子任务进行中');
180
+ viewer.parentCount.hidden = running === 0;
166
181
  for (const sibling of card.run.subagents || []) {
167
182
  let button = viewer.siblingRows.get(sibling.key);
168
183
  if (!button) {
@@ -172,7 +187,8 @@
172
187
  }
173
188
  button.textContent = plain(sibling.title || sibling.jobId || sibling.threadId);
174
189
  button.setAttribute('aria-pressed', String(sibling.key === agent.key));
175
- button.title = statusLabel(Agents.effectiveStatus(sibling, card.run));
190
+ button.dataset.state = Agents.effectiveStatus(sibling, card.run);
191
+ button.title = statusLabel(button.dataset.state);
176
192
  }
177
193
  const childRun = { id: card.id + ':' + agent.key, status: ['pending', 'running'].includes(childStatus) ? 'running' : childStatus,
178
194
  events: agent.events || [], mode: 'ask' };
@@ -5,9 +5,10 @@
5
5
  let lastUserActivityAt = Date.now();
6
6
  let stableSavedSince = 0;
7
7
  let applying = false;
8
+ let saveIdentity;
8
9
  const documentRef = options.document || root.document;
9
10
  const noteActivity = event => {
10
- if (event?.target?.closest?.('#codex-overleaf-panel')) return;
11
+ if (!options.includePanelActivity && event?.target?.closest?.('#codex-overleaf-panel')) return;
11
12
  lastUserActivityAt = Date.now();
12
13
  stableSavedSince = 0;
13
14
  };
@@ -42,6 +43,7 @@
42
43
  saveResult = { ok: false, state: 'unavailable' };
43
44
  }
44
45
  const saved = saveResult?.ok === true && ['verified_saved', 'verified_quiet'].includes(saveResult?.state);
46
+ if (saveResult?.identity !== saveIdentity) { stableSavedSince = 0; saveIdentity = saveResult?.identity; }
45
47
  if (saved) {
46
48
  stableSavedSince = stableSavedSince || Date.now();
47
49
  } else {
@@ -62,6 +64,7 @@
62
64
 
63
65
  return {
64
66
  buildIdleProbe,
67
+ noteActivity,
65
68
  handleMessage,
66
69
  isApplying: () => applying
67
70
  };
@@ -85,9 +85,20 @@
85
85
  } catch (_) { manualCommandCopied = false; }
86
86
  return;
87
87
  }
88
+ if (action === 'restore-previous' && !window.confirm(restorePreviousConfirmText())) return;
88
89
  return runUpdateAction(action);
89
90
  }
90
91
 
92
+ function restorePreviousLabel() {
93
+ const version = currentView?.restorePrevious?.version || '';
94
+ return version ? tx(`Restore v${version}`, `恢复到 v${version}`) : tx('Restore previous version', '恢复到上一版本');
95
+ }
96
+
97
+ function restorePreviousConfirmText() {
98
+ return tx('Restore the previous version? The extension reloads, and saved, idle Overleaf tabs are refreshed. Sessions, settings and provider keys are kept.',
99
+ '恢复到上一版本?扩展会重新加载,已保存且空闲的 Overleaf 标签页会被刷新。会话、设置和模型服务的密钥都会保留。');
100
+ }
101
+
91
102
  async function runUpdateAction(action) {
92
103
  const cancelling = action === 'cancel' || action === 'later';
93
104
  if (cancellationInFlight || ((actionInFlight || settingsActionInFlight) && !cancelling)) return;
@@ -105,7 +116,8 @@
105
116
  cancel: 'codex-overleaf/consent-update-cancel',
106
117
  recover: 'codex-overleaf/consent-update-recover',
107
118
  dismiss: 'codex-overleaf/consent-update-dismiss',
108
- reload: 'codex-overleaf/consent-update-reload'
119
+ reload: 'codex-overleaf/consent-update-reload',
120
+ 'restore-previous': 'codex-overleaf/consent-update-rollback'
109
121
  }[action]);
110
122
  if (sequence === actionSequence) currentView = view;
111
123
  } catch (error) {
@@ -226,6 +238,13 @@
226
238
  button.dataset.updateSettingsBound = 'true';
227
239
  button.addEventListener('click', handleSettingsAction);
228
240
  }
241
+ const restore = panel.querySelector?.('[data-restore-previous-version]');
242
+ if (restore && restore.dataset.updateSettingsBound !== 'true') {
243
+ restore.dataset.updateSettingsBound = 'true';
244
+ restore.addEventListener('click', () => {
245
+ if (window.confirm(restorePreviousConfirmText())) void runUpdateAction('restore-previous');
246
+ });
247
+ }
229
248
  renderSettings();
230
249
  }
231
250
 
@@ -245,6 +264,7 @@
245
264
  if (!currentView) return;
246
265
  renderSettings();
247
266
  if (!notice) return;
267
+ notice.setAttribute('aria-label', tx('Stable update', '稳定版更新'));
248
268
  if (!currentView.showPanel) {
249
269
  notice.hidden = true;
250
270
  notice.replaceChildren();
@@ -355,7 +375,24 @@
355
375
  let statusText = '';
356
376
  let buttonText = tx('Check for updates', '检查更新');
357
377
 
358
- if (['reload_required', 'reload_tabs_required'].includes(stateName)) {
378
+ if (stateName === 'idle' && state.code === 'update_skipped_after_rollback') {
379
+ statusText = tx(
380
+ `v${latestVersion} is skipped because you restored the previous version. Check for updates to install it again.`,
381
+ `你已恢复到上一版本,自动更新会跳过 v${latestVersion}。点“检查更新”可以重新安装。`
382
+ );
383
+ } else if (stateName === 'idle' && state.code === 'update_cooldown') {
384
+ const until = Number(state.cooldownUntil || 0);
385
+ const time = until > 0 && Number.isFinite(until) ? new Date(until).toLocaleString(getLocale() || undefined) : '';
386
+ statusText = tx(
387
+ `Update v${latestVersion} is paused after a failed installation. ${time ? `Check again after ${time}.` : 'Check again after its cooldown ends.'}`,
388
+ `v${latestVersion} 安装失败后已暂停更新。${time ? `请在 ${time} 后再次检查。` : '请在冷却期结束后再次检查。'}`
389
+ );
390
+ } else if (stateName === 'idle' && !state.code && state.lastCheckedAt > 0
391
+ && (!latestVersion || !isNewerStableVersion(latestVersion, currentVersion))) {
392
+ // A finished check with nothing newer still answers the user's question.
393
+ const time = new Date(state.lastCheckedAt).toLocaleTimeString(getLocale() || undefined, { hour: 'numeric', minute: '2-digit' });
394
+ statusText = tx(`You have the latest stable version. Checked at ${time}.`, `已是最新稳定版本。检查时间 ${time}。`);
395
+ } else if (['reload_required', 'reload_tabs_required'].includes(stateName)) {
359
396
  summaryText = tx(`Installed v${latestVersion}; running extension v${currentVersion}.`, `已安装 v${latestVersion};扩展当前运行 v${currentVersion}。`);
360
397
  statusText = getCopy(state).detail;
361
398
  buttonText = stateName === 'reload_required' ? tx('Reload extension', '重新加载扩展') : tx('Refresh Overleaf tabs', '刷新 Overleaf 标签页');
@@ -373,7 +410,7 @@
373
410
  statusText = getCopy(state).detail;
374
411
  buttonText = tx('Update in progress', '更新进行中');
375
412
  } else if (stateName === 'failed' || stateName === 'rolled_back') {
376
- statusText = state.message || getCopy(state).detail;
413
+ statusText = formatUpdateMessage(state) || getCopy(state).detail;
377
414
  buttonText = tx('Retry', '重试');
378
415
  } else if (stateName === 'committed') {
379
416
  summaryText = tx(`Current v${currentVersion}. Update completed successfully.`, `当前版本 v${currentVersion}。更新已成功完成。`);
@@ -396,11 +433,19 @@
396
433
  } else {
397
434
  button.disabled = actionInFlight || settingsActionInFlight || ACTIVE_UPDATE_STATES.has(stateName);
398
435
  }
436
+ const restore = settingsRoot.querySelector?.('[data-restore-previous-version]');
437
+ if (restore) {
438
+ restore.hidden = !currentView?.actions?.restorePrevious;
439
+ restore.textContent = restorePreviousLabel();
440
+ restore.disabled = actionInFlight || settingsActionInFlight;
441
+ }
399
442
  }
400
443
 
401
444
  function visibleActions(state) {
402
445
  const value = currentView?.state || {};
403
- if (value.recoveryPending) return [{ id: 'recover', label: tx('Check recovery', '检查恢复状态'), primary: true }];
446
+ const restore = currentView?.actions?.restorePrevious
447
+ ? [{ id: 'restore-previous', label: restorePreviousLabel(), primary: false }] : [];
448
+ if (value.recoveryPending) return [...restore, { id: 'recover', label: tx('Check recovery', '检查恢复状态'), primary: true }];
404
449
  if (value.cancelRequested || ['checking', 'downloading'].includes(state)) {
405
450
  return [{ id: 'cancel', label: value.cancelRequested
406
451
  ? value.code ? tx('Retry cancellation', '重试取消') : tx('Cancelling…', '正在取消…')
@@ -434,7 +479,7 @@
434
479
  ];
435
480
  }
436
481
  if (state === 'committed') {
437
- return [{ id: 'dismiss', label: tx('Done', '完成'), primary: true }];
482
+ return [...restore, { id: 'dismiss', label: tx('Done', '完成'), primary: true }];
438
483
  }
439
484
  return [];
440
485
  }
@@ -443,11 +488,11 @@
443
488
  const target = state.latestVersion ? 'v' + state.latestVersion : '';
444
489
  if (state.cancelRequested) return {
445
490
  eyebrow: tx('Cancelling update', '正在取消更新'), title: target,
446
- detail: state.message || tx('Stopping this update and clearing its authorization.', '正在停止本次更新并清理更新授权。')
491
+ detail: formatUpdateMessage(state) || tx('Stopping this update and clearing its authorization.', '正在停止本次更新并清理更新授权。')
447
492
  };
448
493
  if (state.recoveryPending) return {
449
494
  eyebrow: tx('Checking update recovery', '正在确认更新恢复状态'), title: target,
450
- detail: state.message || tx('Waiting for the installed version to be confirmed.', '正在确认实际安装的版本。')
495
+ detail: formatUpdateMessage(state) || tx('Waiting for the installed version to be confirmed.', '正在确认实际安装的版本。')
451
496
  };
452
497
  const blocker = blockersCopy(state.blockers?.length ? state.blockers : [state.blocker]);
453
498
  return {
@@ -455,11 +500,11 @@
455
500
  detail: tx('Reading the latest signed release.', '正在读取最新签名版本。') },
456
501
  reload_required: {
457
502
  eyebrow: tx('Installed, awaiting reload', '已安装,等待加载'), title: target,
458
- detail: state.message || tx('The new files are installed. Reload the extension when Overleaf is saved and idle.', '新版文件已安装,请在 Overleaf 保存并空闲后重新加载扩展。')
503
+ detail: formatUpdateMessage(state) || tx('The new files are installed. Reload the extension when Overleaf is saved and idle.', '新版文件已安装,请在 Overleaf 保存并空闲后重新加载扩展。')
459
504
  },
460
505
  reload_tabs_required: {
461
506
  eyebrow: tx('Updated runtime is ready', '新版运行组件已就绪'), title: target,
462
- detail: state.message || tx('Refresh the saved, idle Overleaf tabs to load the updated panel.', '刷新已保存且空闲的 Overleaf 标签页以加载新版面板。')
507
+ detail: formatUpdateMessage(state) || tx('Refresh the saved, idle Overleaf tabs to load the updated panel.', '刷新已保存且空闲的 Overleaf 标签页以加载新版面板。')
463
508
  },
464
509
  manual_install_required: {
465
510
  eyebrow: tx('Reinstall needed for this update', '这次更新需要重新安装'),
@@ -504,12 +549,14 @@
504
549
  rolled_back: {
505
550
  eyebrow: tx('Previous version restored', '已恢复上一版本'),
506
551
  title: state.currentVersion ? 'v' + state.currentVersion : '',
507
- detail: tx('The replacement failed its health check.', '替换版本未通过健康检查。')
552
+ detail: state.code === 'update_user_rollback'
553
+ ? tx('You restored the previous version. Automatic reminders for this version are paused for 24 hours. Check for updates to install it again.', '已恢复到上一版本,刚才退出的版本将暂停自动提醒 24 小时。点“检查更新”可以重新安装。')
554
+ : tx('The replacement failed its health check.', '替换版本未通过健康检查。')
508
555
  },
509
556
  failed: {
510
557
  eyebrow: tx('Update could not continue', '更新无法继续'),
511
558
  title: target,
512
- detail: state.message || tx('Review the update details and retry.', '请查看更新详情后重试。')
559
+ detail: formatUpdateMessage(state) || tx('Review the update details and retry.', '请查看更新详情后重试。')
513
560
  }
514
561
  }[state.state] || { eyebrow: '', title: '', detail: '' };
515
562
  }
@@ -521,6 +568,46 @@
521
568
  return `npm exec --yes codex-overleaf-link@${version} -- install-managed`;
522
569
  }
523
570
 
571
+ function formatUpdateMessage(state = {}) {
572
+ const code = state.code || '';
573
+ const messages = {
574
+ update_phase_timeout: ['The update timed out and its download was stopped. Retry when the connection is available.', '更新超时,下载已停止。连接恢复后请重试。'],
575
+ update_not_applied: ['The update was stopped before installation. Retry when ready.', '更新已在安装前停止,可准备就绪后重试。'],
576
+ update_recovery_pending: ['The update result is being checked. Recovery has not been confirmed.', '正在确认更新结果,恢复状态尚未确认。'],
577
+ update_rolled_back: ['The previous version has been restored.', '已恢复上一版本。'],
578
+ update_user_rollback: ['You restored the previous version.', '你已恢复到上一版本。'],
579
+ update_not_managed: ['Run install-managed once to enable coordinated automatic updates.', '请先运行一次 install-managed,以启用扩展与本机服务的协调更新。'],
580
+ update_native_busy: ['Native Host is still processing a Codex task.', 'Native Host 仍在处理 Codex 任务。'],
581
+ update_revoke_too_late: ['The update is already being installed. Wait for its result.', '更新已进入安装,请等待结果。'],
582
+ update_cancel_unconfirmed: ['Update cancellation is unconfirmed. Check recovery before retrying.', '尚未确认更新已取消,请先检查恢复状态。'],
583
+ update_apply_unconfirmed: ['Update installation is unconfirmed. Check recovery before retrying.', '尚未确认更新安装结果,请先检查恢复状态。'],
584
+ update_rollback_unconfirmed: ['Restoring the previous version is unconfirmed. Check recovery before retrying.', '尚未确认旧版本已恢复,请先检查恢复状态。'],
585
+ update_reload_unavailable: ['No verified installed update is awaiting reload.', '当前没有已验证且等待重载的更新。'],
586
+ update_network_cancelled: ['The update download was cancelled.', '更新下载已取消。'],
587
+ update_apply_failed: ['The update could not be installed. Check recovery and retry.', '无法安装更新,请检查恢复状态后重试。']
588
+ };
589
+ if (messages[code]) return tx(...messages[code]);
590
+ if (['update_health_failed', 'update_request_failed', 'update_failed'].includes(code)) return tx('The updated components could not be confirmed. Check recovery before retrying.', '尚未确认更新后的组件正常,请先检查恢复状态。');
591
+ if (['update_bootstrap_health_timeout', 'update_bootstrap_reload_failed'].includes(code)) return tx('The updated startup components did not finish restarting and confirming. Check recovery before retrying.', '更新后的启动组件未能完成重启和确认,请先检查恢复状态。');
592
+ if (['update_bootstrap_source_mismatch', 'update_bootstrap_source_version_mismatch'].includes(code)) return tx('The installed startup files do not match a supported update source. Restore the previous version in Settings, or reinstall once.', '已安装的启动文件与支持的升级来源不一致。请在设置中恢复到上一版本,或重新安装一次。');
593
+ if (['update_bootstrap_record_missing', 'update_bootstrap_record_invalid', 'update_bootstrap_backup_mismatch', 'update_bootstrap_already_restoring'].includes(code)) return tx('The startup recovery material could not be confirmed. The update result remains unconfirmed.', '尚未确认启动组件的恢复材料有效,更新结果仍待确认。');
594
+ if (['update_bootstrap_confirmation_pending', 'update_bootstrap_native_restart_required'].includes(code)) return tx('Waiting for the updated startup components and Native Host to finish restarting and confirming.', '正在等待更新后的启动组件和本机服务完成重启及确认。');
595
+ if (code === 'update_bootstrap_monitor_unavailable') return tx('The update recovery monitor could not start. Check recovery before retrying.', '更新恢复监护程序未能启动,请先检查恢复状态。');
596
+ if (['update_bootstrap_transaction_mismatch', 'update_bootstrap_transaction_invalid'].includes(code)) return tx('The startup update transaction could not be matched. Check recovery before retrying.', '未能匹配启动组件的更新事务,请先检查恢复状态。');
597
+ if (code.startsWith('update_bootstrap_')) return tx('The startup update package or installed files could not be verified. Check recovery before retrying.', '无法验证启动组件更新包或已安装文件,请先检查恢复状态。');
598
+ if (['update_reload_required', 'update_reload_busy'].includes(code)) {
599
+ const match = /^Reload is waiting for Overleaf to be saved and idle \(([^)]+)\)\.$/.exec(state.message || '');
600
+ if (match) return blockersCopy(match[1].split(', '));
601
+ }
602
+ if (['update_check_timeout', 'update_activation_timeout', 'update_status_timeout'].includes(code)) return tx('The update operation timed out. Check recovery or retry when the connection is available.', '更新操作超时,请检查恢复状态,或在连接恢复后重试。');
603
+ if (['update_candidate_missing', 'update_candidate_stale', 'update_not_staged', 'update_staged_archive_invalid', 'update_recovery_superseded'].includes(code)) return tx('No current verified update is ready. Check for updates again.', '当前没有已验证且可安装的更新,请重新检查更新。');
604
+ if (['update_authorization_in_progress', 'update_transaction_in_progress', 'update_transaction_locked'].includes(code)) return tx('Another update operation is in progress. Wait for it to finish.', '另一项更新操作正在进行,请等待其完成。');
605
+ if (['update_authorization_invalid', 'update_consent_required', 'update_consent_mismatch', 'update_transaction_mismatch', 'update_transaction_state_changed'].includes(code)) return tx('The update authorization or transaction changed. Check for updates and authorize the current candidate again.', '更新授权或事务已变化,请重新检查更新并确认当前候选版本。');
606
+ if (['update_archive_checksum_invalid', 'update_archive_compressed_limit', 'update_archive_duplicate_path', 'update_archive_file_count_limit', 'update_archive_file_limit', 'update_archive_invalid', 'update_archive_path_forbidden', 'update_archive_path_too_long', 'update_archive_required_file_missing', 'update_archive_total_limit', 'update_archive_truncated', 'update_archive_type_forbidden', 'update_staging_not_empty', 'update_operation_invalid', 'update_bundle_hash_invalid', 'update_bundle_name_invalid', 'update_bundle_size_invalid', 'update_manifest_artifacts_invalid', 'update_manifest_commit_invalid', 'update_manifest_invalid', 'update_manifest_manual_install_invalid', 'update_manifest_origin_mismatch', 'update_manifest_protocol_invalid', 'update_manifest_schema_unsupported', 'update_manifest_timestamp_invalid', 'update_manifest_too_large', 'update_manifest_version_invalid', 'update_signature_algorithm_unsupported', 'update_signature_invalid', 'update_signature_key_invalid', 'update_signature_key_unknown', 'update_signature_too_large', 'update_version_invalid'].includes(code)) return tx('The signed update or archive could not be verified. Check the release details and retry.', '无法验证更新签名或压缩包,请检查发布详情后重试。');
607
+ if (['update_github_http_error', 'update_github_response_invalid', 'update_asset_missing', 'update_asset_url_forbidden', 'update_release_asset_invalid', 'update_release_manifest_mismatch', 'update_bundle_size_mismatch', 'update_bundle_hash_mismatch', 'update_native_version_mismatch', 'update_extension_version_mismatch', 'update_runtime_manifest_invalid', 'update_runtime_asset_missing', 'update_bootstrap_manifest_invalid', 'update_managed_layout_invalid', 'update_marker_invalid', 'update_current_version_invalid', 'update_health_version_mismatch', 'update_confirmation_unexpected', 'update_rollback_unavailable'].includes(code)) return tx('The update or installed files could not be verified. Check the update details and repair or retry.', '无法验证更新或已安装文件,请检查更新详情后修复或重试。');
608
+ return state.message || '';
609
+ }
610
+
524
611
  function blockerCopy(value) {
525
612
  return {
526
613
  unsaved: tx('Overleaf has not confirmed that this document is saved.', 'Overleaf 尚未确认当前文档已保存。'),
@@ -541,6 +628,7 @@
541
628
  tab_unavailable: tx('An Overleaf tab is unavailable for the safety check.', '某个 Overleaf 标签页当前无法完成安全检查。'),
542
629
  idle_probe_failed: tx('The Overleaf safety check failed. Reload the affected tab and retry.', 'Overleaf 安全检查失败;请刷新受影响的标签页后重试。'),
543
630
  native_project_locked: tx('Native Host still holds a project lock.', 'Native Host 仍持有项目锁。'),
631
+ native_process_stop_unconfirmed: tx('A previous Codex process has not yet confirmed its exit.', '前一次 Codex 进程尚未确认退出。'),
544
632
  native_run_active: tx('Native Host is still processing a task.', 'Native Host 仍在处理任务。'),
545
633
  busy: tx('A component is still busy.', '某个组件仍处于忙碌状态。')
546
634
  }[value] || tx('Waiting until every Overleaf tab is saved and idle.', '正在等待所有 Overleaf 标签页完成保存并进入空闲状态。');
@@ -555,14 +643,32 @@
555
643
  }
556
644
 
557
645
  function manualReasonText(state = {}) {
646
+ if (state.code === 'prerelease_promotion_requires_manual_update') {
647
+ return tx('The installed release candidate has the same version number as the stable release. Reinstall to use the stable release.',
648
+ '已安装的候选版与稳定版使用相同版本号。请重新安装以使用稳定版。');
649
+ }
650
+ if (state.code === 'update_bootstrap_source_mismatch') {
651
+ return tx('The installed startup files differ from the published release this update starts from, for example after a release candidate or local build. Reinstall once to continue.',
652
+ '已安装的启动文件与这次更新所基于的正式版不一致(例如装过候选版或本地构建)。重新安装一次即可继续。');
653
+ }
558
654
  const reason = state.manualReason || {};
559
655
  return tx(reason.en || reason.zh || '', reason.zh || reason.en || '');
560
656
  }
561
657
 
658
+ // Compares the numeric x.y.z core only; a local build ahead of the release is not behind.
659
+ function isNewerStableVersion(candidate, current) {
660
+ const parts = value => String(value || '').replace(/^v/i, '').split('-')[0].split('.').map(part => Number(part) || 0);
661
+ const [left, right] = [parts(candidate), parts(current)];
662
+ for (let index = 0; index < 3; index++) {
663
+ if ((left[index] || 0) !== (right[index] || 0)) return (left[index] || 0) > (right[index] || 0);
664
+ }
665
+ return false;
666
+ }
667
+
562
668
  function tx(english, chinese) {
563
669
  const locale = String(getLocale() || document.documentElement?.lang || 'en');
564
670
  return /^zh\b/i.test(locale) ? chinese : english;
565
671
  }
566
672
 
567
- return Object.freeze({ mount });
673
+ return Object.freeze({ mount, refreshLocale: render });
568
674
  });
@@ -0,0 +1,58 @@
1
+ (function installUpdateSafetyReceiver(root) {
2
+ 'use strict';
3
+ if (root.__codexOverleafUpdateSafetyReceiverInstalled) return;
4
+ let binding, idle, inFlight = false;
5
+ const blocked = () => ({ idle: false, saved: false, blockers: ['tab_probe_unavailable'] });
6
+ function install(next, dependencies) {
7
+ if (!next?.transactionId || !next.token || next.url !== location.href) return { ok: false };
8
+ if (binding) {
9
+ if (binding.transactionId !== next.transactionId || binding.targetVersion !== next.targetVersion
10
+ || binding.documentId !== next.documentId || binding.url !== next.url || inFlight) return { ok: false };
11
+ binding = Object.freeze({ ...next });
12
+ return { ok: true, url: location.href };
13
+ }
14
+ binding = Object.freeze({ ...next });
15
+ const pageWrites = new Set();
16
+ const visible = node => !node.hidden && node.getAttribute('aria-hidden') !== 'true' && node.getClientRects().length > 0;
17
+ const { UpdateIdle, PageRpcContract } = dependencies;
18
+ idle = UpdateIdle.create({ document, includePanelActivity: true,
19
+ getBusyState: () => ({ reviewAction: pageWrites.size > 0
20
+ || [...document.querySelectorAll('#codex-overleaf-panel [data-run-accept-confirm], #codex-overleaf-panel button:disabled')]
21
+ .some(node => visible(node) && (node.hasAttribute('data-run-accept-confirm') || /Accepting|接受中/.test(node.textContent)
22
+ || (node.hasAttribute('data-run-undo') && !/Undone|已撤销/.test(node.textContent)
23
+ && !/Accepted|已接受/.test(node.closest('[data-run-id]')?.querySelector('[data-run-accept]')?.textContent || '')))),
24
+ run: document.querySelector('#codex-overleaf-panel')?.dataset.running === 'true',
25
+ cancelling: document.querySelector('#codex-overleaf-panel')?.dataset.cancelling === 'true',
26
+ dialog: [...document.querySelectorAll('[role="dialog"], dialog[open]')].some(visible) }),
27
+ checkSaved: async () => {
28
+ const response = await chrome.runtime.sendMessage({ type: 'codex-overleaf/migration-save-probe', ...binding,
29
+ nonce: activeNonce });
30
+ return response?.ok && response.nonce === activeNonce ? response.result : { ok: false, state: 'unavailable' };
31
+ } });
32
+ root.addEventListener('message', event => {
33
+ if (event.source !== root || event.origin !== location.origin || typeof event.data?.id !== 'string') return;
34
+ const data = event.data;
35
+ const mutation = PageRpcContract.getMethod(data.method)?.mutation;
36
+ if (data.source === 'codex-overleaf/content' && ['document', 'page_state', 'navigation'].includes(mutation)) {
37
+ pageWrites.add(data.id); idle.noteActivity();
38
+ } else if (data.source === 'codex-overleaf/page' && pageWrites.delete(data.id)) idle.noteActivity();
39
+ });
40
+ chrome.runtime.onMessage.addListener((message, sender, respond) => {
41
+ if (message?.type !== 'codex-overleaf/migration-idle-probe') return undefined;
42
+ if (sender?.id !== chrome.runtime.id || sender.tab || message.token !== binding.token
43
+ || message.transactionId !== binding.transactionId || typeof message.nonce !== 'string'
44
+ || !message.nonce || location.href !== binding.url || inFlight) { respond(blocked()); return false; }
45
+ inFlight = true;
46
+ activeNonce = message.nonce;
47
+ idle.buildIdleProbe().then(result => respond(location.href === binding.url ? {
48
+ ...result, transactionId: binding.transactionId, token: binding.token, nonce: message.nonce,
49
+ url: location.href, targetVersion: binding.targetVersion, safetyOnly: true
50
+ } : blocked())).catch(() => respond(blocked())).finally(() => { inFlight = false; activeNonce = ''; });
51
+ return true;
52
+ });
53
+ return { ok: true, url: location.href };
54
+ }
55
+ let activeNonce = '';
56
+ root.CodexOverleafUpdateSafetyReceiver = Object.freeze({ install });
57
+ root.__codexOverleafUpdateSafetyReceiverInstalled = true;
58
+ })(globalThis);
@@ -10,6 +10,13 @@
10
10
  function buildSyncApplyOperations(syncChanges = [], project = {}) {
11
11
  const existingPaths = new Set((project.files || []).map(file => file.path));
12
12
  return (syncChanges || []).map(change => {
13
+ // Keep an old Native move distinct so preflight can reject it safely.
14
+ // Falling through would turn a move into an empty create operation.
15
+ if (change.type === 'move') {
16
+ return { type: 'move', path: change.path, to: change.to,
17
+ moveProof: change.moveProof && typeof change.moveProof === 'object' ? { ...change.moveProof } : undefined,
18
+ reason: 'Move the existing Overleaf file while preserving its identity.' };
19
+ }
13
20
  if (change.type === 'binary-create' || change.type === 'overwrite-binary') {
14
21
  return {
15
22
  type: change.type,
@@ -22,6 +29,7 @@
22
29
  previousExists: change.previousExists === true,
23
30
  previousKind: change.previousKind || '',
24
31
  previousSize: Number.isFinite(Number(change.previousSize)) ? Number(change.previousSize) : undefined,
32
+ ...(typeof change.previousSha256 === 'string' ? { previousSha256: change.previousSha256 } : {}),
25
33
  reasonKey: change.type === 'overwrite-binary' ? 'localWorkspaceBinaryOverwrite' : 'localWorkspaceBinaryCreate',
26
34
  reason: change.type === 'overwrite-binary'
27
35
  ? 'Synced a binary asset replacement from the local Codex workspace.'
@@ -158,14 +166,18 @@
158
166
 
159
167
  if ((operation.type === 'rename' || operation.type === 'move') && operation.to) {
160
168
  const previous = filesByPath.get(operation.path) || originalByPath.get(operation.path);
169
+ const destination = filesByPath.get(operation.to);
170
+ const binary = operation.moveProof?.kind === 'binary' || previous?.kind === 'binary';
161
171
  filesByPath.delete(operation.path);
162
172
  filesByPath.set(operation.to, {
163
173
  ...(previous || {}),
174
+ ...(destination || {}),
164
175
  path: operation.to,
165
- kind: 'text',
166
- content: result.verifiedContent || previous?.content || '',
176
+ kind: binary ? 'binary' : 'text',
177
+ ...(!binary ? { content: result.verifiedContent ?? destination?.content ?? previous?.content ?? '' } : {}),
167
178
  source: 'verified-writeback'
168
179
  });
180
+ if (binary) delete filesByPath.get(operation.to).content;
169
181
  continue;
170
182
  }
171
183
 
@@ -227,10 +239,15 @@
227
239
  ? changeOrReason
228
240
  : { reason: changeOrReason };
229
241
  const reason = change.reason;
242
+ if (reason === 'snapshot_content_unavailable') {
243
+ return isEnglish
244
+ ? 'Current Overleaf content was not read; local changes were withheld until the file can be refreshed.'
245
+ : '未读取该文件当前的 Overleaf 内容;刷新取得内容前,本地改动不会写回。';
246
+ }
230
247
  if (reason === 'selection_scope_violation') {
231
248
  return isEnglish
232
- ? 'The local changes exceeded the captured selection. No changes from this task were written back.'
233
- : '本地改动超出了已附加的选区,本轮改动未写回。';
249
+ ? 'This local change exceeded the captured selection and was withheld. Independently valid selection changes may still be written.'
250
+ : '该项本地改动超出了已附加的选区,已阻止写回;独立且合法的选区内改动仍可写入。';
234
251
  }
235
252
  if (reason === 'generated_artifact') {
236
253
  return isEnglish
@@ -314,6 +331,13 @@
314
331
  if (!path || result.ok === false) { unsupported.push(path || 'unknown'); continue; }
315
332
  if (operation.type === 'delete') { expected.set(path, { absent: true }); continue; }
316
333
  if (operation.type === 'rename' || operation.type === 'move') {
334
+ const moveProof = result.moveProof || operation.moveProof;
335
+ if (moveProof?.v === 1 && moveProof.kind === 'binary' && /^[a-f0-9]{64}$/i.test(moveProof.sha256 || '')) {
336
+ if (!operation.to || (operation.moveProof?.sha256 && operation.moveProof.sha256 !== moveProof.sha256)) { unsupported.push(path); continue; }
337
+ if (operation.to !== path) expected.set(path, { absent: true });
338
+ expected.set(operation.to, { sha256: moveProof.sha256.toLowerCase() });
339
+ continue;
340
+ }
317
341
  const content = typeof result.verifiedContent === 'string'
318
342
  ? result.verifiedContent : original.get(path)?.content;
319
343
  if (!operation.to || typeof content !== 'string') { unsupported.push(path); continue; }
@@ -448,6 +472,30 @@
448
472
  } };
449
473
  }
450
474
 
475
+ async function buildMirrorWritebackConfirmation(applied = {}) {
476
+ const entries = Array.isArray(applied.applied) ? applied.applied : [];
477
+ if (!entries.length) return null;
478
+ const postImages = new Map();
479
+ for (const entry of entries) {
480
+ const operation = entry?.operation, result = entry?.result;
481
+ if (operation?.type !== 'edit' || typeof operation.path !== 'string' || !operation.path || result?.ok !== true
482
+ || result.verified !== true || typeof result.verifiedContent !== 'string') return null;
483
+ // Several edits can target one file; the last verified readback is its post-image.
484
+ postImages.set(operation.path, result.verifiedContent);
485
+ }
486
+ try {
487
+ const expectedFiles = [];
488
+ for (const [path, content] of postImages) {
489
+ const sha256 = await textDigest(content);
490
+ if (!/^[a-f0-9]{64}$/.test(sha256)) return null;
491
+ expectedFiles.push({ path, sha256 });
492
+ }
493
+ return { paths: expectedFiles.map(file => file.path), expectedFiles };
494
+ } catch (_error) {
495
+ return null; // No digest evidence: use the authoritative snapshot fallback.
496
+ }
497
+ }
498
+
451
499
  async function bytesDigest(bytes) {
452
500
  if (!globalThis.crypto?.subtle) return '';
453
501
  const digest = await globalThis.crypto.subtle.digest('SHA-256', bytes);
@@ -543,6 +591,7 @@
543
591
 
544
592
  return {
545
593
  confirmPostWriteSave,
594
+ buildMirrorWritebackConfirmation,
546
595
  buildSaveCheck,
547
596
  confirmSaveCheck,
548
597
  buildSavedWritebackExpectations,