From 5b2733e0031145a9a255b38d19842e054d612149 Mon Sep 17 00:00:00 2001 From: flamerged <34665379+flamerged@users.noreply.github.com> Date: Fri, 1 May 2026 22:33:59 +0200 Subject: [PATCH 1/2] feat: add pause/resume/toggle to keep daemon alive without clipboard hijack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 0.7.x model — daemon always rewrites the clipboard with the remote path — assumed every screenshot was AI-bound. In practice ~80% of screenshots go to Slack/Teams/WhatsApp/GitHub PRs/web, where the hijack is actively destructive (you paste a path string instead of an image). The only escape hatch was `sshshot stop` + `sshshot start` + re-selecting the SSH target, which is heavier than `scp`-by-hand. This adds a `paused: boolean` to config and three new commands: sshshot pause — daemon stays alive, target stays selected, clipboard stops being touched sshshot resume — back to processing screenshots normally sshshot toggle — flip between the two Implementation: - processNewImage() early-returns when config.paused is true. Both the poll-driven and fs.watch-driven paths funnel through here, so one guard covers both. - A module-level `lastSeenPausedState` makes the daemon log the transition once instead of repeating "skipping" on every paused screenshot. - `sshshot status` shows `[paused]` next to the running PID so users see which mode they're in at a glance. This is the short-term ergonomic fix. The longer-term work (context-aware clipboard routing — only hijack when the foreground app is a terminal) is queued as Phase 6b in the project memory. --- README.md | 17 ++++++++++++++ src/config.ts | 7 ++++++ src/index.ts | 60 ++++++++++++++++++++++++++++++++++++++++++++++++-- src/monitor.ts | 23 +++++++++++++++++++ 4 files changed, 105 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 31ebca1..97fdb3b 100644 --- a/README.md +++ b/README.md @@ -110,10 +110,27 @@ sshshot stop stop the running daemon sshshot status show running PID + target remote sshshot target show current active target + available targets sshshot target switch active target without restarting the daemon +sshshot pause keep daemon alive but stop touching the clipboard +sshshot resume resume processing screenshots +sshshot toggle flip pause/resume in one command sshshot config add/remove SSH remotes sshshot uninstall stop daemon + remove ~/.config/sshshot ``` +### Pausing without losing your target + +When you want to take a screenshot for Slack/GitHub/iMessage and don't want sshshot replacing your clipboard with the remote path, **don't stop the daemon** — pause it: + +```bash +sshshot pause # daemon stays alive, target stays selected, clipboard untouched +# … take your local screenshot, paste it wherever … +sshshot resume # back to processing screenshots into the remote +# or: +sshshot toggle # flips between the two +``` + +`sshshot status` shows `[paused]` next to the running PID so you know which mode you're in. This is intentionally lighter than `stop`/`start` — no process restart, no re-prompting for which remote, instant flip. + ### Switching targets at runtime If you have multiple remotes configured and want to change which one screenshots ship to without stopping/starting the daemon, use `sshshot target`: diff --git a/src/config.ts b/src/config.ts index 80a3db1..06e55fc 100644 --- a/src/config.ts +++ b/src/config.ts @@ -14,6 +14,13 @@ export interface Config { // on its next poll cycle without restarting. Updated via `sshshot target `. // Set to "local" to save screenshots locally instead of uploading. activeTarget?: string + // When true, the daemon stays alive but skips processing screenshots — + // clipboard isn't touched, no uploads happen. Toggled via + // `sshshot pause` / `sshshot resume` / `sshshot toggle`. Designed for the + // common "I want to take a screenshot for Slack/GitHub right now without + // sshshot stomping my clipboard" case so the user doesn't have to stop + + // restart + re-select target every time. + paused?: boolean } // Allowed character set for remote names: alphanumerics plus `.`, `_`, `-`, diff --git a/src/index.ts b/src/index.ts index 37ca9b1..ac2b8b6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -298,6 +298,9 @@ Commands: stop Stop background process status Show if running target [] Show or change the active target without restarting the daemon + pause Daemon stays alive but stops touching the clipboard + resume Resume processing screenshots + toggle Flip between pause/resume config Modify configuration uninstall Remove config and stop process version Print version and exit @@ -335,6 +338,39 @@ function setActiveTarget(name: string | undefined): void { // and will switch on the next iteration. No restart needed. } +// Pause/resume/toggle the running daemon without changing target or +// restarting the process. The daemon stays alive (so resume is instant); +// processNewImage early-returns while paused, leaving the user's +// clipboard untouched. Designed for the common "I want a screenshot for +// Slack right now, not for Claude Code" case. +function setPausedState(value: boolean): void { + const config = loadConfig() + if (!config) { + console.log("No configuration found. Run 'sshshot' first to set up remotes.") + process.exit(1) + } + if (Boolean(config.paused) === value) { + console.log(value ? 'Already paused' : 'Already active') + return + } + saveConfig({ ...config, paused: value }) + if (value) { + console.log('Paused — sshshot will not touch your clipboard until resume') + console.log("Resume with: 'sshshot resume' or 'sshshot toggle'") + } else { + console.log('Resumed — sshshot is processing screenshots again') + } +} + +function toggleActive(): void { + const config = loadConfig() + if (!config) { + console.log("No configuration found. Run 'sshshot' first to set up remotes.") + process.exit(1) + } + setPausedState(!config.paused) +} + function uninstall(): void { // Stop any running process const count = killAllSshshotProcesses() @@ -369,11 +405,16 @@ function showStatus(): void { return } + // Surface the paused state on the same line — otherwise users wonder + // why screenshots aren't being processed even though `status` says + // "Running". + const pauseLabel = loadConfig()?.paused ? ' [paused]' : '' + for (const proc of processes) { if (proc.target) { - console.log(`Running (PID: ${proc.pid}) -> ${proc.target}`) + console.log(`Running (PID: ${proc.pid}) -> ${proc.target}${pauseLabel}`) } else { - console.log(`Running (PID: ${proc.pid})`) + console.log(`Running (PID: ${proc.pid})${pauseLabel}`) } } } @@ -495,6 +536,21 @@ async function main(): Promise { return } + if (command === 'pause') { + setPausedState(true) + return + } + + if (command === 'resume') { + setPausedState(false) + return + } + + if (command === 'toggle') { + toggleActive() + return + } + if (command === 'config') { await runConfig() return diff --git a/src/monitor.ts b/src/monitor.ts index 79aeaf4..a1b1d29 100644 --- a/src/monitor.ts +++ b/src/monitor.ts @@ -652,11 +652,34 @@ async function copyToClipboard(text: string): Promise { return copyToClipboardX11(text) } +// Module-level pause-state tracker so we log the transition once instead +// of on every paused screenshot. Updated by checkPaused() at the top of +// processNewImage. Initialized to false; the first paused poll logs +// "Paused — skipping…". +let lastSeenPausedState = false + +function checkPaused(): boolean { + const paused = Boolean(loadConfig()?.paused) + if (paused !== lastSeenPausedState) { + log( + paused + ? 'Paused — clipboard untouched until resume' + : 'Resumed — processing screenshots again' + ) + lastSeenPausedState = paused + } + return paused +} + async function processNewImage( imageBuffer: Buffer, remote: string, source: 'clipboard' | 'file' ): Promise { + // Honor `sshshot pause` — daemon stays alive but skips processing so + // the user can take non-AI screenshots without `stop`/`start`+re-select. + if (checkPaused()) return + const filename = generateFilename() const size = Math.round(imageBuffer.length / 1024) From e2e176ec9251ea072594cf32239f9776e365a732 Mon Sep 17 00:00:00 2001 From: flamerged <34665379+flamerged@users.noreply.github.com> Date: Fri, 1 May 2026 22:41:43 +0200 Subject: [PATCH 2/2] fix: bump filename random suffix from 4 to 8 hex chars to defuse CI flake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 100-iteration uniqueness test in test/monitor-helpers.test.ts was asserting "no collisions across 100 same-millisecond calls" against a 4-char hex suffix (16⁴ = 65,536 buckets). Birthday-paradox math says that's a ~7% collision rate at 100 trials — unlucky but not improbable. Round H/I/J/K/L all happened to land lucky draws on every Node version. Round-M (PR #22) finally hit a Node-24-only bad RNG sequence and the test failed with "expected 99 to be 100". Same code, just statistics catching up. Bump random suffix to 4 bytes / 8 hex chars (4.3B buckets), making the collision probability for 100 same-ms calls ~10⁻⁶. The shape constraint (SAFE_REMOTE_FILENAME_RE) is updated to require the new length, and the dangerous-input test fixtures are refreshed to use 8-char suffixes so the "rejects 4-char as old shape" assertion still documents the historical break-point. --- src/monitor.ts | 15 +++++++++------ test/monitor-helpers.test.ts | 19 +++++++++++-------- 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/src/monitor.ts b/src/monitor.ts index a1b1d29..63fcece 100644 --- a/src/monitor.ts +++ b/src/monitor.ts @@ -409,15 +409,18 @@ export function getImageHash(buffer: Buffer): string { } export function generateFilename(): string { - // Include milliseconds + a 4-char random suffix so two screenshots taken - // in the same second don't collide on the remote (Cmd+Shift+3 spam, - // back-to-back paste from clipboard, etc). The previous one-per-second - // resolution silently overwrote earlier images. + // Include milliseconds + an 8-char random suffix so two screenshots taken + // in the same millisecond don't collide on the remote (Cmd+Shift+3 spam, + // back-to-back paste from clipboard, etc). 4 random bytes = 4.3B buckets; + // collision probability for 100 same-ms calls is ~10⁻⁶ — small enough + // for the test loop to be deterministic. (The original 4-char/65K-bucket + // version had a ~7% birthday-collision rate at 100 trials and produced + // a Node-24-only flake — see PR #22 / commit history.) const now = new Date() // toISOString() → 2026-05-01T12:34:56.789Z → slice off the trailing 'Z' // and dot/colon-replace → 2026-05-01T12-34-56-789 const timestamp = now.toISOString().replace(/[:.]/g, '-').slice(0, 23) - const suffix = crypto.randomBytes(2).toString('hex') // 4 hex chars + const suffix = crypto.randomBytes(4).toString('hex') // 8 hex chars return `screenshot-${timestamp}-${suffix}.png` } @@ -426,7 +429,7 @@ export function generateFilename(): string { // today, but if generation ever changes (e.g. accepts user input), this // guard keeps the shell-injection surface closed. export const SAFE_REMOTE_FILENAME_RE = - /^screenshot-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}-[0-9a-f]{4}\.png$/ + /^screenshot-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}-[0-9a-f]{8}\.png$/ function getLocalScreenshotDir(): string { return path.join(os.homedir(), 'sshshot-screenshots') diff --git a/test/monitor-helpers.test.ts b/test/monitor-helpers.test.ts index 338c95c..355fc1c 100644 --- a/test/monitor-helpers.test.ts +++ b/test/monitor-helpers.test.ts @@ -66,9 +66,9 @@ describe('generateFilename', () => { } }) - it('produces strictly screenshot---.png shape', () => { + it('produces strictly screenshot---.png shape', () => { const f = generateFilename() - expect(f).toMatch(/^screenshot-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}-[0-9a-f]{4}\.png$/) + expect(f).toMatch(/^screenshot-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}-[0-9a-f]{8}\.png$/) }) it('produces unique filenames for two calls in the same millisecond', () => { @@ -88,15 +88,16 @@ describe('SAFE_REMOTE_FILENAME_RE', () => { const dangerous = [ 'screenshot-$(rm -rf ~).png', 'screenshot-`whoami`.png', - 'screenshot-2026-05-01T12-00-00-000-abcd.png; rm -rf ~', - 'screenshot-2026-05-01T12-00-00-000-abcd.png && curl evil.com', + 'screenshot-2026-05-01T12-00-00-000-abcdef12.png; rm -rf ~', + 'screenshot-2026-05-01T12-00-00-000-abcdef12.png && curl evil.com', "screenshot-' OR 1=1 --.png", '../../../etc/passwd', 'screenshot.png', 'screenshot-2026-05-01.png', - 'screenshot-2026-05-01T12-00-00-000-ABCD.png', // upper hex rejected - 'screenshot-2026-05-01T12-00-00.png', // old shape (no ms/suffix) rejected - 'screenshot-2026-05-01T12-00-00-000-abcd.PNG' + 'screenshot-2026-05-01T12-00-00-000-ABCDEF12.png', // upper hex rejected + 'screenshot-2026-05-01T12-00-00-000-abcd.png', // old 4-char suffix rejected + 'screenshot-2026-05-01T12-00-00.png', // pre-Round-H shape rejected + 'screenshot-2026-05-01T12-00-00-000-abcdef12.PNG' ] for (const f of dangerous) { expect(SAFE_REMOTE_FILENAME_RE.test(f), f).toBe(false) @@ -104,7 +105,9 @@ describe('SAFE_REMOTE_FILENAME_RE', () => { }) it('accepts the canonical generateFilename output', () => { - expect(SAFE_REMOTE_FILENAME_RE.test('screenshot-2026-05-01T12-34-56-789-abcd.png')).toBe(true) + expect(SAFE_REMOTE_FILENAME_RE.test('screenshot-2026-05-01T12-34-56-789-abcdef12.png')).toBe( + true + ) }) })