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.
- package/README.md +325 -211
- package/extension/bootstrap/manifest.template.json +1 -1
- package/extension/src/background.js +9 -0
- package/extension/src/backgroundUpdateCoordinator.js +88 -4
- package/extension/src/content/composerPanel.js +1 -1
- package/extension/src/content/contentRuntime.js +51 -35
- package/extension/src/content/diagnosticsController.js +13 -3
- package/extension/src/content/diagnosticsPanel.js +1 -1
- package/extension/src/content/generated/content.bundle.js +1233 -298
- package/extension/src/content/generated/content.bundle.meta.json +8 -3
- package/extension/src/content/globalPreferencesController.js +125 -0
- package/extension/src/content/legacyGlobalRegistry.js +4 -0
- package/extension/src/content/otWarmMirror.js +95 -65
- package/extension/src/content/otWarmMirrorController.js +102 -1
- package/extension/src/content/panelMaintenance.js +26 -15
- package/extension/src/content/panelRenderer.js +6 -1
- package/extension/src/content/projectSettingsCoordinator.js +1 -0
- package/extension/src/content/providerSettingsCoordinator.js +16 -0
- package/extension/src/content/providerSettingsDialog.js +24 -15
- package/extension/src/content/runFailureNotice.js +104 -0
- package/extension/src/content/runScrollLayout.js +124 -0
- package/extension/src/content/runTimelineView.js +36 -10
- package/extension/src/content/settingsPanel.js +32 -13
- package/extension/src/content/themeController.js +3 -0
- package/extension/src/content/updateNotice.js +24 -4
- package/extension/src/page/overleafRealtimeObserver.js +116 -24
- package/extension/src/shared/compatibility.js +1 -1
- package/extension/src/shared/globalPreferences.js +131 -0
- package/extension/src/shared/i18n.js +79 -39
- package/extension/src/shared/managedUpdateProjection.js +1 -1
- package/extension/src/shared/otText.js +0 -1
- package/extension/src/shared/sessionState.js +17 -21
- package/extension/src/shared/sessionTitle.js +120 -0
- package/extension/src/shared/updateRuntimeIdentity.js +32 -0
- package/extension/styles/panel.css +90 -16
- package/native-host/src/mirrorWorkspace.js +20 -1
- package/native-host/src/releaseProxy.js +177 -0
- package/native-host/src/releaseTransport.js +154 -0
- package/native-host/src/updateManager.js +40 -39
- package/package.json +1 -1
- 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
|
|
179
|
-
|
|
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
|
|
192
|
+
const releaseResponse = await requestRelease(GITHUB_LATEST_URL, {
|
|
193
|
+
...network, fetch: fetchImpl, stage: 'version_check',
|
|
191
194
|
headers,
|
|
192
195
|
method: 'HEAD',
|
|
193
|
-
|
|
194
|
-
|
|
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
|
|
392
|
-
|
|
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
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
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
|
@@ -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
|
|
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
|
}
|