Add production .deb packaging, apt setup, desktop integration, and icons
Template tests / tests (pull_request) Failing after 31s

Phase 3 of the improvement plan (PR 8 of the sequence): the apt/X11 packaging
half of Linux support, in separate Linux-specific files. Replaces the old
scripts/package-linux.sh, which the audit flagged as "not production
packaging" (it copied the whole dev node_modules — including vulnerable build
deps — plus docs/prompts/examples/audit files, hardcoded amd64, declared only
xinput, lacked desktop/icon/MIME integration, and could build without
node_modules).

Production builder (packaging/linux/debian/package.sh):
- Stages ONLY runtime files: app code, a fixed Electron runtime, and the
  production npm deps (enumerated via npm ls --omit=dev). Never copies the
  development node_modules; guards against electron-builder/app-builder-lib
  leaking in. Fails if node_modules is absent instead of shipping an unusable
  artifact.
- Detects architecture (dpkg --print-architecture, x64/arm64) rather than
  hardcoding amd64. Generates DEBIAN/control from control.in with proper
  runtime Depends, real maintainer, and homepage.
- Installs a desktop entry, hicolor icons (16–512), .sfgz/.sfglt MIME
  registration, the launcher, and the license. postinst makes chrome-sandbox
  setuid and refreshes desktop/MIME/icon caches; postrm cleans them.
- Emits a .deb, a portable tarball that now INCLUDES /usr/bin/stepforge (the
  old tarball omitted it), and a sha256 sums file.

Launcher (packaging/linux/common/launcher.sh):
- Runs sandboxed; prefers the user-namespace sandbox, accepts a root-owned
  setuid helper, and otherwise refuses to launch with an actionable message.
  --no-sandbox requires an explicit STEPFORGE_ALLOW_NO_SANDBOX opt-in. Never
  installs anything at runtime.

Setup (separate build vs runtime, apt only):
- scripts/linux/apt/install-runtime-deps.sh (Chromium/Electron libs, X11
  tools, portal/PipeWire) and install-build-deps.sh (dpkg-dev, fakeroot,
  xvfb). Runtime script installs no build tools.

Assets: original StepForge icon — packaging/assets/stepforge.svg plus a
generator (scripts/make-icons.js) that renders the PNG set with the repo's own
rasterizer/PNG writer (no third-party art). npm run icons regenerates them.

Wiring: package.json gains package:linux:deb / package:linux:rpm / icons;
build-release.sh uses the production builder and requires node_modules; README
points at the apt/dnf guides.

Tests: tests/unit/packaging-linux.test.js (structural: files present in their
separate locations, old script gone, valid desktop entry, templated arch +
runtime Depends, launcher gates --no-sandbox, builder requires node_modules
and guards dev-dep leaks, apt build/runtime dep separation, original icon set
generates a valid PNG) runs in the normal suite;
tests/integration/linux/package-deb.test.sh builds a real .deb and asserts the
right files present and the dev tree / build tooling / app docs absent
(honest skip only when dpkg-deb/node_modules are genuinely missing).

