···11-import { spawn } from "child_process";
22-import { PBCOPY_TIMEOUT_MS } from "./constants";
11+import { writeFile, unlink } from "fs/promises";
22+import clipboard from "clipboardy";
3344-/**
55- * Copies text to the system clipboard using pbcopy (macOS)
66- * @param text The text to copy to clipboard
77- * @returns Promise that resolves when copy is complete
88- * @throws Error if pbcopy fails or times out
99- */
1010-export async function copyToClipboard(text: string): Promise<void> {
1111- return new Promise((resolve, reject) => {
1212- const pbcopy = spawn("pbcopy");
1313- let timeoutId: Timer | null = null;
44+export interface ClipboardContent {
55+ type: "image" | "text";
66+ path: string;
77+}
1481515- pbcopy.stdin.write(text);
1616- pbcopy.stdin.end();
99+export async function getClipboardContent(): Promise<ClipboardContent | null> {
1010+ // Check for images first
1111+ const hasImages = await clipboard.hasImages();
1212+ if (hasImages) {
1313+ const imagePaths = await clipboard.readImages();
1414+ if (imagePaths.length > 0) {
1515+ return { type: "image", path: imagePaths[0] };
1616+ }
1717+ }
17181818- pbcopy.on("close", (code) => {
1919- if (timeoutId) clearTimeout(timeoutId);
2020- if (code === 0) {
2121- resolve();
2222- } else {
2323- reject(new Error(`pbcopy exited with code ${code}`));
2424- }
2525- });
1919+ // Fall back to text
2020+ const text = await clipboard.read();
2121+ if (text && text.length > 0) {
2222+ const tempPath = `/tmp/hop-clipboard-${Date.now()}`;
2323+ await writeFile(tempPath, text);
2424+ return { type: "text", path: tempPath };
2525+ }
2626+2727+ return null;
2828+}
26292727- pbcopy.on("error", (err) => {
2828- if (timeoutId) clearTimeout(timeoutId);
2929- reject(err);
3030- });
3030+export async function cleanupClipboardFile(path: string): Promise<void> {
3131+ try {
3232+ await unlink(path);
3333+ } catch {
3434+ // Ignore cleanup errors
3535+ }
3636+}
31373232- // Add timeout for clipboard operation
3333- timeoutId = setTimeout(() => {
3434- pbcopy.kill("SIGTERM");
3535- reject(new Error("pbcopy timed out"));
3636- }, PBCOPY_TIMEOUT_MS);
3737- });
3838+export async function copyToClipboard(text: string): Promise<void> {
3939+ await clipboard.write(text);
3840}
-12
src/config.ts
···33import { homedir } from "os";
44import { join } from "path";
5566-/**
77- * Configuration interface for hop
88- */
96export interface Config {
1010- /** SSH server in format "user@host" */
117 server: string;
1212- /** Remote path where files will be uploaded */
138 remotePath: string;
1414- /** Base URL for accessing uploaded files */
159 baseUrl: string;
1610}
1711···26202721const CONFIG_TEMPLATE = JSON.stringify(DEFAULT_CONFIG, null, 2);
28222929-/**
3030- * Loads configuration from ~/.config/hop/config.json
3131- * Creates a default config file if it doesn't exist
3232- * @returns The loaded configuration
3333- * @throws Exits process if config is missing or invalid
3434- */
3523export async function loadConfig(): Promise<Config> {
3624 if (!existsSync(CONFIG_PATH)) {
3725 await mkdir(CONFIG_DIR, { recursive: true });
-18
src/constants.ts
···11-/** Application constants */
22-33-/** Length of hash to use for filenames (truncated from SHA256 hex) */
41export const HASH_LENGTH = 7;
55-66-/** Video quality CRF (Constant Rate Factor) for ffmpeg VP9 encoding
77- * Lower values = higher quality, larger file size
88- * 30 is a good balance for screen recordings
99- */
102export const VIDEO_QUALITY_CRF = 30;
1111-1212-/** Maximum filename length (filesystem limit for most systems) */
133export const MAX_FILENAME_LENGTH = 255;
1414-1515-/** Timeout for pbcopy command (clipboard operations) in milliseconds */
164export const PBCOPY_TIMEOUT_MS = 5000;
1717-1818-/** Timeout for defaults command (reading macOS preferences) in milliseconds */
195export const DEFAULTS_TIMEOUT_MS = 5000;
2020-2121-/** Timeout for screencapture screenshot command in milliseconds */
226export const SCREENSHOT_TIMEOUT_MS = 60000;
2323-2424-/** Timeout for ffprobe command (getting video metadata) in milliseconds */
257export const FFPROBE_TIMEOUT_MS = 30000;
+14-5
src/index.ts
···33import { loadConfig } from "./config";
44import { generateHash, getFilename } from "./hash";
55import { uploadWithProgress } from "./upload";
66-import { copyToClipboard } from "./clipboard";
66+import { copyToClipboard, getClipboardContent, cleanupClipboardFile } from "./clipboard";
77import { captureScreenshot } from "./screenshot";
88import { captureRecording } from "./record";
99import { validateFilename } from "./utils";
···136136 }
137137138138 if (!filePath) {
139139- console.error("Error: No file specified");
140140- console.error("Run 'hop --help' for usage information");
141141- process.exit(1);
139139+ const clipboardContent = await getClipboardContent();
140140+ if (!clipboardContent) {
141141+ console.error("Error: No file specified and clipboard is empty");
142142+ console.error("Run 'hop --help' for usage information");
143143+ process.exit(1);
144144+ }
145145+ filePath = clipboardContent.path;
142146 }
143147144148 if (keepFilename && customFilename) {
···183187 }
184188185189 const url = `${config.baseUrl}/${encodeURI(filename)}`;
186186- console.log(`✓ uploaded ${originalName} to ${url}`);
190190+ console.log(`uploaded ${originalName} to ${url}`);
191191+192192+ // Clean up temp clipboard file if it was used
193193+ if (filePath.startsWith('/tmp/hop-clipboard-')) {
194194+ await cleanupClipboardFile(filePath);
195195+ }
187196188197 try {
189198 await copyToClipboard(url);
+1-12
src/record.ts
···77import { VIDEO_QUALITY_CRF, FFPROBE_TIMEOUT_MS } from "./constants";
88import { hasRawMode } from "./types";
991010-/** Options for screen recording */
1110interface RecordingOptions {
1212- /** Whether to include microphone audio */
1311 audio: boolean;
1412}
15131616-/** Recording state machine to prevent race conditions */
1714type RecordingState = "idle" | "recording" | "finishing" | "cancelled";
18151916function generateRecordingFilename(extension: string): string {
···127124 });
128125}
129126130130-/**
131131- * Records the screen using macOS screencapture and converts to WebM
132132- * @param options Recording options
133133- * @returns Promise resolving to WebM file path on success, null on cancellation or error
134134- */
135127export async function captureRecording(
136128 options: RecordingOptions
137129): Promise<string | null> {
···186178 output: process.stdout
187179 });
188180189189- // Only enable raw mode if stdin supports it (not in compiled binary)
190181 const stdinHasRawMode = hasRawMode(process.stdin);
191182 if (stdinHasRawMode) {
192183 process.stdin.setRawMode(true);
···194185 }
195186196187 const handleKeypress = (key: Buffer) => {
197197- // State machine prevents race conditions
198188 if (state !== "recording") return;
199189200190 if (key.toString() === "\r" || key.toString() === "\n") {
···209199 process.stdin.on("data", handleKeypress);
210200211201 const sigintHandler = () => {
212212- // State machine prevents double-handling
213202 if (state !== "recording") return;
214203215204 state = "cancelled";
···284273 }
285274 process.stdin.pause();
286275 rl.close();
287287- console.error(`\nError: screencapture failed - ${err.message}`);
276276+ console.error(`screencapture failed: ${err.message}`);
288277 resolve(null);
289278 });
290279 });
-8
src/screenshot.ts
···22import { join } from "path";
33import { getCaptureDirectory, formatTimestamp } from "./utils";
4455-/** Options for screenshot capture */
65interface ScreenshotOptions {
77- /** Whether to capture a region/window instead of fullscreen */
86 region: boolean;
97}
1010-1111-/**
1212- * Captures a screenshot using macOS screencapture utility
1313- * @param options Screenshot capture options
1414- * @returns Promise resolving to file path on success, null on cancellation or error
1515- */
168179function generateScreenshotFilename(): string {
1810 const timestamp = formatTimestamp(new Date());
-9
src/upload.ts
···2525 return RSYNC_ERRORS[code] || `rsync exited with code ${code}`;
2626}
27272828-/**
2929- * Uploads a file to a remote server via rsync with progress display
3030- * @param localPath Path to the local file to upload
3131- * @param remoteDest Remote destination in format "server:path"
3232- * @param originalName Original filename for display purposes
3333- * @param verbose Whether to show verbose output
3434- * @returns Promise that resolves on success, rejects on failure
3535- * @throws Error if rsync fails
3636- */
3728export async function uploadWithProgress(
3829 localPath: string,
3930 remoteDest: string,
-16
src/utils.ts
···33import { homedir } from "os";
44import { DEFAULTS_TIMEOUT_MS } from "./constants";
5566-/** Result type for operations that can fail */
76export type Result<T, E> =
87 | { ok: true; value: T }
98 | { ok: false; error: E };
1091111-/**
1212- * Formats a date into macOS-style timestamp string
1313- * Format: "YYYY-MM-DD at HH.MM.SS"
1414- */
1510export function formatTimestamp(date: Date): string {
1611 const year = date.getFullYear();
1712 const month = String(date.getMonth() + 1).padStart(2, "0");
···2318 return `${year}-${month}-${day} at ${hours}.${minutes}.${seconds}`;
2419}
25202626-/** Validation errors for filenames */
2721export type FilenameValidationError =
2822 | "empty"
2923 | "too_long"
3024 | "path_traversal"
3125 | "invalid_chars";
32263333-/**
3434- * Validates a custom filename to prevent path traversal and invalid characters
3535- * @param filename The filename to validate
3636- * @returns Result with validated filename or error type
3737- */
3827export function validateFilename(
3928 filename: string
4029): Result<string, FilenameValidationError> {
···6352 return { ok: true, value: filename.trim() };
6453}
65546666-/**
6767- * Gets the macOS screenshot capture directory from system preferences
6868- * Falls back to Desktop if not set or command fails
6969- * @returns Promise resolving to directory path
7070- */
7155export async function getCaptureDirectory(): Promise<string> {
7256 return new Promise((resolve) => {
7357 const proc = spawn("defaults", ["read", "com.apple.screencapture", "location"]);