codex-overleaf-link 1.9.6 → 1.9.8

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.
@@ -0,0 +1,331 @@
1
+ (() => {
2
+ 'use strict';
3
+
4
+ const MESSAGE_TYPES = Object.freeze({
5
+ get: 'codex-overleaf/consent-update-get-state',
6
+ check: 'codex-overleaf/consent-update-check',
7
+ install: 'codex-overleaf/consent-update-install',
8
+ later: 'codex-overleaf/consent-update-later',
9
+ retry: 'codex-overleaf/consent-update-check'
10
+ });
11
+ const ACTIVE_PHASES = new Set([
12
+ 'checking',
13
+ 'downloading',
14
+ 'staged',
15
+ 'waiting',
16
+ 'waiting_for_idle',
17
+ 'waiting_for_safe_point',
18
+ 'applying',
19
+ 'awaiting_health',
20
+ 'awaiting_health_check',
21
+ 'health_checking'
22
+ ]);
23
+
24
+ const elements = {
25
+ badge: document.getElementById('status-badge'),
26
+ title: document.getElementById('update-title'),
27
+ route: document.getElementById('version-route'),
28
+ detail: document.getElementById('update-detail'),
29
+ progressTrack: document.getElementById('progress-track'),
30
+ progressFill: document.getElementById('progress-fill'),
31
+ stages: Array.from(document.querySelectorAll('#update-stages > li')),
32
+ note: document.getElementById('persistence-note'),
33
+ details: document.getElementById('technical-details'),
34
+ technicalMessage: document.getElementById('technical-message'),
35
+ actions: document.getElementById('update-actions')
36
+ };
37
+
38
+ let currentView = {};
39
+ let pollTimer = null;
40
+ let refreshTimer = null;
41
+ let actionPending = false;
42
+
43
+ function asObject(value) {
44
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
45
+ }
46
+
47
+ function unwrapResponse(response) {
48
+ const root = asObject(response) || {};
49
+ if (root.ok === false) {
50
+ const error = new Error(root.error?.message || root.message || 'Update action failed.');
51
+ error.code = root.error?.code || root.code || 'update_failed';
52
+ throw error;
53
+ }
54
+ const candidates = [
55
+ root.view,
56
+ root.result?.view,
57
+ root.result,
58
+ root.updateConsent,
59
+ root.updateState,
60
+ root.state
61
+ ];
62
+ return candidates.find(asObject) || root;
63
+ }
64
+
65
+ async function request(action) {
66
+ const type = MESSAGE_TYPES[action];
67
+ if (!type) throw new Error(`Unsupported update action: ${action}`);
68
+ return unwrapResponse(await chrome.runtime.sendMessage({ type }));
69
+ }
70
+
71
+ function phaseOf(view) {
72
+ const value = view.phase || view.status || view.state || view.lifecycle || 'idle';
73
+ return typeof value === 'string' ? value.toLowerCase().replace(/[- ]+/g, '_') : 'idle';
74
+ }
75
+
76
+ function versionOf(view, candidates, fallback = '') {
77
+ for (const key of candidates) {
78
+ const value = view[key];
79
+ if (typeof value === 'string' && value.trim()) return value.trim().replace(/^v/i, '');
80
+ }
81
+ return fallback;
82
+ }
83
+
84
+ function presentationFor(view) {
85
+ const phase = phaseOf(view);
86
+ const presentations = {
87
+ idle: ['Stable updates', 'Automatically checks signed stable releases.', 'Ready', 'neutral'],
88
+ up_to_date: ['Everything is up to date', 'Extension and Native Host are on the latest stable release.', 'Up to date', 'success'],
89
+ checking: ['Checking for updates', 'Reading the latest signed stable release.', 'Checking', 'active'],
90
+ update_available: ['A stable update is ready', 'Review the target version, then update when convenient.', 'Available', 'success'],
91
+ downloading: ['Downloading the signed release', 'The package is being fetched and verified before any files change.', 'Downloading', 'active'],
92
+ staged: ['Update verified', 'The signed package is ready and waiting for a safe installation point.', 'Verified', 'active'],
93
+ waiting: ['Waiting for a safe point', 'Active Overleaf work will finish before the runtime changes.', 'Waiting', 'warning'],
94
+ waiting_for_idle: ['Waiting for a safe point', 'Active Overleaf work will finish before the runtime changes.', 'Waiting', 'warning'],
95
+ waiting_for_safe_point: ['Waiting for a safe point', 'Active Overleaf work will finish before the runtime changes.', 'Waiting', 'warning'],
96
+ applying: ['Installing the update', 'Extension and Native Host are being switched as one managed unit.', 'Installing', 'active'],
97
+ awaiting_health: ['Verifying the installation', 'The updated components are restarting and reconnecting.', 'Verifying', 'active'],
98
+ awaiting_health_check: ['Verifying the installation', 'The updated components are restarting and reconnecting.', 'Verifying', 'active'],
99
+ health_checking: ['Verifying the installation', 'The updated components are restarting and reconnecting.', 'Verifying', 'active'],
100
+ committed: ['Update complete', 'Extension and Native Host passed the post-update health check.', 'Complete', 'success'],
101
+ failed: ['Update could not continue', 'No further update steps will run until the failure is retried.', 'Needs attention', 'error'],
102
+ rolled_back: ['Previous version restored', 'The health check failed, so the managed update was rolled back safely.', 'Rolled back', 'warning'],
103
+ deferred: ['Update paused', 'The available update will remain ready for a later installation.', 'Later', 'neutral']
104
+ };
105
+ const [title, detail, badge, tone] = presentations[phase] || presentations.idle;
106
+ return { phase, title, detail, badge, tone };
107
+ }
108
+
109
+ function progressFor(view, phase) {
110
+ const raw = asObject(view.progress)?.percent ?? view.progressPercent ?? view.progress;
111
+ if (Number.isFinite(Number(raw))) {
112
+ const numeric = Number(raw);
113
+ return Math.max(0, Math.min(100, numeric <= 1 ? numeric * 100 : numeric));
114
+ }
115
+ const fallback = {
116
+ checking: 8,
117
+ update_available: 0,
118
+ downloading: 25,
119
+ staged: 48,
120
+ waiting: 55,
121
+ waiting_for_idle: 55,
122
+ waiting_for_safe_point: 55,
123
+ applying: 75,
124
+ awaiting_health: 90,
125
+ awaiting_health_check: 90,
126
+ health_checking: 90,
127
+ committed: 100,
128
+ rolled_back: 100
129
+ };
130
+ return fallback[phase] ?? 0;
131
+ }
132
+
133
+ function activeStageFor(phase) {
134
+ if (['downloading'].includes(phase)) return 0;
135
+ if (['staged', 'waiting', 'waiting_for_idle', 'waiting_for_safe_point'].includes(phase)) return 1;
136
+ if (['applying', 'awaiting_health', 'awaiting_health_check', 'health_checking'].includes(phase)) return 2;
137
+ if (phase === 'committed') return 3;
138
+ return -1;
139
+ }
140
+
141
+ function errorStageFor(view, phase) {
142
+ if (!['failed', 'rolled_back'].includes(phase)) return -1;
143
+ const stage = String(view.failureStage || view.error?.stage || '').toLowerCase();
144
+ if (/download|verify|signature|manifest/.test(stage)) return 0;
145
+ if (/idle|safe|wait/.test(stage)) return 1;
146
+ return 2;
147
+ }
148
+
149
+ function technicalText(view) {
150
+ const value = view.technicalDetail || view.error?.technicalDetail || view.error?.message || view.error || view.message;
151
+ if (!value) return '';
152
+ if (typeof value === 'string') return value;
153
+ try {
154
+ return JSON.stringify(value, null, 2);
155
+ } catch {
156
+ return String(value);
157
+ }
158
+ }
159
+
160
+ function actionDefinitions(phase) {
161
+ if (phase === 'update_available') {
162
+ return [
163
+ { id: 'later', label: 'Later', kind: 'quiet' },
164
+ { id: 'install', label: 'Update now', kind: 'primary' }
165
+ ];
166
+ }
167
+ if (['failed', 'rolled_back'].includes(phase)) {
168
+ return [
169
+ { id: 'close', label: 'Close', kind: 'quiet' },
170
+ { id: 'retry', label: 'Retry', kind: 'primary' }
171
+ ];
172
+ }
173
+ if (phase === 'committed') {
174
+ return [{ id: 'close', label: 'Done', kind: 'primary' }];
175
+ }
176
+ if (ACTIVE_PHASES.has(phase)) {
177
+ return [{ id: 'close', label: 'Close window', kind: 'quiet' }];
178
+ }
179
+ return [{ id: 'close', label: 'Close', kind: 'quiet' }];
180
+ }
181
+
182
+ function renderActions(phase) {
183
+ elements.actions.replaceChildren(...actionDefinitions(phase).map(action => {
184
+ const button = document.createElement('button');
185
+ button.type = 'button';
186
+ button.dataset.action = action.id;
187
+ button.dataset.kind = action.kind;
188
+ button.textContent = action.label;
189
+ button.disabled = actionPending;
190
+ return button;
191
+ }));
192
+ }
193
+
194
+ function render(view) {
195
+ currentView = asObject(view) || {};
196
+ const presentation = presentationFor(currentView);
197
+ const installed = versionOf(currentView, ['currentVersion', 'installedVersion', 'extensionVersion'], chrome.runtime.getManifest().version);
198
+ const target = versionOf(currentView, ['latestVersion', 'targetVersion', 'availableVersion'], 'latest stable');
199
+ const hasTarget = target && target !== 'latest stable' && target !== installed;
200
+ elements.route.textContent = hasTarget ? `v${installed} → v${target}` : `Installed v${installed}`;
201
+ elements.title.textContent = presentation.title;
202
+ elements.detail.textContent = typeof currentView.detail === 'string' && currentView.detail.trim()
203
+ ? currentView.detail.trim()
204
+ : presentation.detail;
205
+ elements.badge.textContent = presentation.badge;
206
+ elements.badge.dataset.tone = presentation.tone;
207
+
208
+ const progress = progressFor(currentView, presentation.phase);
209
+ const showProgress = ACTIVE_PHASES.has(presentation.phase) || ['committed', 'rolled_back'].includes(presentation.phase);
210
+ elements.progressTrack.hidden = !showProgress;
211
+ elements.progressTrack.dataset.indeterminate = presentation.phase === 'checking' ? 'true' : 'false';
212
+ elements.progressTrack.setAttribute('aria-valuenow', String(Math.round(progress)));
213
+ elements.progressFill.style.width = `${progress}%`;
214
+
215
+ const activeStage = activeStageFor(presentation.phase);
216
+ const errorStage = errorStageFor(currentView, presentation.phase);
217
+ elements.stages.forEach((stage, index) => {
218
+ let state = 'pending';
219
+ if (index < activeStage || activeStage === 3) state = 'done';
220
+ if (index === activeStage) state = 'active';
221
+ if (index === errorStage) state = 'error';
222
+ stage.dataset.state = state;
223
+ const marker = stage.querySelector('.stage-marker');
224
+ marker.textContent = state === 'done' ? '✓' : String(index + 1);
225
+ });
226
+
227
+ const restarting = ['applying', 'awaiting_health', 'awaiting_health_check', 'health_checking'].includes(presentation.phase);
228
+ elements.note.textContent = restarting
229
+ ? 'The extension may restart briefly. This window will reconnect and restore the saved progress.'
230
+ : 'This window can stay open while work continues elsewhere. Progress is saved automatically.';
231
+
232
+ const technical = technicalText(currentView);
233
+ elements.details.hidden = !technical || !['failed', 'rolled_back'].includes(presentation.phase);
234
+ elements.technicalMessage.textContent = technical;
235
+ renderActions(presentation.phase);
236
+ schedulePolling(presentation.phase);
237
+ }
238
+
239
+ function renderReconnecting() {
240
+ elements.title.textContent = 'Reconnecting after restart';
241
+ elements.detail.textContent = 'The saved update state will return when the extension is available again.';
242
+ elements.badge.textContent = 'Reconnecting';
243
+ elements.badge.dataset.tone = 'active';
244
+ elements.progressTrack.hidden = false;
245
+ elements.progressTrack.dataset.indeterminate = 'true';
246
+ elements.actions.replaceChildren();
247
+ }
248
+
249
+ function renderLocalFailure(error) {
250
+ render({
251
+ ...currentView,
252
+ phase: 'failed',
253
+ detail: error?.message || 'Update action failed.',
254
+ technicalDetail: `${error?.code || 'update_failed'}: ${error?.message || 'Update action failed.'}`
255
+ });
256
+ }
257
+
258
+ function isTransientContextError(error) {
259
+ return /context invalidated|receiving end does not exist|message port closed|could not establish connection/i.test(String(error?.message || error));
260
+ }
261
+
262
+ async function refresh() {
263
+ try {
264
+ render(await request('get'));
265
+ } catch (error) {
266
+ if (isTransientContextError(error)) {
267
+ renderReconnecting();
268
+ window.setTimeout(() => window.location.reload(), 900);
269
+ return;
270
+ }
271
+ renderLocalFailure(error);
272
+ }
273
+ }
274
+
275
+ function scheduleRefresh() {
276
+ window.clearTimeout(refreshTimer);
277
+ refreshTimer = window.setTimeout(refresh, 80);
278
+ }
279
+
280
+ function schedulePolling(phase) {
281
+ window.clearTimeout(pollTimer);
282
+ pollTimer = null;
283
+ if (!ACTIVE_PHASES.has(phase)) return;
284
+ pollTimer = window.setTimeout(async () => {
285
+ await refresh();
286
+ }, 1100);
287
+ }
288
+
289
+ async function runAction(action) {
290
+ if (actionPending) return;
291
+ if (action === 'close') {
292
+ window.close();
293
+ return;
294
+ }
295
+ actionPending = true;
296
+ renderActions(phaseOf(currentView));
297
+ try {
298
+ render(await request(action));
299
+ if (action === 'later') window.close();
300
+ } catch (error) {
301
+ if (isTransientContextError(error)) {
302
+ renderReconnecting();
303
+ window.setTimeout(() => window.location.reload(), 900);
304
+ } else {
305
+ renderLocalFailure(error);
306
+ }
307
+ } finally {
308
+ actionPending = false;
309
+ if (document.visibilityState !== 'unloaded') renderActions(phaseOf(currentView));
310
+ }
311
+ }
312
+
313
+ elements.actions.addEventListener('click', event => {
314
+ const button = event.target.closest('button[data-action]');
315
+ if (button) runAction(button.dataset.action);
316
+ });
317
+
318
+ try {
319
+ chrome.storage.onChanged.addListener((_changes, areaName) => {
320
+ if (areaName === 'local') scheduleRefresh();
321
+ });
322
+ } catch {
323
+ renderReconnecting();
324
+ }
325
+
326
+ const requestedAction = new URLSearchParams(window.location.search).get('action');
327
+ window.history.replaceState(null, '', window.location.pathname);
328
+ refresh().then(() => {
329
+ if (requestedAction && ['install', 'retry'].includes(requestedAction)) runAction(requestedAction);
330
+ });
331
+ })();
@@ -1,6 +1,8 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "matches": [
4
+ "https://www.overleaf.com/project",
5
+ "https://overleaf.com/project",
4
6
  "https://www.overleaf.com/project/*",
5
7
  "https://overleaf.com/project/*"
6
8
  ],
