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:
13 files changed
+97
-21
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -64,7 +64,7 @@ Piik 是一个免费开源的屏幕共享工具,适合游戏围观、一起看
|
||||
Piik 默认使用图示按钮;点击顶部的**中**可显示文字。
|
||||
客户端提供本地房间、临时公网邀请和连接已有站点三种模式,分享期间需保持 App 运行。
|
||||
|
||||
下载以 **`piik-app`** 开头的压缩包,再按文件名中的平台标识选择:
|
||||
下载以 **`piik-app`** 开头的 ZIP 压缩包,再按文件名中的平台标识选择:
|
||||
|
||||
| 文件名包含 | 适用平台 |
|
||||
| --- | --- |
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>';
|
||||
|
||||
@@ -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
@@ -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];
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
@@ -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 }); }
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user