@hellolynk-ai/codex-plugin 0.0.0-stage → 0.2.99
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/.agents/plugins/marketplace.json +20 -0
- package/LICENSE +92 -0
- package/README.md +86 -2
- package/bin/hellolynk-client-setup.mjs +2748 -0
- package/bin/hellolynk-mcp-config-shape.mjs +23 -0
- package/hellolynk-client-pack-manifest.json +619 -0
- package/hellolynk-client-pack-pin.json +4 -0
- package/package.json +58 -4
- package/plugins/hellolynk-codex/.app.json +3 -0
- package/plugins/hellolynk-codex/.codex-plugin/plugin.json +38 -0
- package/plugins/hellolynk-codex/.mcp.json +19 -0
- package/plugins/hellolynk-codex/assets/hellolynk-mark.png +0 -0
- package/plugins/hellolynk-codex/package.json +11 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-client-account.mjs +180 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-client-core.mjs +770 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-client-profile.mjs +191 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-host-auth.mjs +347 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-host-protected-store.mjs +2721 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-local-process-lock.mjs +157 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-neutral-host-installation-store.mjs +765 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-platform-credential-store.mjs +773 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-runtime-safety.cjs +471 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/platform/windows/hellolynk-host-protected-store.ps1 +2866 -0
- package/plugins/hellolynk-codex/runtime/connect-activate.mjs +837 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-agent-configurator.mjs +701 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-client-account.mjs +180 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-client-core.mjs +770 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-client-profile.mjs +191 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-client-update-entry.mjs +31 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-client-update-runtime.mjs +600 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-client-update.mjs +453 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-contact-request-e2ee.mjs +867 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-device-enrollment.mjs +435 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-direct-text-e2ee-client.mjs +1964 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-direct-text-e2ee.mjs +1207 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-host-auth.mjs +347 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-host-protected-store.mjs +2721 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-host-transport-client.mjs +946 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-host.mjs +2374 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-human-confirmation-ledger.mjs +256 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-human-relationship-controller.mjs +1234 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-inbox-read-state.mjs +11 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-indexed-message-store.mjs +1558 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-instance-conversation.mjs +185 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-contact-requests.mjs +1733 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-contacts.mjs +1228 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-data-migration.mjs +766 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-data-protection.mjs +747 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-identity-trust.mjs +274 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-messaging.mjs +3043 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-peer-transport.mjs +103 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-process-lock.mjs +157 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-model-inbox.mjs +230 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-model-notices.cjs +60 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-native-addressing-tools.cjs +231 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-native-conversations.mjs +280 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-native-human-confirmation-broker.mjs +1036 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-native-inbox-runtime.mjs +553 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-native-notice.mjs +50 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-native-reply-routes.mjs +96 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-native-worker-lease.mjs +84 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-assembly.mjs +955 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-installation-store.mjs +765 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-launcher.mjs +665 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-process.mjs +4374 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-store-adapters.mjs +548 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-workbench-read-model.mjs +1365 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-platform-credential-store.mjs +773 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-relay-client-cli.mjs +232 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-release-retention.mjs +160 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-runtime-safety.cjs +471 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-workbench-action-grants.mjs +457 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-workbench-connection.mjs +234 -0
- package/plugins/hellolynk-codex/runtime/neutral-host-bootstrap.mjs +64 -0
- package/plugins/hellolynk-codex/runtime/neutral-host-release-entry.mjs +70 -0
- package/plugins/hellolynk-codex/runtime/neutral-host-release-loader.mjs +156 -0
- package/plugins/hellolynk-codex/runtime/neutral-host-release-manifest.json +358 -0
- package/plugins/hellolynk-codex/runtime/neutral-host-release-pin.json +4 -0
- package/plugins/hellolynk-codex/runtime/platform/windows/configure-agent.ps1 +281 -0
- package/plugins/hellolynk-codex/runtime/platform/windows/credential-store.ps1 +844 -0
- package/plugins/hellolynk-codex/runtime/platform/windows/hellolynk-host-protected-store.ps1 +2866 -0
- package/plugins/hellolynk-codex/runtime/platform/windows/hellolynk-human-confirmation-ledger.ps1 +155 -0
- package/plugins/hellolynk-codex/runtime/platform/windows/hellolynk-local-data-key.ps1 +300 -0
- package/plugins/hellolynk-codex/runtime/platform/windows/hellolynk-native-human-confirmation-broker.ps1 +216 -0
- package/plugins/hellolynk-codex/runtime/workbench/candidate-bootstrap.mjs +54 -0
- package/plugins/hellolynk-codex/runtime/workbench/contract.mjs +1314 -0
- package/plugins/hellolynk-codex/runtime/workbench/hellolynk-workbench-build.json +215 -0
- package/plugins/hellolynk-codex/runtime/workbench/host-capabilities.mjs +80 -0
- package/plugins/hellolynk-codex/runtime/workbench/i18n.mjs +1127 -0
- package/plugins/hellolynk-codex/runtime/workbench/loopback-surface.mjs +740 -0
- package/plugins/hellolynk-codex/runtime/workbench/mcp-app-bundle.mjs +97 -0
- package/plugins/hellolynk-codex/runtime/workbench/service.mjs +485 -0
- package/plugins/hellolynk-codex/runtime/workbench/state.mjs +421 -0
- package/plugins/hellolynk-codex/runtime/workbench/transports.mjs +171 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/app.mjs +6120 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/connection-form.mjs +155 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/hellolynk-mark.png +0 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/http-transport.mjs +247 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/arrow-left.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/close.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/contacts.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/inbox.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/refresh.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/search.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/security.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/settings.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/index.html +139 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/loopback-entry.mjs +26 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/mcp-app-client.mjs +151 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/mcp-app-entry.mjs +452 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/styles.css +686 -0
- package/plugins/hellolynk-codex/server/codex-neutral-production.mjs +520 -0
- package/plugins/hellolynk-codex/server/mcp-proxy.mjs +306 -0
- package/plugins/hellolynk-codex/server/mcp-server.mjs +1193 -0
- package/plugins/hellolynk-codex/server/native-conversation-directory.mjs +33 -0
- package/plugins/hellolynk-codex/server/native-reply-routes.mjs +32 -0
- package/plugins/hellolynk-codex/server/native-task-client.mjs +382 -0
- package/plugins/hellolynk-codex/server/native-task-runtime.mjs +8 -0
- package/plugins/hellolynk-codex/server/neutral-host-adapter.mjs +649 -0
- package/plugins/hellolynk-codex/server/workbench-runtime.mjs +180 -0
- package/plugins/hellolynk-codex/skills/hellolynk-open/SKILL.md +11 -0
- package/plugins/hellolynk-codex/skills/hellolynk-open/agents/openai.yaml +4 -0
- package/tools/hellolynk-activate-neutral-host-clients.mjs +837 -0
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { inflateRawSync } from "node:zlib";
|
|
5
|
+
import { withNeutralHostStoreLock } from "./hellolynk-neutral-host-installation-store.mjs";
|
|
6
|
+
|
|
7
|
+
const CATALOG = "https://hellolynk.com/downloads/clients.json";
|
|
8
|
+
export const CLIENT_UPDATE_CLIENTS = Object.freeze(["codex", "openclaw", "trae-work", "workbuddy", "dsh", "claude-code"]);
|
|
9
|
+
const HOUR = 3_600_000;
|
|
10
|
+
const MAX_ARTIFACT = 64 * 1024 * 1024;
|
|
11
|
+
const HASH = /^[a-f0-9]{64}$/;
|
|
12
|
+
const CONTRACT = "hellolynk-client-update-v1";
|
|
13
|
+
// A failed integrity or path check keeps a downloaded package from installing. Every other failure is transient.
|
|
14
|
+
const BLOCKING = /^client_update_(?:same_version_changed|version_rollback|artifact_integrity_invalid|archive_[a-z_]+|path_invalid|url_invalid|redirect_invalid)$/;
|
|
15
|
+
|
|
16
|
+
// This component only stages verified files. Host lifecycle and client-native
|
|
17
|
+
// installation remain the responsibility of the caller.
|
|
18
|
+
export function createClientUpdateManager(options = {}) {
|
|
19
|
+
const { client, currentVersion, platform = process.platform } = options;
|
|
20
|
+
if (!CLIENT_UPDATE_CLIENTS.includes(client) || !semver(currentVersion) || !path.isAbsolute(String(options.root || ""))) fail("configuration_invalid");
|
|
21
|
+
const root = path.join(path.resolve(options.root), "client-updates", client);
|
|
22
|
+
const stateFile = path.join(root, "state.json");
|
|
23
|
+
const fetcher = options.fetchImpl || globalThis.fetch;
|
|
24
|
+
const clock = options.clock || Date.now;
|
|
25
|
+
const timeoutMs = bounded(options.timeoutMs, 30_000, 50, 120_000);
|
|
26
|
+
const probeTimeoutMs = bounded(options.probeTimeoutMs, 4_000, 50, 10_000);
|
|
27
|
+
const currentSha256 = options.currentSha256;
|
|
28
|
+
if (currentSha256 != null && !HASH.test(currentSha256)) fail("configuration_invalid");
|
|
29
|
+
let inFlight = null;
|
|
30
|
+
|
|
31
|
+
async function readState() {
|
|
32
|
+
try {
|
|
33
|
+
await safeAncestors(stateFile, false);
|
|
34
|
+
const stat = await fs.lstat(stateFile);
|
|
35
|
+
if (!stat.isFile() || stat.nlink !== 1 || stat.size > 64 * 1024) fail("state_invalid");
|
|
36
|
+
const result = JSON.parse(await fs.readFile(stateFile, "utf8"));
|
|
37
|
+
if (result?.contract !== CONTRACT || result.client !== client
|
|
38
|
+
|| !["idle", "current", "downloaded", "error", "unsupported"].includes(result.stage)
|
|
39
|
+
|| !Number.isFinite(result.checkedAt) || result.checkedAt < 0) fail("state_invalid");
|
|
40
|
+
if (result.observedVersion != null && (!semver(result.observedVersion) || !HASH.test(String(result.observedSha256 || "")))) fail("state_invalid");
|
|
41
|
+
if (result.sourceCache != null && (!result.sourceCache || !["official", "github"].includes(result.sourceCache.name)
|
|
42
|
+
|| !["https://hellolynk.com", "https://github.com"].includes(result.sourceCache.origin)
|
|
43
|
+
|| !Number.isFinite(result.sourceCache.selectedAt))) fail("state_invalid");
|
|
44
|
+
if (result.stage === "downloaded") {
|
|
45
|
+
if (!semver(result.version) || !HASH.test(String(result.sha256 || ""))
|
|
46
|
+
|| !Number.isSafeInteger(result.bytes) || result.bytes < 1 || result.bytes > MAX_ARTIFACT
|
|
47
|
+
|| !safeFilename(result.filename)) fail("state_invalid");
|
|
48
|
+
const release = releasePath(result);
|
|
49
|
+
if (result.artifactPath !== path.join(release, result.filename)
|
|
50
|
+
|| result.extractedRoot !== path.join(release, "extracted")) fail("state_invalid");
|
|
51
|
+
await safeAncestors(result.artifactPath, false);
|
|
52
|
+
await safeAncestors(result.extractedRoot, true);
|
|
53
|
+
}
|
|
54
|
+
return Object.freeze(result);
|
|
55
|
+
} catch (error) {
|
|
56
|
+
// Missing or damaged: start over, and the next check overwrites the file.
|
|
57
|
+
// Any other read error (a busy or locked file) says nothing about the contents, so it propagates.
|
|
58
|
+
if (error?.code !== "ENOENT" && error?.name !== "SyntaxError" && error?.code !== "client_update_state_invalid") throw error;
|
|
59
|
+
return Object.freeze({ contract: CONTRACT, client, stage: "idle", checkedAt: 0 });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function releasePath(record) { return path.join(root, "releases", `${record.version}-${record.sha256}`); }
|
|
64
|
+
async function save(record) {
|
|
65
|
+
await safeDirectory(root);
|
|
66
|
+
const temporary = path.join(root, `.state-${crypto.randomUUID()}.tmp`);
|
|
67
|
+
try {
|
|
68
|
+
await fs.writeFile(temporary, `${JSON.stringify(record)}\n`, { flag: "wx", mode: 0o600 });
|
|
69
|
+
await safeAncestors(stateFile, false);
|
|
70
|
+
await fs.rename(temporary, stateFile);
|
|
71
|
+
} finally { await fs.rm(temporary, { force: true }); }
|
|
72
|
+
return Object.freeze(record);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function perform(force) {
|
|
76
|
+
await safeDirectory(root);
|
|
77
|
+
// Reuse the Host's protected, ownership-token lock and its conservative
|
|
78
|
+
// crash recovery rather than adding a second lock protocol.
|
|
79
|
+
return withNeutralHostStoreLock(root, "client-update", async () => {
|
|
80
|
+
let old = await readState();
|
|
81
|
+
const now = clock();
|
|
82
|
+
if (!force && old.checkedAt > 0 && now >= old.checkedAt && now - old.checkedAt < 23 * HOUR) return old;
|
|
83
|
+
if (platform !== "win32") return save({ ...old, stage: "unsupported", checkedAt: now });
|
|
84
|
+
try {
|
|
85
|
+
const metadata = await request(CATALOG, { limit: 256 * 1024, timeout: timeoutMs, kind: "catalog" });
|
|
86
|
+
let catalog;
|
|
87
|
+
try { catalog = JSON.parse(metadata.toString("utf8")); } catch { fail("catalog_invalid"); }
|
|
88
|
+
const artifact = validateCatalog(catalog, client);
|
|
89
|
+
// The catalog has no build for this client: there is nothing to install.
|
|
90
|
+
if (!artifact) {
|
|
91
|
+
// A downloaded package keeps its stage and any integrity block; only an empty cache is "current".
|
|
92
|
+
if (old.stage === "downloaded") return save({ ...old, checkedAt: now, error: BLOCKING.test(old.error || "") ? old.error : null });
|
|
93
|
+
return save({ ...old, stage: "current", checkedAt: now, error: null });
|
|
94
|
+
}
|
|
95
|
+
const installedOrder = compareVersions(artifact.version, currentVersion);
|
|
96
|
+
if ((installedOrder === 0 && currentSha256 && artifact.sha256 !== currentSha256)
|
|
97
|
+
|| (old.observedVersion && compareVersions(old.observedVersion, artifact.version) === 0 && old.observedSha256 !== artifact.sha256)) fail("same_version_changed");
|
|
98
|
+
if (old.observedVersion && compareVersions(artifact.version, old.observedVersion) < 0) fail("version_rollback");
|
|
99
|
+
old = { ...old, observedVersion: artifact.version, observedSha256: artifact.sha256 };
|
|
100
|
+
if (installedOrder <= 0) return save({ ...old, stage: "current", checkedAt: now, error: null });
|
|
101
|
+
// A newer process already installed this release (its record may since have met a failed
|
|
102
|
+
// check): keep it as current instead of downloading it again.
|
|
103
|
+
if (["current", "error"].includes(old.stage) && old.version === artifact.version && old.sha256 === artifact.sha256) {
|
|
104
|
+
return save({ ...old, stage: "current", checkedAt: now, error: null });
|
|
105
|
+
}
|
|
106
|
+
if (old.stage === "downloaded" && old.version === artifact.version && old.sha256 === artifact.sha256) {
|
|
107
|
+
try {
|
|
108
|
+
await verifyDownloaded(old);
|
|
109
|
+
return save({ ...old, checkedAt: now, error: null });
|
|
110
|
+
} catch { /* A missing/corrupt local cache is re-downloaded once. */ }
|
|
111
|
+
}
|
|
112
|
+
const sources = artifact.sources;
|
|
113
|
+
let chosen, selectedAt = now;
|
|
114
|
+
const cache = old.sourceCache;
|
|
115
|
+
if (cache && Number.isFinite(cache.selectedAt) && now >= cache.selectedAt && now - cache.selectedAt < 24 * HOUR) {
|
|
116
|
+
chosen = sources.find((source) => source.name === cache.name && sourceOrigin(source.url) === cache.origin);
|
|
117
|
+
if (chosen) selectedAt = cache.selectedAt;
|
|
118
|
+
}
|
|
119
|
+
const failed = new Set();
|
|
120
|
+
let bytes;
|
|
121
|
+
for (let attempt = 0; attempt < sources.length; attempt += 1) {
|
|
122
|
+
if (!chosen || failed.has(chosen.url)) { chosen = await fastest(sources.filter((item) => !failed.has(item.url))); selectedAt = now; }
|
|
123
|
+
try {
|
|
124
|
+
bytes = await request(chosen.url, { limit: artifact.bytes, timeout: timeoutMs, kind: "artifact" });
|
|
125
|
+
if (bytes.length !== artifact.bytes || sha(bytes) !== artifact.sha256) fail("artifact_integrity_invalid");
|
|
126
|
+
break;
|
|
127
|
+
} catch (error) {
|
|
128
|
+
failed.add(chosen.url);
|
|
129
|
+
bytes = null;
|
|
130
|
+
if (failed.size === sources.length) throw error;
|
|
131
|
+
chosen = null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (!bytes) fail("source_unavailable");
|
|
135
|
+
const release = releasePath(artifact);
|
|
136
|
+
await safeDirectory(path.dirname(release));
|
|
137
|
+
const stage = path.join(root, "releases", `.stage-${crypto.randomUUID()}`);
|
|
138
|
+
await fs.mkdir(stage, { mode: 0o700 });
|
|
139
|
+
try {
|
|
140
|
+
const archivePath = path.join(stage, artifact.filename);
|
|
141
|
+
await fs.writeFile(archivePath, bytes, { flag: "wx", mode: 0o600 });
|
|
142
|
+
await extractClientUpdateArchive({ archivePath, destination: path.join(stage, "extracted") });
|
|
143
|
+
await fs.rename(stage, release).catch(async (error) => {
|
|
144
|
+
if (!["EEXIST", "ENOTEMPTY", "EPERM"].includes(error.code)) throw error;
|
|
145
|
+
try {
|
|
146
|
+
await verifyDownloaded({ ...artifact, artifactPath: path.join(release, artifact.filename), extractedRoot: path.join(release, "extracted") });
|
|
147
|
+
return;
|
|
148
|
+
} catch { /* Preserve damaged evidence without reusing it. */ }
|
|
149
|
+
await safeAncestors(release, true);
|
|
150
|
+
const quarantine = path.join(root, "releases", `.corrupt-${artifact.sha256}-${crypto.randomUUID()}`);
|
|
151
|
+
await fs.rename(release, quarantine);
|
|
152
|
+
try { await fs.rename(stage, release); }
|
|
153
|
+
catch (replacement) { await fs.rename(quarantine, release).catch(() => {}); throw replacement; }
|
|
154
|
+
});
|
|
155
|
+
} finally { await fs.rm(stage, { recursive: true, force: true }); }
|
|
156
|
+
// Finish staging cleanup before starting publication: a failed cleanup
|
|
157
|
+
// must not race an earlier downloaded-state save and lose its error.
|
|
158
|
+
return save({ ...old, stage: "downloaded", version: artifact.version, filename: artifact.filename,
|
|
159
|
+
artifactPath: path.join(release, artifact.filename), extractedRoot: path.join(release, "extracted"),
|
|
160
|
+
sha256: artifact.sha256, bytes: artifact.bytes, checkedAt: now, error: null,
|
|
161
|
+
sourceCache: { name: chosen.name, origin: sourceOrigin(chosen.url), selectedAt },
|
|
162
|
+
});
|
|
163
|
+
} catch (error) {
|
|
164
|
+
const code = /^client_update_[a-z_]+$/.test(error?.code || "") ? error.code : "client_update_check_failed";
|
|
165
|
+
if (BLOCKING.test(code)) return save({ ...old, stage: old.stage === "downloaded" ? "downloaded" : "error", checkedAt: now, error: code });
|
|
166
|
+
// Anything else is transient: a downloaded package stays installable and the next check tries again.
|
|
167
|
+
// checkedAt is backdated so that retry comes after one hour, not after the full 23-hour window.
|
|
168
|
+
const retryAt = now - 22 * HOUR;
|
|
169
|
+
if (old.stage === "downloaded") return save({ ...old, checkedAt: retryAt, error: BLOCKING.test(old.error || "") ? old.error : null });
|
|
170
|
+
return save({ ...old, stage: "error", checkedAt: retryAt, error: code });
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// After an install only the package state.json names stays cached; older
|
|
176
|
+
// versions and quarantined copies go. Holding the check lock means no
|
|
177
|
+
// download is in flight to be mistaken for leftovers.
|
|
178
|
+
async function prune() {
|
|
179
|
+
await safeDirectory(root);
|
|
180
|
+
return withNeutralHostStoreLock(root, "client-update", async () => {
|
|
181
|
+
const state = await readState();
|
|
182
|
+
if (!semver(state.version) || !HASH.test(String(state.sha256 || ""))) return [];
|
|
183
|
+
const keep = path.basename(releasePath(state)), releases = path.join(root, "releases"), removed = [];
|
|
184
|
+
await safeAncestors(releases, true);
|
|
185
|
+
let entries;
|
|
186
|
+
try { entries = await fs.readdir(releases, { withFileTypes: true }); }
|
|
187
|
+
catch (error) { if (error.code === "ENOENT") return removed; throw error; }
|
|
188
|
+
for (const entry of entries) {
|
|
189
|
+
if (entry.name === keep || !entry.isDirectory()) continue;
|
|
190
|
+
await fs.rm(path.join(releases, entry.name), { recursive: true, force: true });
|
|
191
|
+
removed.push(entry.name);
|
|
192
|
+
}
|
|
193
|
+
return removed;
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function fastest(sources) {
|
|
198
|
+
const outcomes = await Promise.allSettled(sources.map(async (source) => {
|
|
199
|
+
const started = performance.now();
|
|
200
|
+
await request(source.url, { limit: 4096, timeout: probeTimeoutMs, kind: "probe" });
|
|
201
|
+
return { source, elapsed: performance.now() - started };
|
|
202
|
+
}));
|
|
203
|
+
const successes = outcomes.filter((item) => item.status === "fulfilled").map((item) => item.value).sort((a, b) => a.elapsed - b.elapsed);
|
|
204
|
+
if (!successes.length) fail("source_unavailable");
|
|
205
|
+
return successes[0].source;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function request(url, { limit, timeout, kind }) {
|
|
209
|
+
const controller = new AbortController();
|
|
210
|
+
const timer = setTimeout(() => controller.abort(), timeout);
|
|
211
|
+
let response;
|
|
212
|
+
try {
|
|
213
|
+
let current = new URL(url);
|
|
214
|
+
for (let redirect = 0; redirect <= 3; redirect += 1) {
|
|
215
|
+
validateUrl(current, kind === "catalog" ? "catalog" : "artifact", redirect > 0, new URL(url));
|
|
216
|
+
response = await fetcher(current.href, { method: "GET", credentials: "omit", redirect: "manual", signal: controller.signal,
|
|
217
|
+
headers: kind === "probe" ? { Range: "bytes=0-4095", Accept: "application/octet-stream" } : { Accept: kind === "catalog" ? "application/json" : "application/octet-stream" } });
|
|
218
|
+
if ([301, 302, 303, 307, 308].includes(response.status)) {
|
|
219
|
+
await response.body?.cancel();
|
|
220
|
+
const next = response.headers.get("location");
|
|
221
|
+
if (!next || redirect === 3) fail("redirect_invalid");
|
|
222
|
+
current = new URL(next, current);
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
if (response.status !== 200 && !(kind === "probe" && response.status === 206)) fail("source_unavailable");
|
|
226
|
+
if (response.url && response.url !== current.href) fail("redirect_invalid");
|
|
227
|
+
if (/text\/html|application\/xhtml/i.test(response.headers.get("content-type") || "")) fail("source_invalid");
|
|
228
|
+
const length = response.headers.get("content-length");
|
|
229
|
+
if (kind !== "probe" && length != null && (!/^\d+$/.test(length) || Number(length) > limit)) fail("response_too_large");
|
|
230
|
+
if (!response.body) fail("source_invalid");
|
|
231
|
+
const reader = response.body.getReader();
|
|
232
|
+
const chunks = []; let total = 0;
|
|
233
|
+
try {
|
|
234
|
+
while (true) {
|
|
235
|
+
const nextChunk = await reader.read();
|
|
236
|
+
if (nextChunk.done) break;
|
|
237
|
+
const chunk = Buffer.from(nextChunk.value);
|
|
238
|
+
if (kind === "probe") {
|
|
239
|
+
const take = chunk.subarray(0, limit - total); chunks.push(take); total += take.length;
|
|
240
|
+
if (total >= limit) break;
|
|
241
|
+
} else {
|
|
242
|
+
total += chunk.length;
|
|
243
|
+
if (total > limit) fail("response_too_large");
|
|
244
|
+
chunks.push(chunk);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
} finally { await reader.cancel().catch(() => {}); }
|
|
248
|
+
const body = Buffer.concat(chunks);
|
|
249
|
+
if (kind !== "catalog" && (body.length < 4 || body.readUInt32LE(0) !== 0x04034b50)) fail("source_invalid");
|
|
250
|
+
return body;
|
|
251
|
+
}
|
|
252
|
+
fail("redirect_invalid");
|
|
253
|
+
} finally {
|
|
254
|
+
clearTimeout(timer);
|
|
255
|
+
await response?.body?.cancel().catch(() => {});
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return Object.freeze({
|
|
260
|
+
check({ force = false } = {}) {
|
|
261
|
+
if (!inFlight) inFlight = perform(force).finally(() => { inFlight = null; });
|
|
262
|
+
return inFlight;
|
|
263
|
+
},
|
|
264
|
+
prune,
|
|
265
|
+
state: readState,
|
|
266
|
+
readState,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function validateCatalog(catalog, client) {
|
|
271
|
+
if (!catalog || !Array.isArray(catalog.clients) || catalog.clients.length > 32) fail("catalog_invalid");
|
|
272
|
+
const matches = catalog.clients.filter((item) => item?.client === client);
|
|
273
|
+
if (matches.length === 0) return null;
|
|
274
|
+
if (matches.length > 1) fail("catalog_invalid");
|
|
275
|
+
const item = matches[0];
|
|
276
|
+
if (!semver(item.version) || !HASH.test(String(item.sha256 || "")) || !safeFilename(item.filename)
|
|
277
|
+
|| !Number.isSafeInteger(item.bytes) || item.bytes < 4 || item.bytes > MAX_ARTIFACT
|
|
278
|
+
|| !Array.isArray(item.platforms) || !item.platforms.includes("Windows")) fail("catalog_invalid");
|
|
279
|
+
const official = new URL(String(item.url || ""), CATALOG);
|
|
280
|
+
if (official.origin !== "https://hellolynk.com" || !official.pathname.startsWith("/downloads/")) fail("catalog_invalid");
|
|
281
|
+
validateUrl(official, "artifact");
|
|
282
|
+
if (decodeURIComponent(official.pathname.split("/").at(-1)) !== item.filename) fail("catalog_invalid");
|
|
283
|
+
const sources = [{ name: "official", url: official.href }];
|
|
284
|
+
if (item.sources != null && (!Array.isArray(item.sources) || item.sources.length > 3)) fail("catalog_invalid");
|
|
285
|
+
for (const source of item.sources || []) {
|
|
286
|
+
if (!source || !["official", "github"].includes(source.name) || typeof source.url !== "string") fail("catalog_invalid");
|
|
287
|
+
const url = new URL(source.url);
|
|
288
|
+
validateUrl(url, "artifact");
|
|
289
|
+
if ((source.name === "official" && url.hostname !== "hellolynk.com")
|
|
290
|
+
|| (source.name === "github" && url.hostname !== "github.com")
|
|
291
|
+
|| decodeURIComponent(url.pathname.split("/").at(-1)) !== item.filename) fail("catalog_invalid");
|
|
292
|
+
if (sources.some((entry) => entry.url === url.href)) continue;
|
|
293
|
+
if (sources.some((entry) => entry.name === source.name)) fail("catalog_invalid");
|
|
294
|
+
sources.push({ name: source.name, url: url.href });
|
|
295
|
+
}
|
|
296
|
+
return { client, version: item.version, filename: item.filename, bytes: item.bytes, sha256: item.sha256, sources };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function validateUrl(url, kind, redirected = false, initial = url) {
|
|
300
|
+
if (url.protocol !== "https:" || url.username || url.password || url.port || url.hash) fail("url_invalid");
|
|
301
|
+
if (kind === "catalog") { if (url.href !== CATALOG) fail("url_invalid"); return; }
|
|
302
|
+
const direct = !url.search && ((url.hostname === "hellolynk.com" && /^\/downloads\/[A-Za-z0-9._/-]+$/.test(url.pathname))
|
|
303
|
+
|| (url.hostname === "github.com" && /^\/haoyun18881-beep\/HelloLynk\/releases\/download\/[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(url.pathname)));
|
|
304
|
+
// GitHub release downloads use a signed CDN redirect. Only this exact CDN is
|
|
305
|
+
// accepted, and only when reached from the fixed repository release URL.
|
|
306
|
+
const githubCdn = redirected && initial.hostname === "github.com"
|
|
307
|
+
&& url.hostname === "release-assets.githubusercontent.com"
|
|
308
|
+
&& /^\/github-production-release-asset\/[A-Za-z0-9/_-]+$/.test(url.pathname);
|
|
309
|
+
if (!direct && !githubCdn) fail("url_invalid");
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export async function extractClientUpdateArchive({ archivePath, destination, maxTotalBytes = 128 * 1024 * 1024, maxFileBytes = 32 * 1024 * 1024, maxEntries = 4096 }) {
|
|
313
|
+
if (!path.isAbsolute(archivePath) || !path.isAbsolute(destination)) fail("archive_path_invalid");
|
|
314
|
+
await safeAncestors(archivePath, false);
|
|
315
|
+
const stat = await fs.lstat(archivePath);
|
|
316
|
+
if (!stat.isFile() || stat.nlink !== 1 || stat.size > MAX_ARTIFACT) fail("archive_invalid");
|
|
317
|
+
const bytes = await fs.readFile(archivePath);
|
|
318
|
+
let eocd = -1;
|
|
319
|
+
for (let offset = bytes.length - 22; offset >= Math.max(0, bytes.length - 65557); offset -= 1) {
|
|
320
|
+
if (bytes.readUInt32LE(offset) === 0x06054b50 && offset + 22 + bytes.readUInt16LE(offset + 20) === bytes.length) { eocd = offset; break; }
|
|
321
|
+
}
|
|
322
|
+
if (eocd < 0 || bytes.readUInt16LE(eocd + 4) !== 0 || bytes.readUInt16LE(eocd + 6) !== 0) fail("archive_invalid");
|
|
323
|
+
const count = bytes.readUInt16LE(eocd + 10), centralSize = bytes.readUInt32LE(eocd + 12), central = bytes.readUInt32LE(eocd + 16);
|
|
324
|
+
if (!count || count > maxEntries || count === 65535 || count !== bytes.readUInt16LE(eocd + 8) || central + centralSize !== eocd) fail("archive_invalid");
|
|
325
|
+
const entries = [], nodes = new Map(), spans = [];
|
|
326
|
+
let cursor = central, total = 0;
|
|
327
|
+
for (let index = 0; index < count; index += 1) {
|
|
328
|
+
if (cursor + 46 > eocd || bytes.readUInt32LE(cursor) !== 0x02014b50) fail("archive_invalid");
|
|
329
|
+
const flags = bytes.readUInt16LE(cursor + 8), method = bytes.readUInt16LE(cursor + 10);
|
|
330
|
+
const crc = bytes.readUInt32LE(cursor + 16), packed = bytes.readUInt32LE(cursor + 20), size = bytes.readUInt32LE(cursor + 24);
|
|
331
|
+
const nameLength = bytes.readUInt16LE(cursor + 28), extraLength = bytes.readUInt16LE(cursor + 30), commentLength = bytes.readUInt16LE(cursor + 32);
|
|
332
|
+
const external = bytes.readUInt32LE(cursor + 38), local = bytes.readUInt32LE(cursor + 42);
|
|
333
|
+
if (cursor + 46 + nameLength + extraLength + commentLength > eocd || flags & ~0x080e || flags & 1
|
|
334
|
+
|| ![0, 8].includes(method) || bytes.readUInt16LE(cursor + 34) !== 0 || size > maxFileBytes
|
|
335
|
+
|| packed === 0xffffffff || size === 0xffffffff || local === 0xffffffff) fail("archive_invalid");
|
|
336
|
+
const nameBytes = bytes.subarray(cursor + 46, cursor + 46 + nameLength);
|
|
337
|
+
const name = nameBytes.toString("utf8");
|
|
338
|
+
if (!Buffer.from(name, "utf8").equals(nameBytes)) fail("archive_path_invalid");
|
|
339
|
+
const directory = name.endsWith("/");
|
|
340
|
+
const segments = safeArchivePath(name, directory);
|
|
341
|
+
const mode = external >>> 16;
|
|
342
|
+
if ((mode & 0xf000) && (mode & 0xf000) !== (directory ? 0x4000 : 0x8000)) fail("archive_path_invalid");
|
|
343
|
+
if ((external & 0x400) !== 0) fail("archive_path_invalid");
|
|
344
|
+
for (let part = 0; part < segments.length; part += 1) {
|
|
345
|
+
const key = segments.slice(0, part + 1).join("/").toLowerCase();
|
|
346
|
+
const leaf = part === segments.length - 1;
|
|
347
|
+
const wanted = !leaf || directory ? "directory" : "file";
|
|
348
|
+
const prior = nodes.get(key);
|
|
349
|
+
if (prior && (prior.kind !== wanted || (leaf && prior.explicit)
|
|
350
|
+
|| prior.spelling !== segments.slice(0, part + 1).join("/"))) fail("archive_duplicate_path");
|
|
351
|
+
nodes.set(key, { kind: wanted, explicit: Boolean(prior?.explicit || leaf), spelling: segments.slice(0, part + 1).join("/") });
|
|
352
|
+
}
|
|
353
|
+
if (local + 30 > central || bytes.readUInt32LE(local) !== 0x04034b50
|
|
354
|
+
|| bytes.readUInt16LE(local + 6) !== flags || bytes.readUInt16LE(local + 8) !== method) fail("archive_invalid");
|
|
355
|
+
const localNameLength = bytes.readUInt16LE(local + 26), localExtraLength = bytes.readUInt16LE(local + 28);
|
|
356
|
+
const start = local + 30 + localNameLength + localExtraLength;
|
|
357
|
+
if (start + packed > central || !bytes.subarray(local + 30, local + 30 + localNameLength).equals(nameBytes)) fail("archive_invalid");
|
|
358
|
+
if (!(flags & 8) && (bytes.readUInt32LE(local + 14) !== crc || bytes.readUInt32LE(local + 18) !== packed || bytes.readUInt32LE(local + 22) !== size)) fail("archive_invalid");
|
|
359
|
+
if (directory && (size !== 0 || packed > 2)) fail("archive_invalid");
|
|
360
|
+
total += size;
|
|
361
|
+
if (total > maxTotalBytes) fail("archive_too_large");
|
|
362
|
+
spans.push([local, start + packed]);
|
|
363
|
+
entries.push({ segments, directory, start, packed, size, method, crc });
|
|
364
|
+
cursor += 46 + nameLength + extraLength + commentLength;
|
|
365
|
+
}
|
|
366
|
+
if (cursor !== eocd) fail("archive_invalid");
|
|
367
|
+
spans.sort((a, b) => a[0] - b[0]);
|
|
368
|
+
if (spans[0][0] !== 0 || spans.some((entry, index) => index > 0 && entry[0] < spans[index - 1][1])) fail("archive_invalid");
|
|
369
|
+
await safeAncestors(destination, true);
|
|
370
|
+
await fs.mkdir(destination, { mode: 0o700 });
|
|
371
|
+
for (const entry of entries) {
|
|
372
|
+
const target = path.join(destination, ...entry.segments);
|
|
373
|
+
if (entry.directory) { await safeDirectory(target); continue; }
|
|
374
|
+
await safeDirectory(path.dirname(target));
|
|
375
|
+
const compressed = bytes.subarray(entry.start, entry.start + entry.packed);
|
|
376
|
+
let body;
|
|
377
|
+
try { body = entry.method === 0 ? compressed : inflateRawSync(compressed, { maxOutputLength: Math.max(1, entry.size) }); }
|
|
378
|
+
catch { fail("archive_integrity_invalid"); }
|
|
379
|
+
if (body.length !== entry.size || crc32(body) !== entry.crc) fail("archive_integrity_invalid");
|
|
380
|
+
await fs.writeFile(target, body, { flag: "wx", mode: 0o600 });
|
|
381
|
+
}
|
|
382
|
+
return Object.freeze({ files: entries.filter((item) => !item.directory).length, bytes: total });
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function safeArchivePath(name, directory) {
|
|
386
|
+
if (!name || name.length > 240 || name.includes("\\") || name.startsWith("/")) fail("archive_path_invalid");
|
|
387
|
+
const segments = (directory ? name.slice(0, -1) : name).split("/");
|
|
388
|
+
for (const segment of segments) {
|
|
389
|
+
if (!segment || segment === "." || segment === ".." || /[\x00-\x1f\x7f<>:"|?*~]/.test(segment)
|
|
390
|
+
|| /[. ]$/.test(segment) || /^(?:con|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³])(?:\.|$)/i.test(segment)
|
|
391
|
+
|| /^(?:conin\$|conout\$)(?:\.|$)/i.test(segment)
|
|
392
|
+
|| segment.normalize("NFC") !== segment) fail("archive_path_invalid");
|
|
393
|
+
}
|
|
394
|
+
return segments;
|
|
395
|
+
}
|
|
396
|
+
async function verifyDownloaded(record) {
|
|
397
|
+
await safeAncestors(record.artifactPath, false);
|
|
398
|
+
await safeAncestors(record.extractedRoot, true);
|
|
399
|
+
const stat = await fs.lstat(record.artifactPath);
|
|
400
|
+
if (!stat.isFile() || stat.nlink !== 1 || stat.size !== record.bytes || sha(await fs.readFile(record.artifactPath)) !== record.sha256) fail("artifact_integrity_invalid");
|
|
401
|
+
if (!(await fs.lstat(record.extractedRoot)).isDirectory()) fail("artifact_integrity_invalid");
|
|
402
|
+
}
|
|
403
|
+
async function safeDirectory(directory) { await safeAncestors(directory, true); await fs.mkdir(directory, { recursive: true, mode: 0o700 }); await safeAncestors(directory, true); }
|
|
404
|
+
async function safeAncestors(target, directory) {
|
|
405
|
+
const absolute = path.resolve(target), base = path.parse(absolute).root;
|
|
406
|
+
let current = base;
|
|
407
|
+
const parts = absolute.slice(base.length).split(path.sep).filter(Boolean);
|
|
408
|
+
for (let index = 0; index < parts.length; index += 1) {
|
|
409
|
+
current = path.join(current, parts[index]);
|
|
410
|
+
try {
|
|
411
|
+
const stat = await fs.lstat(current);
|
|
412
|
+
if (stat.isSymbolicLink() || ((index < parts.length - 1 || directory) && !stat.isDirectory())) fail("path_invalid");
|
|
413
|
+
} catch (error) { if (error.code === "ENOENT") continue; throw error; }
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
function safeFilename(value) { return typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}\.zip$/.test(value); }
|
|
417
|
+
function sourceOrigin(url) { return new URL(url).origin; }
|
|
418
|
+
function sha(value) { return crypto.createHash("sha256").update(value).digest("hex"); }
|
|
419
|
+
function bounded(value, fallback, min, max) { return Number.isInteger(value) && value >= min && value <= max ? value : fallback; }
|
|
420
|
+
function updateError(code) { return Object.assign(new Error(`client_update_${code}`), { code: `client_update_${code}` }); }
|
|
421
|
+
function fail(code) { throw updateError(code); }
|
|
422
|
+
function semver(value) {
|
|
423
|
+
if (typeof value !== "string" || value.length > 100) return null;
|
|
424
|
+
const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/.exec(value);
|
|
425
|
+
if (!match || match.slice(1, 4).some((part) => !Number.isSafeInteger(Number(part)))) return null;
|
|
426
|
+
const pre = match[4]?.split(".") || [];
|
|
427
|
+
if (pre.some((part) => /^\d+$/.test(part) && ((part.length > 1 && part[0] === "0") || !Number.isSafeInteger(Number(part))))) return null;
|
|
428
|
+
return { nums: match.slice(1, 4).map(Number), pre };
|
|
429
|
+
}
|
|
430
|
+
export function compareClientUpdateVersions(a, b) { return compareVersions(a, b); }
|
|
431
|
+
function compareVersions(a, b) {
|
|
432
|
+
const left = semver(a), right = semver(b);
|
|
433
|
+
if (!left || !right) fail("version_invalid");
|
|
434
|
+
for (let index = 0; index < 3; index += 1) if (left.nums[index] !== right.nums[index]) return left.nums[index] > right.nums[index] ? 1 : -1;
|
|
435
|
+
if (!left.pre.length || !right.pre.length) return left.pre.length === right.pre.length ? 0 : left.pre.length ? -1 : 1;
|
|
436
|
+
for (let index = 0; index < Math.max(left.pre.length, right.pre.length); index += 1) {
|
|
437
|
+
const x = left.pre[index], y = right.pre[index];
|
|
438
|
+
if (x == null || y == null) return x == null ? -1 : 1;
|
|
439
|
+
if (x === y) continue;
|
|
440
|
+
const xn = /^\d+$/.test(x), yn = /^\d+$/.test(y);
|
|
441
|
+
if (xn !== yn) return xn ? -1 : 1;
|
|
442
|
+
return xn ? Number(x) > Number(y) ? 1 : -1 : x > y ? 1 : -1;
|
|
443
|
+
}
|
|
444
|
+
return 0;
|
|
445
|
+
}
|
|
446
|
+
function crc32(bytes) {
|
|
447
|
+
let crc = 0xffffffff;
|
|
448
|
+
for (const byte of bytes) {
|
|
449
|
+
crc ^= byte;
|
|
450
|
+
for (let bit = 0; bit < 8; bit += 1) crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0);
|
|
451
|
+
}
|
|
452
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
453
|
+
}
|