codex-overleaf-link 2.3.4 → 2.3.6

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 (41) hide show
  1. package/README.md +325 -211
  2. package/extension/bootstrap/manifest.template.json +1 -1
  3. package/extension/src/background.js +9 -0
  4. package/extension/src/backgroundUpdateCoordinator.js +88 -4
  5. package/extension/src/content/composerPanel.js +1 -1
  6. package/extension/src/content/contentRuntime.js +51 -35
  7. package/extension/src/content/diagnosticsController.js +13 -3
  8. package/extension/src/content/diagnosticsPanel.js +1 -1
  9. package/extension/src/content/generated/content.bundle.js +1233 -298
  10. package/extension/src/content/generated/content.bundle.meta.json +8 -3
  11. package/extension/src/content/globalPreferencesController.js +125 -0
  12. package/extension/src/content/legacyGlobalRegistry.js +4 -0
  13. package/extension/src/content/otWarmMirror.js +95 -65
  14. package/extension/src/content/otWarmMirrorController.js +102 -1
  15. package/extension/src/content/panelMaintenance.js +26 -15
  16. package/extension/src/content/panelRenderer.js +6 -1
  17. package/extension/src/content/projectSettingsCoordinator.js +1 -0
  18. package/extension/src/content/providerSettingsCoordinator.js +16 -0
  19. package/extension/src/content/providerSettingsDialog.js +24 -15
  20. package/extension/src/content/runFailureNotice.js +104 -0
  21. package/extension/src/content/runScrollLayout.js +124 -0
  22. package/extension/src/content/runTimelineView.js +36 -10
  23. package/extension/src/content/settingsPanel.js +32 -13
  24. package/extension/src/content/themeController.js +3 -0
  25. package/extension/src/content/updateNotice.js +24 -4
  26. package/extension/src/page/overleafRealtimeObserver.js +116 -24
  27. package/extension/src/shared/compatibility.js +1 -1
  28. package/extension/src/shared/globalPreferences.js +131 -0
  29. package/extension/src/shared/i18n.js +79 -39
  30. package/extension/src/shared/managedUpdateProjection.js +1 -1
  31. package/extension/src/shared/otText.js +0 -1
  32. package/extension/src/shared/sessionState.js +17 -21
  33. package/extension/src/shared/sessionTitle.js +120 -0
  34. package/extension/src/shared/updateRuntimeIdentity.js +32 -0
  35. package/extension/styles/panel.css +90 -16
  36. package/native-host/src/mirrorWorkspace.js +20 -1
  37. package/native-host/src/releaseProxy.js +177 -0
  38. package/native-host/src/releaseTransport.js +154 -0
  39. package/native-host/src/updateManager.js +40 -39
  40. package/package.json +1 -1
  41. package/scripts/install-managed.mjs +1 -1
