ubuntu not working. idk I'm just gonna use windows anyway

This commit is contained in:
2026-06-26 18:00:22 -05:00
parent 3c5c520799
commit 0325b6efbc
14 changed files with 214 additions and 16 deletions
+1 -1
View File
@@ -62,7 +62,7 @@ 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 [GETTING_STARTED_WITH_LINUX.md](GETTING_STARTED_WITH_LINUX.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).
Requirements: Node.js 20+ and npm (Electron is the only dependency). Requirements: Node.js 20+ and npm (Electron is the only dependency).
+31 -7
View File
@@ -113,8 +113,13 @@ function hasBinary(name) {
// Treating it as "available" would leave the session with no click capture AND // Treating it as "available" would leave the session with no click capture AND
// no interval fallback, so zero steps get captured. // no interval fallback, so zero steps get captured.
function isWayland() { function isWayland() {
return process.platform === 'linux' if (process.platform !== 'linux') return false;
&& (process.env.XDG_SESSION_TYPE === 'wayland' || Boolean(process.env.WAYLAND_DISPLAY)); // XDG_SESSION_TYPE is the authoritative session hint when present. Some
// desktops still export WAYLAND_DISPLAY even when the active session is X11,
// so only fall back to it when XDG_SESSION_TYPE is unavailable.
const sessionType = String(process.env.XDG_SESSION_TYPE || '').toLowerCase();
if (sessionType) return sessionType === 'wayland';
return Boolean(process.env.WAYLAND_DISPLAY);
} }
// ---- evdev (Linux kernel input) click reader -------------------------------- // ---- evdev (Linux kernel input) click reader --------------------------------
@@ -263,6 +268,16 @@ class CaptureService {
return this.settings.get('capture.strictClickFrames') !== false; return this.settings.get('capture.strictClickFrames') !== false;
} }
fallbackCaptureTrigger() {
const raw = String(this.settings.get('capture.fallbackTrigger') || 'interval').toLowerCase();
return raw === 'hotkey' ? 'hotkey' : 'interval';
}
fallbackIntervalSec() {
const raw = Number(this.settings.get('capture.autoIntervalSec'));
return Number.isFinite(raw) && raw > 0 ? raw : 5;
}
clickCaptureAvailable() { clickCaptureAvailable() {
if (this._clickAvail === undefined) { if (this._clickAvail === undefined) {
// Three click sources, in order of fidelity: // Three click sources, in order of fidelity:
@@ -296,12 +311,19 @@ class CaptureService {
startSession(guideId, { intervalSec = null } = {}) { startSession(guideId, { intervalSec = null } = {}) {
this.finishSession(); this.finishSession();
// Default trigger: clicks when the platform supports it, otherwise an // Default trigger: clicks when the platform supports it, otherwise the
// interval so a session always produces steps even if the global hotkey // user-selected fallback (timer or hotkey-only). That keeps Linux from
// never fires (common under Wayland/WSLg). // silently dropping into an unwanted 5-second timer when click capture
// is unavailable.
let interval = intervalSec; let interval = intervalSec;
if (interval == null) { if (interval == null) {
interval = this.clickCaptureAvailable() ? 0 : (this.settings.get('capture.autoIntervalSec') || 5); if (this.clickCaptureAvailable()) {
interval = 0;
} else if (this.fallbackCaptureTrigger() === 'hotkey') {
interval = 0;
} else {
interval = this.fallbackIntervalSec();
}
} }
// Sessions start paused: nothing hides and no capturing happens until // Sessions start paused: nothing hides and no capturing happens until
// the user explicitly presses "Start recording" in the capture bar, so // the user explicitly presses "Start recording" in the capture bar, so
@@ -1381,7 +1403,9 @@ public static class SFHook {
console.error(`[stepforge] click watcher stopped${detail ? `: ${detail}` : ''}`); console.error(`[stepforge] click watcher stopped${detail ? `: ${detail}` : ''}`);
if (!this.session) return; if (!this.session) return;
if (!this.session.intervalSec) { if (!this.session.intervalSec) {
this.session.intervalSec = this.settings.get('capture.autoIntervalSec') || 5; this.session.intervalSec = this.fallbackCaptureTrigger() === 'hotkey'
? 0
: this.fallbackIntervalSec();
this.applyInterval(); this.applyInterval();
} }
this.notify('capture:state', this.state()); this.notify('capture:state', this.state());
+1
View File
@@ -122,6 +122,7 @@ class StepForgeApp {
api.library.trashList(), api.library.trashList(),
]); ]);
this.state.info = info; this.state.info = info;
document.body.classList.toggle('platform-linux', info.platform === 'linux');
this.state.settings = settings; this.state.settings = settings;
this.state.library = { this.state.library = {
guides: library.guides || [], guides: library.guides || [],
+18
View File
@@ -312,6 +312,11 @@ function showSettingsDialog({
const previewCount = makeInput(settings.exports?.previewStepCount ?? 3, 'number', { min: 1, step: 1 }); const previewCount = makeInput(settings.exports?.previewStepCount ?? 3, 'number', { min: 1, step: 1 });
const openFolder = el('input', { type: 'checkbox', checked: Boolean(settings.exports?.openFolderAfterExport) }); const openFolder = el('input', { type: 'checkbox', checked: Boolean(settings.exports?.openFolderAfterExport) });
const captureOutside = el('input', { type: 'checkbox', checked: Boolean(settings.capture?.captureOutsideClicks) }); const captureOutside = el('input', { type: 'checkbox', checked: Boolean(settings.capture?.captureOutsideClicks) });
const fallbackTrigger = makeSelect(settings.capture?.fallbackTrigger || 'interval', [
{ value: 'interval', label: 'Timed interval' },
{ value: 'hotkey', label: 'Hotkey only' },
]);
const autoIntervalSec = makeInput(settings.capture?.autoIntervalSec ?? 5, 'number', { min: 1, step: 1 });
const confirmSimple = el('input', { type: 'checkbox', checked: Boolean(settings.capture?.confirmSimpleCapture) }); const confirmSimple = el('input', { type: 'checkbox', checked: Boolean(settings.capture?.confirmSimpleCapture) });
const keepLast = makeInput(settings.backups?.keepLast ?? 10, 'number', { min: 0, step: 1 }); const keepLast = makeInput(settings.backups?.keepLast ?? 10, 'number', { min: 0, step: 1 });
const aiEnabled = el('input', { type: 'checkbox', checked: Boolean(settings.ai?.enabled) }); const aiEnabled = el('input', { type: 'checkbox', checked: Boolean(settings.ai?.enabled) });
@@ -326,6 +331,12 @@ function showSettingsDialog({
void api.settings.set({ keyPath: 'ai.ollama.model', value: model }).catch(() => {}); void api.settings.set({ keyPath: 'ai.ollama.model', value: model }).catch(() => {});
}, 250); }, 250);
const syncFallbackUi = () => {
autoIntervalSec.disabled = fallbackTrigger.value === 'hotkey';
};
fallbackTrigger.addEventListener('change', syncFallbackUi);
syncFallbackUi();
const updateAiStatus = (message, { error = false } = {}) => { const updateAiStatus = (message, { error = false } = {}) => {
aiStatus.textContent = message; aiStatus.textContent = message;
aiStatus.classList.toggle('error', Boolean(error)); aiStatus.classList.toggle('error', Boolean(error));
@@ -403,9 +414,14 @@ function showSettingsDialog({
labeledRow('Delay (ms)', delayMs), labeledRow('Delay (ms)', delayMs),
labeledRow('Click marker', clickMarker), labeledRow('Click marker', clickMarker),
labeledRow('Capture outside clicks', captureOutside), labeledRow('Capture outside clicks', captureOutside),
labeledRow('When clicks are unavailable', fallbackTrigger),
labeledRow('Timer interval (seconds)', autoIntervalSec),
labeledRow('Confirm simple capture', confirmSimple), labeledRow('Confirm simple capture', confirmSimple),
labeledRow('Capture hotkey', captureHotkey), labeledRow('Capture hotkey', captureHotkey),
labeledRow('Pause / resume hotkey', pauseHotkey), labeledRow('Pause / resume hotkey', pauseHotkey),
el('div.muted', {},
'Hotkey fallback uses the Capture hotkey. Timer fallback uses the interval above.',
),
), ),
el('fieldset', {}, el('fieldset', {},
el('legend', {}, 'Editor'), el('legend', {}, 'Editor'),
@@ -455,6 +471,8 @@ function showSettingsDialog({
delayMs: Number(delayMs.value || 0), delayMs: Number(delayMs.value || 0),
mode: captureMode.value, mode: captureMode.value,
clickMarker: clickMarker.checked, clickMarker: clickMarker.checked,
fallbackTrigger: fallbackTrigger.value === 'hotkey' ? 'hotkey' : 'interval',
autoIntervalSec: Math.max(1, Number(autoIntervalSec.value || 5)),
hotkeyCapture: captureHotkey.value.trim(), hotkeyCapture: captureHotkey.value.trim(),
hotkeyPauseResume: pauseHotkey.value.trim(), hotkeyPauseResume: pauseHotkey.value.trim(),
captureOutsideClicks: captureOutside.checked, captureOutsideClicks: captureOutside.checked,
+25
View File
@@ -49,6 +49,16 @@ body {
overflow: hidden; overflow: hidden;
} }
body.platform-linux button {
padding: 5px 11px;
}
body.platform-linux input,
body.platform-linux select,
body.platform-linux textarea {
padding: 6px 9px;
}
*::selection { background: rgba(0, 104, 255, 0.2); } *::selection { background: rgba(0, 104, 255, 0.2); }
#app { display: flex; flex-direction: column; height: 100vh; } #app { display: flex; flex-direction: column; height: 100vh; }
@@ -183,6 +193,21 @@ kbd {
color: #fff; color: #fff;
} }
body.platform-linux #topbar {
height: 52px;
gap: 10px;
padding: 0 14px;
}
body.platform-linux #capture-status {
padding: 5px 9px;
gap: 7px;
}
body.platform-linux #capture-status button {
padding: 2px 7px;
}
.library, .editor { flex: 1; min-height: 0; display: flex; } .library, .editor { flex: 1; min-height: 0; display: flex; }
.lib-side { .lib-side {
+3
View File
@@ -18,6 +18,9 @@ const DEFAULT_SETTINGS = {
hotkeyPauseResume: 'CommandOrControl+Shift+2', hotkeyPauseResume: 'CommandOrControl+Shift+2',
captureOutsideClicks: true, captureOutsideClicks: true,
confirmSimpleCapture: false, confirmSimpleCapture: false,
// Fallback trigger when click capture is unavailable: keep the old timer
// fallback by default, but let users switch to hotkey-only recordings.
fallbackTrigger: 'interval', // interval | hotkey
// Leading-edge click debounce (ms): clicks of the same button closer // Leading-edge click debounce (ms): clicks of the same button closer
// together than this collapse into one step, so accidental fast/double // together than this collapse into one step, so accidental fast/double
// clicks don't each become a step. Clicks spaced further apart always // clicks don't each become a step. Clicks spaced further apart always
@@ -97,8 +97,13 @@ captures a screenshot on each click.
### Adjusting the timed-capture interval ### Adjusting the timed-capture interval
If you don't enable the `input` group, StepForge captures on a timer. Change the If you don't enable the `input` group, StepForge captures on a timer. Change the
interval in **Settings → Capture** (`capture.autoIntervalSec`, default 5 fallback in **Settings → Capture**:
seconds).
- `When clicks are unavailable` -> `Hotkey only` to use the Capture hotkey
instead of a timer.
- `When clicks are unavailable` -> `Timed interval`, then set
`Timer interval (seconds)` (`capture.autoIntervalSec`, default 5 seconds)
if you want timed captures.
--- ---
+1 -1
View File
@@ -8,7 +8,7 @@
"private": true, "private": true,
"scripts": { "scripts": {
"start": "node scripts/start-electron.js", "start": "node scripts/start-electron.js",
"test": "node --test tests/unit/", "test": "node --test \"tests/unit/*.test.js\"",
"sample": "node scripts/make-sample-guide.js", "sample": "node scripts/make-sample-guide.js",
"package:windows": "node scripts/package-windows.js", "package:windows": "node scripts/package-windows.js",
"build": "bash scripts/build-release.sh", "build": "bash scripts/build-release.sh",
+21
View File
@@ -60,6 +60,26 @@ function sanitizeElectronEnv(baseEnv = process.env) {
return env; return env;
} }
function linuxSandboxLaunchArgs({
electronPath,
platform = process.platform,
statSync = fs.statSync,
} = {}) {
if (platform !== 'linux') return [];
if (!electronPath) return ['--no-sandbox'];
const helperPath = path.join(path.dirname(electronPath), 'chrome-sandbox');
try {
const stat = statSync(helperPath);
const ownedByRoot = stat.uid === 0;
const hasSetuid = Boolean(stat.mode & 0o4000);
if (ownedByRoot && hasSetuid) return [];
} catch {
// Missing or unreadable helper: fall back to the unsandboxed launcher.
}
return ['--no-sandbox'];
}
function electronBinaryCandidates({ packageRoot, distDir, platform }) { function electronBinaryCandidates({ packageRoot, distDir, platform }) {
const candidatePaths = []; const candidatePaths = [];
const pathHint = packageRoot ? readElectronPathHint(packageRoot) : null; const pathHint = packageRoot ? readElectronPathHint(packageRoot) : null;
@@ -292,6 +312,7 @@ module.exports = {
runNpmRebuild, runNpmRebuild,
runNpmInstall, runNpmInstall,
sanitizeElectronEnv, sanitizeElectronEnv,
linuxSandboxLaunchArgs,
resolveElectronBinary, resolveElectronBinary,
resolveElectronPackageRoot, resolveElectronPackageRoot,
platformBinaryCandidates, platformBinaryCandidates,
+14 -1
View File
@@ -46,8 +46,21 @@ fi
cat > "$WORK_DIR/usr/bin/stepforge" <<'EOF' cat > "$WORK_DIR/usr/bin/stepforge" <<'EOF'
#!/usr/bin/env sh #!/usr/bin/env sh
APP_DIR=/opt/stepforge 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 cd "$APP_DIR" || exit 1
exec "$APP_DIR/node_modules/.bin/electron" "$APP_DIR" "$@" 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 EOF
chmod 0755 "$WORK_DIR/usr/bin/stepforge" chmod 0755 "$WORK_DIR/usr/bin/stepforge"
+13 -4
View File
@@ -3,7 +3,11 @@
const { spawn } = require('node:child_process'); const { spawn } = require('node:child_process');
const { resolveElectronBinary, sanitizeElectronEnv } = require('./electron-launcher'); const {
linuxSandboxLaunchArgs,
resolveElectronBinary,
sanitizeElectronEnv,
} = require('./electron-launcher');
let electronPath; let electronPath;
try { try {
@@ -13,11 +17,16 @@ try {
process.exit(1); process.exit(1);
} }
const env = sanitizeElectronEnv(); const env = sanitizeElectronEnv();
const sandboxArgs = linuxSandboxLaunchArgs({ electronPath });
if (sandboxArgs.includes('--no-sandbox')) {
console.warn('[stepforge] Electron sandbox helper is not configured for this install; starting with --no-sandbox');
}
// On Linux/Wayland, enable PipeWire-based screen capture so desktopCapturer // On Linux, prefer the native Ozone path when available and enable PipeWire-
// can go through the XDG Desktop Portal when XWayland isn't the only option. // based screen capture so desktopCapturer can go through the XDG Desktop
// Portal on Wayland without affecting X11.
const extraArgs = process.platform === 'linux' const extraArgs = process.platform === 'linux'
? ['--enable-features=WebRTCPipeWireCapturer'] ? ['--enable-features=WebRTCPipeWireCapturer', '--ozone-platform-hint=auto', ...sandboxArgs]
: []; : [];
const child = spawn(electronPath, [...extraArgs, '.'], { const child = spawn(electronPath, [...extraArgs, '.'], {
+59
View File
@@ -33,6 +33,22 @@ function makeService({ settings: settingsOverrides, screenApi } = {}) {
}); });
} }
test('XDG_SESSION_TYPE=x11 wins over a stray WAYLAND_DISPLAY value', () => {
const prevSessionType = process.env.XDG_SESSION_TYPE;
const prevWaylandDisplay = process.env.WAYLAND_DISPLAY;
try {
process.env.XDG_SESSION_TYPE = 'x11';
process.env.WAYLAND_DISPLAY = 'wayland-0';
const service = makeService();
assert.equal(service.onWayland(), false);
} finally {
if (prevSessionType === undefined) delete process.env.XDG_SESSION_TYPE;
else process.env.XDG_SESSION_TYPE = prevSessionType;
if (prevWaylandDisplay === undefined) delete process.env.WAYLAND_DISPLAY;
else process.env.WAYLAND_DISPLAY = prevWaylandDisplay;
}
});
// The raw/regular twin window plus margin: how long a test must wait for a // The raw/regular twin window plus margin: how long a test must wait for a
// held Linux raw press to fire when no coordinate twin arrives. // held Linux raw press to fire when no coordinate twin arrives.
const TWIN_FLUSH_MS = 60; const TWIN_FLUSH_MS = 60;
@@ -840,6 +856,49 @@ test('losing the click watcher mid-session falls back to interval capture', () =
} }
}); });
test('losing the click watcher mid-session can fall back to hotkey-only', () => {
const service = makeService();
service.settings.get = (key) => {
if (key === 'capture.fallbackTrigger') return 'hotkey';
if (key === 'capture.autoIntervalSec') return 3;
return null;
};
service.session = { guideId: 'guide-loss-hotkey', paused: false, count: 0, intervalSec: 0 };
const states = [];
service.notify = (channel, payload) => {
states.push({ channel, payload });
};
try {
service.handleClickWatcherLoss('exited with code 1');
assert.equal(service.session.intervalSec, 0,
'hotkey fallback must not silently start the 5-second timer');
assert.equal(service.intervalTimer, null);
assert.ok(states.some((s) => s.channel === 'capture:state'));
} finally {
service.finishSession();
}
});
test('starting a session with hotkey fallback keeps the timer off when clicks are unavailable', () => {
const service = makeService();
service.clickCaptureAvailable = () => false;
service.settings.get = (key) => {
if (key === 'capture.fallbackTrigger') return 'hotkey';
if (key === 'capture.autoIntervalSec') return 7;
if (key === 'capture.captureOutsideClicks') return false;
return null;
};
service.startSession('guide-hotkey-fallback');
assert.equal(service.session.intervalSec, 0);
assert.equal(service.state().clickCaptureAvailable, false);
assert.equal(service.state().clickCapture, false);
service.finishSession();
});
// ---- strict frame selection ----------------------------------------------------- // ---- strict frame selection -----------------------------------------------------
test('a click is served instantly from the freshly buffered frame', async () => { test('a click is served instantly from the freshly buffered frame', async () => {
+19
View File
@@ -7,6 +7,7 @@ const path = require('node:path');
const { const {
buildMissingElectronError, buildMissingElectronError,
linuxSandboxLaunchArgs,
repairElectronInstall, repairElectronInstall,
resolveElectronBinary, resolveElectronBinary,
} = require('../../scripts/electron-launcher'); } = require('../../scripts/electron-launcher');
@@ -171,3 +172,21 @@ test('reports a helpful error when the runtime is missing', (t) => {
assert.match(message, /Electron could not be started/); assert.match(message, /Electron could not be started/);
assert.match(message, /Expected the binary in:/); assert.match(message, /Expected the binary in:/);
}); });
test('uses --no-sandbox when the Linux sandbox helper is not root-owned and setuid', () => {
const args = linuxSandboxLaunchArgs({
electronPath: '/tmp/stepforge/node_modules/electron/dist/electron',
platform: 'linux',
statSync: () => ({ uid: 1000, mode: 0o100755 }),
});
assert.deepEqual(args, ['--no-sandbox']);
});
test('keeps the sandbox enabled when the Linux helper is root-owned and setuid', () => {
const args = linuxSandboxLaunchArgs({
electronPath: '/tmp/stepforge/node_modules/electron/dist/electron',
platform: 'linux',
statSync: () => ({ uid: 0, mode: 0o104755 }),
});
assert.deepEqual(args, []);
});
+1
View File
@@ -65,6 +65,7 @@ test('settings persist, deep-merge with defaults, and store global placeholders'
assert.equal(s2.get('capture.delayMs'), 1500); assert.equal(s2.get('capture.delayMs'), 1500);
assert.equal(s2.get('ai.ollama.model'), 'qwen3:0.6b'); assert.equal(s2.get('ai.ollama.model'), 'qwen3:0.6b');
assert.equal(s2.get('capture.clickMarker'), DEFAULT_SETTINGS.capture.clickMarker); assert.equal(s2.get('capture.clickMarker'), DEFAULT_SETTINGS.capture.clickMarker);
assert.equal(s2.get('capture.fallbackTrigger'), DEFAULT_SETTINGS.capture.fallbackTrigger);
assert.deepEqual(s2.getGlobalPlaceholders(), { Company: 'Acme', Author: 'Tyler' }); assert.deepEqual(s2.getGlobalPlaceholders(), { Company: 'Acme', Author: 'Tyler' });
}); });