Skip to content

Commit cf26a2e

Browse files
beautyfreeclaude
andauthored
v0.2.2: static AppImage runtime — FUSE-agnostic Linux build (#10)
Replaces the stock AppImage runtime (libfuse.so.2 dynamic dep) with the static runtime from AppImage/type2-runtime on every Linux build. The static runtime bundles squashfuse directly, so the produced AppImage launches on: - Ubuntu/Debian (has libfuse2) ✓ - CachyOS / Manjaro / EndeavourOS (fuse3 only, no fuse2) ✓ NEW - NixOS / containers without FUSE kernel module ✓ (falls back to extract-and-run mode automatically) Implementation — `scripts/patch-appimage-runtime.mjs`, wired as an `afterAllArtifactBuild` hook in electron-builder.yml: 1. Download the static runtime from the type2-runtime continuous release once per build. Cached in build-resources/ (gitignored). 2. Find the squashfs magic ("hsqs") inside the AppImage produced by electron-builder — that's where the embedded filesystem starts. Everything before it is the old ELF runtime. 3. Concat [static runtime | squashfs image] and overwrite the AppImage. 4. Recompute sha512 + size, rewrite `latest-linux.yml` so electron-updater's signature check stays valid. ~90 LOC of straightforward Node, no native deps (uses the `yaml` package already in dependencies). Doesn't break the existing `.tar.xz` target — users still have both options, AppImage is just no longer friction on Arch-based distros. README updated to drop the libfuse2 caveat from the Linux row. Version bump to 0.2.2 so an 0.2.1 install can exercise the auto-updater flow into this release. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 32bcb42 commit cf26a2e

5 files changed

Lines changed: 139 additions & 2 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ build
1414
out
1515
artifacts
1616

17+
# Cached AppImage static runtime (downloaded by scripts/patch-appimage-runtime.mjs)
18+
/build-resources/appimage-runtime-*
19+
1720
# TypeScript project references incremental build output
1821
*.tsbuildinfo
1922
# Emitted by `tsc --build` for root config files (kept as side-effects)

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ Grab the installer for your OS from the [**latest release**](https://github.com/
8181
| --- | --- | --- |
8282
| macOS (Apple Silicon) | `Skiller-<version>-macos-arm64.dmg` | Signed + notarized. Open the DMG and drag Skiller to Applications. |
8383
| Windows (x64) | `Skiller-<version>-win-x64.exe` | NSIS installer. SmartScreen may show a one-time warning — click "More info" → "Run anyway". |
84-
| Linux (x64) | `Skiller-<version>-linux-x86_64.AppImage`, `.deb`, or `.tar.xz` | AppImage for most distros (`chmod +x`, run — needs `libfuse2`). `.deb` for Ubuntu/Debian. `.tar.xz` is the no-dependency escape hatch for Arch-based distros (CachyOS, Manjaro, EndeavourOS) — extract, run `./skiller`. |
84+
| Linux (x64) | `Skiller-<version>-linux-x86_64.AppImage`, `.deb`, or `.tar.xz` | AppImage: `chmod +x`, run — ships a static runtime with squashfuse built in, so no `libfuse2` required (works on CachyOS/Manjaro/EndeavourOS out of the box). `.deb` for Ubuntu/Debian. `.tar.xz` is the no-auto-integration extract-and-run alternative. |
8585

8686
Every release is built and published by the CI matrix in `.github/workflows/release.yml` — tagging `vX.Y.Z` produces all three platforms automatically.
8787

electron-builder.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ extraResources:
2626
asarUnpack:
2727
- "**/*.node"
2828

29+
# After every platform finishes, run post-processing. Currently: swap the
30+
# stock AppImage runtime for the static one from AppImage/type2-runtime so
31+
# the AppImage works on distros without libfuse2 (CachyOS and other Arch
32+
# spin-offs). Also recomputes sha512/size in latest-linux.yml.
33+
afterAllArtifactBuild: scripts/patch-appimage-runtime.mjs
34+
2935
# Notarization: electron-builder 26+ handles it natively when the expected env
3036
# vars are present (APPLE_API_KEY, APPLE_API_KEY_ID, APPLE_API_ISSUER for the
3137
# API-key flow; APPLE_ID + APPLE_APP_SPECIFIC_PASSWORD + APPLE_TEAM_ID as

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "skiller",
3-
"version": "0.2.1",
3+
"version": "0.2.2",
44
"description": "A desktop app for managing AI agent skills across Claude Code, Cursor, Copilot, Gemini CLI, and more",
55
"license": "MIT",
66
"author": {

scripts/patch-appimage-runtime.mjs

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
/**
2+
* electron-builder `afterAllArtifactBuild` hook.
3+
*
4+
* Swaps the ELF runtime at the head of every produced AppImage with the
5+
* static runtime from https://github.com/AppImage/type2-runtime. That runtime
6+
* ships with squashfuse compiled in, so the resulting AppImage launches on
7+
* systems that have libfuse2, libfuse3, or no FUSE at all — which matters
8+
* most for Arch-based distros (CachyOS, Manjaro, EndeavourOS) where libfuse2
9+
* is not preinstalled.
10+
*
11+
* AppImage layout (type 2):
12+
* [ ELF runtime (~180 KB) | squashfs FS image ('hsqs' magic) ]
13+
*
14+
* We locate the squashfs magic, drop everything before it, prepend the new
15+
* runtime. Then we recompute sha512 + size and patch `latest-linux.yml` so
16+
* electron-updater's signature check keeps passing.
17+
*/
18+
import { createHash } from "node:crypto";
19+
import { existsSync } from "node:fs";
20+
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
21+
import { basename, dirname, join } from "node:path";
22+
import YAML from "yaml";
23+
24+
const STATIC_RUNTIME_URL =
25+
"https://github.com/AppImage/type2-runtime/releases/download/continuous/runtime-x86_64";
26+
const RUNTIME_CACHE_DIR = join(process.cwd(), "build-resources");
27+
const RUNTIME_CACHE_PATH = join(RUNTIME_CACHE_DIR, "appimage-runtime-x86_64");
28+
29+
/** Little-endian squashfs magic: "hsqs" (0x68 0x73 0x71 0x73). */
30+
const SQUASHFS_MAGIC = Buffer.from([0x68, 0x73, 0x71, 0x73]);
31+
32+
async function downloadStaticRuntime() {
33+
if (existsSync(RUNTIME_CACHE_PATH)) {
34+
return RUNTIME_CACHE_PATH;
35+
}
36+
await mkdir(RUNTIME_CACHE_DIR, { recursive: true });
37+
console.log(
38+
`[patch-appimage] downloading static runtime from ${STATIC_RUNTIME_URL}`,
39+
);
40+
const res = await fetch(STATIC_RUNTIME_URL);
41+
if (!res.ok) {
42+
throw new Error(
43+
`[patch-appimage] failed to download static runtime: HTTP ${res.status}`,
44+
);
45+
}
46+
const buf = Buffer.from(await res.arrayBuffer());
47+
await writeFile(RUNTIME_CACHE_PATH, buf);
48+
await chmod(RUNTIME_CACHE_PATH, 0o755);
49+
return RUNTIME_CACHE_PATH;
50+
}
51+
52+
async function patchAppImage(appImagePath) {
53+
const runtimePath = await downloadStaticRuntime();
54+
const [runtime, original] = await Promise.all([
55+
readFile(runtimePath),
56+
readFile(appImagePath),
57+
]);
58+
59+
const squashfsOffset = original.indexOf(SQUASHFS_MAGIC);
60+
if (squashfsOffset === -1) {
61+
throw new Error(
62+
`[patch-appimage] squashfs magic not found in ${appImagePath}`,
63+
);
64+
}
65+
console.log(
66+
`[patch-appimage] ${basename(appImagePath)}: squashfs@${squashfsOffset}, runtime=${runtime.length}B (was ${squashfsOffset}B)`,
67+
);
68+
69+
const patched = Buffer.concat([runtime, original.subarray(squashfsOffset)]);
70+
await writeFile(appImagePath, patched);
71+
await chmod(appImagePath, 0o755);
72+
return patched;
73+
}
74+
75+
async function updateLatestLinuxYaml(ymlPath, appImageName, patched) {
76+
if (!existsSync(ymlPath)) {
77+
console.log(
78+
`[patch-appimage] ${basename(ymlPath)} not found — skipping manifest update`,
79+
);
80+
return;
81+
}
82+
const raw = await readFile(ymlPath, "utf-8");
83+
const manifest = YAML.parse(raw);
84+
const sha512 = createHash("sha512").update(patched).digest("base64");
85+
const size = patched.length;
86+
87+
let changed = false;
88+
for (const file of manifest?.files ?? []) {
89+
if (file?.url === appImageName) {
90+
file.sha512 = sha512;
91+
file.size = size;
92+
changed = true;
93+
}
94+
}
95+
if (manifest?.path === appImageName) {
96+
manifest.sha512 = sha512;
97+
changed = true;
98+
}
99+
100+
if (changed) {
101+
await writeFile(ymlPath, YAML.stringify(manifest));
102+
console.log(
103+
`[patch-appimage] rewrote ${basename(ymlPath)} with new sha512/size`,
104+
);
105+
} else {
106+
console.warn(
107+
`[patch-appimage] ${basename(ymlPath)} had no entry for ${appImageName}`,
108+
);
109+
}
110+
}
111+
112+
export default async function afterAllArtifactBuild(buildResult) {
113+
const appImages = (buildResult?.artifactPaths ?? []).filter((p) =>
114+
p.endsWith(".AppImage"),
115+
);
116+
if (appImages.length === 0) {
117+
return [];
118+
}
119+
120+
for (const appImagePath of appImages) {
121+
const patched = await patchAppImage(appImagePath);
122+
const ymlPath = join(dirname(appImagePath), "latest-linux.yml");
123+
await updateLatestLinuxYaml(ymlPath, basename(appImagePath), patched);
124+
}
125+
126+
// Don't advertise additional artifacts — we mutated existing ones in place.
127+
return [];
128+
}

0 commit comments

Comments
 (0)