Verified locally: 276 unit tests pass; the integration test builds and
validates stepforge_0.3.2_amd64.deb; build-release E2E passes with the new
production package.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
2026-07-03 23:26:44 -05:00
co-authored by Claude Fable 5
parent 901940993c
commit fedf1d24c0
24 changed files with 671 additions and 94 deletions
+6 -1
View File
@@ -73,7 +73,12 @@ using only Node built-ins.
For a Windows installation, see [docs/windows_installation](docs/windows_installation.md) or for a developer/more in depth walkthrough, see [docs/GETTING_STARTED.md](docs/GETTING_STARTED.md).
On **Linux** (⚠️ work in progress — X11 vs Wayland, enabling per-click capture, the screen-share prompt), see [docs/GETTING_STARTED_WITH_LINUX.md](docs/GETTING_STARTED_WITH_LINUX.md).
On **Linux**, install from a package built for your distro family:
apt-based (Debian/Ubuntu) → [docs/linux/apt.md](docs/linux/apt.md); dnf-based
(Fedora) → [docs/linux/dnf.md](docs/linux/dnf.md). Wayland uses the XDG portal
for screen capture and a hotkey/interval trigger (per-click capture with a
marker needs X11 + xinput). The general developer walkthrough is
[docs/GETTING_STARTED_WITH_LINUX.md](docs/GETTING_STARTED_WITH_LINUX.md).
Requirements: Node.js 22.12+ and npm (pinned in `.nvmrc`; installs are
refused on older Nodes because the packaging toolchain needs 22.12+).
+67
View File
@@ -0,0 +1,67 @@
# StepForge on apt-based Linux (Debian / Ubuntu)
This is the setup and packaging guide for **apt-based** distributions. Fedora
and other dnf-based systems have a separate guide: [dnf.md](dnf.md).
## Install from the .deb
```bash
sudo apt install ./stepforge_<version>_amd64.deb
```
apt pulls the required runtime libraries automatically (they are declared as
`Depends`). The package installs:
- the app and a fixed Electron runtime under `/opt/stepforge`,
- the `stepforge` launcher at `/usr/bin/stepforge`,
- a desktop entry, icons, and `.sfgz`/`.sfglt` file associations.
Launch it from your application menu or run `stepforge`.
### Sandbox
The launcher runs **sandboxed**. On most modern kernels the Chromium
user-namespace sandbox works out of the box; the package's `postinst` also
makes the setuid `chrome-sandbox` helper usable as a fallback. StepForge will
**not** silently launch unsandboxed — see the launcher's message if the
sandbox is unavailable.
## Install from the portable tarball
```bash
tar -xzf stepforge_<version>_linux-x64.tar.gz
# Install the runtime libraries first (see below), then run:
./usr/bin/stepforge # or move opt/stepforge to /opt and use the launcher
```
The tarball includes the `/usr/bin/stepforge` launcher (unlike older builds).
Install the runtime libraries with:
```bash
bash scripts/linux/apt/install-runtime-deps.sh
```
## Capture capabilities on apt systems
- **X11**: full per-click capture with an accurate marker (needs `xinput`).
- **Wayland**: screen capture via the XDG Desktop Portal + PipeWire; the
portal asks permission once per recording. Per-click capture with
coordinates is not exposed by Wayland, so recording uses a global hotkey or
interval trigger. StepForge reports the active trigger honestly.
Run StepForge and open Settings → Diagnostics to see the detected session
type, portal/PipeWire status, and the active capture profile.
## Build the .deb yourself
```bash
bash scripts/linux/apt/install-build-deps.sh # dpkg-dev, fakeroot, xvfb, …
nvm install && nvm use # pinned Node 22 (see .nvmrc)
npm ci
npm run package:linux:deb # -> build/artifacts/*.deb + tarball + sha256
```
The builder stages **only** runtime files: the app code, a fixed Electron
runtime, and production npm dependencies. It never copies the development
`node_modules`, docs, prompts, or examples, and it fails if `node_modules` is
missing rather than producing an unusable artifact.
+3
View File
@@ -14,7 +14,10 @@
"start": "node scripts/start-electron.js",
"test": "node scripts/run-unit-tests.js",
"sample": "node scripts/make-sample-guide.js",
"icons": "node scripts/make-icons.js",
"package:windows": "node scripts/package-windows.js",
"package:linux:deb": "bash packaging/linux/debian/package.sh",
"package:linux:rpm": "bash packaging/linux/fedora/package.sh",
"build": "bash scripts/build-release.sh",
"verify": "bash scripts/verify.sh",
"bootstrap": "bash scripts/bootstrap-offline.sh"
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 352 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 482 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 611 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

+23
View File
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
StepForge application icon — original artwork.
A rising staircase of three blocks (the "steps" of a step-by-step guide)
over a rounded square, in the app's blue. No third-party assets.
-->
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#2563eb"/>
<stop offset="1" stop-color="#1e3a8a"/>
</linearGradient>
</defs>
<rect x="0" y="0" width="256" height="256" rx="56" fill="url(#bg)"/>
<!-- Three ascending steps -->
<g fill="#ffffff">
<rect x="52" y="150" width="52" height="54" rx="8"/>
<rect x="102" y="116" width="52" height="88" rx="8"/>
<rect x="152" y="82" width="52" height="122" rx="8"/>
</g>
<!-- Capture spark on the top step -->
<circle cx="178" cy="60" r="16" fill="#facc15"/>
</svg>

After

Width:  |  Height:  |  Size: 925 B

