## Problem and outcome App downloads used tar.gz archives, which are less convenient to open with desktop file managers. Windows, macOS and Linux App packages now ship as ZIP files. Packaging extracts the completed ZIP and runs the existing runtime checks against those extracted files, covering the actual distributed package. The website links directly to the published ZIP for each platform, with an explicitly labeled GitHub primary download and matching Gitee alternative. Server deployment archives retain their existing format. ## Release notes ### 中文 - Windows、macOS 和 Linux 客户端改用 ZIP 压缩包,下载后可直接使用系统解压工具完整解压。 - 官网按系统提供 ZIP 直接下载和 Gitee 备用下载入口。 ### English - Windows, macOS and Linux App downloads now use ZIP archives for easier extraction with desktop file managers. - The website provides direct ZIP downloads for each platform, with Gitee as an alternative download source. ## Verification - `npm run check`: 58 test files / 760 tests, TypeScript and production build passed. - Real ZIP round-trip verification covers nested, hidden and Unicode paths; Unix checks also verify executable modes and launch the extracted fixtures. Windows archive checks passed locally; all three native packaging runners execute the existing runtime checks from the final extracted ZIP. - Website build, release/mirror validation tests, documentation, repository hygiene and diff checks passed. ## Impact Compatible packaging change. Product behavior, protocols and existing published release artifacts remain unchanged. The release pipeline publishes a new patch version and refreshes website links after publication.
394 lines
13 KiB
JavaScript
394 lines
13 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { spawn, spawnSync } from "node:child_process";
|
|
import { createHash } from "node:crypto";
|
|
import {
|
|
chmodSync,
|
|
createWriteStream,
|
|
existsSync,
|
|
mkdirSync,
|
|
readFileSync,
|
|
readdirSync,
|
|
realpathSync,
|
|
rmSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import { networkInterfaces } from "node:os";
|
|
import { createServer } from "node:net";
|
|
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
import { Readable } from "node:stream";
|
|
import { pipeline } from "node:stream/promises";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
import { clientPackageTarget, CLOUDFLARED_VERSION } from "./client-package-targets.mjs";
|
|
import { createZip, extractZip, tarExecutable } from "./archive-tool.mjs";
|
|
import { resetBuildWorkspace } from "./build-workspace.mjs";
|
|
|
|
function fail(message) {
|
|
throw new Error(message);
|
|
}
|
|
|
|
function run(command, args, cwd) {
|
|
const result = spawnSync(command, args, {
|
|
cwd,
|
|
encoding: "utf8",
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
if (result.error) throw result.error;
|
|
if (result.status !== 0) {
|
|
fail(`${basename(command)} failed: ${(result.stderr || result.stdout).trim()}`);
|
|
}
|
|
return result.stdout.trim();
|
|
}
|
|
|
|
function sha256(path) {
|
|
return createHash("sha256").update(readFileSync(path)).digest("hex");
|
|
}
|
|
|
|
function assertOutsideRepository(repositoryRoot, outputRoot) {
|
|
const path = relative(repositoryRoot, outputRoot);
|
|
if (path === "" || (path.split(/[\\/]/)[0] !== ".." && !isAbsolute(path))) {
|
|
fail("Client candidate output must be outside the repository");
|
|
}
|
|
if (existsSync(outputRoot)) {
|
|
fail("Client candidate output directory must not already exist");
|
|
}
|
|
}
|
|
|
|
function applicationDescriptor(directory) {
|
|
const descriptors = readdirSync(directory)
|
|
.filter((name) => name.endsWith(".release.json"));
|
|
if (descriptors.length !== 1) {
|
|
fail("Application release directory must contain one descriptor");
|
|
}
|
|
return join(directory, descriptors[0]);
|
|
}
|
|
|
|
async function download(url, path) {
|
|
const response = await fetch(url);
|
|
if (!response.ok || !response.body) {
|
|
fail(`Public-link runtime download failed with HTTP ${response.status}`);
|
|
}
|
|
await pipeline(Readable.fromWeb(response.body), createWriteStream(path));
|
|
}
|
|
|
|
function delay(ms) {
|
|
return new Promise((resolveDelay) => setTimeout(resolveDelay, ms));
|
|
}
|
|
|
|
async function reservePort() {
|
|
const server = createServer();
|
|
return await new Promise((resolvePort, rejectPort) => {
|
|
server.once("error", rejectPort);
|
|
server.listen(0, "127.0.0.1", () => {
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
rejectPort(new Error("Client smoke port reservation failed"));
|
|
return;
|
|
}
|
|
server.close((error) =>
|
|
error ? rejectPort(error) : resolvePort(address.port),
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
function smokeLANAddress() {
|
|
const addresses = Object.values(networkInterfaces())
|
|
.flatMap((entries) => entries ?? [])
|
|
.filter((entry) => entry.family === "IPv4" && !entry.internal)
|
|
.map((entry) => entry.address)
|
|
.sort();
|
|
if (addresses.length === 0) fail("Client smoke has no active LAN IPv4 address");
|
|
return addresses[0];
|
|
}
|
|
|
|
async function waitForExit(child, timeoutMs) {
|
|
if (child.exitCode !== null || child.signalCode !== null) return child.exitCode;
|
|
return await new Promise((resolveExit, rejectExit) => {
|
|
const timer = setTimeout(() => {
|
|
child.off("exit", onExit);
|
|
rejectExit(new Error("Packaged Client did not stop"));
|
|
}, timeoutMs);
|
|
const onExit = (code) => {
|
|
clearTimeout(timer);
|
|
resolveExit(code);
|
|
};
|
|
child.once("exit", onExit);
|
|
});
|
|
}
|
|
|
|
async function stopSmokeChild(child) {
|
|
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
try {
|
|
child.stdin.write("\n");
|
|
} catch {}
|
|
try {
|
|
await waitForExit(child, 5_000);
|
|
return;
|
|
} catch {}
|
|
if (process.platform === "win32" && child.pid !== undefined) {
|
|
spawnSync("taskkill.exe", ["/PID", String(child.pid), "/T", "/F"], {
|
|
stdio: "ignore",
|
|
windowsHide: true,
|
|
});
|
|
} else {
|
|
child.kill("SIGTERM");
|
|
}
|
|
try {
|
|
await waitForExit(child, 3_000);
|
|
return;
|
|
} catch {}
|
|
child.kill("SIGKILL");
|
|
await waitForExit(child, 5_000).catch(() => undefined);
|
|
}
|
|
|
|
async function verifyLocalPackage(root, target, temporaryRoot) {
|
|
const client = join(root, target.clientName);
|
|
const port = await reservePort();
|
|
const healthURL = `http://127.0.0.1:${port}/healthz`;
|
|
const noticeURL = `http://127.0.0.1:${port}/third-party-licenses.txt`;
|
|
const child = spawn(client, [
|
|
"--local",
|
|
"--config", join(temporaryRoot, "smoke-client.json"),
|
|
"--lan-address", smokeLANAddress(),
|
|
"--port", String(port),
|
|
], {
|
|
cwd: root,
|
|
env: { ...process.env, PIIK_CLIENT_GATE_NO_BROWSER: "true" },
|
|
stdio: "pipe",
|
|
windowsHide: true,
|
|
});
|
|
let spawnFailure = null;
|
|
let stderr = "";
|
|
let stdout = "";
|
|
let clientReady = false;
|
|
child.once("error", (error) => {
|
|
spawnFailure = error;
|
|
});
|
|
child.stdin.on("error", () => undefined);
|
|
child.stdout.on("data", (chunk) => {
|
|
stdout = `${stdout}${chunk.toString()}`.slice(-2_048);
|
|
if (stdout.includes("Local access password: ") || stdout.includes("Local access: open")) {
|
|
clientReady = true;
|
|
stdout = "";
|
|
}
|
|
});
|
|
child.stderr.on("data", (chunk) => {
|
|
stderr = `${stderr}${chunk.toString()}`.slice(-4_096);
|
|
});
|
|
try {
|
|
const deadline = Date.now() + 15_000;
|
|
let healthReady = false;
|
|
while (Date.now() < deadline) {
|
|
if (spawnFailure) break;
|
|
if (child.exitCode !== null || child.signalCode !== null) break;
|
|
if (!healthReady) {
|
|
try {
|
|
const response = await fetch(healthURL, {
|
|
headers: { Connection: "close" },
|
|
signal: AbortSignal.timeout(500),
|
|
});
|
|
healthReady = response.ok && (await response.json())?.status === "ok";
|
|
} catch {}
|
|
}
|
|
if (healthReady && clientReady) break;
|
|
await delay(100);
|
|
}
|
|
if (!healthReady || !clientReady) {
|
|
fail("Packaged Client Local health did not become ready");
|
|
}
|
|
// The Web notices ship inside the binary; the embedded assets serve them.
|
|
const notices = await fetch(noticeURL, {
|
|
headers: { Connection: "close" },
|
|
signal: AbortSignal.timeout(2_000),
|
|
});
|
|
if (!notices.ok || (await notices.text()).length === 0) {
|
|
fail("Packaged Client did not serve its Web third-party notices");
|
|
}
|
|
child.stdin.write("\n");
|
|
const exitCode = await waitForExit(child, 10_000);
|
|
if (exitCode !== 0) {
|
|
const detail = stderr
|
|
.trim()
|
|
.replace(/https?:\/\/\S+/g, "<url>")
|
|
.replace(/\s+/g, " ")
|
|
.slice(-1_000);
|
|
fail(
|
|
`Packaged Client did not stop cleanly (${exitCode ?? child.signalCode ?? "unknown"})${detail ? `: ${detail}` : ""}`,
|
|
);
|
|
}
|
|
const closeDeadline = Date.now() + 5_000;
|
|
while (Date.now() < closeDeadline) {
|
|
try {
|
|
await fetch(healthURL, {
|
|
headers: { Connection: "close" },
|
|
signal: AbortSignal.timeout(300),
|
|
});
|
|
} catch {
|
|
return;
|
|
}
|
|
await delay(100);
|
|
}
|
|
fail("Packaged Client left its Local server listening");
|
|
} finally {
|
|
await stopSmokeChild(child);
|
|
}
|
|
}
|
|
|
|
async function verifyPackage(root, target, revision, temporaryRoot) {
|
|
const packagedRevision = readFileSync(join(root, "REVISION"), "ascii").trim();
|
|
if (packagedRevision !== revision) fail("Client package revision mismatch");
|
|
for (const file of ["LICENSE", "THIRD-PARTY-NOTICES.txt",
|
|
"runtime/tunnel/THIRD-PARTY-NOTICES.txt"]) {
|
|
if (!existsSync(join(root, file)) || readFileSync(join(root, file)).length === 0) {
|
|
fail(`Client package license text is missing: ${file}`);
|
|
}
|
|
}
|
|
|
|
const client = join(root, target.clientName);
|
|
const tunnel = join(root, "runtime", "tunnel", target.tunnelName);
|
|
run(client, ["--help"], root);
|
|
run(tunnel, ["--version"], root);
|
|
verifyPlatformAssets(root, target);
|
|
|
|
if (target.captureName) {
|
|
const capture = join(root, "runtime", "native", target.captureName);
|
|
const probe = JSON.parse(run(capture, ["--probe"], root));
|
|
if (probe?.protocol !== 7) fail("Packaged native capture probe is invalid");
|
|
}
|
|
await verifyLocalPackage(root, target, temporaryRoot);
|
|
}
|
|
|
|
function verifyPlatformAssets(root, target) {
|
|
if (target.goos === "linux") {
|
|
const desktop = join(root, "share", "applications", "piik-app.desktop");
|
|
const icon = join(
|
|
root,
|
|
"share",
|
|
"icons",
|
|
"hicolor",
|
|
"256x256",
|
|
"apps",
|
|
"piik-app.png",
|
|
);
|
|
if (!existsSync(desktop) || !existsSync(icon)) {
|
|
fail("Linux Client icon assets are missing");
|
|
}
|
|
if (!readFileSync(desktop, "utf8").includes("Icon=piik-app\n")) {
|
|
fail("Linux desktop entry does not name its icon");
|
|
}
|
|
if (!readFileSync(icon).subarray(0, 8).equals(Buffer.from([
|
|
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
|
]))) {
|
|
fail("Linux Client icon is not PNG");
|
|
}
|
|
}
|
|
if (target.goos === "darwin") {
|
|
const bundle = join(root, "Piik App.app");
|
|
const launcher = join(bundle, "Contents", "MacOS", "launcher");
|
|
const plist = join(bundle, "Contents", "Info.plist");
|
|
const icon = join(bundle, "Contents", "Resources", "piik.icns");
|
|
if (!existsSync(launcher) || !existsSync(plist) || !existsSync(icon)) {
|
|
fail("macOS Client app icon assets are missing");
|
|
}
|
|
if (!readFileSync(icon).subarray(0, 4).equals(Buffer.from("icns"))) {
|
|
fail("macOS Client icon is not ICNS");
|
|
}
|
|
const plistText = readFileSync(plist, "utf8");
|
|
if (
|
|
!plistText.includes("<key>NSScreenCaptureUsageDescription</key>") ||
|
|
!plistText.includes("<key>NSAudioCaptureUsageDescription</key>")
|
|
) {
|
|
fail("macOS Client capture usage descriptions are missing");
|
|
}
|
|
if (process.platform === "darwin") run(launcher, ["--help"], root);
|
|
}
|
|
}
|
|
|
|
if (process.argv.length !== 5) {
|
|
fail(
|
|
"Usage: node scripts/package-client-candidate.mjs <app-release-directory> <target> <new-output-directory>",
|
|
);
|
|
}
|
|
|
|
const repositoryRoot = realpathSync(resolve(dirname(fileURLToPath(import.meta.url)), ".."));
|
|
const applicationRoot = realpathSync(resolve(process.argv[2]));
|
|
const target = clientPackageTarget(process.argv[3]);
|
|
const outputRoot = resolve(process.cwd(), process.argv[4]);
|
|
if (!target) fail("Client package target is invalid");
|
|
if (process.platform !== target.nodePlatform || process.arch !== target.nodeArch) {
|
|
fail(`Client candidate ${target.id} requires its native runner`);
|
|
}
|
|
assertOutsideRepository(repositoryRoot, outputRoot);
|
|
|
|
const revision = run("git", ["rev-parse", "HEAD"], repositoryRoot).toLowerCase();
|
|
const descriptorPath = applicationDescriptor(applicationRoot);
|
|
const { version } = JSON.parse(readFileSync(descriptorPath, "utf8"));
|
|
const temporaryRoot = resetBuildWorkspace(repositoryRoot, "client-package", target.id, "candidate");
|
|
try {
|
|
const tunnelDownload = join(temporaryRoot, target.tunnelAsset);
|
|
await download(
|
|
`https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}/${target.tunnelAsset}`,
|
|
tunnelDownload,
|
|
);
|
|
if (sha256(tunnelDownload) !== target.tunnelSha256) {
|
|
fail("Public-link runtime digest does not match the pinned release");
|
|
}
|
|
|
|
let tunnel = tunnelDownload;
|
|
if (target.tunnelArchive) {
|
|
run(tarExecutable(), ["-xzf", tunnelDownload, "-C", temporaryRoot], repositoryRoot);
|
|
tunnel = join(temporaryRoot, target.tunnelName);
|
|
}
|
|
if (!existsSync(tunnel)) fail("Public-link runtime is missing");
|
|
if (process.platform !== "win32") chmodSync(tunnel, 0o755);
|
|
|
|
let capture = null;
|
|
if (target.captureName) {
|
|
run(
|
|
process.execPath,
|
|
[join(repositoryRoot, "scripts", "check-client.mjs"), "--capture-only"],
|
|
repositoryRoot,
|
|
);
|
|
capture = join(repositoryRoot, "build", "client-check", target.captureName);
|
|
}
|
|
|
|
const packageRoot = join(temporaryRoot, `piik-app-${target.id}`);
|
|
const assembleArguments = [
|
|
join(repositoryRoot, "scripts", "assemble-client.mjs"),
|
|
descriptorPath,
|
|
packageRoot,
|
|
"--target",
|
|
target.id,
|
|
"--tunnel",
|
|
tunnel,
|
|
];
|
|
if (capture) assembleArguments.push("--capture", capture);
|
|
run(process.execPath, assembleArguments, repositoryRoot);
|
|
|
|
mkdirSync(outputRoot, { recursive: false, mode: 0o700 });
|
|
const shortRevision = revision.slice(0, 7);
|
|
const archiveName = `piik-app-${target.id}-${shortRevision}.zip`;
|
|
const archive = join(outputRoot, archiveName);
|
|
createZip(packageRoot, archive);
|
|
const extractedRoot = join(temporaryRoot, "extracted");
|
|
mkdirSync(extractedRoot);
|
|
extractZip(archive, extractedRoot);
|
|
await verifyPackage(extractedRoot, target, revision, temporaryRoot);
|
|
const digest = sha256(archive);
|
|
writeFileSync(join(outputRoot, `${archiveName}.sha256`), `${digest} ${archiveName}\n`, "ascii");
|
|
writeFileSync(join(outputRoot, archiveName.replace(/\.zip$/, ".release.json")),
|
|
`${JSON.stringify({ schema: 2, version, revision, target: target.id,
|
|
artifact: archiveName, artifactSha256: digest }, null, 2)}\n`);
|
|
process.stdout.write(`${JSON.stringify({
|
|
target: target.id,
|
|
version,
|
|
revision,
|
|
archive,
|
|
sha256: digest,
|
|
})}\n`);
|
|
} finally {
|
|
rmSync(temporaryRoot, { recursive: true, force: true });
|
|
}
|