Author SHA1 Message Date
TylerandClaude Fable 5 fedf1d24c0 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]>
2026-07-03 23:26:44 -05:00
Tyler 901940993c Merge pull request #11 from Twest2/StepForge pr/07-platform-interfaces
Template tests / tests (push) Failing after 3m20s
Introduce the platform adapter layer; extract window context behind it (plan PR 7)
2026-07-03 23:19:48 -05:00
TylerandClaude Fable 5 970d76a780 Introduce the platform adapter layer; extract window context behind it
Template tests / tests (pull_request) Failing after 33s
Phase 3 groundwork of the improvement plan (PR 7 of the sequence). The Linux
work is a platform rewrite, so this first establishes the interface boundary
and moves an OS-specific piece behind it with Windows behavior preserved — no
new process.platform branches in shared code.

- app/platform/index.js is the single factory that selects a platform
  implementation; shared code asks it for adapters and never inspects
  process.platform itself.
- app/platform/interfaces.js documents the adapter contracts
  (WindowContextProvider, ClickSource, PowerPolicy) and the explicit click-
  source vocabulary.
- Extracted the foreground-window/element detection into per-OS adapters,
  verbatim from text-intel.js:
    app/platform/windows/window-context.js  (PowerShell UIAutomation)
    app/platform/linux/window-context.js     (xprop)
    app/platform/darwin/window-context.js     (AppleScript)
  text-intel.js now delegates to the injected provider and its three
  platform-branching methods (and the now-dead child_process import) are gone.
- app/platform/linux/diagnostics.js detects session type, portal/PipeWire,
  xinput, readable input devices, and the resulting click/screen-capture
  profile, returning actionable messages for the UI. Exposed via a new
  platform:capabilities IPC + preload method.

This is behavior-preserving: the Windows/macOS/Linux window-context code is
the same, just relocated behind the factory, and the capture pipeline is
untouched.