+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env sh
# StepForge launcher installed at /usr/bin/stepforge.
#
# Runs the packaged Electron runtime against the installed app at
# /opt/stepforge. It NEVER installs or repairs anything at runtime and it does
# NOT silently disable the Chromium sandbox: an unsandboxed launch requires the
# explicit STEPFORGE_ALLOW_NO_SANDBOX=1 opt-in (development/CI only).
set -eu
APP_DIR=/opt/stepforge
ELECTRON="$APP_DIR/node_modules/electron/dist/electron"
SANDBOX_HELPER="$APP_DIR/node_modules/electron/dist/chrome-sandbox"
if [ ! -x "$ELECTRON" ]; then
echo "stepforge: Electron runtime missing at $ELECTRON (reinstall the package)." >&2
exit 1
fi
cd "$APP_DIR" || exit 1
# Linux screen capture: enable the PipeWire path for Wayland portals; harmless
# on X11 where Ozone auto-selects.
COMMON_ARGS="--enable-features=WebRTCPipeWireCapturer --ozone-platform-hint=auto"
sandbox_ok() {
[ -e "$SANDBOX_HELPER" ] || return 1
helper_uid="$(stat -c '%u' "$SANDBOX_HELPER" 2>/dev/null || echo '')"
helper_mode="$(stat -c '%a' "$SANDBOX_HELPER" 2>/dev/null || echo '')"
[ "$helper_uid" = "0" ] || return 1
[ -n "$helper_mode" ] || return 1
# setuid bit set?
[ $(( $((8#$helper_mode)) & 04000 )) -ne 0 ] || return 1
return 0
}
userns_ok() {
# Namespaced sandbox works without the setuid helper on kernels that allow
# unprivileged user namespaces.
if [ -r /proc/sys/kernel/unprivileged_userns_clone ]; then
[ "$(cat /proc/sys/kernel/unprivileged_userns_clone)" = "1" ] && return 0 || return 1
fi
if [ -r /proc/sys/kernel/apparmor_restrict_unprivileged_userns ]; then
[ "$(cat /proc/sys/kernel/apparmor_restrict_unprivileged_userns)" = "0" ] && return 0 || return 1
fi
[ -e /proc/self/ns/user ] && return 0 || return 1
}
if sandbox_ok || userns_ok; then
exec "$ELECTRON" $COMMON_ARGS "$APP_DIR" "$@"
fi
if [ "${STEPFORGE_ALLOW_NO_SANDBOX:-}" = "1" ] || [ "${ELECTRON_DISABLE_SANDBOX:-}" = "1" ]; then
echo "stepforge: launching WITHOUT the Chromium sandbox (explicit opt-in)." >&2
exec "$ELECTRON" --no-sandbox $COMMON_ARGS "$APP_DIR" "$@"
fi
cat >&2 <<'MSG'
stepforge: the Chromium sandbox is not available and StepForge will not launch
unsandboxed by default.
Fix one of the following:
* Make the setuid sandbox helper usable:
sudo chown root:root /opt/stepforge/node_modules/electron/dist/chrome-sandbox
sudo chmod 4755 /opt/stepforge/node_modules/electron/dist/chrome-sandbox
* Enable unprivileged user namespaces (kernel/sysctl dependent):
sudo sysctl -w kernel.unprivileged_userns_clone=1
For development/CI only you may set STEPFORGE_ALLOW_NO_SANDBOX=1 to override.
MSG
exit 1
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<mime-info xmlns="http://www.freedesktop.org/standards/shared-mime-info">
<mime-type type="application/x-stepforge-guide">
<comment>StepForge guide archive</comment>
<glob pattern="*.sfgz"/>
<icon name="stepforge"/>
</mime-type>
<mime-type type="application/x-stepforge-template">
<comment>StepForge export template</comment>
<glob pattern="*.sfglt"/>
<icon name="stepforge"/>
</mime-type>
</mime-info>
+13
View File
@@ -0,0 +1,13 @@
[Desktop Entry]
Type=Application
Name=StepForge
GenericName=Step-by-step guide capture
Comment=Capture, annotate, and export step-by-step guides
Exec=stepforge %U
Icon=stepforge
Terminal=false
Categories=Office;Graphics;Utility;
Keywords=documentation;screenshot;guide;capture;steps;
StartupNotify=true
StartupWMClass=StepForge
MimeType=application/x-stepforge-guide;
+17
View File
@@ -0,0 +1,17 @@
Package: stepforge
Version: @VERSION@
Section: utils
Priority: optional
Architecture: @ARCH@
Depends: libnss3, libnspr4, libatk1.0-0, libatk-bridge2.0-0, libcups2, libgbm1, libasound2, libgtk-3-0, libxkbcommon0, libatspi2.0-0
Recommends: xinput, x11-utils, xdg-desktop-portal, pipewire
Maintainer: @MAINTAINER@
Homepage: https://github.com/Twest2/StepForge
Description: Local-first step-by-step guide capture and export tool
StepForge captures step-by-step workflows as screenshots, lets you annotate
and describe each step, and exports to Markdown, PDF, DOCX, PPTX, HTML, and
more. Local-first: no telemetry, with an optional user-configured local AI
integration.
.
This package bundles a fixed Electron runtime and only production
dependencies; it does not install anything at runtime.
+143
View File
@@ -0,0 +1,143 @@
#!/usr/bin/env bash
# Build a production StepForge .deb (and a matching portable tarball) from a
# pruned, runtime-only tree.
#
# Unlike the old scripts/package-linux.sh this does NOT copy the development
# node_modules, docs, prompts, examples, or stale audit files; it stages only
# the app code plus a runtime dependency set (the fixed Electron runtime and
# production npm deps), a real desktop entry, icons, MIME registration, and a
# license. Architecture is detected, not hardcoded.
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
cd "$ROOT_DIR"
VERSION="$(node -p "require('./package.json').version")"
MAINTAINER="${STEPFORGE_MAINTAINER:-StepForge <[email protected]>}"
OUT_DIR="${STEPFORGE_PACKAGE_DIR:-$ROOT_DIR/build/artifacts}"
mkdir -p "$OUT_DIR"
# Map dpkg architecture to a Node-style label for the tarball name.
DEB_ARCH="$(dpkg --print-architecture 2>/dev/null || echo amd64)"
case "$DEB_ARCH" in
amd64) NODE_ARCH="x64" ;;
arm64) NODE_ARCH="arm64" ;;
*) NODE_ARCH="$DEB_ARCH" ;;
esac
# A packaged app must contain a fixed runtime; never install at build time from
# within the package step, and never ship without node_modules.
if [ ! -d "$ROOT_DIR/node_modules/electron/dist" ]; then
echo "error: node_modules/electron is missing. Run 'npm ci' before packaging." >&2
exit 1
fi
WORK_DIR="$(mktemp -d "${OUT_DIR%/}/.deb.XXXXXX")"
trap 'rm -rf "$WORK_DIR"' EXIT
APP_DIR="$WORK_DIR/opt/stepforge"
mkdir -p "$APP_DIR" "$WORK_DIR/usr/bin" "$WORK_DIR/DEBIAN"
mkdir -p "$WORK_DIR/usr/share/applications"
mkdir -p "$WORK_DIR/usr/share/mime/packages"
mkdir -p "$WORK_DIR/usr/share/doc/stepforge"
# --- application code (runtime only) ----------------------------------------
for item in app core exporters package.json package-lock.json; do
cp -a "$ROOT_DIR/$item" "$APP_DIR/$item"
done
# --- runtime node_modules ----------------------------------------------------
# The fixed Electron runtime (needed at runtime even though it is a dev dep):
mkdir -p "$APP_DIR/node_modules"
cp -a "$ROOT_DIR/node_modules/electron" "$APP_DIR/node_modules/electron"
# Production npm dependencies (tesseract.js + language data + transitive):
while IFS= read -r dep; do
[ -n "$dep" ] || continue
rel="${dep#"$ROOT_DIR"/}"
[ "$rel" != "$dep" ] || continue # only paths under the repo
[ -d "$dep" ] || continue
mkdir -p "$APP_DIR/$(dirname "$rel")"
cp -a "$dep" "$APP_DIR/$rel"
done < <(npm ls --omit=dev --all --parseable 2>/dev/null | tail -n +2)
# Guard: the development-only packaging toolchain must not have leaked in.
if [ -d "$APP_DIR/node_modules/electron-builder" ] || [ -d "$APP_DIR/node_modules/app-builder-lib" ]; then
echo "error: build-only dependency leaked into the package payload." >&2
exit 1
fi
# --- launcher ----------------------------------------------------------------
install -m 0755 "$ROOT_DIR/packaging/linux/common/launcher.sh" "$WORK_DIR/usr/bin/stepforge"
# --- desktop entry, icons, MIME ---------------------------------------------
install -m 0644 "$ROOT_DIR/packaging/linux/common/stepforge.desktop" "$WORK_DIR/usr/share/applications/stepforge.desktop"
install -m 0644 "$ROOT_DIR/packaging/linux/common/stepforge-mime.xml" "$WORK_DIR/usr/share/mime/packages/stepforge.xml"
for size in 16 32 48 64 128 256 512; do
icon="$ROOT_DIR/packaging/assets/icons/stepforge-${size}.png"
[ -f "$icon" ] || continue
dest="$WORK_DIR/usr/share/icons/hicolor/${size}x${size}/apps"
mkdir -p "$dest"
install -m 0644 "$icon" "$dest/stepforge.png"
done
# --- license + docs pointer --------------------------------------------------
if [ -f "$ROOT_DIR/LICENSE" ]; then
install -m 0644 "$ROOT_DIR/LICENSE" "$WORK_DIR/usr/share/doc/stepforge/copyright"
elif [ -f "$ROOT_DIR/docs/LICENSE" ]; then
install -m 0644 "$ROOT_DIR/docs/LICENSE" "$WORK_DIR/usr/share/doc/stepforge/copyright"
fi
# --- DEBIAN control + maintainer scripts ------------------------------------
sed -e "s/@VERSION@/$VERSION/" -e "s/@ARCH@/$DEB_ARCH/" -e "s#@MAINTAINER@#$MAINTAINER#" \
"$ROOT_DIR/packaging/linux/debian/control.in" > "$WORK_DIR/DEBIAN/control"
cat > "$WORK_DIR/DEBIAN/postinst" <<'POSTINST'
#!/bin/sh
set -e
# Make the Chromium setuid sandbox helper usable so the app launches sandboxed.
HELPER=/opt/stepforge/node_modules/electron/dist/chrome-sandbox
if [ -e "$HELPER" ]; then
chown root:root "$HELPER" || true
chmod 4755 "$HELPER" || true
fi
# Refresh desktop/MIME/icon caches (best effort).
if command -v update-desktop-database >/dev/null 2>&1; then update-desktop-database -q /usr/share/applications || true; fi
if command -v update-mime-database >/dev/null 2>&1; then update-mime-database /usr/share/mime || true; fi
if command -v gtk-update-icon-cache >/dev/null 2>&1; then gtk-update-icon-cache -q /usr/share/icons/hicolor || true; fi
exit 0
POSTINST
cat > "$WORK_DIR/DEBIAN/prerm" <<'PRERM'
#!/bin/sh
set -e
exit 0
PRERM
cat > "$WORK_DIR/DEBIAN/postrm" <<'POSTRM'
#!/bin/sh
set -e
if [ "$1" = "remove" ] || [ "$1" = "purge" ]; then
if command -v update-desktop-database >/dev/null 2>&1; then update-desktop-database -q /usr/share/applications || true; fi
if command -v update-mime-database >/dev/null 2>&1; then update-mime-database /usr/share/mime || true; fi
if command -v gtk-update-icon-cache >/dev/null 2>&1; then gtk-update-icon-cache -q /usr/share/icons/hicolor || true; fi
fi
exit 0
POSTRM
chmod 0755 "$WORK_DIR/DEBIAN/postinst" "$WORK_DIR/DEBIAN/prerm" "$WORK_DIR/DEBIAN/postrm"
# --- build the .deb ----------------------------------------------------------
DEB_FILE="$OUT_DIR/stepforge_${VERSION}_${DEB_ARCH}.deb"
if command -v fakeroot >/dev/null 2>&1; then
fakeroot dpkg-deb --build "$WORK_DIR" "$DEB_FILE" >/dev/null
else
dpkg-deb --build "$WORK_DIR" "$DEB_FILE" >/dev/null
fi
# --- portable tarball (INCLUDES the launcher, unlike the old script) ---------
TAR_FILE="$OUT_DIR/stepforge_${VERSION}_linux-${NODE_ARCH}.tar.gz"
tar -C "$WORK_DIR" -czf "$TAR_FILE" opt usr/bin/stepforge usr/share/applications usr/share/mime usr/share/icons
# --- checksums ---------------------------------------------------------------
( cd "$OUT_DIR" && sha256sum "$(basename "$DEB_FILE")" "$(basename "$TAR_FILE")" > "stepforge_${VERSION}_${DEB_ARCH}.sha256" )
echo "$DEB_FILE"
echo "$TAR_FILE"
+8 -1
View File
@@ -14,7 +14,14 @@ mkdir -p "$BUILD_ROOT"
bash "$ROOT_DIR/scripts/bootstrap-offline.sh"
node "$ROOT_DIR/scripts/make-sample-guide.js" --root "$EXAMPLES_ROOT"
STEPFORGE_PACKAGE_DIR="$ARTIFACT_DIR" bash "$ROOT_DIR/scripts/package-linux.sh" >/dev/null
# Production Linux package: a pruned runtime tree with real desktop
# integration. Requires node_modules (fails otherwise); never installs at
# build time. Skipped only when the Electron runtime is genuinely absent.
if [ -d "$ROOT_DIR/node_modules/electron/dist" ]; then
STEPFORGE_PACKAGE_DIR="$ARTIFACT_DIR" bash "$ROOT_DIR/packaging/linux/debian/package.sh" >/dev/null
else
echo "[build-release] skipping Linux .deb: node_modules/electron missing (run npm ci)" >&2
fi
BUILD_ROOT="$BUILD_ROOT" \
ARTIFACT_DIR="$ARTIFACT_DIR" \
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Install the BUILD toolchain for producing StepForge packages on apt-based
# systems. These are for DEVELOPERS/packagers only and are never shipped inside
# the end-user package.
set -euo pipefail
if ! command -v apt-get >/dev/null 2>&1; then
echo "This script is for apt-based systems (Debian/Ubuntu)." >&2
exit 1
fi
SUDO=""
if [ "$(id -u)" -ne 0 ]; then SUDO="sudo"; fi
PACKAGES=(
dpkg-dev fakeroot # build the .deb
desktop-file-utils # validate the .desktop entry
ca-certificates # npm ci over https
xvfb # headless smoke test under Xvfb
)
echo "Installing StepForge build dependencies via apt..."
$SUDO apt-get update
$SUDO apt-get install -y --no-install-recommends "${PACKAGES[@]}"
cat <<'MSG'
Done. Also install the pinned Node toolchain (see .nvmrc — Node 22.12+):
nvm install && nvm use # or another Node 22 LTS install method
Then, from the repo root:
npm ci
npm run package:linux:deb
MSG
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Install the RUNTIME libraries StepForge needs on apt-based systems
# (Debian/Ubuntu). These are the shared libraries the packaged Electron runtime
# links against, plus the X11/portal integration used for capture. This is for
# END USERS installing from the tarball; the .deb declares the same set as
# Depends so apt pulls them automatically.
set -euo pipefail
if ! command -v apt-get >/dev/null 2>&1; then
echo "This script is for apt-based systems (Debian/Ubuntu). Use the dnf script on Fedora." >&2
exit 1
fi
SUDO=""
if [ "$(id -u)" -ne 0 ]; then SUDO="sudo"; fi
PACKAGES=(
# Chromium/Electron shared libraries
libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2
libgtk-3-0 libgbm1 libasound2 libxkbcommon0 libatspi2.0-0
libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libxshmfence1
# X11 per-click capture (marker-accurate) — X11 sessions only
xinput x11-utils
# Wayland screen-share via the XDG portal + PipeWire
xdg-desktop-portal pipewire
)
echo "Installing StepForge runtime dependencies via apt..."
$SUDO apt-get update
$SUDO apt-get install -y --no-install-recommends "${PACKAGES[@]}"
echo "Done. StepForge runtime dependencies are installed."
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env node
'use strict';
// Generate the StepForge PNG icon set from original geometry using the repo's
// own rasterizer + PNG writer (no external image tooling or third-party art).
// Mirrors packaging/assets/stepforge.svg. Output: packaging/assets/icons/.
const fs = require('node:fs');
const path = require('node:path');
const { createImage, fillRect, fillOval } = require('../core/raster');
const { encodePng } = require('../core/png');
const OUT_DIR = path.join(__dirname, '..', 'packaging', 'assets', 'icons');
const SIZES = [16, 32, 48, 64, 128, 256, 512];
const BG_TOP = [37, 99, 235, 255];
const BG_BOTTOM = [30, 58, 138, 255];
const WHITE = [255, 255, 255, 255];
const SPARK = [250, 204, 21, 255];
function lerp(a, b, t) {
return [
Math.round(a[0] + (b[0] - a[0]) * t),
Math.round(a[1] + (b[1] - a[1]) * t),
Math.round(a[2] + (b[2] - a[2]) * t),
255,
];
}
function renderIcon(size) {
const img = createImage(size, size, [0, 0, 0, 0]);
const s = size / 256; // scale from the 256px reference design
// Rounded-square background approximated by a vertical gradient fill.
for (let y = 0; y < size; y += 1) {
fillRect(img, 0, y, size, 1, lerp(BG_TOP, BG_BOTTOM, y / size));
}
// Three ascending steps (x, y, w, h in reference px).
const steps = [
[52, 150, 52, 54],
[102, 116, 52, 88],
[152, 82, 52, 122],
];
for (const [x, y, w, h] of steps) {
fillRect(img, Math.round(x * s), Math.round(y * s), Math.round(w * s), Math.round(h * s), WHITE);
}
// Capture spark on the top step.
const r = Math.max(2, Math.round(16 * s));
fillOval(img, Math.round(178 * s - r), Math.round(60 * s - r), r * 2, r * 2, SPARK);
return img;
}
function main() {
fs.mkdirSync(OUT_DIR, { recursive: true });
for (const size of SIZES) {
const png = encodePng(renderIcon(size));
fs.writeFileSync(path.join(OUT_DIR, `stepforge-${size}.png`), png);
}
// A conventional default name for the desktop entry / hicolor 256px slot.
fs.copyFileSync(path.join(OUT_DIR, 'stepforge-256.png'), path.join(OUT_DIR, 'stepforge.png'));
console.log(`wrote ${SIZES.length + 1} icons to ${OUT_DIR}`);
}
if (require.main === module) main();
module.exports = { renderIcon, SIZES };
-92
View File
@@ -1,92 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
VERSION="$(node -p "const pkg=require('${ROOT_DIR}/package.json'); pkg.buildVersion || pkg.version" 2>/dev/null || echo 0.0.0)"
OUT_DIR="${STEPFORGE_PACKAGE_DIR:-$ROOT_DIR/build/artifacts}"
mkdir -p "$OUT_DIR"
WORK_DIR="$(mktemp -d "${OUT_DIR%/}/.pkg.XXXXXX")"
APP_DIR="$WORK_DIR/opt/stepforge"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
mkdir -p "$APP_DIR" "$WORK_DIR/usr/bin" "$WORK_DIR/DEBIAN"
copy_item() {
local src="$1"
local dest="$2"
if [[ -e "$ROOT_DIR/$src" ]]; then
mkdir -p "$(dirname "$dest")"
cp -a "$ROOT_DIR/$src" "$dest"
fi
}
# Application payload: only the files needed to run the app.
copy_item app "$APP_DIR/app"
copy_item core "$APP_DIR/core"
copy_item exporters "$APP_DIR/exporters"
copy_item scripts "$APP_DIR/scripts"
copy_item README.md "$APP_DIR/README.md"
copy_item LICENSE "$APP_DIR/LICENSE"
copy_item docs "$APP_DIR/docs"
copy_item ai_prompts "$APP_DIR/ai_prompts"
copy_item package.json "$APP_DIR/package.json"
copy_item package-lock.json "$APP_DIR/package-lock.json"
copy_item examples "$APP_DIR/examples"
copy_item build/agent_audit.md "$APP_DIR/build/agent_audit.md"
if [[ -d "$ROOT_DIR/node_modules" ]]; then
cp -a "$ROOT_DIR/node_modules" "$APP_DIR/node_modules"
fi
cat > "$WORK_DIR/usr/bin/stepforge" <<'EOF'
#!/usr/bin/env sh
APP_DIR=/opt/stepforge
ELECTRON="$APP_DIR/node_modules/.bin/electron"
SANDBOX_HELPER="$APP_DIR/node_modules/electron/dist/chrome-sandbox"
cd "$APP_DIR" || exit 1
if command -v stat >/dev/null 2>&1 && [ -e "$SANDBOX_HELPER" ]; then
helper_uid="$(stat -c '%u' "$SANDBOX_HELPER" 2>/dev/null || echo '')"
helper_mode="$(stat -c '%a' "$SANDBOX_HELPER" 2>/dev/null || echo '')"
if [ "$helper_uid" = "0" ] && [ -n "$helper_mode" ]; then
helper_mode_num=$((8#$helper_mode))
if [ $((helper_mode_num & 04000)) -ne 0 ]; then
exec "$ELECTRON" "$APP_DIR" "$@"
fi
fi
fi
printf '%s\n' '[stepforge] Electron sandbox helper is not configured for this install; starting with --no-sandbox' >&2
exec "$ELECTRON" --no-sandbox "$APP_DIR" "$@"
EOF
chmod 0755 "$WORK_DIR/usr/bin/stepforge"
cat > "$WORK_DIR/DEBIAN/control" <<EOF
Package: stepforge
Version: $VERSION
Section: utils
Priority: optional
Architecture: amd64
Depends: xinput
Maintainer: StepForge <[email protected]>
Description: Offline desktop guide capture and export tool
A fully offline desktop app for step-by-step documentation, built for local
capture, annotation, and export workflows.
EOF
DEB_FILE="$OUT_DIR/stepforge_${VERSION}_amd64.deb"
TAR_FILE="$OUT_DIR/stepforge_${VERSION}_linux-x64.tar.gz"
if command -v dpkg-deb >/dev/null 2>&1; then
dpkg-deb --build "$WORK_DIR" "$DEB_FILE" >/dev/null
else
echo "dpkg-deb is not installed; skipping .deb build" >&2
fi
tar -C "$WORK_DIR/opt" -czf "$TAR_FILE" stepforge
printf '%s\n' "$DEB_FILE"
printf '%s\n' "$TAR_FILE"
@@ -0,0 +1,78 @@
#!/usr/bin/env bash
# Integration test: build the production .deb and assert it is a real,
# runtime-only package — the right files present, and the dev tree / build
# tooling / app docs absent. A package is NOT accepted merely because
# dpkg-deb produced a file.
#
# Honest skip policy: skip ONLY when the prerequisites are genuinely absent
# (not apt-based, dpkg-deb missing, or node_modules not installed). Once we
# build, any structural failure fails the test.
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
cd "$ROOT_DIR"
if ! command -v dpkg-deb >/dev/null 2>&1; then
echo "package-deb SKIPPED: dpkg-deb not installed (not an apt-based build host)"
exit 0
fi
if [ ! -d "$ROOT_DIR/node_modules/electron/dist" ]; then
echo "package-deb SKIPPED: node_modules/electron missing (run npm ci first)"
exit 0
fi
OUT_DIR="$(mktemp -d)"
trap 'rm -rf "$OUT_DIR"' EXIT
DEB="$(STEPFORGE_PACKAGE_DIR="$OUT_DIR" bash packaging/linux/debian/package.sh | head -1)"
if [ ! -f "$DEB" ]; then
echo "package-deb FAILED: builder did not produce a .deb" >&2
exit 1
fi
fail() { echo "package-deb FAILED: $1" >&2; exit 1; }
listing="$(dpkg-deb -c "$DEB")"
control="$(dpkg-deb -f "$DEB")"
# Required install items.
for needle in \
'./usr/bin/stepforge' \
'./usr/share/applications/stepforge.desktop' \
'./usr/share/mime/packages/stepforge.xml' \
'./usr/share/icons/hicolor/256x256/apps/stepforge.png' \
'./opt/stepforge/node_modules/electron/dist/electron' \
'./opt/stepforge/app/main.js' \
'./usr/share/doc/stepforge/copyright'; do
echo "$listing" | grep -qF "$needle" || fail "missing packaged file: $needle"
done
# The development node_modules / build tooling must NOT be present.
for banned in \
'node_modules/electron-builder' \
'node_modules/app-builder-lib' \
'node_modules/dmg-builder'; do
echo "$listing" | grep -qF "$banned" && fail "build-only dependency leaked: $banned" || true
done
# The app's own docs/prompts/examples must not be shipped.
for banned in \
'./opt/stepforge/docs/' \
'./opt/stepforge/ai_prompts/' \
'./opt/stepforge/examples/'; do
echo "$listing" | grep -qF "$banned" && fail "app extra shipped: $banned" || true
done
# Control metadata sanity.
echo "$control" | grep -q '^Package: stepforge' || fail "control missing Package"
echo "$control" | grep -q '^Depends:.*libnss3' || fail "control missing runtime Depends"
echo "$control" | grep -Eq '^Architecture: (amd64|arm64)' || fail "control has no concrete Architecture"
# Sandbox is set up, not disabled: postinst makes chrome-sandbox setuid.
dpkg-deb --info "$DEB" | grep -q 'postinst' || fail "no postinst maintainer script"
# The launcher must refuse an unsandboxed launch by default.
grep -q 'STEPFORGE_ALLOW_NO_SANDBOX' packaging/linux/common/launcher.sh \
|| fail "launcher does not gate --no-sandbox behind an explicit opt-in"
echo "package-deb OK ($(basename "$DEB"), $(du -h "$DEB" | cut -f1))"
+97
View File
@@ -0,0 +1,97 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.resolve(__dirname, '..', '..');
const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
const exists = (rel) => fs.existsSync(path.join(ROOT, rel));
// These are structural checks that run in the normal (cross-platform) unit
// suite so the Linux packaging config can't rot silently; the actual .deb
// build/inspection lives in tests/integration/linux/package-deb.test.sh.
test('Linux packaging files exist in their expected separate locations', () => {
for (const f of [
'packaging/linux/debian/package.sh',
'packaging/linux/debian/control.in',
'packaging/linux/common/stepforge.desktop',
'packaging/linux/common/stepforge-mime.xml',
'packaging/linux/common/launcher.sh',
'scripts/linux/apt/install-runtime-deps.sh',
'scripts/linux/apt/install-build-deps.sh',
'docs/linux/apt.md',
'tests/integration/linux/package-deb.test.sh',
]) {
assert.ok(exists(f), `expected ${f} to exist`);
}
});
test('the old non-production package-linux.sh is gone', () => {
assert.equal(exists('scripts/package-linux.sh'), false);
});
test('the desktop entry is valid and app-branded', () => {
const desktop = read('packaging/linux/common/stepforge.desktop');
assert.match(desktop, /^\[Desktop Entry\]/);
assert.match(desktop, /^Type=Application$/m);
assert.match(desktop, /^Exec=stepforge %U$/m);
assert.match(desktop, /^Icon=stepforge$/m);
assert.match(desktop, /^Categories=.+;$/m);
assert.match(desktop, /MimeType=application\/x-stepforge-guide/);
});
test('the control template declares runtime deps, no hardcoded arch, real maintainer slot', () => {
const control = read('packaging/linux/debian/control.in');
assert.match(control, /^Architecture: @ARCH@$/m, 'arch must be templated, not hardcoded');
assert.match(control, /^Depends:.*libnss3/m);
assert.match(control, /@VERSION@/);
assert.match(control, /@MAINTAINER@/);
// The false "fully offline" wording must not reappear here.
assert.doesNotMatch(control, /fully offline/i);
});
test('the launcher refuses an unsandboxed launch unless explicitly opted in', () => {
const launcher = read('packaging/linux/common/launcher.sh');
assert.match(launcher, /STEPFORGE_ALLOW_NO_SANDBOX/);
// It must not unconditionally exec with --no-sandbox.
assert.doesNotMatch(launcher, /^exec .*--no-sandbox/m);
// It never installs anything at runtime.
assert.doesNotMatch(launcher, /npm (install|ci|rebuild)/);
});
test('the package builder requires node_modules and guards against dev-dep leaks', () => {
const script = read('packaging/linux/debian/package.sh');
assert.match(script, /node_modules\/electron\/dist/);
assert.match(script, /npm ls --omit=dev/);
assert.match(script, /electron-builder/); // the leak guard references it
assert.match(script, /dpkg --print-architecture/); // arch detected, not hardcoded
assert.doesNotMatch(script, /cp -a "\$ROOT_DIR\/node_modules" /); // never copy the whole dev tree
});
test('apt setup scripts target apt and keep build vs runtime deps separate', () => {
const runtime = read('scripts/linux/apt/install-runtime-deps.sh');
const build = read('scripts/linux/apt/install-build-deps.sh');
assert.match(runtime, /apt-get/);
assert.match(runtime, /libnss3/);
assert.doesNotMatch(runtime, /dpkg-dev|fakeroot/, 'runtime script must not install build tools');
assert.match(build, /dpkg-dev/);
assert.match(build, /fakeroot/);
});
test('an original icon set is generated (not a placeholder/third-party asset)', () => {
assert.ok(exists('packaging/assets/stepforge.svg'));
assert.ok(exists('scripts/make-icons.js'));
// The generated PNGs are committed for packaging.
for (const size of [16, 48, 256]) {
assert.ok(exists(`packaging/assets/icons/stepforge-${size}.png`), `icon ${size} missing`);
}
// Regenerate the 256px icon and confirm the generator is deterministic and
// produces a valid PNG (starts with the PNG signature).
const { renderIcon } = require('../../scripts/make-icons');
const { encodePng } = require('../../core/png');
const png = encodePng(renderIcon(256));
assert.deepEqual([...png.subarray(0, 4)], [0x89, 0x50, 0x4e, 0x47]);
});