@@ -10,6 +10,8 @@
10
10
  const CHECK_REQUEST_TIMEOUT_MS = 15 * 1000;
11
11
  const LEGACY_GUARD = Number.MAX_SAFE_INTEGER;
12
12
  const OVERLEAF_MATCHES = [
13
+ 'https://www.overleaf.com/project',
14
+ 'https://overleaf.com/project',
13
15
  'https://www.overleaf.com/project/*',
14
16
  'https://overleaf.com/project/*'
15
17
  ];
@@ -18,12 +20,14 @@
18
20
  'codex-overleaf/consent-update-check',
19
21
  'codex-overleaf/consent-update-install',
20
22
  'codex-overleaf/consent-update-later',
21
- 'codex-overleaf/consent-update-dismiss'
23
+ 'codex-overleaf/consent-update-dismiss',
24
+ 'codex-overleaf/consent-update-open-center'
22
25
  ]);
23
26
 
24
27
  const policy = root.CodexOverleafUpdateConsent;
25
28
  let nativeBridge = null;
26
29
  let initialized = false;
30
+ let updateCenterWindowId = null;
27
31
  let policyTail = Promise.resolve();
28
32
 
29
33
  function init(options = {}) {
@@ -80,6 +84,8 @@
80
84
  return postponeUpdate();
81
85
  case 'codex-overleaf/consent-update-dismiss':
82
86
  return dismissCompletedUpdate();
87
+ case 'codex-overleaf/consent-update-open-center':
88
+ return openUpdateCenter(message.action);
83
89
  default:
84
90
  throw codedError('unknown_update_action', 'Unknown update action.');
85
91
  }
