Vibecode CleanShot X
track this build5 phases, 15 steps, beginner friendly0%The core loop is small enough for a capable coding agent to produce a useful local version in one sitting. For CleanShot X, capture screenshots or screen video, annotate them, and copy or save locally. The hard boundary is deep macos integration, scrolling capture, cloud sharing, and years of polish, plus capture polish, hosting, and collaboration.
You are building a lean indie version of CleanShot X. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # CleanShot X · indie build A capture tool that takes region, window and full-screen shots through ScreenCaptureKit, records screen video with microphone audio in sync, lets you annotate with arrows, boxes and a blur that actually destroys pixels, trims the result, and exports to MP4 or GIF on your own disk. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Language | Swift 6 and SwiftUI in Xcode | ScreenCaptureKit and AVFoundation are the only sanctioned capture path on modern macOS | | Capture | ScreenCaptureKit, requires macOS 12.3 or newer | the older CGWindowListCreateImage path is deprecated and excluded from newer privacy prompts | | Video | AVAssetWriter writing H.264 into MP4 | hardware encoding through the system stack, no ffmpeg needed for the common case | | GIF export | ffmpeg invoked as a subprocess with a generated palette | AVFoundation cannot write GIF, and a palette pass is the difference between eight colours and something watchable | | Storage | Flat files in a capture directory plus a SQLite index | the images are the artefact; the database only records where they are and what was drawn on them | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **A Mac running macOS 13 or newer** · free if you already own a Mac - Why: ScreenCaptureKit needs 12.3 as an absolute floor, and the screenshot API used here is cleaner on 13 and later. - Get it: Apple menu, then About This Mac, to check your version. - Verify: About This Mac reports macOS 13 or higher - [ ] **Xcode 16 or newer** · free - Why: Builds and signs the app and provides the video debugging tools you will need for the sync work in Phase 3. - Get it: Install from the Mac App Store or developer.apple.com/xcode. It is a multi-gigabyte download. - Verify: xcodebuild -version prints 16 or higher - [ ] **ffmpeg** · free - Why: AVFoundation cannot write GIF. Without ffmpeg, Phase 4 loses GIF export and nothing else. - Get it: brew install ffmpeg, or download a static build from ffmpeg.org. Decide early whether you will bundle it or require it, because bundling affects your signing. - Verify: ffmpeg -version prints a version banner - [ ] **Willingness to re-grant Screen Recording during development** · free - Why: Every rebuild that changes the binary path or signature can invalidate the grant, and macOS will not re-prompt. - Get it: System Settings > Privacy and Security > Screen Recording. Remove the stale entry, then run the app again. - Verify: The app appears in the Screen Recording list and capture returns pixels rather than a black frame - [ ] **Apple Developer Program membership** (optional) · 99 USD per year - Why: Product phase only. Without it there is no Developer ID certificate, so no notarization, so a Gatekeeper warning on every other Mac. - Get it: Enrol at developer.apple.com/programs; review can take a day or two. - Verify: A Developer ID Application certificate is listed in Xcode > Settings > Accounts ## Quick start ```sh ffmpeg -i in.mp4 -vf "fps=15,scale=800:-1:flags=lanczos,palettegen" palette.png ffmpeg -i in.mp4 -i palette.png -lavfi "fps=15,scale=800:-1:flags=lanczos[x];[x][1:v]paletteuse" out.gif ``` Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Scrolling capture of long pages or documents. - Hosted sharing links, which is the actual product behind the cloud tiers. - Viewer analytics, which cannot exist without hosting. - Team libraries, shared branding and the annotation polish of a decade-old app. - deep macOS integration, scrolling capture, cloud sharing, and years of polish - instant hosted sharing - viewer analytics - team libraries - cross-device capture If one of those is essential to you, that is the reason to keep paying for CleanShot X, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · CleanShot X The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a local screenshot and screen recorder for macOS with annotation, trimming and export to replace CleanShot X. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Language: Swift 6 and SwiftUI in Xcode · ScreenCaptureKit and AVFoundation are the only sanctioned capture path on modern macOS - Capture: ScreenCaptureKit, requires macOS 12.3 or newer · the older CGWindowListCreateImage path is deprecated and excluded from newer privacy prompts - Video: AVAssetWriter writing H.264 into MP4 · hardware encoding through the system stack, no ffmpeg needed for the common case - GIF export: ffmpeg invoked as a subprocess with a generated palette · AVFoundation cannot write GIF, and a palette pass is the difference between eight colours and something watchable - Storage: Flat files in a capture directory plus a SQLite index · the images are the artefact; the database only records where they are and what was drawn on them ### Data model (create this before Phase 1) - captures(id, kind, path, width, height, scale, created_at, duration_ms) · kind is image or video - annotations(id, capture_id, type, geometry_json, color, thickness, z) · type is arrow, box, text, highlight or redact - settings(key, value) · capture directory, default format, whether to copy to pasteboard on capture - Annotations are stored separately from the pixels so an export can be re-run, except redaction, which is destructive at export time by design. ### Phase 1 · Permission and a region screenshot Build: Request Screen Recording, then use SCShareableContent to enumerate displays and SCScreenshotManager to capture. Draw a selection overlay on a transparent full-screen window, and write the cropped result as PNG. Trap: The Screen Recording grant is tied to your code signature. Re-signing with a different identity invalidates it and macOS will not prompt a second time; the user has to delete the entry in System Settings first. This will bite you during development every time the binary moves. Trap: A captured image is in physical pixels, not points. On a Retina display a 400 point selection is an 800 pixel image, and forgetting the backing scale factor gives you a half-size or double-size file. Done when: Dragging a 400 by 300 point region on a Retina display writes a PNG whose pixel dimensions are 800 by 600. Denying the permission shows your own explanation with a button that opens the right System Settings pane, rather than failing silently. Do not build yet: No annotation, no video, no clipboard. One correct still image on disk. ### Phase 2 · Annotation, and a redaction that is real Build: An editor window over the captured image with arrow, box, text and redact tools. Store shapes in annotations. On export, composite them into the pixels. Redaction must resample the underlying region, not draw an opaque shape on top of it. Trap: A blur or a black box drawn as an overlay leaves the original pixels in the file if you ever export the layered form, and pixelation with a large enough block size is still reversible if the block grid is fine. Downsample the region to a few pixels and scale it back up, or fill it with a solid colour. Then verify by opening the exported file somewhere else. Done when: Exporting an image with a redacted region and opening the PNG in Preview shows no readable text under the redaction, and the file size does not encode the original region. Moving an arrow and re-exporting produces a different file from the same source capture. Do not build yet: No video and no GIF. Still images only. ### Phase 3 · Screen recording with microphone audio Build: An SCStream delivering screen sample buffers, plus microphone input through AVCaptureSession. Write both into one MP4 with AVAssetWriter using two inputs. Trap: Screen samples and microphone samples arrive on separate callbacks with unrelated clocks. Start the AVAssetWriter session once, from the first video sample's presentation timestamp, and translate audio timestamps into that same timeline. Starting each input from its own first sample is the standard way to get audio that drifts steadily out of sync. Done when: A sixty second recording of a metronome or a clapping hand stays within about one hundred milliseconds of sync at the end, checked by scrubbing to the last second. Stopping the recording produces a file that plays in QuickTime with no repair prompt. Do not build yet: No trimming and no GIF yet. ### Phase 4 · Trim, export and the capture pipeline Build: A trim UI over the recorded video using AVAssetExportSession with a time range. GIF export by piping frames through ffmpeg with a two-pass palette. Global hotkeys for region, window and full screen, and an option to copy the result to the pasteboard. Trap: A one-pass GIF from ffmpeg uses a fixed palette and looks like it came from 1998. Generate a palette with palettegen from the actual frames, then apply it with paletteuse. It is two commands and it is the whole difference. Done when: Trimming a recording to a five second range produces a file whose duration reads five seconds in QuickTime, not the original length with a playback range. A GIF export of the same clip is under a size limit you set and shows smooth colour, and the file appears in the capture directory with a timestamped name. Do not build yet: No sharing or hosting. ### Phase 5 · Library and cleanup Build: A grid of recent captures reading from the SQLite index, with search by date and kind, reveal in Finder, delete, and a retention rule that deletes captures older than a chosen age unless they are starred. Trap: Deleting the row without deleting the file, or the file without the row, is the bug you will ship. Make deletion one function that does both and reconciles orphans on launch. Done when: Deleting a capture from the library removes both the row and the file. Launching after manually deleting a file from Finder reconciles the index rather than showing a broken thumbnail. A capture older than the retention age is removed on the next launch unless starred. Do not build yet: No distribution yet. ### Phase 6 · Signing, notarization and updates Build: Hardened runtime, Developer ID signing, notarytool submission, stapling, and Sparkle for updates. Document the permission story before anyone installs it. Trap: Because the Screen Recording grant is bound to the signature, your first signed release invalidates the grant your testers gave the unsigned build. Say so in the release notes and tell them where to remove the stale entry. Done when: A notarized build on a Mac that never had it installed opens with no warning, spctl reports accepted, and requesting a capture prompts for Screen Recording exactly once. An update from your appcast installs and relaunches. Do not build yet: Nothing after this. ### Out of scope (and why) - Scrolling capture. Stitching a scrolling window means driving another application's scroll state and detecting overlap, and it is harder than everything above put together. - Cloud upload and instant share links. That is a server, a storage bill, an abuse policy and a link that has to keep working for years. - Viewer analytics on shared links, which only exists because the hosting exists. - Team libraries and shared branding. Both are account features, not capture features. ### README must contain - That Screen Recording and Microphone are separate permissions, and what each one unlocks - That redaction is destructive at export and cannot be undone from the exported file - Where captures are written and how retention decides what to delete - Whether ffmpeg is bundled or expected on PATH, and how to install it if not - That re-signing the app invalidates the existing Screen Recording grant ===== AGENTS.md ===== # Agent instructions · CleanShot X indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Swift 6 and SwiftUI in Xcode, ScreenCaptureKit, requires macOS 12.3 or newer, AVAssetWriter writing H.264 into MP4, ffmpeg invoked as a subprocess with a generated palette, Flat files in a capture directory plus a SQLite index. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - The Screen Recording grant is tied to your code signature. Re-signing with a different identity invalidates it and macOS will not prompt a second time; the user has to delete the entry in System Settings first. This will bite you during development every time the binary moves. - A captured image is in physical pixels, not points. On a Retina display a 400 point selection is an 800 pixel image, and forgetting the backing scale factor gives you a half-size or double-size file. - A blur or a black box drawn as an overlay leaves the original pixels in the file if you ever export the layered form, and pixelation with a large enough block size is still reversible if the block grid is fine. Downsample the region to a few pixels and scale it back up, or fill it with a solid colour. Then verify by opening the exported file somewhere else. - Screen samples and microphone samples arrive on separate callbacks with unrelated clocks. Start the AVAssetWriter session once, from the first video sample's presentation timestamp, and translate audio timestamps into that same timeline. Starting each input from its own first sample is the standard way to get audio that drifts steadily out of sync. - A one-pass GIF from ffmpeg uses a fixed palette and looks like it came from 1998. Generate a palette with palettegen from the actual frames, then apply it with paletteuse. It is two commands and it is the whole difference. - Deleting the row without deleting the file, or the file without the row, is the bug you will ship. Make deletion one function that does both and reconciles orphans on launch. ===== BUILD_PLAN.md ===== # Build plan · CleanShot X A capture tool that takes region, window and full-screen shots through ScreenCaptureKit, records screen video with microphone audio in sync, lets you annotate with arrows, boxes and a blur that actually destroys pixels, trims the result, and exports to MP4 or GIF on your own disk. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Permission and a region screenshot Dragging a region writes a correct PNG to disk, with the Screen Recording permission handled honestly. ### Steps 1. Request Screen Recording and handle refusal Call CGRequestScreenCaptureAccess early. If it returns false, show a panel explaining what is lost and a button that opens x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture. Files: `Permissions.swift` 2. Draw the selection overlay A borderless transparent window at screen level covering every display, with a crosshair cursor and a dimmed region outside the selection. Escape cancels. Files: `SelectionOverlay.swift` 3. Capture and write with the correct scale SCContentFilter for the display, set the SCStreamConfiguration width and height to the selection multiplied by the display backing scale factor, then write PNG through CGImageDestination. Files: `Capture.swift` ### Done when - [ ] A 400 by 300 point selection on a Retina display produces an 800 by 600 pixel PNG - [ ] Escape during selection cancels and writes nothing - [ ] Denying the permission shows an explanation and a working Settings link - [ ] Capturing across two displays with different scale factors produces correct pixel sizes on both ### Watch out - The Screen Recording grant is tied to your code signature. Re-signing with a different identity invalidates it and macOS will not prompt a second time; the user has to delete the entry in System Settings first. This will bite you during development every time the binary moves. - A captured image is in physical pixels, not points. On a Retina display a 400 point selection is an 800 pixel image, and forgetting the backing scale factor gives you a half-size or double-size file. ## Phase 2 · Annotation, and a redaction that is real You can draw on a capture, and a redacted region is unrecoverable in the exported file. ### Steps 1. Build the shape model and the editor canvas Shapes are values with a type, a rect or a pair of points, a colour and a z index. Render them in a SwiftUI Canvas over the image. Undo is a stack of shape arrays. Files: `Editor.swift`, `Shape.swift` 2. Implement destructive redaction For each redact shape, crop the region, resize it to roughly eight pixels across with CIImage, scale it back to the region size, and draw the result into the output context before any other annotation. Files: `Redact.swift` 3. Composite and export Draw the base image, then redactions, then the remaining shapes in z order, into a CGContext at full pixel size. Write PNG or JPEG. Files: `Export.swift` ### Done when - [ ] Text under a redacted region is unreadable in the exported file opened in another application - [ ] Undo removes the last shape and redo restores it - [ ] Moving an arrow changes the exported bytes - [ ] Text annotations render at the same size in the export as on screen ### Watch out - A blur or a black box drawn as an overlay leaves the original pixels in the file if you ever export the layered form, and pixelation with a large enough block size is still reversible if the block grid is fine. Downsample the region to a few pixels and scale it back up, or fill it with a solid colour. Then verify by opening the exported file somewhere else. ## Phase 3 · Screen recording with microphone audio A recording plays back with audio and video still in sync a minute in. ### Steps 1. Set up SCStream for video SCStreamConfiguration with the display size, a frame rate you choose deliberately, and showsCursor as a setting. Handle the stream stopping when the display configuration changes. Files: `Recorder.swift` 2. Add microphone capture and request permission AVCaptureSession with an audio device. Microphone is its own privacy grant, separate from Screen Recording, and it has its own usage description string in Info.plist. Files: `Audio.swift`, `Info.plist` 3. Write one file with a shared session clock Create AVAssetWriter with a video input and an audio input. Call startSession(atSourceTime:) once with the first video sample's timestamp, and append audio using the same timebase. Files: `Writer.swift` ### Done when - [ ] A sixty second recording is still in sync at the end when scrubbed - [ ] The finished MP4 opens in QuickTime without a repair prompt - [ ] Denying microphone access still records video and says so - [ ] Changing display resolution mid-recording either stops cleanly or continues, never writes a corrupt file ### Watch out - Screen samples and microphone samples arrive on separate callbacks with unrelated clocks. Start the AVAssetWriter session once, from the first video sample's presentation timestamp, and translate audio timestamps into that same timeline. Starting each input from its own first sample is the standard way to get audio that drifts steadily out of sync. ## Phase 4 · Trim, export and the capture pipeline Every capture lands where you expect, in the format you asked for, with one keystroke. ### Steps 1. Add trimming with a real re-encode AVAssetExportSession with timeRange set. Verify the output duration, because setting a range on a player is not the same as writing a trimmed file. Files: `Trim.swift` 2. Add GIF export through ffmpeg Run ffmpeg twice: once with palettegen into a temporary PNG, once with paletteuse. Bundle ffmpeg or detect it and tell the user how to install it. ```sh ffmpeg -i in.mp4 -vf "fps=15,scale=800:-1:flags=lanczos,palettegen" palette.png ffmpeg -i in.mp4 -i palette.png -lavfi "fps=15,scale=800:-1:flags=lanczos[x];[x][1:v]paletteuse" out.gif ``` 3. Wire the global hotkeys and the pasteboard option Three hotkeys for region, window and full screen. On capture, write the file and optionally put it on the pasteboard so the next Cmd-V pastes the image. Files: `Hotkeys.swift` ### Done when - [ ] A trimmed export reports the trimmed duration in QuickTime, not the original - [ ] A GIF export shows smooth gradients rather than banded colour - [ ] Each hotkey produces the right capture kind - [ ] With the pasteboard option on, Cmd-V in another app pastes the capture ### Watch out - A one-pass GIF from ffmpeg uses a fixed palette and looks like it came from 1998. Generate a palette with palettegen from the actual frames, then apply it with paletteuse. It is two commands and it is the whole difference. ## Phase 5 · Library and cleanup Captures accumulate somewhere you can find them, and old ones do not fill the disk quietly. ### Steps 1. Build the library grid Read captures ordered by created_at, render thumbnails generated at capture time rather than by decoding full images on scroll. Files: `Library.swift` 2. Make deletion atomic and reconcile on launch One delete function removes the file then the row. On launch, drop rows whose file is missing and offer to import files with no row. Files: `Store.swift` 3. Add retention A setting for maximum age and maximum total size. Apply on launch and after each capture, oldest first, never starred. Files: `Retention.swift` ### Done when - [ ] Deleting from the library removes the file from disk - [ ] A file deleted in Finder does not leave a broken row after relaunch - [ ] Retention removes an aged capture and leaves a starred one - [ ] The grid scrolls smoothly with several hundred captures ### Watch out - Deleting the row without deleting the file, or the file without the row, is the bug you will ship. Make deletion one function that does both and reconciles orphans on launch. ## Not in this build - Scrolling capture of long pages or documents. - Hosted sharing links, which is the actual product behind the cloud tiers. - Viewer analytics, which cannot exist without hosting. - Team libraries, shared branding and the annotation polish of a decade-old app. ## After v1, if you want it - Window capture with the window shadow removed, which is fiddly and worth it - A capture history hotkey that pastes the previous capture without opening the app - Self-hosted sharing by writing to an S3-compatible bucket you own ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Where images and videos are written. Pick something Spotlight indexes so you can find captures outside the app. CAPTURE_DIR=~/Pictures/Captures # Required. The SQLite index of captures and annotations. CAPTURE_DB_PATH=~/Library/Application Support/Shots/index.db # Optional. Only needed if ffmpeg is not bundled and not on PATH. GIF export is disabled when it cannot be found. FFMPEG_PATH=/opt/homebrew/bin/ffmpeg # Optional. Captures older than this are deleted on launch unless starred. Leave unset to keep everything. RETENTION_DAYS=90 # Optional. Product phase only. Where your signed appcast is published. SPARKLE_FEED_URL=https://example.com/appcast.xml
You are building a lean indie version of CleanShot X. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # CleanShot X · indie build A capture tool that takes region, window and full-screen shots through ScreenCaptureKit, records screen video with microphone audio in sync, lets you annotate with arrows, boxes and a blur that actually destroys pixels, trims the result, and exports to MP4 or GIF on your own disk. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Language | Swift 6 and SwiftUI in Xcode | ScreenCaptureKit and AVFoundation are the only sanctioned capture path on modern macOS | | Capture | ScreenCaptureKit, requires macOS 12.3 or newer | the older CGWindowListCreateImage path is deprecated and excluded from newer privacy prompts | | Video | AVAssetWriter writing H.264 into MP4 | hardware encoding through the system stack, no ffmpeg needed for the common case | | GIF export | ffmpeg invoked as a subprocess with a generated palette | AVFoundation cannot write GIF, and a palette pass is the difference between eight colours and something watchable | | Storage | Flat files in a capture directory plus a SQLite index | the images are the artefact; the database only records where they are and what was drawn on them | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **A Mac running macOS 13 or newer** · free if you already own a Mac - Why: ScreenCaptureKit needs 12.3 as an absolute floor, and the screenshot API used here is cleaner on 13 and later. - Get it: Apple menu, then About This Mac, to check your version. - Verify: About This Mac reports macOS 13 or higher - [ ] **Xcode 16 or newer** · free - Why: Builds and signs the app and provides the video debugging tools you will need for the sync work in Phase 3. - Get it: Install from the Mac App Store or developer.apple.com/xcode. It is a multi-gigabyte download. - Verify: xcodebuild -version prints 16 or higher - [ ] **ffmpeg** · free - Why: AVFoundation cannot write GIF. Without ffmpeg, Phase 4 loses GIF export and nothing else. - Get it: brew install ffmpeg, or download a static build from ffmpeg.org. Decide early whether you will bundle it or require it, because bundling affects your signing. - Verify: ffmpeg -version prints a version banner - [ ] **Willingness to re-grant Screen Recording during development** · free - Why: Every rebuild that changes the binary path or signature can invalidate the grant, and macOS will not re-prompt. - Get it: System Settings > Privacy and Security > Screen Recording. Remove the stale entry, then run the app again. - Verify: The app appears in the Screen Recording list and capture returns pixels rather than a black frame - [ ] **Apple Developer Program membership** (optional) · 99 USD per year - Why: Product phase only. Without it there is no Developer ID certificate, so no notarization, so a Gatekeeper warning on every other Mac. - Get it: Enrol at developer.apple.com/programs; review can take a day or two. - Verify: A Developer ID Application certificate is listed in Xcode > Settings > Accounts ## Quick start ```sh ffmpeg -i in.mp4 -vf "fps=15,scale=800:-1:flags=lanczos,palettegen" palette.png ffmpeg -i in.mp4 -i palette.png -lavfi "fps=15,scale=800:-1:flags=lanczos[x];[x][1:v]paletteuse" out.gif ``` Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Scrolling capture of long pages or documents. - Hosted sharing links, which is the actual product behind the cloud tiers. - Viewer analytics, which cannot exist without hosting. - Team libraries, shared branding and the annotation polish of a decade-old app. - deep macOS integration, scrolling capture, cloud sharing, and years of polish - instant hosted sharing - viewer analytics - team libraries - cross-device capture If one of those is essential to you, that is the reason to keep paying for CleanShot X, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · CleanShot X The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a local screenshot and screen recorder for macOS with annotation, trimming and export to replace CleanShot X. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Language: Swift 6 and SwiftUI in Xcode · ScreenCaptureKit and AVFoundation are the only sanctioned capture path on modern macOS - Capture: ScreenCaptureKit, requires macOS 12.3 or newer · the older CGWindowListCreateImage path is deprecated and excluded from newer privacy prompts - Video: AVAssetWriter writing H.264 into MP4 · hardware encoding through the system stack, no ffmpeg needed for the common case - GIF export: ffmpeg invoked as a subprocess with a generated palette · AVFoundation cannot write GIF, and a palette pass is the difference between eight colours and something watchable - Storage: Flat files in a capture directory plus a SQLite index · the images are the artefact; the database only records where they are and what was drawn on them ### Data model (create this before Phase 1) - captures(id, kind, path, width, height, scale, created_at, duration_ms) · kind is image or video - annotations(id, capture_id, type, geometry_json, color, thickness, z) · type is arrow, box, text, highlight or redact - settings(key, value) · capture directory, default format, whether to copy to pasteboard on capture - Annotations are stored separately from the pixels so an export can be re-run, except redaction, which is destructive at export time by design. ### Phase 1 · Permission and a region screenshot Build: Request Screen Recording, then use SCShareableContent to enumerate displays and SCScreenshotManager to capture. Draw a selection overlay on a transparent full-screen window, and write the cropped result as PNG. Trap: The Screen Recording grant is tied to your code signature. Re-signing with a different identity invalidates it and macOS will not prompt a second time; the user has to delete the entry in System Settings first. This will bite you during development every time the binary moves. Trap: A captured image is in physical pixels, not points. On a Retina display a 400 point selection is an 800 pixel image, and forgetting the backing scale factor gives you a half-size or double-size file. Done when: Dragging a 400 by 300 point region on a Retina display writes a PNG whose pixel dimensions are 800 by 600. Denying the permission shows your own explanation with a button that opens the right System Settings pane, rather than failing silently. Do not build yet: No annotation, no video, no clipboard. One correct still image on disk. ### Phase 2 · Annotation, and a redaction that is real Build: An editor window over the captured image with arrow, box, text and redact tools. Store shapes in annotations. On export, composite them into the pixels. Redaction must resample the underlying region, not draw an opaque shape on top of it. Trap: A blur or a black box drawn as an overlay leaves the original pixels in the file if you ever export the layered form, and pixelation with a large enough block size is still reversible if the block grid is fine. Downsample the region to a few pixels and scale it back up, or fill it with a solid colour. Then verify by opening the exported file somewhere else. Done when: Exporting an image with a redacted region and opening the PNG in Preview shows no readable text under the redaction, and the file size does not encode the original region. Moving an arrow and re-exporting produces a different file from the same source capture. Do not build yet: No video and no GIF. Still images only. ### Phase 3 · Screen recording with microphone audio Build: An SCStream delivering screen sample buffers, plus microphone input through AVCaptureSession. Write both into one MP4 with AVAssetWriter using two inputs. Trap: Screen samples and microphone samples arrive on separate callbacks with unrelated clocks. Start the AVAssetWriter session once, from the first video sample's presentation timestamp, and translate audio timestamps into that same timeline. Starting each input from its own first sample is the standard way to get audio that drifts steadily out of sync. Done when: A sixty second recording of a metronome or a clapping hand stays within about one hundred milliseconds of sync at the end, checked by scrubbing to the last second. Stopping the recording produces a file that plays in QuickTime with no repair prompt. Do not build yet: No trimming and no GIF yet. ### Phase 4 · Trim, export and the capture pipeline Build: A trim UI over the recorded video using AVAssetExportSession with a time range. GIF export by piping frames through ffmpeg with a two-pass palette. Global hotkeys for region, window and full screen, and an option to copy the result to the pasteboard. Trap: A one-pass GIF from ffmpeg uses a fixed palette and looks like it came from 1998. Generate a palette with palettegen from the actual frames, then apply it with paletteuse. It is two commands and it is the whole difference. Done when: Trimming a recording to a five second range produces a file whose duration reads five seconds in QuickTime, not the original length with a playback range. A GIF export of the same clip is under a size limit you set and shows smooth colour, and the file appears in the capture directory with a timestamped name. Do not build yet: No sharing or hosting. ### Phase 5 · Library and cleanup Build: A grid of recent captures reading from the SQLite index, with search by date and kind, reveal in Finder, delete, and a retention rule that deletes captures older than a chosen age unless they are starred. Trap: Deleting the row without deleting the file, or the file without the row, is the bug you will ship. Make deletion one function that does both and reconciles orphans on launch. Done when: Deleting a capture from the library removes both the row and the file. Launching after manually deleting a file from Finder reconciles the index rather than showing a broken thumbnail. A capture older than the retention age is removed on the next launch unless starred. Do not build yet: No distribution yet. ### Phase 6 · Signing, notarization and updates Build: Hardened runtime, Developer ID signing, notarytool submission, stapling, and Sparkle for updates. Document the permission story before anyone installs it. Trap: Because the Screen Recording grant is bound to the signature, your first signed release invalidates the grant your testers gave the unsigned build. Say so in the release notes and tell them where to remove the stale entry. Done when: A notarized build on a Mac that never had it installed opens with no warning, spctl reports accepted, and requesting a capture prompts for Screen Recording exactly once. An update from your appcast installs and relaunches. Do not build yet: Nothing after this. ### Out of scope (and why) - Scrolling capture. Stitching a scrolling window means driving another application's scroll state and detecting overlap, and it is harder than everything above put together. - Cloud upload and instant share links. That is a server, a storage bill, an abuse policy and a link that has to keep working for years. - Viewer analytics on shared links, which only exists because the hosting exists. - Team libraries and shared branding. Both are account features, not capture features. ### README must contain - That Screen Recording and Microphone are separate permissions, and what each one unlocks - That redaction is destructive at export and cannot be undone from the exported file - Where captures are written and how retention decides what to delete - Whether ffmpeg is bundled or expected on PATH, and how to install it if not - That re-signing the app invalidates the existing Screen Recording grant ===== AGENTS.md ===== # Agent instructions · CleanShot X indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Swift 6 and SwiftUI in Xcode, ScreenCaptureKit, requires macOS 12.3 or newer, AVAssetWriter writing H.264 into MP4, ffmpeg invoked as a subprocess with a generated palette, Flat files in a capture directory plus a SQLite index. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - The Screen Recording grant is tied to your code signature. Re-signing with a different identity invalidates it and macOS will not prompt a second time; the user has to delete the entry in System Settings first. This will bite you during development every time the binary moves. - A captured image is in physical pixels, not points. On a Retina display a 400 point selection is an 800 pixel image, and forgetting the backing scale factor gives you a half-size or double-size file. - A blur or a black box drawn as an overlay leaves the original pixels in the file if you ever export the layered form, and pixelation with a large enough block size is still reversible if the block grid is fine. Downsample the region to a few pixels and scale it back up, or fill it with a solid colour. Then verify by opening the exported file somewhere else. - Screen samples and microphone samples arrive on separate callbacks with unrelated clocks. Start the AVAssetWriter session once, from the first video sample's presentation timestamp, and translate audio timestamps into that same timeline. Starting each input from its own first sample is the standard way to get audio that drifts steadily out of sync. - A one-pass GIF from ffmpeg uses a fixed palette and looks like it came from 1998. Generate a palette with palettegen from the actual frames, then apply it with paletteuse. It is two commands and it is the whole difference. - Deleting the row without deleting the file, or the file without the row, is the bug you will ship. Make deletion one function that does both and reconciles orphans on launch. ===== BUILD_PLAN.md ===== # Build plan · CleanShot X A capture tool that takes region, window and full-screen shots through ScreenCaptureKit, records screen video with microphone audio in sync, lets you annotate with arrows, boxes and a blur that actually destroys pixels, trims the result, and exports to MP4 or GIF on your own disk. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Permission and a region screenshot Dragging a region writes a correct PNG to disk, with the Screen Recording permission handled honestly. ### Steps 1. Request Screen Recording and handle refusal Call CGRequestScreenCaptureAccess early. If it returns false, show a panel explaining what is lost and a button that opens x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture. Files: `Permissions.swift` 2. Draw the selection overlay A borderless transparent window at screen level covering every display, with a crosshair cursor and a dimmed region outside the selection. Escape cancels. Files: `SelectionOverlay.swift` 3. Capture and write with the correct scale SCContentFilter for the display, set the SCStreamConfiguration width and height to the selection multiplied by the display backing scale factor, then write PNG through CGImageDestination. Files: `Capture.swift` ### Done when - [ ] A 400 by 300 point selection on a Retina display produces an 800 by 600 pixel PNG - [ ] Escape during selection cancels and writes nothing - [ ] Denying the permission shows an explanation and a working Settings link - [ ] Capturing across two displays with different scale factors produces correct pixel sizes on both ### Watch out - The Screen Recording grant is tied to your code signature. Re-signing with a different identity invalidates it and macOS will not prompt a second time; the user has to delete the entry in System Settings first. This will bite you during development every time the binary moves. - A captured image is in physical pixels, not points. On a Retina display a 400 point selection is an 800 pixel image, and forgetting the backing scale factor gives you a half-size or double-size file. ## Phase 2 · Annotation, and a redaction that is real You can draw on a capture, and a redacted region is unrecoverable in the exported file. ### Steps 1. Build the shape model and the editor canvas Shapes are values with a type, a rect or a pair of points, a colour and a z index. Render them in a SwiftUI Canvas over the image. Undo is a stack of shape arrays. Files: `Editor.swift`, `Shape.swift` 2. Implement destructive redaction For each redact shape, crop the region, resize it to roughly eight pixels across with CIImage, scale it back to the region size, and draw the result into the output context before any other annotation. Files: `Redact.swift` 3. Composite and export Draw the base image, then redactions, then the remaining shapes in z order, into a CGContext at full pixel size. Write PNG or JPEG. Files: `Export.swift` ### Done when - [ ] Text under a redacted region is unreadable in the exported file opened in another application - [ ] Undo removes the last shape and redo restores it - [ ] Moving an arrow changes the exported bytes - [ ] Text annotations render at the same size in the export as on screen ### Watch out - A blur or a black box drawn as an overlay leaves the original pixels in the file if you ever export the layered form, and pixelation with a large enough block size is still reversible if the block grid is fine. Downsample the region to a few pixels and scale it back up, or fill it with a solid colour. Then verify by opening the exported file somewhere else. ## Phase 3 · Screen recording with microphone audio A recording plays back with audio and video still in sync a minute in. ### Steps 1. Set up SCStream for video SCStreamConfiguration with the display size, a frame rate you choose deliberately, and showsCursor as a setting. Handle the stream stopping when the display configuration changes. Files: `Recorder.swift` 2. Add microphone capture and request permission AVCaptureSession with an audio device. Microphone is its own privacy grant, separate from Screen Recording, and it has its own usage description string in Info.plist. Files: `Audio.swift`, `Info.plist` 3. Write one file with a shared session clock Create AVAssetWriter with a video input and an audio input. Call startSession(atSourceTime:) once with the first video sample's timestamp, and append audio using the same timebase. Files: `Writer.swift` ### Done when - [ ] A sixty second recording is still in sync at the end when scrubbed - [ ] The finished MP4 opens in QuickTime without a repair prompt - [ ] Denying microphone access still records video and says so - [ ] Changing display resolution mid-recording either stops cleanly or continues, never writes a corrupt file ### Watch out - Screen samples and microphone samples arrive on separate callbacks with unrelated clocks. Start the AVAssetWriter session once, from the first video sample's presentation timestamp, and translate audio timestamps into that same timeline. Starting each input from its own first sample is the standard way to get audio that drifts steadily out of sync. ## Phase 4 · Trim, export and the capture pipeline Every capture lands where you expect, in the format you asked for, with one keystroke. ### Steps 1. Add trimming with a real re-encode AVAssetExportSession with timeRange set. Verify the output duration, because setting a range on a player is not the same as writing a trimmed file. Files: `Trim.swift` 2. Add GIF export through ffmpeg Run ffmpeg twice: once with palettegen into a temporary PNG, once with paletteuse. Bundle ffmpeg or detect it and tell the user how to install it. ```sh ffmpeg -i in.mp4 -vf "fps=15,scale=800:-1:flags=lanczos,palettegen" palette.png ffmpeg -i in.mp4 -i palette.png -lavfi "fps=15,scale=800:-1:flags=lanczos[x];[x][1:v]paletteuse" out.gif ``` 3. Wire the global hotkeys and the pasteboard option Three hotkeys for region, window and full screen. On capture, write the file and optionally put it on the pasteboard so the next Cmd-V pastes the image. Files: `Hotkeys.swift` ### Done when - [ ] A trimmed export reports the trimmed duration in QuickTime, not the original - [ ] A GIF export shows smooth gradients rather than banded colour - [ ] Each hotkey produces the right capture kind - [ ] With the pasteboard option on, Cmd-V in another app pastes the capture ### Watch out - A one-pass GIF from ffmpeg uses a fixed palette and looks like it came from 1998. Generate a palette with palettegen from the actual frames, then apply it with paletteuse. It is two commands and it is the whole difference. ## Phase 5 · Library and cleanup Captures accumulate somewhere you can find them, and old ones do not fill the disk quietly. ### Steps 1. Build the library grid Read captures ordered by created_at, render thumbnails generated at capture time rather than by decoding full images on scroll. Files: `Library.swift` 2. Make deletion atomic and reconcile on launch One delete function removes the file then the row. On launch, drop rows whose file is missing and offer to import files with no row. Files: `Store.swift` 3. Add retention A setting for maximum age and maximum total size. Apply on launch and after each capture, oldest first, never starred. Files: `Retention.swift` ### Done when - [ ] Deleting from the library removes the file from disk - [ ] A file deleted in Finder does not leave a broken row after relaunch - [ ] Retention removes an aged capture and leaves a starred one - [ ] The grid scrolls smoothly with several hundred captures ### Watch out - Deleting the row without deleting the file, or the file without the row, is the bug you will ship. Make deletion one function that does both and reconciles orphans on launch. ## Not in this build - Scrolling capture of long pages or documents. - Hosted sharing links, which is the actual product behind the cloud tiers. - Viewer analytics, which cannot exist without hosting. - Team libraries, shared branding and the annotation polish of a decade-old app. ## After v1, if you want it - Window capture with the window shadow removed, which is fiddly and worth it - A capture history hotkey that pastes the previous capture without opening the app - Self-hosted sharing by writing to an S3-compatible bucket you own ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Where images and videos are written. Pick something Spotlight indexes so you can find captures outside the app. CAPTURE_DIR=~/Pictures/Captures # Required. The SQLite index of captures and annotations. CAPTURE_DB_PATH=~/Library/Application Support/Shots/index.db # Optional. Only needed if ffmpeg is not bundled and not on PATH. GIF export is disabled when it cannot be found. FFMPEG_PATH=/opt/homebrew/bin/ffmpeg # Optional. Captures older than this are deleted on launch unless starred. Leave unset to keep everything. RETENTION_DAYS=90 # Optional. Product phase only. Where your signed appcast is published. SPARKLE_FEED_URL=https://example.com/appcast.xml
You are building a production product version of CleanShot X. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== PRODUCT.md ===== # CleanShot X · product brief ## Problem The core loop is small enough for a capable coding agent to produce a useful local version in one sitting. For CleanShot X, capture screenshots or screen video, annotate them, and copy or save locally. The hard boundary is deep macos integration, scrolling capture, cloud sharing, and years of polish, plus capture polish, hosting, and collaboration. ## Product outcome A signed, notarized capture app another person can install, that asks for exactly the permissions it needs, records in sync, and never leaves a redaction that can be undone. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - macOS or Windows screen-recording permission - microphone permission - ffmpeg - local disk space ## Explicit non-goals for v1 - Scrolling capture of long pages or documents. - Hosted sharing links, which is the actual product behind the cloud tiers. - Viewer analytics, which cannot exist without hosting. - Team libraries, shared branding and the annotation polish of a decade-old app. - deep macOS integration, scrolling capture, cloud sharing, and years of polish - instant hosted sharing - viewer analytics - team libraries - cross-device capture ## Success criteria - A sixty second recording is verified in sync at the end - A redacted export is opened in another application and confirmed unreadable - spctl accepts the notarized build on a Mac that never ran it - Denying each permission separately leaves the app usable and explanatory rather than broken - Retention is exercised against a directory of aged test captures before it runs against real ones ===== BRIEF.md ===== # Build brief · CleanShot X The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a local screenshot and screen recorder for macOS with annotation, trimming and export to replace CleanShot X. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Language: Swift 6 and SwiftUI in Xcode · ScreenCaptureKit and AVFoundation are the only sanctioned capture path on modern macOS - Capture: ScreenCaptureKit, requires macOS 12.3 or newer · the older CGWindowListCreateImage path is deprecated and excluded from newer privacy prompts - Video: AVAssetWriter writing H.264 into MP4 · hardware encoding through the system stack, no ffmpeg needed for the common case - GIF export: ffmpeg invoked as a subprocess with a generated palette · AVFoundation cannot write GIF, and a palette pass is the difference between eight colours and something watchable - Storage: Flat files in a capture directory plus a SQLite index · the images are the artefact; the database only records where they are and what was drawn on them ### Data model (create this before Phase 1) - captures(id, kind, path, width, height, scale, created_at, duration_ms) · kind is image or video - annotations(id, capture_id, type, geometry_json, color, thickness, z) · type is arrow, box, text, highlight or redact - settings(key, value) · capture directory, default format, whether to copy to pasteboard on capture - Annotations are stored separately from the pixels so an export can be re-run, except redaction, which is destructive at export time by design. ### Phase 1 · Permission and a region screenshot Build: Request Screen Recording, then use SCShareableContent to enumerate displays and SCScreenshotManager to capture. Draw a selection overlay on a transparent full-screen window, and write the cropped result as PNG. Trap: The Screen Recording grant is tied to your code signature. Re-signing with a different identity invalidates it and macOS will not prompt a second time; the user has to delete the entry in System Settings first. This will bite you during development every time the binary moves. Trap: A captured image is in physical pixels, not points. On a Retina display a 400 point selection is an 800 pixel image, and forgetting the backing scale factor gives you a half-size or double-size file. Done when: Dragging a 400 by 300 point region on a Retina display writes a PNG whose pixel dimensions are 800 by 600. Denying the permission shows your own explanation with a button that opens the right System Settings pane, rather than failing silently. Do not build yet: No annotation, no video, no clipboard. One correct still image on disk. ### Phase 2 · Annotation, and a redaction that is real Build: An editor window over the captured image with arrow, box, text and redact tools. Store shapes in annotations. On export, composite them into the pixels. Redaction must resample the underlying region, not draw an opaque shape on top of it. Trap: A blur or a black box drawn as an overlay leaves the original pixels in the file if you ever export the layered form, and pixelation with a large enough block size is still reversible if the block grid is fine. Downsample the region to a few pixels and scale it back up, or fill it with a solid colour. Then verify by opening the exported file somewhere else. Done when: Exporting an image with a redacted region and opening the PNG in Preview shows no readable text under the redaction, and the file size does not encode the original region. Moving an arrow and re-exporting produces a different file from the same source capture. Do not build yet: No video and no GIF. Still images only. ### Phase 3 · Screen recording with microphone audio Build: An SCStream delivering screen sample buffers, plus microphone input through AVCaptureSession. Write both into one MP4 with AVAssetWriter using two inputs. Trap: Screen samples and microphone samples arrive on separate callbacks with unrelated clocks. Start the AVAssetWriter session once, from the first video sample's presentation timestamp, and translate audio timestamps into that same timeline. Starting each input from its own first sample is the standard way to get audio that drifts steadily out of sync. Done when: A sixty second recording of a metronome or a clapping hand stays within about one hundred milliseconds of sync at the end, checked by scrubbing to the last second. Stopping the recording produces a file that plays in QuickTime with no repair prompt. Do not build yet: No trimming and no GIF yet. ### Phase 4 · Trim, export and the capture pipeline Build: A trim UI over the recorded video using AVAssetExportSession with a time range. GIF export by piping frames through ffmpeg with a two-pass palette. Global hotkeys for region, window and full screen, and an option to copy the result to the pasteboard. Trap: A one-pass GIF from ffmpeg uses a fixed palette and looks like it came from 1998. Generate a palette with palettegen from the actual frames, then apply it with paletteuse. It is two commands and it is the whole difference. Done when: Trimming a recording to a five second range produces a file whose duration reads five seconds in QuickTime, not the original length with a playback range. A GIF export of the same clip is under a size limit you set and shows smooth colour, and the file appears in the capture directory with a timestamped name. Do not build yet: No sharing or hosting. ### Phase 5 · Library and cleanup Build: A grid of recent captures reading from the SQLite index, with search by date and kind, reveal in Finder, delete, and a retention rule that deletes captures older than a chosen age unless they are starred. Trap: Deleting the row without deleting the file, or the file without the row, is the bug you will ship. Make deletion one function that does both and reconciles orphans on launch. Done when: Deleting a capture from the library removes both the row and the file. Launching after manually deleting a file from Finder reconciles the index rather than showing a broken thumbnail. A capture older than the retention age is removed on the next launch unless starred. Do not build yet: No distribution yet. ### Phase 6 · Signing, notarization and updates Build: Hardened runtime, Developer ID signing, notarytool submission, stapling, and Sparkle for updates. Document the permission story before anyone installs it. Trap: Because the Screen Recording grant is bound to the signature, your first signed release invalidates the grant your testers gave the unsigned build. Say so in the release notes and tell them where to remove the stale entry. Done when: A notarized build on a Mac that never had it installed opens with no warning, spctl reports accepted, and requesting a capture prompts for Screen Recording exactly once. An update from your appcast installs and relaunches. Do not build yet: Nothing after this. ### Out of scope (and why) - Scrolling capture. Stitching a scrolling window means driving another application's scroll state and detecting overlap, and it is harder than everything above put together. - Cloud upload and instant share links. That is a server, a storage bill, an abuse policy and a link that has to keep working for years. - Viewer analytics on shared links, which only exists because the hosting exists. - Team libraries and shared branding. Both are account features, not capture features. ### README must contain - That Screen Recording and Microphone are separate permissions, and what each one unlocks - That redaction is destructive at export and cannot be undone from the exported file - Where captures are written and how retention decides what to delete - Whether ffmpeg is bundled or expected on PATH, and how to install it if not - That re-signing the app invalidates the existing Screen Recording grant ===== ARCHITECTURE.md ===== # Architecture · CleanShot X ## Stack | Part | Choice | Why | | --- | --- | --- | | Language | Swift 6 and SwiftUI in Xcode | ScreenCaptureKit and AVFoundation are the only sanctioned capture path on modern macOS | | Capture | ScreenCaptureKit, requires macOS 12.3 or newer | the older CGWindowListCreateImage path is deprecated and excluded from newer privacy prompts | | Video | AVAssetWriter writing H.264 into MP4 | hardware encoding through the system stack, no ffmpeg needed for the common case | | GIF export | ffmpeg invoked as a subprocess with a generated palette | AVFoundation cannot write GIF, and a palette pass is the difference between eight colours and something watchable | | Storage | Flat files in a capture directory plus a SQLite index | the images are the artefact; the database only records where they are and what was drawn on them | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Capture | ScreenCaptureKit configuration, permission state and producing pixels. | Replacing ScreenCaptureKit is not an option on macOS. What you can swap is the selection overlay, which is pure AppKit and independent. | | Editor | The shape model, undo and the on-screen rendering. | Shapes are plain values, so an alternative renderer or a different tool set is additive and does not touch export. | | Export | Compositing shapes into pixels, redaction, and file writing. | The GIF path already shells out to ffmpeg, so a different encoder is a command change. The PNG and MP4 paths are Apple frameworks and stay. | | Recorder | The stream, the audio session and the shared timeline. | The timeline logic is the valuable part and should survive any change of writer; keep it behind one interface with a test that feeds synthetic timestamps. | | Library | The index, thumbnails, retention and orphan reconciliation. | SQLite could become plain JSON at this size, but reconciliation is the behaviour that matters, not the store. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `CAPTURE_DIR` · required · Where images and videos are written. Pick something Spotlight indexes so you can find captures outside the app. - `CAPTURE_DB_PATH` · required · The SQLite index of captures and annotations. - `FFMPEG_PATH` · optional · Only needed if ffmpeg is not bundled and not on PATH. GIF export is disabled when it cannot be found. - `RETENTION_DAYS` · optional · Captures older than this are deleted on launch unless starred. Leave unset to keep everything. - `SPARKLE_FEED_URL` · optional · Product phase only. Where your signed appcast is published. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it. ===== AGENTS.md ===== # Agent instructions · CleanShot X product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Swift 6 and SwiftUI in Xcode, ScreenCaptureKit, requires macOS 12.3 or newer, AVAssetWriter writing H.264 into MP4, ffmpeg invoked as a subprocess with a generated palette, Flat files in a capture directory plus a SQLite index. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - The Screen Recording grant is tied to your code signature. Re-signing with a different identity invalidates it and macOS will not prompt a second time; the user has to delete the entry in System Settings first. This will bite you during development every time the binary moves. - A captured image is in physical pixels, not points. On a Retina display a 400 point selection is an 800 pixel image, and forgetting the backing scale factor gives you a half-size or double-size file. - A blur or a black box drawn as an overlay leaves the original pixels in the file if you ever export the layered form, and pixelation with a large enough block size is still reversible if the block grid is fine. Downsample the region to a few pixels and scale it back up, or fill it with a solid colour. Then verify by opening the exported file somewhere else. - Screen samples and microphone samples arrive on separate callbacks with unrelated clocks. Start the AVAssetWriter session once, from the first video sample's presentation timestamp, and translate audio timestamps into that same timeline. Starting each input from its own first sample is the standard way to get audio that drifts steadily out of sync. - A one-pass GIF from ffmpeg uses a fixed palette and looks like it came from 1998. Generate a palette with palettegen from the actual frames, then apply it with paletteuse. It is two commands and it is the whole difference. - Deleting the row without deleting the file, or the file without the row, is the bug you will ship. Make deletion one function that does both and reconciles orphans on launch. - Because the Screen Recording grant is bound to the signature, your first signed release invalidates the grant your testers gave the unsigned build. Say so in the release notes and tell them where to remove the stale entry. ===== MILESTONES.md ===== # Delivery milestones · CleanShot X Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Permission and a region screenshot Dragging a region writes a correct PNG to disk, with the Screen Recording permission handled honestly. ### Steps 1. Request Screen Recording and handle refusal Call CGRequestScreenCaptureAccess early. If it returns false, show a panel explaining what is lost and a button that opens x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture. Files: `Permissions.swift` 2. Draw the selection overlay A borderless transparent window at screen level covering every display, with a crosshair cursor and a dimmed region outside the selection. Escape cancels. Files: `SelectionOverlay.swift` 3. Capture and write with the correct scale SCContentFilter for the display, set the SCStreamConfiguration width and height to the selection multiplied by the display backing scale factor, then write PNG through CGImageDestination. Files: `Capture.swift` ### Done when - [ ] A 400 by 300 point selection on a Retina display produces an 800 by 600 pixel PNG - [ ] Escape during selection cancels and writes nothing - [ ] Denying the permission shows an explanation and a working Settings link - [ ] Capturing across two displays with different scale factors produces correct pixel sizes on both ### Watch out - The Screen Recording grant is tied to your code signature. Re-signing with a different identity invalidates it and macOS will not prompt a second time; the user has to delete the entry in System Settings first. This will bite you during development every time the binary moves. - A captured image is in physical pixels, not points. On a Retina display a 400 point selection is an 800 pixel image, and forgetting the backing scale factor gives you a half-size or double-size file. ## M2 · Annotation, and a redaction that is real You can draw on a capture, and a redacted region is unrecoverable in the exported file. ### Steps 1. Build the shape model and the editor canvas Shapes are values with a type, a rect or a pair of points, a colour and a z index. Render them in a SwiftUI Canvas over the image. Undo is a stack of shape arrays. Files: `Editor.swift`, `Shape.swift` 2. Implement destructive redaction For each redact shape, crop the region, resize it to roughly eight pixels across with CIImage, scale it back to the region size, and draw the result into the output context before any other annotation. Files: `Redact.swift` 3. Composite and export Draw the base image, then redactions, then the remaining shapes in z order, into a CGContext at full pixel size. Write PNG or JPEG. Files: `Export.swift` ### Done when - [ ] Text under a redacted region is unreadable in the exported file opened in another application - [ ] Undo removes the last shape and redo restores it - [ ] Moving an arrow changes the exported bytes - [ ] Text annotations render at the same size in the export as on screen ### Watch out - A blur or a black box drawn as an overlay leaves the original pixels in the file if you ever export the layered form, and pixelation with a large enough block size is still reversible if the block grid is fine. Downsample the region to a few pixels and scale it back up, or fill it with a solid colour. Then verify by opening the exported file somewhere else. ## M3 · Screen recording with microphone audio A recording plays back with audio and video still in sync a minute in. ### Steps 1. Set up SCStream for video SCStreamConfiguration with the display size, a frame rate you choose deliberately, and showsCursor as a setting. Handle the stream stopping when the display configuration changes. Files: `Recorder.swift` 2. Add microphone capture and request permission AVCaptureSession with an audio device. Microphone is its own privacy grant, separate from Screen Recording, and it has its own usage description string in Info.plist. Files: `Audio.swift`, `Info.plist` 3. Write one file with a shared session clock Create AVAssetWriter with a video input and an audio input. Call startSession(atSourceTime:) once with the first video sample's timestamp, and append audio using the same timebase. Files: `Writer.swift` ### Done when - [ ] A sixty second recording is still in sync at the end when scrubbed - [ ] The finished MP4 opens in QuickTime without a repair prompt - [ ] Denying microphone access still records video and says so - [ ] Changing display resolution mid-recording either stops cleanly or continues, never writes a corrupt file ### Watch out - Screen samples and microphone samples arrive on separate callbacks with unrelated clocks. Start the AVAssetWriter session once, from the first video sample's presentation timestamp, and translate audio timestamps into that same timeline. Starting each input from its own first sample is the standard way to get audio that drifts steadily out of sync. ## M4 · Trim, export and the capture pipeline Every capture lands where you expect, in the format you asked for, with one keystroke. ### Steps 1. Add trimming with a real re-encode AVAssetExportSession with timeRange set. Verify the output duration, because setting a range on a player is not the same as writing a trimmed file. Files: `Trim.swift` 2. Add GIF export through ffmpeg Run ffmpeg twice: once with palettegen into a temporary PNG, once with paletteuse. Bundle ffmpeg or detect it and tell the user how to install it. ```sh ffmpeg -i in.mp4 -vf "fps=15,scale=800:-1:flags=lanczos,palettegen" palette.png ffmpeg -i in.mp4 -i palette.png -lavfi "fps=15,scale=800:-1:flags=lanczos[x];[x][1:v]paletteuse" out.gif ``` 3. Wire the global hotkeys and the pasteboard option Three hotkeys for region, window and full screen. On capture, write the file and optionally put it on the pasteboard so the next Cmd-V pastes the image. Files: `Hotkeys.swift` ### Done when - [ ] A trimmed export reports the trimmed duration in QuickTime, not the original - [ ] A GIF export shows smooth gradients rather than banded colour - [ ] Each hotkey produces the right capture kind - [ ] With the pasteboard option on, Cmd-V in another app pastes the capture ### Watch out - A one-pass GIF from ffmpeg uses a fixed palette and looks like it came from 1998. Generate a palette with palettegen from the actual frames, then apply it with paletteuse. It is two commands and it is the whole difference. ## M5 · Library and cleanup Captures accumulate somewhere you can find them, and old ones do not fill the disk quietly. ### Steps 1. Build the library grid Read captures ordered by created_at, render thumbnails generated at capture time rather than by decoding full images on scroll. Files: `Library.swift` 2. Make deletion atomic and reconcile on launch One delete function removes the file then the row. On launch, drop rows whose file is missing and offer to import files with no row. Files: `Store.swift` 3. Add retention A setting for maximum age and maximum total size. Apply on launch and after each capture, oldest first, never starred. Files: `Retention.swift` ### Done when - [ ] Deleting from the library removes the file from disk - [ ] A file deleted in Finder does not leave a broken row after relaunch - [ ] Retention removes an aged capture and leaves a starred one - [ ] The grid scrolls smoothly with several hundred captures ### Watch out - Deleting the row without deleting the file, or the file without the row, is the bug you will ship. Make deletion one function that does both and reconciles orphans on launch. ## M6 · Signing, notarization and updates (production only) Someone else can install it without a Gatekeeper warning and receive fixes. ### Steps 1. Sign with the hardened runtime Enable hardened runtime, sign with Developer ID Application, and confirm the microphone and screen capture usage strings are present in Info.plist or the prompts will not appear. ```sh codesign --force --options runtime --sign "Developer ID Application: YOUR NAME" Shots.app ``` 2. Notarize and staple Submit the zip with notarytool and staple the ticket so validation works offline. ```sh xcrun notarytool submit Shots.zip --keychain-profile AC --wait xcrun stapler staple Shots.app ``` 3. Publish updates with Sparkle Generate an EdDSA key pair, keep the private half out of the repository, and serve appcast.xml over HTTPS. Files: `appcast.xml` ### Done when - [ ] spctl --assess --verbose reports the app accepted - [ ] A fresh Mac opens the download with no Gatekeeper warning - [ ] Screen Recording is requested once and the capture then succeeds - [ ] An appcast update installs and relaunches ### Watch out - Because the Screen Recording grant is bound to the signature, your first signed release invalidates the grant your testers gave the unsigned build. Say so in the release notes and tell them where to remove the stale entry. ===== OPERATIONS.md ===== # Operations · CleanShot X ## Backup The capture directory is the data. It is ordinary files, so any backup tool covers it. The SQLite index is rebuildable from the files, and your reconciliation code is what makes that true. ## Restore Restore the capture directory, delete the index, and launch. Reconciliation should import every file it finds. If it does not, that is a bug worth fixing before you need it. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Watch disk use of the capture directory and encoder CPU during recording. A dropped frame count from SCStream is worth logging, because a stream that silently degrades looks like a bad recording rather than a bug. ## Incident checklist The recurring incident is a macOS update changing capture behaviour or resetting a permission. Keep the previous signed build available, and set a minimum supported macOS version in the appcast so an incompatible update is never offered. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] A sixty second recording is verified in sync at the end - [ ] A redacted export is opened in another application and confirmed unreadable - [ ] spctl accepts the notarized build on a Mac that never ran it - [ ] Denying each permission separately leaves the app usable and explanatory rather than broken - [ ] Retention is exercised against a directory of aged test captures before it runs against real ones ## Launch constraint Do not market omitted CleanShot X capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered. ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Where images and videos are written. Pick something Spotlight indexes so you can find captures outside the app. CAPTURE_DIR=~/Pictures/Captures # Required. The SQLite index of captures and annotations. CAPTURE_DB_PATH=~/Library/Application Support/Shots/index.db # Optional. Only needed if ffmpeg is not bundled and not on PATH. GIF export is disabled when it cannot be found. FFMPEG_PATH=/opt/homebrew/bin/ffmpeg # Optional. Captures older than this are deleted on launch unless starred. Leave unset to keep everything. RETENTION_DAYS=90 # Optional. Product phase only. Where your signed appcast is published. SPARKLE_FEED_URL=https://example.com/appcast.xml
# CleanShot X · indie build A capture tool that takes region, window and full-screen shots through ScreenCaptureKit, records screen video with microphone audio in sync, lets you annotate with arrows, boxes and a blur that actually destroys pixels, trims the result, and exports to MP4 or GIF on your own disk. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Language | Swift 6 and SwiftUI in Xcode | ScreenCaptureKit and AVFoundation are the only sanctioned capture path on modern macOS | | Capture | ScreenCaptureKit, requires macOS 12.3 or newer | the older CGWindowListCreateImage path is deprecated and excluded from newer privacy prompts | | Video | AVAssetWriter writing H.264 into MP4 | hardware encoding through the system stack, no ffmpeg needed for the common case | | GIF export | ffmpeg invoked as a subprocess with a generated palette | AVFoundation cannot write GIF, and a palette pass is the difference between eight colours and something watchable | | Storage | Flat files in a capture directory plus a SQLite index | the images are the artefact; the database only records where they are and what was drawn on them | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **A Mac running macOS 13 or newer** · free if you already own a Mac - Why: ScreenCaptureKit needs 12.3 as an absolute floor, and the screenshot API used here is cleaner on 13 and later. - Get it: Apple menu, then About This Mac, to check your version. - Verify: About This Mac reports macOS 13 or higher - [ ] **Xcode 16 or newer** · free - Why: Builds and signs the app and provides the video debugging tools you will need for the sync work in Phase 3. - Get it: Install from the Mac App Store or developer.apple.com/xcode. It is a multi-gigabyte download. - Verify: xcodebuild -version prints 16 or higher - [ ] **ffmpeg** · free - Why: AVFoundation cannot write GIF. Without ffmpeg, Phase 4 loses GIF export and nothing else. - Get it: brew install ffmpeg, or download a static build from ffmpeg.org. Decide early whether you will bundle it or require it, because bundling affects your signing. - Verify: ffmpeg -version prints a version banner - [ ] **Willingness to re-grant Screen Recording during development** · free - Why: Every rebuild that changes the binary path or signature can invalidate the grant, and macOS will not re-prompt. - Get it: System Settings > Privacy and Security > Screen Recording. Remove the stale entry, then run the app again. - Verify: The app appears in the Screen Recording list and capture returns pixels rather than a black frame - [ ] **Apple Developer Program membership** (optional) · 99 USD per year - Why: Product phase only. Without it there is no Developer ID certificate, so no notarization, so a Gatekeeper warning on every other Mac. - Get it: Enrol at developer.apple.com/programs; review can take a day or two. - Verify: A Developer ID Application certificate is listed in Xcode > Settings > Accounts ## Quick start ```sh ffmpeg -i in.mp4 -vf "fps=15,scale=800:-1:flags=lanczos,palettegen" palette.png ffmpeg -i in.mp4 -i palette.png -lavfi "fps=15,scale=800:-1:flags=lanczos[x];[x][1:v]paletteuse" out.gif ``` Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Scrolling capture of long pages or documents. - Hosted sharing links, which is the actual product behind the cloud tiers. - Viewer analytics, which cannot exist without hosting. - Team libraries, shared branding and the annotation polish of a decade-old app. - deep macOS integration, scrolling capture, cloud sharing, and years of polish - instant hosted sharing - viewer analytics - team libraries - cross-device capture If one of those is essential to you, that is the reason to keep paying for CleanShot X, and the README should say so rather than pretend.
# Build brief · CleanShot X The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a local screenshot and screen recorder for macOS with annotation, trimming and export to replace CleanShot X. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Language: Swift 6 and SwiftUI in Xcode · ScreenCaptureKit and AVFoundation are the only sanctioned capture path on modern macOS - Capture: ScreenCaptureKit, requires macOS 12.3 or newer · the older CGWindowListCreateImage path is deprecated and excluded from newer privacy prompts - Video: AVAssetWriter writing H.264 into MP4 · hardware encoding through the system stack, no ffmpeg needed for the common case - GIF export: ffmpeg invoked as a subprocess with a generated palette · AVFoundation cannot write GIF, and a palette pass is the difference between eight colours and something watchable - Storage: Flat files in a capture directory plus a SQLite index · the images are the artefact; the database only records where they are and what was drawn on them ### Data model (create this before Phase 1) - captures(id, kind, path, width, height, scale, created_at, duration_ms) · kind is image or video - annotations(id, capture_id, type, geometry_json, color, thickness, z) · type is arrow, box, text, highlight or redact - settings(key, value) · capture directory, default format, whether to copy to pasteboard on capture - Annotations are stored separately from the pixels so an export can be re-run, except redaction, which is destructive at export time by design. ### Phase 1 · Permission and a region screenshot Build: Request Screen Recording, then use SCShareableContent to enumerate displays and SCScreenshotManager to capture. Draw a selection overlay on a transparent full-screen window, and write the cropped result as PNG. Trap: The Screen Recording grant is tied to your code signature. Re-signing with a different identity invalidates it and macOS will not prompt a second time; the user has to delete the entry in System Settings first. This will bite you during development every time the binary moves. Trap: A captured image is in physical pixels, not points. On a Retina display a 400 point selection is an 800 pixel image, and forgetting the backing scale factor gives you a half-size or double-size file. Done when: Dragging a 400 by 300 point region on a Retina display writes a PNG whose pixel dimensions are 800 by 600. Denying the permission shows your own explanation with a button that opens the right System Settings pane, rather than failing silently. Do not build yet: No annotation, no video, no clipboard. One correct still image on disk. ### Phase 2 · Annotation, and a redaction that is real Build: An editor window over the captured image with arrow, box, text and redact tools. Store shapes in annotations. On export, composite them into the pixels. Redaction must resample the underlying region, not draw an opaque shape on top of it. Trap: A blur or a black box drawn as an overlay leaves the original pixels in the file if you ever export the layered form, and pixelation with a large enough block size is still reversible if the block grid is fine. Downsample the region to a few pixels and scale it back up, or fill it with a solid colour. Then verify by opening the exported file somewhere else. Done when: Exporting an image with a redacted region and opening the PNG in Preview shows no readable text under the redaction, and the file size does not encode the original region. Moving an arrow and re-exporting produces a different file from the same source capture. Do not build yet: No video and no GIF. Still images only. ### Phase 3 · Screen recording with microphone audio Build: An SCStream delivering screen sample buffers, plus microphone input through AVCaptureSession. Write both into one MP4 with AVAssetWriter using two inputs. Trap: Screen samples and microphone samples arrive on separate callbacks with unrelated clocks. Start the AVAssetWriter session once, from the first video sample's presentation timestamp, and translate audio timestamps into that same timeline. Starting each input from its own first sample is the standard way to get audio that drifts steadily out of sync. Done when: A sixty second recording of a metronome or a clapping hand stays within about one hundred milliseconds of sync at the end, checked by scrubbing to the last second. Stopping the recording produces a file that plays in QuickTime with no repair prompt. Do not build yet: No trimming and no GIF yet. ### Phase 4 · Trim, export and the capture pipeline Build: A trim UI over the recorded video using AVAssetExportSession with a time range. GIF export by piping frames through ffmpeg with a two-pass palette. Global hotkeys for region, window and full screen, and an option to copy the result to the pasteboard. Trap: A one-pass GIF from ffmpeg uses a fixed palette and looks like it came from 1998. Generate a palette with palettegen from the actual frames, then apply it with paletteuse. It is two commands and it is the whole difference. Done when: Trimming a recording to a five second range produces a file whose duration reads five seconds in QuickTime, not the original length with a playback range. A GIF export of the same clip is under a size limit you set and shows smooth colour, and the file appears in the capture directory with a timestamped name. Do not build yet: No sharing or hosting. ### Phase 5 · Library and cleanup Build: A grid of recent captures reading from the SQLite index, with search by date and kind, reveal in Finder, delete, and a retention rule that deletes captures older than a chosen age unless they are starred. Trap: Deleting the row without deleting the file, or the file without the row, is the bug you will ship. Make deletion one function that does both and reconciles orphans on launch. Done when: Deleting a capture from the library removes both the row and the file. Launching after manually deleting a file from Finder reconciles the index rather than showing a broken thumbnail. A capture older than the retention age is removed on the next launch unless starred. Do not build yet: No distribution yet. ### Phase 6 · Signing, notarization and updates Build: Hardened runtime, Developer ID signing, notarytool submission, stapling, and Sparkle for updates. Document the permission story before anyone installs it. Trap: Because the Screen Recording grant is bound to the signature, your first signed release invalidates the grant your testers gave the unsigned build. Say so in the release notes and tell them where to remove the stale entry. Done when: A notarized build on a Mac that never had it installed opens with no warning, spctl reports accepted, and requesting a capture prompts for Screen Recording exactly once. An update from your appcast installs and relaunches. Do not build yet: Nothing after this. ### Out of scope (and why) - Scrolling capture. Stitching a scrolling window means driving another application's scroll state and detecting overlap, and it is harder than everything above put together. - Cloud upload and instant share links. That is a server, a storage bill, an abuse policy and a link that has to keep working for years. - Viewer analytics on shared links, which only exists because the hosting exists. - Team libraries and shared branding. Both are account features, not capture features. ### README must contain - That Screen Recording and Microphone are separate permissions, and what each one unlocks - That redaction is destructive at export and cannot be undone from the exported file - Where captures are written and how retention decides what to delete - Whether ffmpeg is bundled or expected on PATH, and how to install it if not - That re-signing the app invalidates the existing Screen Recording grant
# Agent instructions · CleanShot X indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Swift 6 and SwiftUI in Xcode, ScreenCaptureKit, requires macOS 12.3 or newer, AVAssetWriter writing H.264 into MP4, ffmpeg invoked as a subprocess with a generated palette, Flat files in a capture directory plus a SQLite index. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - The Screen Recording grant is tied to your code signature. Re-signing with a different identity invalidates it and macOS will not prompt a second time; the user has to delete the entry in System Settings first. This will bite you during development every time the binary moves. - A captured image is in physical pixels, not points. On a Retina display a 400 point selection is an 800 pixel image, and forgetting the backing scale factor gives you a half-size or double-size file. - A blur or a black box drawn as an overlay leaves the original pixels in the file if you ever export the layered form, and pixelation with a large enough block size is still reversible if the block grid is fine. Downsample the region to a few pixels and scale it back up, or fill it with a solid colour. Then verify by opening the exported file somewhere else. - Screen samples and microphone samples arrive on separate callbacks with unrelated clocks. Start the AVAssetWriter session once, from the first video sample's presentation timestamp, and translate audio timestamps into that same timeline. Starting each input from its own first sample is the standard way to get audio that drifts steadily out of sync. - A one-pass GIF from ffmpeg uses a fixed palette and looks like it came from 1998. Generate a palette with palettegen from the actual frames, then apply it with paletteuse. It is two commands and it is the whole difference. - Deleting the row without deleting the file, or the file without the row, is the bug you will ship. Make deletion one function that does both and reconciles orphans on launch.
# Build plan · CleanShot X A capture tool that takes region, window and full-screen shots through ScreenCaptureKit, records screen video with microphone audio in sync, lets you annotate with arrows, boxes and a blur that actually destroys pixels, trims the result, and exports to MP4 or GIF on your own disk. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Permission and a region screenshot Dragging a region writes a correct PNG to disk, with the Screen Recording permission handled honestly. ### Steps 1. Request Screen Recording and handle refusal Call CGRequestScreenCaptureAccess early. If it returns false, show a panel explaining what is lost and a button that opens x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture. Files: `Permissions.swift` 2. Draw the selection overlay A borderless transparent window at screen level covering every display, with a crosshair cursor and a dimmed region outside the selection. Escape cancels. Files: `SelectionOverlay.swift` 3. Capture and write with the correct scale SCContentFilter for the display, set the SCStreamConfiguration width and height to the selection multiplied by the display backing scale factor, then write PNG through CGImageDestination. Files: `Capture.swift` ### Done when - [ ] A 400 by 300 point selection on a Retina display produces an 800 by 600 pixel PNG - [ ] Escape during selection cancels and writes nothing - [ ] Denying the permission shows an explanation and a working Settings link - [ ] Capturing across two displays with different scale factors produces correct pixel sizes on both ### Watch out - The Screen Recording grant is tied to your code signature. Re-signing with a different identity invalidates it and macOS will not prompt a second time; the user has to delete the entry in System Settings first. This will bite you during development every time the binary moves. - A captured image is in physical pixels, not points. On a Retina display a 400 point selection is an 800 pixel image, and forgetting the backing scale factor gives you a half-size or double-size file. ## Phase 2 · Annotation, and a redaction that is real You can draw on a capture, and a redacted region is unrecoverable in the exported file. ### Steps 1. Build the shape model and the editor canvas Shapes are values with a type, a rect or a pair of points, a colour and a z index. Render them in a SwiftUI Canvas over the image. Undo is a stack of shape arrays. Files: `Editor.swift`, `Shape.swift` 2. Implement destructive redaction For each redact shape, crop the region, resize it to roughly eight pixels across with CIImage, scale it back to the region size, and draw the result into the output context before any other annotation. Files: `Redact.swift` 3. Composite and export Draw the base image, then redactions, then the remaining shapes in z order, into a CGContext at full pixel size. Write PNG or JPEG. Files: `Export.swift` ### Done when - [ ] Text under a redacted region is unreadable in the exported file opened in another application - [ ] Undo removes the last shape and redo restores it - [ ] Moving an arrow changes the exported bytes - [ ] Text annotations render at the same size in the export as on screen ### Watch out - A blur or a black box drawn as an overlay leaves the original pixels in the file if you ever export the layered form, and pixelation with a large enough block size is still reversible if the block grid is fine. Downsample the region to a few pixels and scale it back up, or fill it with a solid colour. Then verify by opening the exported file somewhere else. ## Phase 3 · Screen recording with microphone audio A recording plays back with audio and video still in sync a minute in. ### Steps 1. Set up SCStream for video SCStreamConfiguration with the display size, a frame rate you choose deliberately, and showsCursor as a setting. Handle the stream stopping when the display configuration changes. Files: `Recorder.swift` 2. Add microphone capture and request permission AVCaptureSession with an audio device. Microphone is its own privacy grant, separate from Screen Recording, and it has its own usage description string in Info.plist. Files: `Audio.swift`, `Info.plist` 3. Write one file with a shared session clock Create AVAssetWriter with a video input and an audio input. Call startSession(atSourceTime:) once with the first video sample's timestamp, and append audio using the same timebase. Files: `Writer.swift` ### Done when - [ ] A sixty second recording is still in sync at the end when scrubbed - [ ] The finished MP4 opens in QuickTime without a repair prompt - [ ] Denying microphone access still records video and says so - [ ] Changing display resolution mid-recording either stops cleanly or continues, never writes a corrupt file ### Watch out - Screen samples and microphone samples arrive on separate callbacks with unrelated clocks. Start the AVAssetWriter session once, from the first video sample's presentation timestamp, and translate audio timestamps into that same timeline. Starting each input from its own first sample is the standard way to get audio that drifts steadily out of sync. ## Phase 4 · Trim, export and the capture pipeline Every capture lands where you expect, in the format you asked for, with one keystroke. ### Steps 1. Add trimming with a real re-encode AVAssetExportSession with timeRange set. Verify the output duration, because setting a range on a player is not the same as writing a trimmed file. Files: `Trim.swift` 2. Add GIF export through ffmpeg Run ffmpeg twice: once with palettegen into a temporary PNG, once with paletteuse. Bundle ffmpeg or detect it and tell the user how to install it. ```sh ffmpeg -i in.mp4 -vf "fps=15,scale=800:-1:flags=lanczos,palettegen" palette.png ffmpeg -i in.mp4 -i palette.png -lavfi "fps=15,scale=800:-1:flags=lanczos[x];[x][1:v]paletteuse" out.gif ``` 3. Wire the global hotkeys and the pasteboard option Three hotkeys for region, window and full screen. On capture, write the file and optionally put it on the pasteboard so the next Cmd-V pastes the image. Files: `Hotkeys.swift` ### Done when - [ ] A trimmed export reports the trimmed duration in QuickTime, not the original - [ ] A GIF export shows smooth gradients rather than banded colour - [ ] Each hotkey produces the right capture kind - [ ] With the pasteboard option on, Cmd-V in another app pastes the capture ### Watch out - A one-pass GIF from ffmpeg uses a fixed palette and looks like it came from 1998. Generate a palette with palettegen from the actual frames, then apply it with paletteuse. It is two commands and it is the whole difference. ## Phase 5 · Library and cleanup Captures accumulate somewhere you can find them, and old ones do not fill the disk quietly. ### Steps 1. Build the library grid Read captures ordered by created_at, render thumbnails generated at capture time rather than by decoding full images on scroll. Files: `Library.swift` 2. Make deletion atomic and reconcile on launch One delete function removes the file then the row. On launch, drop rows whose file is missing and offer to import files with no row. Files: `Store.swift` 3. Add retention A setting for maximum age and maximum total size. Apply on launch and after each capture, oldest first, never starred. Files: `Retention.swift` ### Done when - [ ] Deleting from the library removes the file from disk - [ ] A file deleted in Finder does not leave a broken row after relaunch - [ ] Retention removes an aged capture and leaves a starred one - [ ] The grid scrolls smoothly with several hundred captures ### Watch out - Deleting the row without deleting the file, or the file without the row, is the bug you will ship. Make deletion one function that does both and reconciles orphans on launch. ## Not in this build - Scrolling capture of long pages or documents. - Hosted sharing links, which is the actual product behind the cloud tiers. - Viewer analytics, which cannot exist without hosting. - Team libraries, shared branding and the annotation polish of a decade-old app. ## After v1, if you want it - Window capture with the window shadow removed, which is fiddly and worth it - A capture history hotkey that pastes the previous capture without opening the app - Self-hosted sharing by writing to an S3-compatible bucket you own
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Where images and videos are written. Pick something Spotlight indexes so you can find captures outside the app. CAPTURE_DIR=~/Pictures/Captures # Required. The SQLite index of captures and annotations. CAPTURE_DB_PATH=~/Library/Application Support/Shots/index.db # Optional. Only needed if ffmpeg is not bundled and not on PATH. GIF export is disabled when it cannot be found. FFMPEG_PATH=/opt/homebrew/bin/ffmpeg # Optional. Captures older than this are deleted on launch unless starred. Leave unset to keep everything. RETENTION_DAYS=90 # Optional. Product phase only. Where your signed appcast is published. SPARKLE_FEED_URL=https://example.com/appcast.xml
# CleanShot X · product brief ## Problem The core loop is small enough for a capable coding agent to produce a useful local version in one sitting. For CleanShot X, capture screenshots or screen video, annotate them, and copy or save locally. The hard boundary is deep macos integration, scrolling capture, cloud sharing, and years of polish, plus capture polish, hosting, and collaboration. ## Product outcome A signed, notarized capture app another person can install, that asks for exactly the permissions it needs, records in sync, and never leaves a redaction that can be undone. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - macOS or Windows screen-recording permission - microphone permission - ffmpeg - local disk space ## Explicit non-goals for v1 - Scrolling capture of long pages or documents. - Hosted sharing links, which is the actual product behind the cloud tiers. - Viewer analytics, which cannot exist without hosting. - Team libraries, shared branding and the annotation polish of a decade-old app. - deep macOS integration, scrolling capture, cloud sharing, and years of polish - instant hosted sharing - viewer analytics - team libraries - cross-device capture ## Success criteria - A sixty second recording is verified in sync at the end - A redacted export is opened in another application and confirmed unreadable - spctl accepts the notarized build on a Mac that never ran it - Denying each permission separately leaves the app usable and explanatory rather than broken - Retention is exercised against a directory of aged test captures before it runs against real ones
# Build brief · CleanShot X The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a local screenshot and screen recorder for macOS with annotation, trimming and export to replace CleanShot X. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Language: Swift 6 and SwiftUI in Xcode · ScreenCaptureKit and AVFoundation are the only sanctioned capture path on modern macOS - Capture: ScreenCaptureKit, requires macOS 12.3 or newer · the older CGWindowListCreateImage path is deprecated and excluded from newer privacy prompts - Video: AVAssetWriter writing H.264 into MP4 · hardware encoding through the system stack, no ffmpeg needed for the common case - GIF export: ffmpeg invoked as a subprocess with a generated palette · AVFoundation cannot write GIF, and a palette pass is the difference between eight colours and something watchable - Storage: Flat files in a capture directory plus a SQLite index · the images are the artefact; the database only records where they are and what was drawn on them ### Data model (create this before Phase 1) - captures(id, kind, path, width, height, scale, created_at, duration_ms) · kind is image or video - annotations(id, capture_id, type, geometry_json, color, thickness, z) · type is arrow, box, text, highlight or redact - settings(key, value) · capture directory, default format, whether to copy to pasteboard on capture - Annotations are stored separately from the pixels so an export can be re-run, except redaction, which is destructive at export time by design. ### Phase 1 · Permission and a region screenshot Build: Request Screen Recording, then use SCShareableContent to enumerate displays and SCScreenshotManager to capture. Draw a selection overlay on a transparent full-screen window, and write the cropped result as PNG. Trap: The Screen Recording grant is tied to your code signature. Re-signing with a different identity invalidates it and macOS will not prompt a second time; the user has to delete the entry in System Settings first. This will bite you during development every time the binary moves. Trap: A captured image is in physical pixels, not points. On a Retina display a 400 point selection is an 800 pixel image, and forgetting the backing scale factor gives you a half-size or double-size file. Done when: Dragging a 400 by 300 point region on a Retina display writes a PNG whose pixel dimensions are 800 by 600. Denying the permission shows your own explanation with a button that opens the right System Settings pane, rather than failing silently. Do not build yet: No annotation, no video, no clipboard. One correct still image on disk. ### Phase 2 · Annotation, and a redaction that is real Build: An editor window over the captured image with arrow, box, text and redact tools. Store shapes in annotations. On export, composite them into the pixels. Redaction must resample the underlying region, not draw an opaque shape on top of it. Trap: A blur or a black box drawn as an overlay leaves the original pixels in the file if you ever export the layered form, and pixelation with a large enough block size is still reversible if the block grid is fine. Downsample the region to a few pixels and scale it back up, or fill it with a solid colour. Then verify by opening the exported file somewhere else. Done when: Exporting an image with a redacted region and opening the PNG in Preview shows no readable text under the redaction, and the file size does not encode the original region. Moving an arrow and re-exporting produces a different file from the same source capture. Do not build yet: No video and no GIF. Still images only. ### Phase 3 · Screen recording with microphone audio Build: An SCStream delivering screen sample buffers, plus microphone input through AVCaptureSession. Write both into one MP4 with AVAssetWriter using two inputs. Trap: Screen samples and microphone samples arrive on separate callbacks with unrelated clocks. Start the AVAssetWriter session once, from the first video sample's presentation timestamp, and translate audio timestamps into that same timeline. Starting each input from its own first sample is the standard way to get audio that drifts steadily out of sync. Done when: A sixty second recording of a metronome or a clapping hand stays within about one hundred milliseconds of sync at the end, checked by scrubbing to the last second. Stopping the recording produces a file that plays in QuickTime with no repair prompt. Do not build yet: No trimming and no GIF yet. ### Phase 4 · Trim, export and the capture pipeline Build: A trim UI over the recorded video using AVAssetExportSession with a time range. GIF export by piping frames through ffmpeg with a two-pass palette. Global hotkeys for region, window and full screen, and an option to copy the result to the pasteboard. Trap: A one-pass GIF from ffmpeg uses a fixed palette and looks like it came from 1998. Generate a palette with palettegen from the actual frames, then apply it with paletteuse. It is two commands and it is the whole difference. Done when: Trimming a recording to a five second range produces a file whose duration reads five seconds in QuickTime, not the original length with a playback range. A GIF export of the same clip is under a size limit you set and shows smooth colour, and the file appears in the capture directory with a timestamped name. Do not build yet: No sharing or hosting. ### Phase 5 · Library and cleanup Build: A grid of recent captures reading from the SQLite index, with search by date and kind, reveal in Finder, delete, and a retention rule that deletes captures older than a chosen age unless they are starred. Trap: Deleting the row without deleting the file, or the file without the row, is the bug you will ship. Make deletion one function that does both and reconciles orphans on launch. Done when: Deleting a capture from the library removes both the row and the file. Launching after manually deleting a file from Finder reconciles the index rather than showing a broken thumbnail. A capture older than the retention age is removed on the next launch unless starred. Do not build yet: No distribution yet. ### Phase 6 · Signing, notarization and updates Build: Hardened runtime, Developer ID signing, notarytool submission, stapling, and Sparkle for updates. Document the permission story before anyone installs it. Trap: Because the Screen Recording grant is bound to the signature, your first signed release invalidates the grant your testers gave the unsigned build. Say so in the release notes and tell them where to remove the stale entry. Done when: A notarized build on a Mac that never had it installed opens with no warning, spctl reports accepted, and requesting a capture prompts for Screen Recording exactly once. An update from your appcast installs and relaunches. Do not build yet: Nothing after this. ### Out of scope (and why) - Scrolling capture. Stitching a scrolling window means driving another application's scroll state and detecting overlap, and it is harder than everything above put together. - Cloud upload and instant share links. That is a server, a storage bill, an abuse policy and a link that has to keep working for years. - Viewer analytics on shared links, which only exists because the hosting exists. - Team libraries and shared branding. Both are account features, not capture features. ### README must contain - That Screen Recording and Microphone are separate permissions, and what each one unlocks - That redaction is destructive at export and cannot be undone from the exported file - Where captures are written and how retention decides what to delete - Whether ffmpeg is bundled or expected on PATH, and how to install it if not - That re-signing the app invalidates the existing Screen Recording grant
# Architecture · CleanShot X ## Stack | Part | Choice | Why | | --- | --- | --- | | Language | Swift 6 and SwiftUI in Xcode | ScreenCaptureKit and AVFoundation are the only sanctioned capture path on modern macOS | | Capture | ScreenCaptureKit, requires macOS 12.3 or newer | the older CGWindowListCreateImage path is deprecated and excluded from newer privacy prompts | | Video | AVAssetWriter writing H.264 into MP4 | hardware encoding through the system stack, no ffmpeg needed for the common case | | GIF export | ffmpeg invoked as a subprocess with a generated palette | AVFoundation cannot write GIF, and a palette pass is the difference between eight colours and something watchable | | Storage | Flat files in a capture directory plus a SQLite index | the images are the artefact; the database only records where they are and what was drawn on them | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Capture | ScreenCaptureKit configuration, permission state and producing pixels. | Replacing ScreenCaptureKit is not an option on macOS. What you can swap is the selection overlay, which is pure AppKit and independent. | | Editor | The shape model, undo and the on-screen rendering. | Shapes are plain values, so an alternative renderer or a different tool set is additive and does not touch export. | | Export | Compositing shapes into pixels, redaction, and file writing. | The GIF path already shells out to ffmpeg, so a different encoder is a command change. The PNG and MP4 paths are Apple frameworks and stay. | | Recorder | The stream, the audio session and the shared timeline. | The timeline logic is the valuable part and should survive any change of writer; keep it behind one interface with a test that feeds synthetic timestamps. | | Library | The index, thumbnails, retention and orphan reconciliation. | SQLite could become plain JSON at this size, but reconciliation is the behaviour that matters, not the store. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `CAPTURE_DIR` · required · Where images and videos are written. Pick something Spotlight indexes so you can find captures outside the app. - `CAPTURE_DB_PATH` · required · The SQLite index of captures and annotations. - `FFMPEG_PATH` · optional · Only needed if ffmpeg is not bundled and not on PATH. GIF export is disabled when it cannot be found. - `RETENTION_DAYS` · optional · Captures older than this are deleted on launch unless starred. Leave unset to keep everything. - `SPARKLE_FEED_URL` · optional · Product phase only. Where your signed appcast is published. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
# Agent instructions · CleanShot X product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Swift 6 and SwiftUI in Xcode, ScreenCaptureKit, requires macOS 12.3 or newer, AVAssetWriter writing H.264 into MP4, ffmpeg invoked as a subprocess with a generated palette, Flat files in a capture directory plus a SQLite index. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - The Screen Recording grant is tied to your code signature. Re-signing with a different identity invalidates it and macOS will not prompt a second time; the user has to delete the entry in System Settings first. This will bite you during development every time the binary moves. - A captured image is in physical pixels, not points. On a Retina display a 400 point selection is an 800 pixel image, and forgetting the backing scale factor gives you a half-size or double-size file. - A blur or a black box drawn as an overlay leaves the original pixels in the file if you ever export the layered form, and pixelation with a large enough block size is still reversible if the block grid is fine. Downsample the region to a few pixels and scale it back up, or fill it with a solid colour. Then verify by opening the exported file somewhere else. - Screen samples and microphone samples arrive on separate callbacks with unrelated clocks. Start the AVAssetWriter session once, from the first video sample's presentation timestamp, and translate audio timestamps into that same timeline. Starting each input from its own first sample is the standard way to get audio that drifts steadily out of sync. - A one-pass GIF from ffmpeg uses a fixed palette and looks like it came from 1998. Generate a palette with palettegen from the actual frames, then apply it with paletteuse. It is two commands and it is the whole difference. - Deleting the row without deleting the file, or the file without the row, is the bug you will ship. Make deletion one function that does both and reconciles orphans on launch. - Because the Screen Recording grant is bound to the signature, your first signed release invalidates the grant your testers gave the unsigned build. Say so in the release notes and tell them where to remove the stale entry.
# Delivery milestones · CleanShot X Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Permission and a region screenshot Dragging a region writes a correct PNG to disk, with the Screen Recording permission handled honestly. ### Steps 1. Request Screen Recording and handle refusal Call CGRequestScreenCaptureAccess early. If it returns false, show a panel explaining what is lost and a button that opens x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture. Files: `Permissions.swift` 2. Draw the selection overlay A borderless transparent window at screen level covering every display, with a crosshair cursor and a dimmed region outside the selection. Escape cancels. Files: `SelectionOverlay.swift` 3. Capture and write with the correct scale SCContentFilter for the display, set the SCStreamConfiguration width and height to the selection multiplied by the display backing scale factor, then write PNG through CGImageDestination. Files: `Capture.swift` ### Done when - [ ] A 400 by 300 point selection on a Retina display produces an 800 by 600 pixel PNG - [ ] Escape during selection cancels and writes nothing - [ ] Denying the permission shows an explanation and a working Settings link - [ ] Capturing across two displays with different scale factors produces correct pixel sizes on both ### Watch out - The Screen Recording grant is tied to your code signature. Re-signing with a different identity invalidates it and macOS will not prompt a second time; the user has to delete the entry in System Settings first. This will bite you during development every time the binary moves. - A captured image is in physical pixels, not points. On a Retina display a 400 point selection is an 800 pixel image, and forgetting the backing scale factor gives you a half-size or double-size file. ## M2 · Annotation, and a redaction that is real You can draw on a capture, and a redacted region is unrecoverable in the exported file. ### Steps 1. Build the shape model and the editor canvas Shapes are values with a type, a rect or a pair of points, a colour and a z index. Render them in a SwiftUI Canvas over the image. Undo is a stack of shape arrays. Files: `Editor.swift`, `Shape.swift` 2. Implement destructive redaction For each redact shape, crop the region, resize it to roughly eight pixels across with CIImage, scale it back to the region size, and draw the result into the output context before any other annotation. Files: `Redact.swift` 3. Composite and export Draw the base image, then redactions, then the remaining shapes in z order, into a CGContext at full pixel size. Write PNG or JPEG. Files: `Export.swift` ### Done when - [ ] Text under a redacted region is unreadable in the exported file opened in another application - [ ] Undo removes the last shape and redo restores it - [ ] Moving an arrow changes the exported bytes - [ ] Text annotations render at the same size in the export as on screen ### Watch out - A blur or a black box drawn as an overlay leaves the original pixels in the file if you ever export the layered form, and pixelation with a large enough block size is still reversible if the block grid is fine. Downsample the region to a few pixels and scale it back up, or fill it with a solid colour. Then verify by opening the exported file somewhere else. ## M3 · Screen recording with microphone audio A recording plays back with audio and video still in sync a minute in. ### Steps 1. Set up SCStream for video SCStreamConfiguration with the display size, a frame rate you choose deliberately, and showsCursor as a setting. Handle the stream stopping when the display configuration changes. Files: `Recorder.swift` 2. Add microphone capture and request permission AVCaptureSession with an audio device. Microphone is its own privacy grant, separate from Screen Recording, and it has its own usage description string in Info.plist. Files: `Audio.swift`, `Info.plist` 3. Write one file with a shared session clock Create AVAssetWriter with a video input and an audio input. Call startSession(atSourceTime:) once with the first video sample's timestamp, and append audio using the same timebase. Files: `Writer.swift` ### Done when - [ ] A sixty second recording is still in sync at the end when scrubbed - [ ] The finished MP4 opens in QuickTime without a repair prompt - [ ] Denying microphone access still records video and says so - [ ] Changing display resolution mid-recording either stops cleanly or continues, never writes a corrupt file ### Watch out - Screen samples and microphone samples arrive on separate callbacks with unrelated clocks. Start the AVAssetWriter session once, from the first video sample's presentation timestamp, and translate audio timestamps into that same timeline. Starting each input from its own first sample is the standard way to get audio that drifts steadily out of sync. ## M4 · Trim, export and the capture pipeline Every capture lands where you expect, in the format you asked for, with one keystroke. ### Steps 1. Add trimming with a real re-encode AVAssetExportSession with timeRange set. Verify the output duration, because setting a range on a player is not the same as writing a trimmed file. Files: `Trim.swift` 2. Add GIF export through ffmpeg Run ffmpeg twice: once with palettegen into a temporary PNG, once with paletteuse. Bundle ffmpeg or detect it and tell the user how to install it. ```sh ffmpeg -i in.mp4 -vf "fps=15,scale=800:-1:flags=lanczos,palettegen" palette.png ffmpeg -i in.mp4 -i palette.png -lavfi "fps=15,scale=800:-1:flags=lanczos[x];[x][1:v]paletteuse" out.gif ``` 3. Wire the global hotkeys and the pasteboard option Three hotkeys for region, window and full screen. On capture, write the file and optionally put it on the pasteboard so the next Cmd-V pastes the image. Files: `Hotkeys.swift` ### Done when - [ ] A trimmed export reports the trimmed duration in QuickTime, not the original - [ ] A GIF export shows smooth gradients rather than banded colour - [ ] Each hotkey produces the right capture kind - [ ] With the pasteboard option on, Cmd-V in another app pastes the capture ### Watch out - A one-pass GIF from ffmpeg uses a fixed palette and looks like it came from 1998. Generate a palette with palettegen from the actual frames, then apply it with paletteuse. It is two commands and it is the whole difference. ## M5 · Library and cleanup Captures accumulate somewhere you can find them, and old ones do not fill the disk quietly. ### Steps 1. Build the library grid Read captures ordered by created_at, render thumbnails generated at capture time rather than by decoding full images on scroll. Files: `Library.swift` 2. Make deletion atomic and reconcile on launch One delete function removes the file then the row. On launch, drop rows whose file is missing and offer to import files with no row. Files: `Store.swift` 3. Add retention A setting for maximum age and maximum total size. Apply on launch and after each capture, oldest first, never starred. Files: `Retention.swift` ### Done when - [ ] Deleting from the library removes the file from disk - [ ] A file deleted in Finder does not leave a broken row after relaunch - [ ] Retention removes an aged capture and leaves a starred one - [ ] The grid scrolls smoothly with several hundred captures ### Watch out - Deleting the row without deleting the file, or the file without the row, is the bug you will ship. Make deletion one function that does both and reconciles orphans on launch. ## M6 · Signing, notarization and updates (production only) Someone else can install it without a Gatekeeper warning and receive fixes. ### Steps 1. Sign with the hardened runtime Enable hardened runtime, sign with Developer ID Application, and confirm the microphone and screen capture usage strings are present in Info.plist or the prompts will not appear. ```sh codesign --force --options runtime --sign "Developer ID Application: YOUR NAME" Shots.app ``` 2. Notarize and staple Submit the zip with notarytool and staple the ticket so validation works offline. ```sh xcrun notarytool submit Shots.zip --keychain-profile AC --wait xcrun stapler staple Shots.app ``` 3. Publish updates with Sparkle Generate an EdDSA key pair, keep the private half out of the repository, and serve appcast.xml over HTTPS. Files: `appcast.xml` ### Done when - [ ] spctl --assess --verbose reports the app accepted - [ ] A fresh Mac opens the download with no Gatekeeper warning - [ ] Screen Recording is requested once and the capture then succeeds - [ ] An appcast update installs and relaunches ### Watch out - Because the Screen Recording grant is bound to the signature, your first signed release invalidates the grant your testers gave the unsigned build. Say so in the release notes and tell them where to remove the stale entry.
# Operations · CleanShot X ## Backup The capture directory is the data. It is ordinary files, so any backup tool covers it. The SQLite index is rebuildable from the files, and your reconciliation code is what makes that true. ## Restore Restore the capture directory, delete the index, and launch. Reconciliation should import every file it finds. If it does not, that is a bug worth fixing before you need it. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Watch disk use of the capture directory and encoder CPU during recording. A dropped frame count from SCStream is worth logging, because a stream that silently degrades looks like a bad recording rather than a bug. ## Incident checklist The recurring incident is a macOS update changing capture behaviour or resetting a permission. Keep the previous signed build available, and set a minimum supported macOS version in the appcast so an incompatible update is never offered. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] A sixty second recording is verified in sync at the end - [ ] A redacted export is opened in another application and confirmed unreadable - [ ] spctl accepts the notarized build on a Mac that never ran it - [ ] Denying each permission separately leaves the app usable and explanatory rather than broken - [ ] Retention is exercised against a directory of aged test captures before it runs against real ones ## Launch constraint Do not market omitted CleanShot X capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Where images and videos are written. Pick something Spotlight indexes so you can find captures outside the app. CAPTURE_DIR=~/Pictures/Captures # Required. The SQLite index of captures and annotations. CAPTURE_DB_PATH=~/Library/Application Support/Shots/index.db # Optional. Only needed if ffmpeg is not bundled and not on PATH. GIF export is disabled when it cannot be found. FFMPEG_PATH=/opt/homebrew/bin/ffmpeg # Optional. Captures older than this are deleted on launch unless starred. Leave unset to keep everything. RETENTION_DAYS=90 # Optional. Product phase only. Where your signed appcast is published. SPARKLE_FEED_URL=https://example.com/appcast.xml
$ choose a build depth, inspect the files, then open the complete pack in your agent
People still pay for CleanShot X because the recording itself is easy; the subscription removes upload, transcoding, sharing, organization, and support work. The recurring cost buys OS permissions, codecs, rendering performance, uploads, storage, links, and viewer analytics, not just the visible interface.
xdeep macOS integration, scrolling capture, cloud sharing, and years of polish
xinstant hosted sharing
xviewer analytics
xteam libraries
xcross-device capture
Don't feel like building it? These folks already made it free.
no votes, no pay-to-list · just what's real
CleanShot X pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| app + cloud basic | custom | — | CleanShot X app; 1 year of updates; 1 GB cloud storage |
| app + cloud pro | $10/user | $8/user | Latest app updates; unlimited cloud storage; custom domain, branding, security and team features |
free tierno free tier and no direct CleanShot trial; Setapp offers a separate 7-day trial
billingone-time Basic license plus optional $19/year renewal, or monthly/annual Pro subscription
hidden costsThe $29 purchase includes updates for only one year and just 1 GB of cloud; continued updates/cloud service costs $19/year, while Pro is per user and subject to fair-use limits.
verified 2026-08-14 · source ↗
Vibecode CleanShot X
Yes. A competent AI coding agent (Claude Code, Codex, Cursor) can build a usable personal CleanShot X replacement in one session with the prompt on this page. It runs on your own machine or server with no subscription.
How much does CleanShot X cost?
CleanShot X's pricing is usage-based or varies by plan · Primary purchase is a one-time Mac license; optional cloud plans are separate..
What do I lose by replacing CleanShot X?
Honestly: deep macOS integration, scrolling capture, cloud sharing, and years of polish; instant hosted sharing; viewer analytics; team libraries; cross-device capture. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to CleanShot X?
Yes: ShareX (CleanShot after a Windows power user designed every menu.) The prompt is for when you want it exactly your way.