@@ -0,0 +1,154 @@
1
+ 'use strict';
2
+
3
+ const { updateError } = require('./updateTrust');
4
+ const { fetchRelease } = require('./releaseProxy');
5
+
6
+ const RETRYABLE_HTTP = new Set([408, 429, 500, 502, 503, 504]);
7
+ const CERTIFICATE_ERRORS = /CERT|TLS_CERT|SELF_SIGNED|UNABLE_TO_VERIFY|DEPTH_ZERO/;
8
+
9
+ function abortable(promise, signal) {
10
+ if (signal.aborted) {
11
+ void Promise.resolve(promise).catch(() => {});
12
+ return Promise.reject(signal.reason);
13
+ }
14
+ return new Promise((resolve, reject) => {
15
+ const abort = () => reject(signal.reason);
16
+ signal.addEventListener('abort', abort, { once: true });
17
+ Promise.resolve(promise).then(resolve, reject).finally(() => signal.removeEventListener('abort', abort));
18
+ });
19
+ }
20
+
21
+ function terminalError(code, message) {
22
+ return Object.assign(updateError(code, message), { retryable: false });
23
+ }
24
+
25
+ async function readBoundedBody(response, limit, signal) {
26
+ const declared = Number(response.headers?.get?.('content-length') || 0);
27
+ if (declared > limit) throw terminalError('update_download_limit', 'Update response exceeds its size limit.');
28
+ const reader = response.body?.getReader?.();
29
+ if (!reader) {
30
+ const bytes = Buffer.from(await abortable(response.arrayBuffer(), signal));
31
+ if (!bytes.length || bytes.length > limit) throw terminalError('update_download_limit', 'Update response exceeds its size limit.');
32
+ return bytes;
33
+ }
34
+ const chunks = [];
35
+ let length = 0;
36
+ try {
37
+ while (true) {
38
+ const chunk = await abortable(reader.read(), signal);
39
+ if (chunk.done) break;
40
+ const bytes = Buffer.from(chunk.value);
41
+ length += bytes.length;
42
+ if (length > limit) throw terminalError('update_download_limit', 'Update response exceeds its size limit.');
43
+ chunks.push(bytes);
44
+ }
45
+ if (!length) throw terminalError('update_download_limit', 'Update response is empty.');
46
+ return Buffer.concat(chunks, length);
47
+ } catch (error) {
48
+ void reader.cancel().catch(() => {});
49
+ throw error;
50
+ } finally {
51
+ reader.releaseLock();
52
+ }
53
+ }
54
+
55
+ function networkFailure(error, signal, callerSignal) {
56
+ let cause = error;
57
+ for (let depth = 0; depth < 8 && cause?.cause && cause.cause !== cause; depth += 1) cause = cause.cause;
58
+ const reason = String(typeof cause?.code === 'string' ? cause.code : cause?.name || error?.name || 'network_error');
59
+ if (callerSignal?.aborted) return { code: 'update_network_cancelled', reason: 'cancelled', retryable: false };
60
+ if (error?.retryable === false) return { code: error.code || 'update_network_failed', reason, retryable: false };
61
+ if (CERTIFICATE_ERRORS.test(reason)) return { code: 'update_network_certificate', reason, retryable: false };
62
+ if (signal.aborted || /TIMEOUT|Timeout|AbortError/.test(reason)) {
63
+ return { code: 'update_network_timeout', reason, retryable: true };
64
+ }
65
+ if (/ENOTFOUND|EAI_AGAIN/.test(reason)) return { code: 'update_network_dns', reason, retryable: true };
66
+ return { code: /^update_[a-z0-9_]+$/.test(error?.code || '') ? error.code : 'update_network_failed',
67
+ reason, retryable: error?.retryable !== false };
68
+ }
69
+
70
+ function retryDelay(response, fallback) {
71
+ const value = response?.headers?.get?.('retry-after');
72
+ if (!value) return fallback;
73
+ const seconds = Number(value);
74
+ const delay = Number.isFinite(seconds) ? seconds * 1000 : Date.parse(value) - Date.now();
75
+ return Number.isFinite(delay) ? Math.max(fallback, delay, 0) : fallback;
76
+ }
77
+
78
+ function delay(milliseconds, signal) {
79
+ if (!milliseconds) return Promise.resolve();
80
+ return new Promise((resolve, reject) => {
81
+ const abort = () => { clearTimeout(timer); reject(signal.reason); };
82
+ const timer = setTimeout(() => { signal?.removeEventListener('abort', abort); resolve(); }, milliseconds);
83
+ if (signal?.aborted) abort();
84
+ else signal?.addEventListener('abort', abort, { once: true });
85
+ });
86
+ }
87
+
88
+ async function requestRelease(url, options = {}) {
89
+ const started = Date.now();
90
+ const deadline = options.deadlineAt || started + (options.totalTimeoutMs ?? 45000);
91
+ const maxAttempts = Math.max(1, Math.min(5, options.maxAttempts ?? 3));
92
+ const host = new URL(url).hostname;
93
+ let failure;
94
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
95
+ const remaining = deadline - Date.now();
96
+ if (remaining <= 0) break;
97
+ const controller = new AbortController();
98
+ const timer = setTimeout(() => controller.abort(new DOMException('Update request timed out.', 'TimeoutError')),
99
+ Math.min(remaining, options.timeoutMs ?? 12000));
100
+ const cancel = () => controller.abort(options.signal.reason);
101
+ if (options.signal?.aborted) cancel();
102
+ else options.signal?.addEventListener('abort', cancel, { once: true });
103
+ let response;
104
+ let retryAfter = 0;
105
+ try {
106
+ const fetchImpl = options.fetch || ((value, init) => fetchRelease(value, init, options));
107
+ response = await abortable(fetchImpl(url, {
108
+ method: options.method || 'GET', headers: options.headers,
109
+ redirect: 'follow', signal: controller.signal
110
+ }), controller.signal);
111
+ const finalUrl = new URL(response.url || url);
112
+ if (options.allowedHosts && (finalUrl.protocol !== 'https:' || !options.allowedHosts.has(finalUrl.hostname))) {
113
+ throw terminalError('update_asset_redirect_forbidden', 'Release request redirected to an untrusted host.');
114
+ }
115
+ if (!response.ok && response.status !== 304) {
116
+ throw Object.assign(updateError(options.httpErrorCode || 'update_asset_http_error',
117
+ 'Update request failed with HTTP ' + response.status + '.'), { retryable: RETRYABLE_HTTP.has(response.status) });
118
+ }
119
+ const bytes = options.method === 'HEAD' || response.status === 304
120
+ ? null : await readBoundedBody(response, options.limit ?? 32 * 1024 * 1024, controller.signal);
121
+ return { ok: response.ok, status: response.status, url: finalUrl.href, headers: response.headers,
122
+ bytes, attempts: attempt, elapsedMs: Date.now() - started };
123
+ } catch (error) {
124
+ const classified = networkFailure(error, controller.signal, options.signal);
125
+ const diagnostics = { stage: options.stage || 'download', host, attempt,
126
+ elapsedMs: Date.now() - started, reason: classified.reason, retryable: classified.retryable,
127
+ ...(response ? { httpStatus: response.status } : {}) };
128
+ const description = classified.code === 'update_network_timeout' ? 'Connection or download timed out'
129
+ : classified.code === 'update_network_dns' ? 'DNS lookup failed'
130
+ : classified.code === 'update_network_certificate' ? 'TLS certificate verification failed'
131
+ : classified.code === 'update_network_cancelled' ? 'Update request was cancelled'
132
+ : error?.retryable === false ? error.message : 'Network request failed';
133
+ const reason = response ? 'HTTP ' + response.status
134
+ : /^[A-Z][A-Z0-9_]{1,79}$/.test(classified.reason) ? classified.reason : '';
135
+ failure = updateError(classified.code,
136
+ description.replace(/\.$/, '') + ' (' + diagnostics.stage + ', ' + host + ', attempt ' + attempt +
137
+ (reason ? ', ' + reason : '') + ', ' + (diagnostics.elapsedMs / 1000).toFixed(1) + 's).',
138
+ { cause: error, network: diagnostics });
139
+ try { options.onDiagnostic?.(diagnostics); } catch (_error) { /* diagnostics are best effort */ }
140
+ if (!classified.retryable || attempt === maxAttempts) throw failure;
141
+ retryAfter = retryDelay(response, (options.delayMs ?? 500) * attempt);
142
+ } finally {
143
+ clearTimeout(timer);
144
+ options.signal?.removeEventListener('abort', cancel);
145
+ controller.abort();
146
+ if (response?.body && !response.body.locked) void response.body.cancel().catch(() => {});
147
+ }
148
+ if (Date.now() + retryAfter >= deadline) break;
149
+ await delay(retryAfter, options.signal);
150
+ }
151
+ throw failure || updateError('update_network_timeout', 'The update network time budget was exhausted.');
152
+ }
153
+
154
+ module.exports = { requestRelease };
@@ -11,6 +11,8 @@ const {
11
11
  verifySignedReleaseManifest
12
12
  } = require('./updateTrust');