@@ -412,18 +418,58 @@
412
418
  }
413
419
 
414
420
  function isAllowedSender(sender) {
415
- if (sender?.id && sender.id !== chrome.runtime.id) return false;
416
- if (!sender?.tab) return true;
421
+ if (sender?.id !== chrome.runtime.id) return false;
417
422
  try {
418
- const url = new URL(sender.tab.url || '');
423
+ const url = new URL(sender?.url || sender?.tab?.url || '');
424
+ const extensionRoot = new URL(chrome.runtime.getURL(''));
425
+ if (url.origin === extensionRoot.origin) {
426
+ return url.pathname === '/bootstrap/popup.html' ||
427
+ url.pathname === '/bootstrap/update.html';
428
+ }
419
429
  return url.protocol === 'https:' &&
420
430
  (url.hostname === 'www.overleaf.com' || url.hostname === 'overleaf.com') &&
421
- url.pathname.startsWith('/project/');
431
+ (url.pathname === '/project' || url.pathname.startsWith('/project/'));
422
432
  } catch (_error) {
423
433
  return false;
424
434
  }
425
435
  }
426
436
 
437
+ async function openUpdateCenter(requestedAction = '') {
438
+ const action = String(requestedAction || '');
439
+ if (action && action !== 'install' && action !== 'retry') {
440
+ throw codedError('invalid_update_center_action', 'Unsupported Update Center action.');
441
+ }
442
+ const url = new URL(chrome.runtime.getURL('bootstrap/update.html'));
443
+ if (action) url.searchParams.set('action', action);
444
+
445
+ if (Number.isInteger(updateCenterWindowId)) {
446
+ try {
447
+ const existing = await chrome.windows.get(updateCenterWindowId, { populate: true });
448
+ await chrome.windows.update(updateCenterWindowId, { focused: true });
449
+ const tabId = existing?.tabs?.[0]?.id;
450
+ if (action && Number.isInteger(tabId)) {
451
+ await chrome.tabs.update(tabId, { url: url.href });
452
+ }
453
+ return { opened: true, focused: true, windowId: updateCenterWindowId };
454
+ } catch (_error) {
455
+ updateCenterWindowId = null;
456
+ }
457
+ }
458
+
459
+ const created = await chrome.windows.create({
460
+ url: url.href,
461
+ type: 'popup',
462
+ width: 420,
463
+ height: 560,
464
+ focused: true
465
+ });
466
+ if (!Number.isInteger(created?.id)) {
467
+ throw codedError('update_center_open_failed', 'Could not open Update Center.');
468
+ }
469
+ updateCenterWindowId = created.id;
470
+ return { opened: true, focused: true, windowId: created.id };
471
+ }
472
+
427
473
  function currentVersion() {
428
474
  return String(
429
475
  root.CodexOverleafCompatibility?.BUILD_TARGET_VERSION ||
@@ -146,6 +146,18 @@
146
146
  <p class="codex-set-row-help" data-i18n="languageHelp">Panel display language.</p>
147
147
  </div>
148
148
  </details>
149
+ <details class="codex-set-group" data-set-group="updates" open>
150
+ <summary class="codex-set-group-head">
151
+ <span class="codex-set-group-title">${codexSetIcon('appearance')}<span data-i18n="softwareUpdatesTitle">Software updates</span></span>
152
+ </summary>
153
+ <div class="codex-set-card" data-update-settings>
154
+ <div class="codex-set-row">
155
+ <p class="codex-set-row-help" data-update-settings-summary data-i18n="softwareUpdatesHelp">Automatically checks signed stable releases for the Extension and Native Host.</p>
156
+ <p class="codex-set-note" data-update-settings-state aria-live="polite" hidden></p>
157
+ <button type="button" class="codex-set-btn" data-check-updates data-i18n="checkForUpdates">Check for updates</button>
158
+ </div>
159
+ </div>
160
+ </details>
149
161
  <details class="codex-set-group" data-set-group="storage" data-storage-card>
150
162
  <summary class="codex-set-group-head">
151
163
  <span class="codex-set-group-title">${codexSetIcon('database')}<span data-i18n="storageTitle">History &amp; storage</span></span>
@@ -8,6 +8,8 @@
8
8
  let completionTimer = null;
9
9
  let listenerInstalled = false;
10
10
  let getLocale = () => '';
11
+ let settingsRoot = null;
12
+ let settingsActionInFlight = false;
11
13
 
12
14
  function mount(panelLike, options = {}) {
13
15
  if (typeof options.getLocale === 'function') {
@@ -16,10 +18,12 @@
16
18
  const panel = panelLike?.panelEl || panelLike;
17
19
  if (!panel) return;
18
20
  if (mountedPanel === panel) {
21
+ bindSettingsControls(panel);
19
22
  render();
20
23
  return;
21
24
  }
22
25
  mountedPanel = panel;
26
+ bindSettingsControls(panel);
23
27
  notice?.remove();
24
28
  notice = document.createElement('section');
25
29
  notice.className = 'codex-update-notice';
@@ -59,11 +63,13 @@
59
63
  actionInFlight = true;
60
64
  render();
61
65
  try {
62
- currentView = await request({
63
- install: 'codex-overleaf/consent-update-install',
64
- later: 'codex-overleaf/consent-update-later',
65
- retry: 'codex-overleaf/consent-update-check'
66
- }[action]);
66
+ if (action === 'install' || action === 'retry') {
67
+ await openUpdateCenter(action);
68
+ } else {
69
+ currentView = await request({
70
+ later: 'codex-overleaf/consent-update-later'
71
+ }[action]);
72
+ }
67
73
  } catch (error) {
68
74
  currentView = {
69
75
  ...(currentView || {}),
@@ -93,8 +99,66 @@
93
99
  return response.result;
94
100
  }
95
101
 
102
+ async function openUpdateCenter(action = '') {
103
+ const response = await chrome.runtime.sendMessage({
104
+ type: 'codex-overleaf/consent-update-open-center',
105
+ action
106
+ });
107
+ if (!response?.ok) {
108
+ const error = new Error(response?.error?.message || tx('Could not open Update Center.', '无法打开更新中心。'));
109
+ error.code = response?.error?.code || 'update_center_open_failed';
110
+ throw error;
111
+ }
112
+ return response.result;
113
+ }
114
+
115
+ function bindSettingsControls(panel) {
116
+ settingsRoot = panel;
117
+ const button = panel.querySelector?.('[data-check-updates]');
118
+ if (button && button.dataset.updateSettingsBound !== 'true') {
119
+ button.dataset.updateSettingsBound = 'true';
120
+ button.addEventListener('click', handleSettingsAction);
121
+ }
122
+ renderSettings();
123
+ }
124
+
125
+ async function handleSettingsAction() {
126
+ if (settingsActionInFlight) return;
127
+ settingsActionInFlight = true;
128
+ renderSettings();
129
+ const stateName = currentView?.state?.state || 'idle';
130
+ try {
131
+ if (stateName === 'update_available') {
132
+ await openUpdateCenter('install');
133
+ } else if (['failed', 'rolled_back'].includes(stateName)) {
134
+ await openUpdateCenter('retry');
135
+ } else if (['downloading', 'staged', 'waiting_for_idle', 'applying', 'awaiting_health'].includes(stateName)) {
136
+ await openUpdateCenter('');
137
+ } else {
138
+ currentView = await request('codex-overleaf/consent-update-check');
139
+ }
140
+ } catch (error) {
141
+ currentView = {
142
+ ...(currentView || {}),
143
+ state: {
144
+ ...(currentView?.state || {}),
145
+ state: 'failed',
146
+ code: error?.code || 'update_failed',
147
+ message: error?.message || tx('Update check failed.', '更新检查失败。')
148
+ },
149
+ progress: { value: 0, determinate: true, phase: 'failed' },
150
+ showPanel: true
151
+ };
152
+ } finally {
153
+ settingsActionInFlight = false;
154
+ render();
155
+ }
156
+ }
157
+
96
158
  function render() {
97
- if (!notice || !currentView) return;
159
+ if (!currentView) return;
160
+ renderSettings();
161
+ if (!notice) return;
98
162
  clearTimeout(completionTimer);
99
163
  if (!currentView.showPanel) {
100
164
  notice.hidden = true;
@@ -157,6 +221,53 @@
157
221
  }
158
222
  }
159
223
 
224
+ function renderSettings() {
225
+ if (!settingsRoot) return;
226
+ const summary = settingsRoot.querySelector?.('[data-update-settings-summary]');
227
+ const status = settingsRoot.querySelector?.('[data-update-settings-state]');
228
+ const button = settingsRoot.querySelector?.('[data-check-updates]');
229
+ if (!summary || !status || !button) return;
230
+
231
+ const state = currentView?.state || {};
232
+ const stateName = state.state || 'idle';
233
+ const manifestVersion = typeof chrome.runtime.getManifest === 'function'
234
+ ? chrome.runtime.getManifest().version
235
+ : '';
236
+ const currentVersion = state.currentVersion || manifestVersion || '';
237
+ const latestVersion = state.latestVersion || '';
238
+ const activeStates = ['downloading', 'staged', 'waiting_for_idle', 'applying', 'awaiting_health'];
239
+ let summaryText = tx(
240
+ `Current v${currentVersion}. Signed stable releases are checked automatically.`,
241
+ `当前版本 v${currentVersion}。系统会自动检查签名稳定版本。`
242
+ );
243
+ let statusText = '';
244
+ let buttonText = tx('Check for updates', '检查更新');
245
+
246
+ if (stateName === 'checking') {
247
+ statusText = tx('Checking the latest stable release…', '正在检查最新稳定版本…');
248
+ buttonText = tx('Checking…', '检查中…');
249
+ } else if (stateName === 'update_available') {
250
+ summaryText = tx(`Stable v${latestVersion} is available.`, `稳定版本 v${latestVersion} 已可用。`);
251
+ statusText = tx('Ready to download and verify.', '已可下载并验证。');
252
+ buttonText = tx('Update now', '立即更新');
253
+ } else if (activeStates.includes(stateName)) {
254
+ statusText = getCopy(state).detail;
255
+ buttonText = tx('View update', '查看更新');
256
+ } else if (stateName === 'failed' || stateName === 'rolled_back') {
257
+ statusText = state.message || getCopy(state).detail;
258
+ buttonText = tx('Retry', '重试');
259
+ } else if (stateName === 'committed') {
260
+ summaryText = tx(`Current v${currentVersion}. Update completed successfully.`, `当前版本 v${currentVersion}。更新已成功完成。`);
261
+ statusText = tx('Both components passed the health check.', '两个组件均已通过健康检查。');
262
+ }
263
+
264
+ summary.textContent = summaryText;
265
+ status.textContent = statusText;
266
+ status.hidden = !statusText;
267
+ button.textContent = buttonText;
268
+ button.disabled = settingsActionInFlight || stateName === 'checking';
269
+ }
270
+
160
271
  function visibleActions(state) {
161
272
  if (actionInFlight) return [];
162
273
  if (state === 'update_available') {