ubuntu not working. idk I'm just gonna use windows anyway
This commit is contained in:
@@ -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).
|
||||
|
||||
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).
|
||||
|
||||
|
||||
+31
-7
@@ -113,8 +113,13 @@ function hasBinary(name) {
|
||||
// Treating it as "available" would leave the session with no click capture AND
|
||||
// no interval fallback, so zero steps get captured.
|
||||
function isWayland() {
|
||||
return process.platform === 'linux'
|
||||
&& (process.env.XDG_SESSION_TYPE === 'wayland' || Boolean(process.env.WAYLAND_DISPLAY));
|
||||
if (process.platform !== 'linux') return false;
|
||||
// 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 --------------------------------
|
||||
@@ -263,6 +268,16 @@ class CaptureService {
|
||||
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() {
|
||||
if (this._clickAvail === undefined) {
|
||||
// Three click sources, in order of fidelity:
|
||||
@@ -296,12 +311,19 @@ class CaptureService {
|
||||
|
||||
startSession(guideId, { intervalSec = null } = {}) {
|
||||
this.finishSession();
|
||||
// Default trigger: clicks when the platform supports it, otherwise an
|
||||
// interval so a session always produces steps even if the global hotkey
|
||||
// never fires (common under Wayland/WSLg).
|
||||
// Default trigger: clicks when the platform supports it, otherwise the
|
||||
// user-selected fallback (timer or hotkey-only). That keeps Linux from
|
||||
// silently dropping into an unwanted 5-second timer when click capture
|
||||
// is unavailable.
|
||||
let interval = intervalSec;
|
||||
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
|
||||
// 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}` : ''}`);
|
||||
if (!this.session) return;
|
||||
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.notify('capture:state', this.state());
|
||||
|
||||
@@ -122,6 +122,7 @@ class StepForgeApp {
|
||||
api.library.trashList(),
|
||||
]);
|
||||
this.state.info = info;
|
||||
document.body.classList.toggle('platform-linux', info.platform === 'linux');
|
||||
this.state.settings = settings;
|
||||
this.state.library = {
|
||||
guides: library.guides || [],
|
||||
|
||||
@@ -312,6 +312,11 @@ function showSettingsDialog({
|
||||
const previewCount = makeInput(settings.exports?.previewStepCount ?? 3, 'number', { min: 1, step: 1 });
|
||||
const openFolder = el('input', { type: 'checkbox', checked: Boolean(settings.exports?.openFolderAfterExport) });
|
||||
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 keepLast = makeInput(settings.backups?.keepLast ?? 10, 'number', { min: 0, step: 1 });
|
||||
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(() => {});
|
||||
}, 250);
|
||||
|
||||
const syncFallbackUi = () => {
|
||||
autoIntervalSec.disabled = fallbackTrigger.value === 'hotkey';
|
||||
};
|
||||
fallbackTrigger.addEventListener('change', syncFallbackUi);
|
||||
syncFallbackUi();
|
||||
|
||||
const updateAiStatus = (message, { error = false } = {}) => {
|
||||
aiStatus.textContent = message;
|
||||
aiStatus.classList.toggle('error', Boolean(error));
|
||||
@@ -403,9 +414,14 @@ function showSettingsDialog({
|
||||
labeledRow('Delay (ms)', delayMs),
|
||||
labeledRow('Click marker', clickMarker),
|
||||
labeledRow('Capture outside clicks', captureOutside),
|
||||
labeledRow('When clicks are unavailable', fallbackTrigger),
|
||||
labeledRow('Timer interval (seconds)', autoIntervalSec),
|
||||
labeledRow('Confirm simple capture', confirmSimple),
|
||||
labeledRow('Capture hotkey', captureHotkey),
|
||||
labeledRow('Pause / resume hotkey', pauseHotkey),
|
||||
el('div.muted', {},
|
||||
'Hotkey fallback uses the Capture hotkey. Timer fallback uses the interval above.',
|
||||
),
|
||||
),
|
||||
el('fieldset', {},
|
||||
el('legend', {}, 'Editor'),
|
||||
@@ -455,6 +471,8 @@ function showSettingsDialog({
|
||||
delayMs: Number(delayMs.value || 0),
|
||||
mode: captureMode.value,
|
||||
clickMarker: clickMarker.checked,
|
||||
fallbackTrigger: fallbackTrigger.value === 'hotkey' ? 'hotkey' : 'interval',
|
||||
autoIntervalSec: Math.max(1, Number(autoIntervalSec.value || 5)),
|
||||
hotkeyCapture: captureHotkey.value.trim(),
|
||||
hotkeyPauseResume: pauseHotkey.value.trim(),
|
||||
captureOutsideClicks: captureOutside.checked,
|
||||
|
||||
@@ -49,6 +49,16 @@ body {
|
||||
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); }
|
||||
|
||||
#app { display: flex; flex-direction: column; height: 100vh; }
|
||||
@@ -183,6 +193,21 @@ kbd {
|
||||
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; }
|
||||
|
||||
.lib-side {
|
||||
|
||||
@@ -18,6 +18,9 @@ const DEFAULT_SETTINGS = {
|
||||
hotkeyPauseResume: 'CommandOrControl+Shift+2',
|
||||
captureOutsideClicks: true,
|
||||
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
|
||||
// together than this collapse into one step, so accidental fast/double
|
||||
// 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
|
||||
|
||||
If you don't enable the `input` group, StepForge captures on a timer. Change the
|
||||
interval in **Settings → Capture** (`capture.autoIntervalSec`, default 5
|
||||
seconds).
|
||||
fallback in **Settings → Capture**:
|
||||
|
||||
- `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
@@ -8,7 +8,7 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"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",
|
||||
"package:windows": "node scripts/package-windows.js",
|
||||
"build": "bash scripts/build-release.sh",
|
||||
|
||||
@@ -60,6 +60,26 @@ function sanitizeElectronEnv(baseEnv = process.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 }) {
|
||||
const candidatePaths = [];
|
||||
const pathHint = packageRoot ? readElectronPathHint(packageRoot) : null;
|
||||
@@ -292,6 +312,7 @@ module.exports = {
|
||||
runNpmRebuild,
|
||||
runNpmInstall,
|
||||
sanitizeElectronEnv,
|
||||
linuxSandboxLaunchArgs,
|
||||
resolveElectronBinary,
|
||||
resolveElectronPackageRoot,
|
||||
platformBinaryCandidates,
|
||||
|
||||
@@ -46,8 +46,21 @@ 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
|
||||
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
|
||||
chmod 0755 "$WORK_DIR/usr/bin/stepforge"
|
||||
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
|
||||
const { spawn } = require('node:child_process');
|
||||
|
||||
const { resolveElectronBinary, sanitizeElectronEnv } = require('./electron-launcher');
|
||||
const {
|
||||
linuxSandboxLaunchArgs,
|
||||
resolveElectronBinary,
|
||||
sanitizeElectronEnv,
|
||||
} = require('./electron-launcher');
|
||||
|
||||
let electronPath;
|
||||
try {
|
||||
@@ -13,11 +17,16 @@ try {
|
||||
process.exit(1);
|
||||
}
|
||||
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
|
||||
// can go through the XDG Desktop Portal when XWayland isn't the only option.
|
||||
// On Linux, prefer the native Ozone path when available and enable PipeWire-
|
||||
// based screen capture so desktopCapturer can go through the XDG Desktop
|
||||
// Portal on Wayland without affecting X11.
|
||||
const extraArgs = process.platform === 'linux'
|
||||
? ['--enable-features=WebRTCPipeWireCapturer']
|
||||
? ['--enable-features=WebRTCPipeWireCapturer', '--ozone-platform-hint=auto', ...sandboxArgs]
|
||||
: [];
|
||||
|
||||
const child = spawn(electronPath, [...extraArgs, '.'], {
|
||||
|
||||
@@ -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
|
||||
// held Linux raw press to fire when no coordinate twin arrives.
|
||||
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 -----------------------------------------------------
|
||||
|
||||
test('a click is served instantly from the freshly buffered frame', async () => {
|
||||
|
||||
@@ -7,6 +7,7 @@ const path = require('node:path');
|
||||
|
||||
const {
|
||||
buildMissingElectronError,
|
||||
linuxSandboxLaunchArgs,
|
||||
repairElectronInstall,
|
||||
resolveElectronBinary,
|
||||
} = 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, /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, []);
|
||||
});
|
||||
|
||||
@@ -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('ai.ollama.model'), 'qwen3:0.6b');
|
||||
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' });
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user