13
13
  const { extractVerifiedUpdateBundle } = require('./updateArchive');
14
+ const { requestRelease } = require('./releaseTransport');
15
+ const RUNNING_VERSION = require('../../package.json').version;
14
16
 
15
17
  const GITHUB_LATEST_URL = 'https://github.com/Ghqqqq/codex-overleaf-link/releases/latest';
16
18
  const GITHUB_RELEASE_DOWNLOAD_ROOT = 'https://github.com/Ghqqqq/codex-overleaf-link/releases/download';
@@ -175,11 +177,11 @@ function delay(milliseconds) {
175
177
  }
176
178
 
177
179
  async function checkForUpdate(context, params = {}, options = {}) {
178
- const fetchImpl = options.fetch || globalThis.fetch;
179
- if (typeof fetchImpl !== 'function') {
180
- throw updateError('update_network_unavailable', 'This Node runtime cannot check GitHub Releases.');
181
- }
180
+ const fetchImpl = options.fetch;
181
+ const network = { ...options.network, deadlineAt: Date.now() + 40000 };
182
182
  const currentVersion = normalizeCurrentVersion(params.currentVersion, context);
183
+ const authorized = readAuthorizedCandidate(context, currentVersion);
184
+ if (authorized) return authorized;
183
185
  const headers = {
184
186
  Accept: 'text/html,application/xhtml+xml',
185
187
  'User-Agent': 'codex-overleaf-link-updater'
@@ -187,11 +189,13 @@ async function checkForUpdate(context, params = {}, options = {}) {
187
189
  if (params.etag && typeof params.etag === 'string' && params.etag.length < 300) {
188
190
  headers['If-None-Match'] = params.etag;
189
191
  }
190
- const releaseResponse = await fetchWithTimeout(fetchImpl, GITHUB_LATEST_URL, {
192
+ const releaseResponse = await requestRelease(GITHUB_LATEST_URL, {
193
+ ...network, fetch: fetchImpl, stage: 'version_check',
191
194
  headers,
192
195
  method: 'HEAD',
193
- redirect: 'follow'
194
- }, 12000);
196
+ allowedHosts: new Set(['github.com']),
197
+ httpErrorCode: 'update_github_http_error'
198
+ });
195
199
  if (releaseResponse.status === 304) {
196
200
  const candidate = readJsonSafe(path.join(context.updatesRoot, CANDIDATE_FILE), null);
197
201
  if (candidate?.manifestBase64 && candidate?.signatureBase64) {
@@ -247,12 +251,12 @@ async function checkForUpdate(context, params = {}, options = {}) {
247
251
  const manifestBytes = await fetchReleaseAsset(
248
252
  fetchImpl,
249
253
  buildReleaseAssetUrl(releaseTag, 'release-manifest.json'),
250
- 256 * 1024
254
+ 256 * 1024, { ...network, stage: 'release_manifest' }
251
255
  );
252
256
  const signatureBytes = await fetchReleaseAsset(
253
257
  fetchImpl,
254
258
  buildReleaseAssetUrl(releaseTag, 'release-manifest.sig'),
255
- 16 * 1024
259
+ 16 * 1024, { ...network, stage: 'release_signature' }
256
260
  );
257
261
  const manifest = verifySignedReleaseManifest(manifestBytes, signatureBytes);
258
262
  if (manifest.version !== latestVersion || manifest.tag !== releaseTag) {
@@ -271,6 +275,24 @@ async function checkForUpdate(context, params = {}, options = {}) {
271
275
  return { managed: true, available: true, currentVersion, latestVersion, etag: candidate.etag };
272
276
  }
273
277
 
278
+ function readAuthorizedCandidate(context, currentVersion) {
279
+ const authorization = readAuthorization(context);
280
+ if (!authorization || !['authorized', 'bound'].includes(authorization.state)) return null;
281
+ const candidate = readJsonSafe(path.join(context.updatesRoot, CANDIDATE_FILE), null);
282
+ if (!candidate) throw updateError('update_candidate_missing', 'The authorized update candidate is missing.');
283
+ const manifest = verifySignedReleaseManifest(
284
+ Buffer.from(candidate.manifestBase64 || '', 'base64'),
285
+ Buffer.from(candidate.signatureBase64 || '', 'base64')
286
+ );
287
+ if (candidate.latestVersion !== manifest.version || authorization.targetVersion !== manifest.version ||
288
+ authorization.sourceVersion !== currentVersion || !isNewerStableVersion(manifest.version, currentVersion)) {
289
+ throw updateError('update_consent_mismatch', 'The signed candidate no longer matches the authorized update.');
290
+ }
291
+ requireAuthorization(context, manifest.version, ['authorized', 'bound']);
292
+ return { managed: true, available: true, currentVersion, latestVersion: manifest.version,
293
+ etag: candidate.etag || '', cached: true, authorized: true };
294
+ }
295
+
274
296
  function parseStableReleaseTag(value) {
275
297
  try {
276
298
  const url = new URL(String(value || ''));
@@ -388,8 +410,8 @@ async function stageCandidate(context, options = {}) {
388
410
  fs.mkdirSync(stageRoot, { recursive: true, mode: 0o700 });
389
411
  try {
390
412
  const archivePath = path.join(stageRoot, manifest.updateBundle.name);
391
- const fetchImpl = options.fetch || globalThis.fetch;
392
- const bundleBytes = await fetchReleaseAsset(fetchImpl, candidate.bundleUrl, manifest.updateBundle.size + 1);
413
+ const bundleBytes = await fetchReleaseAsset(options.fetch, candidate.bundleUrl, manifest.updateBundle.size + 1,
414
+ { ...options.network, totalTimeoutMs: 90000, timeoutMs: 30000, stage: 'update_bundle' });
393
415
  if (bundleBytes.length !== manifest.updateBundle.size) {
394
416
  throw updateError('update_bundle_size_mismatch', 'Downloaded update bundle size does not match the signed manifest.');
395
417
  }
@@ -764,6 +786,8 @@ function recoverAndReadStatus(context) {
764
786
  return {
765
787
  managed: true,
766
788
  activeVersion: readVersionPointer(context.nativeRoot, 'active-version'),
789
+ runtimeVersion: context.env?.CODEX_OVERLEAF_ACTIVE_VERSION || RUNNING_VERSION,
790
+ installedAligned: Boolean(alignedVersion),
767
791
  previousVersion: readVersionPointer(context.nativeRoot, 'previous-version'),
768
792
  transaction: journal ? publicTransaction(journal) : null,
769
793
  authorization: publicAuthorization(readAuthorization(context))
@@ -941,38 +965,15 @@ function rewriteManagedManifestVersion(manifestPath, version) {
941
965
  atomicWriteText(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
942
966
  }
943
967
 
944
- async function fetchReleaseAsset(fetchImpl, url, limit) {
968
+ async function fetchReleaseAsset(fetchImpl, url, limit, network = {}) {
945
969
  const parsed = new URL(String(url || ''));
946
970
  if (parsed.protocol !== 'https:' || parsed.hostname !== 'github.com' || !parsed.pathname.startsWith('/Ghqqqq/codex-overleaf-link/releases/download/')) {
947
971
  throw updateError('update_asset_url_forbidden', 'Release asset URL is outside the trusted repository.');
948
972
  }
949
- const response = await fetchWithTimeout(fetchImpl, parsed.href, { redirect: 'follow' }, 20000);
950
- if (!response.ok) throw updateError('update_asset_http_error', 'Release asset download failed with HTTP ' + response.status + '.');
951
- const finalUrl = new URL(response.url || parsed.href);
952
- if (finalUrl.protocol !== 'https:' || !RELEASE_ASSET_HOSTS.has(finalUrl.hostname)) {
953
- throw updateError('update_asset_redirect_forbidden', 'Release asset redirected to an untrusted host.');
954
- }
955
- return readResponseBytes(response, limit);
956
- }
957
-
958
- async function fetchWithTimeout(fetchImpl, url, options, timeoutMs) {
959
- const controller = new AbortController();
960
- const timer = setTimeout(() => controller.abort(), timeoutMs);
961
- try {
962
- return await fetchImpl(url, { ...options, signal: controller.signal });
963
- } catch (error) {
964
- throw updateError('update_network_failed', 'Update network request failed.', { cause: error });
965
- } finally {
966
- clearTimeout(timer);
967
- }
968
- }
969
-
970
- async function readResponseBytes(response, limit) {
971
- const declared = Number(response.headers?.get?.('content-length') || 0);
972
- if (declared > limit) throw updateError('update_download_limit', 'Update response exceeds its size limit.');
973
- const buffer = Buffer.from(await response.arrayBuffer());
974
- if (!buffer.length || buffer.length > limit) throw updateError('update_download_limit', 'Update response exceeds its size limit.');
975
- return buffer;
973
+ const response = await requestRelease(parsed.href, {
974
+ timeoutMs: 20000, ...network, fetch: fetchImpl, limit, allowedHosts: RELEASE_ASSET_HOSTS
975
+ });
976
+ return response.bytes;
976
977
  }
977
978
 
978
979
  function findReleaseAsset(assets, name) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-overleaf-link",
3
- "version": "2.3.4",
3
+ "version": "2.3.6",
4
4
  "description": "Cross-platform Chrome bridge that connects Codex to the active Overleaf project.",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",
@@ -21,7 +21,7 @@ export function formatInstallManagedHuman(result) {
21
21
  '1. Open chrome://extensions and enable Developer mode.',
22
22
  '2. Remove the previous Codex Overleaf Link entry if Chrome shows a duplicate.',
23
23
  '3. Choose Load unpacked and select: ' + result.loadUnpackedPath,
24
- 'Future stable extension and native-host updates will install automatically when Overleaf is saved and idle.',
24
+ 'Future signed stable updates are checked automatically. Choose Update now to authorize a version; installation waits until Overleaf is saved and idle.',
25
25
  ''
26
26
  ].join('\n');
27
27
  }