fix(release): provide directly downloadable ZIP app packages (#391)

## 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.
This commit is contained in:
AAA authored and GitHub committed 2026-09-12 23:15:53 +08:00
1 parent 4ff970f483
commit 0dde5da859
13 files changed
+97 -21

No files matched your search

+3 -1
View File
@@ -168,10 +168,12 @@ jobs:
# installed here too.
- name: Install dependencies
run: npm ci
- name: Install Linux native capture dependencies
- name: Install Linux packaging and native capture dependencies
if: matrix.target == 'linux-amd64'
run: >-
sudo apt-get update && sudo apt-get install -y
zip
unzip
libportal-dev
libgstreamer1.0-dev
libgstreamer-plugins-base1.0-dev
+1 -1
View File
@@ -67,7 +67,7 @@ Piik uses icon controls by default; select **EN** in the header to show labels.
The App offers a local room, a temporary public link, or a connection to your own
site. Keep the App running while sharing through it.
Download an archive beginning with **`piik-app`**. The filename also identifies the platform:
Download a ZIP beginning with **`piik-app`**. The filename also identifies the platform:
| Filename contains | Platform |
| --- | --- |
+1 -1
View File
@@ -64,7 +64,7 @@ Piik 是一个免费开源的屏幕共享工具,适合游戏围观、一起看
Piik 默认使用图示按钮;点击顶部的**中**可显示文字。
客户端提供本地房间、临时公网邀请和连接已有站点三种模式,分享期间需保持 App 运行。
下载以 **`piik-app`** 开头的压缩包,再按文件名中的平台标识选择:
下载以 **`piik-app`** 开头的 ZIP 压缩包,再按文件名中的平台标识选择:
| 文件名包含 | 适用平台 |
| --- | --- |
+1 -1
View File
@@ -92,7 +92,7 @@ node scripts/package-client-candidate.mjs \
The wrapper downloads the pinned public-link sidecar, verifies its digest,
builds the target App and available capture process, executes every packaged
runtime, and emits one `tar.gz` plus its SHA-256 file.
runtime from the extracted ZIP, and emits one `.zip` plus its SHA-256 file.
Retain the descriptor and successful deployment output as release metadata. Do
not create a follow-up source commit solely to duplicate their revision, asset,
+1 -1
View File
@@ -74,7 +74,7 @@ ranges for seeking the soundtrack. Keep asset links relative so both a custom
domain and GitHub's `/Piik/` project path work.
Downloads are static links to the latest published GitHub packages, with explicit
Windows x64, macOS Apple silicon and Linux x64 cards. `PIIK_WEBSITE_RELEASE_DATA`
Windows x64, macOS Apple silicon and Linux x64 ZIP cards. `PIIK_WEBSITE_RELEASE_DATA`
can point the build at a temporary JSON containing the GitHub release and the
same-tag Gitee release with its attachment list. CI supplies this input; the
builder validates version, source identity and platform URLs. A missing/incomplete
+23 -1
View File
@@ -1,5 +1,6 @@
import { execFileSync } from "node:child_process";
import { existsSync } from "node:fs";
import { join } from "node:path";
import { join, resolve } from "node:path";
export function tarExecutable() {
if (process.platform !== "win32") return "tar";
@@ -8,3 +9,24 @@ export function tarExecutable() {
const systemTar = join(systemRoot, "System32", "tar.exe");
return existsSync(systemTar) ? systemTar : "tar";
}
export function createZip(source, archive) {
archive = resolve(archive);
if (process.platform === "linux") {
execFileSync("zip", ["-qry", archive, "."], { cwd: source, stdio: "pipe" });
} else {
execFileSync(tarExecutable(), ["--format=zip", "-cf", archive, "."], {
cwd: source, stdio: "pipe", windowsHide: true,
});
}
}
export function extractZip(archive, destination) {
if (process.platform === "linux") {
execFileSync("unzip", ["-q", resolve(archive), "-d", destination], { stdio: "pipe" });
} else {
execFileSync(tarExecutable(), ["-xpf", resolve(archive), "-C", destination], {
stdio: "pipe", windowsHide: true,
});
}
}
+4 -4
View File
@@ -41,8 +41,8 @@ if (process.env.PIIK_WEBSITE_RELEASE_DATA) {
const release = websiteDownloads(JSON.parse(await readFile(process.env.PIIK_WEBSITE_RELEASE_DATA, "utf8")));
homepage = homepage.replace(/<p class="release-note" id="download-status">[\s\S]*?<\/p>/,
`<p class="release-note" id="download-status"><span class="release-label">${release.version}</span>` +
`<span lang="en">Download for your system, then extract the archive.</span>` +
`<span lang="zh-CN">选择对应系统,下载后解压。</span>` +
`<span lang="en">Download the ZIP for your system, then extract it.</span>` +
`<span lang="zh-CN">选择对应系统,下载 ZIP 后解压。</span>` +
`<a href="${release.notes}"><span lang="en">Release notes</span><span lang="zh-CN">版本说明</span></a></p>`);
homepage = homepage.replace(/<a\b([^>]*data-download="([^"]+)"[^>]*data-provider="([^"]+)"[^>]*)>([\s\S]*?)<\/a\s*>/g,
(original, attributes, target, provider, content) => {
@@ -50,8 +50,8 @@ if (process.env.PIIK_WEBSITE_RELEASE_DATA) {
const href = provider === 'github' ? item?.url : item?.mirrorURL;
if (!href) return original;
const label = provider === 'github'
? { en: 'Download from GitHub', zh: 'GitHub 下载' }
: { en: 'Download from Gitee', zh: 'Gitee 备用下载' };
? { en: 'Download ZIP · GitHub', zh: '下载 ZIP · GitHub' }
: { en: 'Gitee alternative (ZIP)', zh: 'Gitee 备用下载(ZIP)' };
return `<a${attributes.replace(/href="[^"]*"/, `href="${href}"`)}>` + content
.replace(/<span lang="en">[\s\S]*?<\/span\s*>/, `<span lang="en">${label.en}</span>`)
.replace(/<span lang="zh-CN">[\s\S]*?<\/span\s*>/, `<span lang="zh-CN">${label.zh}</span>`) + '</a>';
+8 -5
View File
@@ -21,7 +21,7 @@ import { pipeline } from "node:stream/promises";
import { fileURLToPath } from "node:url";
import { clientPackageTarget, CLOUDFLARED_VERSION } from "./client-package-targets.mjs";
import { tarExecutable } from "./archive-tool.mjs";
import { createZip, extractZip, tarExecutable } from "./archive-tool.mjs";
import { resetBuildWorkspace } from "./build-workspace.mjs";
function fail(message) {
@@ -366,16 +366,19 @@ try {
];
if (capture) assembleArguments.push("--capture", capture);
run(process.execPath, assembleArguments, repositoryRoot);
await verifyPackage(packageRoot, target, revision, temporaryRoot);
mkdirSync(outputRoot, { recursive: false, mode: 0o700 });
const shortRevision = revision.slice(0, 7);
const archiveName = `piik-app-${target.id}-${shortRevision}.tar.gz`;
const archiveName = `piik-app-${target.id}-${shortRevision}.zip`;
const archive = join(outputRoot, archiveName);
run(tarExecutable(), ["-czf", archive, "-C", packageRoot, "."], repositoryRoot);
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(/\.tar\.gz$/, ".release.json")),
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({
+1 -1
View File
@@ -21,7 +21,7 @@ export function websiteDownloads(data: { github: unknown; mirror?: unknown }) {
version: release.version,
notes: release.url,
packages: targets.map(target => {
const name = `piik-app-${target}-${release.revision!.slice(0, 7)}.tar.gz`;
const name = `piik-app-${target}-${release.revision!.slice(0, 7)}.zip`;
const url = `https://github.com/TNTcraftHIM/Piik/releases/download/${release.version}/${name}`;
const matches = primaryAssets.filter(asset => asset.name === name);
const asset = matches[0];
+49
View File
@@ -0,0 +1,49 @@
import { execFileSync } from "node:child_process";
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { describe, expect, it } from "vitest";
import { createZip, extractZip } from "../scripts/archive-tool.mjs";
describe("App ZIP distribution", () => {
it("round-trips hidden files, spaced paths and executable permissions", () => {
const root = mkdtempSync(join(tmpdir(), "piik-archive-"));
try {
const source = join(root, "assembled app");
const extracted = join(root, "extracted app");
const archive = join(root, "Piik App.zip");
const files = {
"piik-app": "#!/bin/sh\nexit 0\n",
"Piik App.app/Contents/MacOS/launcher": "#!/bin/sh\nexit 0\n",
"runtime/.native/画面.txt": "Native capture\n",
".release-metadata": "Hidden metadata\n",
};
for (const [name, content] of Object.entries(files)) {
const path = join(source, name);
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, content);
}
const executables = ["piik-app", "Piik App.app/Contents/MacOS/launcher"];
for (const name of executables) chmodSync(join(source, name), 0o755);
createZip(source, archive);
expect(readFileSync(archive).subarray(0, 4)).toEqual(Buffer.from([0x50, 0x4b, 0x03, 0x04]));
mkdirSync(extracted);
extractZip(archive, extracted);
expect(readdirSync(extracted, { recursive: true }).sort()).toEqual(
readdirSync(source, { recursive: true }).sort(),
);
for (const [name, content] of Object.entries(files)) {
expect(readFileSync(join(extracted, name), "utf8")).toBe(content);
}
if (process.platform !== "win32") {
for (const name of executables) {
expect(statSync(join(extracted, name)).mode & 0o777).toBe(0o755);
execFileSync(join(extracted, name), [], { cwd: extracted });
}
}
} finally {
rmSync(root, { recursive: true, force: true });
}
});
});
+1 -1
View File
@@ -14,7 +14,7 @@ const directory = mkdtempSync(join(tmpdir(), "piik-publisher-"));
const version = "v1.0.1", revision = "a".repeat(40);
const hash = (value: string | Buffer) => createHash("sha256").update(value).digest("hex");
for (const target of ["server", "windows-amd64", "linux-amd64", "darwin-arm64"]) {
const artifact = `${target}.tar.gz`;
const artifact = `${target}.${target === "server" ? "tar.gz" : "zip"}`;
writeFileSync(join(directory, artifact), target);
writeFileSync(join(directory, `${target}.release.json`), JSON.stringify({
schema: 2, version, revision, artifact, artifactSha256: hash(target),
+2 -2
View File
@@ -160,7 +160,7 @@ describe("release automation", () => {
const run = () => spawnSync(process.execPath, [publisher, root, version, revision, "--dry-run"], { encoding: "utf8" });
try {
for (const target of ["server", "windows-amd64", "linux-amd64", "darwin-arm64"]) {
const artifact = `${target}.tar.gz`;
const artifact = `${target}.${target === "server" ? "tar.gz" : "zip"}`;
writeFileSync(join(root, artifact), target);
const descriptor = { schema: 2, version, revision, artifact, artifactSha256: sha(target),
...(target === "server" ? { manifest: "server.manifest.tsv", manifestSha256: sha("manifest") } : { target }) };
@@ -176,7 +176,7 @@ describe("release automation", () => {
writeFileSync(path, JSON.stringify({ ...JSON.parse(original), revision: "b".repeat(40) }));
expect(run().stderr).toContain("Release identity mismatch");
writeFileSync(path, original);
writeFileSync(join(root, "windows-amd64.tar.gz"), "changed bytes");
writeFileSync(join(root, "windows-amd64.zip"), "changed bytes");
expect(run().stderr).toContain("checksum mismatch");
} finally { rmSync(root, { recursive: true, force: true }); }
});
+2 -2
View File
@@ -7,7 +7,7 @@ const github = {
tag_name: version, target_commitish: revision, draft: false, prerelease: false,
html_url: `https://github.com/TNTcraftHIM/Piik/releases/tag/${version}`,
assets: ["windows-amd64", "darwin-arm64", "linux-amd64"].map(target => {
const name = `piik-app-${target}-${revision.slice(0, 7)}.tar.gz`;
const name = `piik-app-${target}-${revision.slice(0, 7)}.zip`;
return { name, state: "uploaded", size: 1024,
browser_download_url: `https://github.com/TNTcraftHIM/Piik/releases/download/${version}/${name}` };
}),
@@ -43,7 +43,7 @@ describe("website package downloads", () => {
{ ...mirror, tag_name: "v1.1.0" }, { ...mirror, assets: [] }]) {
expect(websiteDownloads({ github, mirror: changed }).packages.every(item => item.mirrorURL === null)).toBe(true);
}
for (const change of [{ size: 2048 }, { browser_download_url: "https://evil.example/package.tar.gz" }]) {
for (const change of [{ size: 2048 }, { browser_download_url: "https://evil.example/package.zip" }]) {
const result = websiteDownloads({ github,
mirror: { ...mirror, assets: [{ ...mirror.assets[0], ...change }, ...mirror.assets.slice(1)] } });
expect(result.packages[0]!.mirrorURL).toBeNull();