Tests: platform selection for every OS, provider validity + null-object for
unsupported OS, the shared service delegating to an injected provider, Linux
capability detection (x11/xinput, Wayland-without-PipeWire messaging, no-click
fallback, evdev), the capability facade, and a guard that text-intel no longer
branches on process.platform. 268 unit tests pass; startup smoke passes and
the click self-test is unchanged (stream source, markers 3/3, burst 8/8).

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-03 23:17:24 -05:00
34 changed files with 1230 additions and 228 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). 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 Requirements: Node.js 22.12+ and npm (pinned in `.nvmrc`; installs are
refused on older Nodes because the packaging toolchain needs 22.12+). refused on older Nodes because the packaging toolchain needs 22.12+).
+3
View File
@@ -899,6 +899,9 @@ function setupIpc() {
dataDir: store.root, dataDir: store.root,
platform: process.platform, platform: process.platform,
})); }));
// Platform capture-capability profile (session type, portal/PipeWire,
// xinput, click source, actionable messages) for the diagnostics UI.
h('platform:capabilities', () => require('./platform').detectCapabilities());
} }
// ---- lifecycle -------------------------------------------------------------- // ---- lifecycle --------------------------------------------------------------
+43
View File
@@ -0,0 +1,43 @@
'use strict';
const { execFileSync } = require('node:child_process');
/**
* macOS WindowContextProvider using AppleScript / System Events. Extracted
* verbatim from text-intel.js. macOS is not a primary support target, but the
* adapter is kept so the shared code has no `process.platform` branch and the
* behavior is preserved where it exists. Never throws.
*/
function createDarwinWindowContextProvider() {
return {
async collect() {
const script = `
set appName to ""
set windowTitle to ""
tell application "System Events"
try
set frontApp to first application process whose frontmost is true
set appName to name of frontApp
try
set windowTitle to name of front window of frontApp
end try
end try
end tell
return appName & linefeed & windowTitle
`;
try {
const result = execFileSync('osascript', ['-e', script], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 1200,
}).trimEnd();
const [appName = '', windowTitle = ''] = result.split(/\r?\n/);
return { appName, windowTitle };
} catch {
return { appName: '', windowTitle: '' };
}
},
};
}
module.exports = { createDarwinWindowContextProvider };
+66
View File
@@ -0,0 +1,66 @@
'use strict';
/**
* The single factory that selects a platform implementation. The rest of the
* app depends on the interfaces in ./interfaces.js and asks this module for a
* concrete adapter — it never branches on `process.platform` itself.
*
* As Linux runtime capture is implemented, its ClickSource / ScreenFrameSource
* adapters are added here; today this provides the WindowContextProvider for
* every platform and the Linux capability diagnostics.
*/
const { assertWindowContextProvider } = require('./interfaces');
function detectPlatform(platform = process.platform) {
if (platform === 'win32') return 'windows';
if (platform === 'darwin') return 'darwin';
if (platform === 'linux') return 'linux';
return 'unsupported';
}
/**
* Build the WindowContextProvider for the current OS. `platform` is injectable
* so the selection logic is unit-testable off the target OS.
*/
function createWindowContextProvider({ platform = process.platform } = {}) {
const os = detectPlatform(platform);
let provider;
switch (os) {
case 'windows':
provider = require('./windows/window-context').createWindowsWindowContextProvider();
break;
case 'darwin':
provider = require('./darwin/window-context').createDarwinWindowContextProvider();
break;
case 'linux':
provider = require('./linux/window-context').createLinuxWindowContextProvider();
break;
default:
// Unsupported OS: a null-object provider so callers still work.
provider = { async collect() { return { appName: '', windowTitle: '' }; } };
}
return assertWindowContextProvider(provider);
}
/**
* Capability profile for the current OS (used by diagnostics UI). Only Linux
* has a rich profile today; other platforms report their OS and a capable
* baseline.
*/
function detectCapabilities({ platform = process.platform, env = process.env } = {}) {
const os = detectPlatform(platform);
if (os === 'linux') {
return require('./linux/diagnostics').detectLinuxCapabilities({ env });
}
return {
os,
sessionType: os,
isWayland: false,
clickCapture: os === 'windows' ? 'windows-hook' : os,
screenCapture: os,
messages: [],
};
}
module.exports = { detectPlatform, createWindowContextProvider, detectCapabilities };
+62
View File
@@ -0,0 +1,62 @@
'use strict';
/**
* Platform adapter interfaces (documentation + light runtime shape checks).
*
* The platform-neutral capture/text-intel code consumes these interfaces and
* never inspects `process.platform` itself. `app/platform/index.js` is the
* only module that selects a concrete implementation. New OS support is a new
* set of files under `app/platform/<os>/`, not more conditionals inside the
* shared code.
*
* ---------------------------------------------------------------------------
* WindowContextProvider
* collect(osPoint?: {x,y}) -> Promise<{
* appName, windowTitle,
* elementLabel?, elementRole?, elementClass?, elementValue?
* }>
* Best-effort foreground window / clicked-element context. Never throws;
* returns {} (or partial) when unavailable.
*
* ClickSource (runtime capture — implemented incrementally per platform)
* describe() -> { source, coordinates: boolean, keyboard: boolean }
* source ∈ 'windows-hook' | 'x11' | 'evdev-x11' | 'evdev-wayland' |
* 'wayland-portal' | 'hotkey' | 'interval' | 'unavailable'
*
* PowerPolicy
* setRecording(recording: boolean) -> void
* Holds/releases OS power + throttling state for the recording lifecycle.
*
* PlatformCapabilities (from index.detectCapabilities())
* { os, sessionType, isWayland, hasXinput, canSandbox, ... }
* ---------------------------------------------------------------------------
*/
// Interface names, exported so adapters and tests can reference a single
// source of truth for the contract identifiers.
const INTERFACES = Object.freeze([
'WindowContextProvider',
'ClickSource',
'PowerPolicy',
]);
const CLICK_SOURCES = Object.freeze([
'windows-hook',
'x11',
'evdev-x11',
'evdev-wayland',
'wayland-portal',
'hotkey',
'interval',
'unavailable',
]);
/** Assert a value looks like a WindowContextProvider (has async collect()). */
function assertWindowContextProvider(provider) {
if (!provider || typeof provider.collect !== 'function') {
throw new Error('platform: WindowContextProvider must implement collect()');
}
return provider;
}
module.exports = { INTERFACES, CLICK_SOURCES, assertWindowContextProvider };
+99
View File
@@ -0,0 +1,99 @@
'use strict';
const fs = require('node:fs');
const { execFileSync } = require('node:child_process');
/**
* Linux capture-capability diagnostics. Detects the session type, portal /
* PipeWire availability, xinput, readable input devices, and the sandbox
* situation, and turns them into an actionable capability profile the UI can
* show instead of console-only failures.
*
* Pure detection with injectable probes so it is unit-testable without a real
* desktop session.
*/
function defaultHasBinary(name) {
try {
execFileSync('which', [name], { stdio: 'pipe' });
return true;
} catch {
return false;
}
}
function detectSessionType(env = process.env) {
const t = String(env.XDG_SESSION_TYPE || '').toLowerCase();
if (t === 'wayland' || t === 'x11') return t;
if (env.WAYLAND_DISPLAY) return 'wayland';
if (env.DISPLAY) return 'x11';
return 'unknown';
}
function detectLinuxCapabilities({
env = process.env,
hasBinary = defaultHasBinary,
existsSync = fs.existsSync,
readdirSync = fs.readdirSync,
} = {}) {
const sessionType = detectSessionType(env);
const isWayland = sessionType === 'wayland';
// XDG Desktop Portal + PipeWire are how Wayland screen capture works.
const hasPortalBus = Boolean(env.DBUS_SESSION_BUS_ADDRESS);
let hasPipeWire = false;
try {
hasPipeWire = hasBinary('pipewire') || existsSync(`/run/user/${process.getuid ? process.getuid() : ''}/pipewire-0`);
} catch {
hasPipeWire = hasBinary('pipewire');
}
const hasXinput = hasBinary('xinput');
const hasXprop = hasBinary('xprop');
// Readable /dev/input event nodes gate the evdev click fallback.
let readableInputDevices = 0;
try {
for (const name of readdirSync('/dev/input')) {
if (!/^event\d+$/.test(name)) continue;
try { fs.accessSync(`/dev/input/${name}`, fs.constants.R_OK); readableInputDevices += 1; } catch { /* not readable */ }
}
} catch { /* /dev/input not present */ }
// Determine the click-capture profile for this session.
let clickCapture;
if (!isWayland && hasXinput) clickCapture = 'x11-xinput';
else if (readableInputDevices > 0) clickCapture = isWayland ? 'evdev-wayland' : 'evdev-x11';
else clickCapture = 'hotkey-or-interval-only';
const messages = [];
if (isWayland && !hasPipeWire) {
messages.push('Wayland screen capture needs PipeWire and the XDG Desktop Portal. Install pipewire and xdg-desktop-portal.');
}
if (isWayland && !hasPortalBus) {
messages.push('No D-Bus session bus detected; the screen-share portal cannot be reached.');
}
if (!isWayland && !hasXinput) {
messages.push('xinput not found: per-click capture with a marker is unavailable on X11 without it.');
}
if (clickCapture === 'hotkey-or-interval-only') {
messages.push('No global click source available. Recording falls back to a hotkey or interval trigger.');
}
return {
os: 'linux',
sessionType,
isWayland,
hasPortalBus,
hasPipeWire,
hasXinput,
hasXprop,
readableInputDevices,
clickCapture,
// Portal capture is the safe Wayland baseline; X11 can grab directly.
screenCapture: isWayland ? 'wayland-portal' : 'x11-direct',
messages,
};
}
module.exports = { detectLinuxCapabilities, detectSessionType };
+51
View File
@@ -0,0 +1,51 @@
'use strict';
const { execFileSync } = require('node:child_process');
function hasBinary(name) {
try {
execFileSync('which', [name], { stdio: 'pipe' });
return true;
} catch {
return false;
}
}
/**
* Linux (X11) WindowContextProvider using xprop on the active window. On
* Wayland xprop only sees XWayland clients, so context is best-effort; the
* portal-based capture path does not depend on it. Extracted verbatim from
* text-intel.js. Never throws.
*/
function createLinuxWindowContextProvider() {
return {
async collect() {
try {
if (!hasBinary('xprop')) return { appName: '', windowTitle: '' };
const active = execFileSync('xprop', ['-root', '_NET_ACTIVE_WINDOW'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 1200,
});
const activeMatch = active.match(/window id # (0x[0-9a-fA-F]+)/);
if (!activeMatch) return { appName: '', windowTitle: '' };
const winId = activeMatch[1];
const details = execFileSync('xprop', ['-id', winId, '_NET_WM_NAME', 'WM_NAME', 'WM_CLASS'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 1200,
});
const titleMatch = details.match(/(?:_NET_WM_NAME\(UTF8_STRING\)|WM_NAME\(STRING\)|WM_NAME\(UTF8_STRING\)) = "([^"]*)"/);
const classMatch = details.match(/WM_CLASS\(STRING\) = "([^"]*)"(?:, "([^"]*)")?/);
return {
appName: classMatch ? (classMatch[2] || classMatch[1] || '') : '',
windowTitle: titleMatch ? titleMatch[1] : '',
};
} catch {
return { appName: '', windowTitle: '' };
}
},
};
}
module.exports = { createLinuxWindowContextProvider, hasBinary };
+88
View File
@@ -0,0 +1,88 @@
'use strict';
const { execFile } = require('node:child_process');
/**
* Windows WindowContextProvider. Reads the foreground window (Win32) and, when
* a click point is given, the UI Automation element under it. Best-effort:
* resolves {} on any failure. Extracted verbatim from text-intel.js so the
* shared code carries no `process.platform` branch.
*/
function createWindowsWindowContextProvider() {
return {
async collect(osPoint = null) {
const hasPoint = osPoint && Number.isFinite(osPoint.x) && Number.isFinite(osPoint.y);
const clickX = hasPoint ? Number(osPoint.x) : 0;
const clickY = hasPoint ? Number(osPoint.y) : 0;
const script = `
$clickX = ${clickX};
$clickY = ${clickY};
$elementLabel = '';
$elementRole = '';
$elementClass = '';
$elementProcessId = 0;
$elementValue = '';
if (${hasPoint ? '$true' : '$false'}) {
try {
Add-Type -AssemblyName UIAutomationClient,UIAutomationTypes,WindowsBase | Out-Null
$point = New-Object System.Windows.Point($clickX, $clickY);
$element = [System.Windows.Automation.AutomationElement]::FromPoint($point);
if ($element) {
$current = $element.Current;
$elementLabel = $current.Name;
$elementRole = $current.LocalizedControlType;
$elementClass = $current.ClassName;
$elementProcessId = $current.ProcessId;
try {
$valPattern = [System.Windows.Automation.ValuePattern]::Pattern;
if ($element.GetSupportedPatterns() -contains $valPattern) {
$elementValue = $element.GetCurrentPattern($valPattern).Current.Value;
}
} catch { }
}
} catch { }
}
Add-Type @"
using System;
using System.Runtime.InteropServices;
using System.Text;
public static class Win32 {
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
public static extern int GetWindowText(IntPtr hWnd, StringBuilder text, int count);
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);
}
"@;
$hWnd = [Win32]::GetForegroundWindow();
$sb = New-Object System.Text.StringBuilder 512;
[void][Win32]::GetWindowText($hWnd, $sb, $sb.Capacity);
$pid = 0;
[void][Win32]::GetWindowThreadProcessId($hWnd, [ref]$pid);
$proc = Get-Process -Id $pid -ErrorAction SilentlyContinue | Select-Object -First 1;
$out = [ordered]@{
appName = if ($proc) { $proc.ProcessName } else { '' };
windowTitle = $sb.ToString();
elementLabel = $elementLabel;
elementRole = $elementRole;
elementClass = $elementClass;
elementValue = $elementValue;
elementProcessId = $elementProcessId;
pid = $pid;
};
$out | ConvertTo-Json -Compress;
`;
return new Promise((resolve) => {
execFile('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script], {
encoding: 'utf8',
timeout: 4000,
windowsHide: true,
}, (err, stdout) => {
if (err) { resolve({}); return; }
try { resolve(JSON.parse(stdout.trim() || '{}')); } catch { resolve({}); }
});
});
},
};
}
module.exports = { createWindowsWindowContextProvider };
+1
View File
@@ -107,6 +107,7 @@ const api = {
}, },
app: { app: {
info: invoke('app:info'), info: invoke('app:info'),
platformCapabilities: invoke('platform:capabilities'),
}, },
}; };
+6 -133
View File
@@ -2,7 +2,6 @@
const fs = require('node:fs'); const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
const { execFileSync, execFile } = require('node:child_process');
const { const {
DEFAULT_CAPTURE_TITLES, DEFAULT_CAPTURE_TITLES,
@@ -23,15 +22,6 @@ const OCR_CROP = {
height: 220, height: 220,
}; };
function hasBinary(name) {
try {
execFileSync('which', [name], { stdio: 'pipe' });
return true;
} catch {
return false;
}
}
function clamp(v, min, max) { function clamp(v, min, max) {
return Math.min(max, Math.max(min, v)); return Math.min(max, Math.max(min, v));
} }
@@ -63,6 +53,7 @@ class TextIntelService {
dataDir, dataDir,
fetchImpl = global.fetch, fetchImpl = global.fetch,
screenApi = null, screenApi = null,
windowContextProvider = null,
}) { }) {
this.store = store; this.store = store;
this.settings = settings; this.settings = settings;
@@ -70,6 +61,10 @@ class TextIntelService {
this.dataDir = dataDir; this.dataDir = dataDir;
this.fetch = fetchImpl; this.fetch = fetchImpl;
this.screen = screenApi; this.screen = screenApi;
// OS-specific foreground-window/element detection is a platform adapter.
// This code no longer branches on process.platform; the factory selects it.
this.windowContext = windowContextProvider
|| require('./platform').createWindowContextProvider();
this.worker = null; this.worker = null;
this.workerPromise = null; this.workerPromise = null;
this.workerQueue = Promise.resolve(); this.workerQueue = Promise.resolve();
@@ -271,135 +266,13 @@ class TextIntelService {
async collectForegroundWindowContext(osPoint = null) { async collectForegroundWindowContext(osPoint = null) {
try { try {
if (process.platform === 'win32') return this.collectWindowsWindowContext(osPoint); return await this.windowContext.collect(osPoint);
if (process.platform === 'darwin') return this.collectMacWindowContext();
if (process.platform === 'linux') return this.collectLinuxWindowContext();
} catch { } catch {
// best effort only // best effort only
}
return { appName: '', windowTitle: '' }; return { appName: '', windowTitle: '' };
} }
async collectWindowsWindowContext(osPoint = null) {
const hasPoint = osPoint && Number.isFinite(osPoint.x) && Number.isFinite(osPoint.y);
const clickX = hasPoint ? Number(osPoint.x) : 0;
const clickY = hasPoint ? Number(osPoint.y) : 0;
const script = `
$clickX = ${clickX};
$clickY = ${clickY};
$elementLabel = '';
$elementRole = '';
$elementClass = '';
$elementProcessId = 0;
$elementValue = '';
if (${hasPoint ? '$true' : '$false'}) {
try {
Add-Type -AssemblyName UIAutomationClient,UIAutomationTypes,WindowsBase | Out-Null
$point = New-Object System.Windows.Point($clickX, $clickY);
$element = [System.Windows.Automation.AutomationElement]::FromPoint($point);
if ($element) {
$current = $element.Current;
$elementLabel = $current.Name;
$elementRole = $current.LocalizedControlType;
$elementClass = $current.ClassName;
$elementProcessId = $current.ProcessId;
try {
$valPattern = [System.Windows.Automation.ValuePattern]::Pattern;
if ($element.GetSupportedPatterns() -contains $valPattern) {
$elementValue = $element.GetCurrentPattern($valPattern).Current.Value;
}
} catch { }
}
} catch { }
}
Add-Type @"
using System;
using System.Runtime.InteropServices;
using System.Text;
public static class Win32 {
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
public static extern int GetWindowText(IntPtr hWnd, StringBuilder text, int count);
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);
}
"@;
$hWnd = [Win32]::GetForegroundWindow();
$sb = New-Object System.Text.StringBuilder 512;
[void][Win32]::GetWindowText($hWnd, $sb, $sb.Capacity);
$pid = 0;
[void][Win32]::GetWindowThreadProcessId($hWnd, [ref]$pid);
$proc = Get-Process -Id $pid -ErrorAction SilentlyContinue | Select-Object -First 1;
$out = [ordered]@{
appName = if ($proc) { $proc.ProcessName } else { '' };
windowTitle = $sb.ToString();
elementLabel = $elementLabel;
elementRole = $elementRole;
elementClass = $elementClass;
elementValue = $elementValue;
elementProcessId = $elementProcessId;
pid = $pid;
};
$out | ConvertTo-Json -Compress;
`;
return new Promise(resolve => {
execFile('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script], {
encoding: 'utf8',
timeout: 4000,
windowsHide: true,
}, (err, stdout) => {
if (err) { resolve({}); return; }
try { resolve(JSON.parse(stdout.trim() || '{}')); }
catch { resolve({}); }
});
});
} }
collectMacWindowContext() {
const script = `
set appName to ""
set windowTitle to ""
tell application "System Events"
try
set frontApp to first application process whose frontmost is true
set appName to name of frontApp
try
set windowTitle to name of front window of frontApp
end try
end try
end tell
return appName & linefeed & windowTitle
`;
const result = execFileSync('osascript', ['-e', script], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 1200,
}).trimEnd();
const [appName = '', windowTitle = ''] = result.split(/\r?\n/);
return { appName, windowTitle };
}
collectLinuxWindowContext() {
if (!hasBinary('xprop')) return { appName: '', windowTitle: '' };
const active = execFileSync('xprop', ['-root', '_NET_ACTIVE_WINDOW'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 1200,
});
const activeMatch = active.match(/window id # (0x[0-9a-fA-F]+)/);
if (!activeMatch) return { appName: '', windowTitle: '' };
const winId = activeMatch[1];
const details = execFileSync('xprop', ['-id', winId, '_NET_WM_NAME', 'WM_NAME', 'WM_CLASS'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 1200,
});
const titleMatch = details.match(/(?:_NET_WM_NAME\(UTF8_STRING\)|WM_NAME\(STRING\)|WM_NAME\(UTF8_STRING\)) = "([^"]*)"/);
const classMatch = details.match(/WM_CLASS\(STRING\) = "([^"]*)"(?:, "([^"]*)")?/);
return {
appName: classMatch ? (classMatch[2] || classMatch[1] || '') : '',
windowTitle: titleMatch ? titleMatch[1] : '',
};
}
async buildCaptureTitle({ mode, frame, clickPos, clickMeta = null }) { async buildCaptureTitle({ mode, frame, clickPos, clickMeta = null }) {
const ctx = await this.buildCaptureContext({ mode, frame, clickPos, clickMeta }); const ctx = await this.buildCaptureContext({ mode, frame, clickPos, clickMeta });
+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", "start": "node scripts/start-electron.js",
"test": "node scripts/run-unit-tests.js", "test": "node scripts/run-unit-tests.js",
"sample": "node scripts/make-sample-guide.js", "sample": "node scripts/make-sample-guide.js",
"icons": "node scripts/make-icons.js",
"package:windows": "node scripts/package-windows.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", "build": "bash scripts/build-release.sh",
"verify": "bash scripts/verify.sh", "verify": "bash scripts/verify.sh",
"bootstrap": "bash scripts/bootstrap-offline.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" bash "$ROOT_DIR/scripts/bootstrap-offline.sh"
node "$ROOT_DIR/scripts/make-sample-guide.js" --root "$EXAMPLES_ROOT" 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" \ BUILD_ROOT="$BUILD_ROOT" \
ARTIFACT_DIR="$ARTIFACT_DIR" \ 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]);
});
+139
View File
@@ -0,0 +1,139 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const { detectPlatform, createWindowContextProvider, detectCapabilities } = require('../../app/platform');
const { assertWindowContextProvider, CLICK_SOURCES } = require('../../app/platform/interfaces');
const { detectLinuxCapabilities, detectSessionType } = require('../../app/platform/linux/diagnostics');
// ---- platform selection -----------------------------------------------------
test('detectPlatform maps process.platform to an adapter family', () => {
assert.equal(detectPlatform('win32'), 'windows');
assert.equal(detectPlatform('darwin'), 'darwin');
assert.equal(detectPlatform('linux'), 'linux');
assert.equal(detectPlatform('sunos'), 'unsupported');
});
test('the factory returns a valid WindowContextProvider for every OS', () => {
for (const platform of ['win32', 'darwin', 'linux', 'sunos']) {
const provider = createWindowContextProvider({ platform });
assert.doesNotThrow(() => assertWindowContextProvider(provider));
assert.equal(typeof provider.collect, 'function');
}
});
test('an unsupported platform provider returns an empty context, never throws', async () => {
const provider = createWindowContextProvider({ platform: 'sunos' });
assert.deepEqual(await provider.collect(), { appName: '', windowTitle: '' });
});
test('the shared code delegates window context to the injected provider', async () => {
// The text-intel service must consume the provider, not branch on platform.
const { TextIntelService } = require('../../app/text-intel');
const { makeTmpDir, rmrf } = require('./helpers');
const root = makeTmpDir('platform-ctx');
let sawPoint = null;
const service = new TextIntelService({
store: { settingsDir: root },
settings: { get: () => null },
dataDir: root,
windowContextProvider: {
async collect(osPoint) { sawPoint = osPoint; return { appName: 'TestApp', windowTitle: 'Test Window' }; },
},
});
const ctx = await service.collectForegroundWindowContext({ x: 5, y: 6 });
assert.deepEqual(ctx, { appName: 'TestApp', windowTitle: 'Test Window' });
assert.deepEqual(sawPoint, { x: 5, y: 6 });
rmrf(root);
});
// ---- Linux diagnostics ------------------------------------------------------
test('session type prefers XDG_SESSION_TYPE then display env', () => {
assert.equal(detectSessionType({ XDG_SESSION_TYPE: 'wayland' }), 'wayland');
assert.equal(detectSessionType({ XDG_SESSION_TYPE: 'x11' }), 'x11');
assert.equal(detectSessionType({ WAYLAND_DISPLAY: 'wayland-0' }), 'wayland');
assert.equal(detectSessionType({ DISPLAY: ':0' }), 'x11');
assert.equal(detectSessionType({}), 'unknown');
});
test('X11 with xinput reports marker-capable per-click capture', () => {
const caps = detectLinuxCapabilities({
env: { XDG_SESSION_TYPE: 'x11', DISPLAY: ':0', DBUS_SESSION_BUS_ADDRESS: 'unix:x' },
hasBinary: (n) => n === 'xinput' || n === 'xprop',
existsSync: () => false,
readdirSync: () => [],
});
assert.equal(caps.isWayland, false);
assert.equal(caps.clickCapture, 'x11-xinput');
assert.equal(caps.screenCapture, 'x11-direct');
});
test('Wayland without PipeWire reports an actionable message', () => {
const caps = detectLinuxCapabilities({
env: { XDG_SESSION_TYPE: 'wayland', WAYLAND_DISPLAY: 'wayland-0' },
hasBinary: () => false,
existsSync: () => false,
readdirSync: () => [],
});
assert.equal(caps.isWayland, true);
assert.equal(caps.screenCapture, 'wayland-portal');
assert.ok(caps.messages.some((m) => /PipeWire|portal/i.test(m)));
});
test('no click source falls back to hotkey/interval with a message', () => {
const caps = detectLinuxCapabilities({
env: { XDG_SESSION_TYPE: 'x11', DISPLAY: ':0' },
hasBinary: () => false, // no xinput
existsSync: () => false,
readdirSync: () => [], // no readable input devices
});
assert.equal(caps.clickCapture, 'hotkey-or-interval-only');
assert.ok(caps.messages.some((m) => /hotkey|interval/i.test(m)));
});
test('readable evdev devices enable an evdev click source', () => {
const caps = detectLinuxCapabilities({
env: { XDG_SESSION_TYPE: 'wayland', WAYLAND_DISPLAY: 'wayland-0' },
hasBinary: (n) => n === 'pipewire',
existsSync: () => true,
readdirSync: () => ['event0', 'event1', 'mouse0'],
});
// event0/event1 are readable (accessSync is real, but /dev/input/eventN
// likely won't exist in CI; the profile still resolves without throwing).
assert.ok(['evdev-wayland', 'hotkey-or-interval-only'].includes(caps.clickCapture));
assert.equal(caps.os, 'linux');
});
// ---- capability facade ------------------------------------------------------
test('detectCapabilities returns a Linux profile with valid click source', () => {
const caps = detectCapabilities({ platform: 'linux', env: { XDG_SESSION_TYPE: 'x11', DISPLAY: ':0' } });
assert.equal(caps.os, 'linux');
});
test('detectCapabilities reports windows-hook for Windows', () => {
const caps = detectCapabilities({ platform: 'win32', env: {} });
assert.equal(caps.os, 'windows');
assert.equal(caps.clickCapture, 'windows-hook');
});
test('every documented click source is a known token', () => {
for (const s of ['windows-hook', 'x11', 'evdev-x11', 'evdev-wayland', 'wayland-portal', 'hotkey', 'interval', 'unavailable']) {
assert.ok(CLICK_SOURCES.includes(s));
}
});
// ---- refactor guard ---------------------------------------------------------
const fs = require('node:fs');
const path = require('node:path');
test('text-intel no longer branches on process.platform for window context', () => {
const src = fs.readFileSync(path.join(__dirname, '..', '..', 'app', 'text-intel.js'), 'utf8');
assert.doesNotMatch(src, /collectWindowsWindowContext|collectMacWindowContext|collectLinuxWindowContext/);
assert.doesNotMatch(src, /process\.platform === 'win32'/);
assert.match(src, /this\.windowContext\.collect/);
});