SnapSparrow · Implementation Plan

Image import, alongside pasted screenshots

Date 11 September 2026 Task A45EB9D2 Branch main New files 2 Status implemented, builds clean

Decisions locked

Entry points
Drop onto canvas · picker (toolbar + ⌘O)
File types
PNG and JPEG only
Destination
Same as paste
Toolbar button
Dropped — three routes in already
⌘O vs. drop
Split kept, matching paste

One flag on file types

macOS screenshots written to disk are PNG, so the common case is covered. Images arriving from an iPhone via Photos or AirDrop are usually HEIC, and some Preview exports default to TIFF — both are rejected under this choice. The accepted-type list is a single two-element constant, so adding them later is a one-line change. Files filtered out report "2 files skipped (unsupported type)" rather than vanishing silently.

What already exists

Every image reaching the app today comes from the clipboard. There is no file import, no drag-and-drop of external images, and no screen-capture API usage anywhere — the app relies on macOS's own ⌘⇧4 and then reads the pasteboard. Two near-identical routines do the ingesting:

RoutineBehaviorReached from
AppDelegate.addImageFromData
SnapSparrowApp.swift:488
Appends to the active grid, marks stripState.newImageIds, records .addImage, saves immediately ⌥⇧S, ⌥⇧4, strip Paste button, status menu, ClipboardMonitor auto-add
FlowSceneView.pasteImageFromClipboard
FlowSceneView.swift:1722
Inserts at activeIndex + 1, caches the bitmap, animates to the new slide ⌘V while the compose window is focused

Both normalize to PNG and write into sessions/{docId}/. ClipboardService.readImage() already accepts image file URLs off the pasteboard, so the decode-and-normalize logic import needs exists already — it is just private and clipboard-bound.

The app is LSUIElement: true with no SwiftUI menu bar and no WindowGroup; its only scene is Settings. There is no Commands block to hang ⌘O on, so shortcuts live either in AppDelegate.installKeyboardMonitor() or as .onKeyPress in the focused view.

Sandbox — no change needed

The sandbox is on, and files.user-selected.read-write is already granted, which is exactly what NSOpenPanel and the URLs it returns require. Dropped files arrive with an implicit sandbox extension and read fine. project.yml disagrees with the committed entitlements file, which is a separate problem — see the build step.

Plan

1Extract the shared decode path

new — SnapSparrow/Services/ImageNormalization.swift

ClipboardService keeps imageSize(from:) and convertToPNG(_:) private. Import needs the same two steps, so lift them into one enum rather than copying them:

enum ImageNormalization {
    /// Decodes `data`, returning PNG-normalized bytes and pixel dimensions.
    /// Returns nil when the bytes are not a decodable bitmap.
    static func normalizedPNG(from data: Data) -> (data: Data, size: CGSize)?
}

Rewrite ClipboardService.readImage's three call sites to use it. Behavior is identical — the convertToPNG(data) ?? data fallback moves inside normalizedPNG unchanged.

2The import service

new — SnapSparrow/Services/ImageImportService.swift

enum ImageImportService {
    /// The one place accepted types are declared.
    static let acceptedTypes: [UTType] = [.png, .jpeg]
    static let acceptedExtensions: Set<String> = ["png", "jpg", "jpeg"]

    struct Loaded { let data: Data; let size: CGSize; let sourceName: String }
    struct Outcome { let loaded: [Loaded]; let failures: [String] }

    /// Modal NSOpenPanel, multi-select, files only. Returns [] on cancel.
    @MainActor static func chooseFiles() -> [URL]

    /// Reads and PNG-normalizes each URL **in the order given**. Unreadable
    /// files land in `failures` by name; the rest still import.
    static func load(urls: [URL]) -> Outcome

    /// Filters NSItemProvider-resolved URLs to accepted extensions.
    static func acceptable(_ urls: [URL]) -> [URL]
}

The panel sets allowsMultipleSelection = true, canChooseDirectories = false, prompt = "Import". Because the app is LSUIElement, raising the panel from the status menu with no key window needs NSApp.activate(ignoringOtherApps: true) first.

3Batch undo

AnnotationUndoManager.swift

A six-file import has to undo in one ⌘Z, not six. Two new mutually-inverse cases beside the existing .addImage / .removeAddedImage pair:

case addImages([CapturedImage])
case removeAddedImages([CapturedImage])

.addImages appends each in array order, matching Grid.addImage, which rewrites order to images.count. .removeAddedImages removes each by id. Single-file imports use the same case with a one-element array, so there is no special path.

4Append-path import

SnapSparrowApp.swift

5Compose-window import and the drop target

FlowSceneView.swift

6Toolbar button — dropped

Left untouched by decision

FlowToolbarView measures a fixed intrinsic width per tier and picks one with ViewThatFits across four tiers, and ContentView's minWidth: 900 was derived from the .minimal tier's width with the sidebar expanded — commit c8d50ff had only just finished tuning that. Adding roughly 26pt to every tier would have shifted the floor for no reach the feature lacks: ⌘O, the status menu, and drag-and-drop are three routes in.

The tier system and ContentView.swift:68 are unmodified.

7Empty-state copy

FlowSceneView.swift:433

"Capture screenshots to get started" becomes "Paste a screenshot, drop an image, or press ⌘O to import". The current copy misdescribes what the app can do. This is labeling only — the canvas-level onDrop from step 5 already covers this region.

8Build step — hand-edit the pbxproj, do not run xcodegen

Measured, not guessed — I ran this and restored it

The committed project has drifted from project.yml, and xcodegen generate silently discards real configuration:

FileWhat regeneration does
SnapSparrow.entitlements Overwrites all three keys with a lone app-sandbox: false — drops network.client, so Sparkle stops reaching the appcast, and files.user-selected.read-write, the entitlement this feature needs
Info.plist Drops LSApplicationCategoryType and ITSAppUsesNonExemptEncryption; re-adds three SU* keys with an empty SUPublicEDKey
SnapSparrow.xcschemeDeleted outright

I ran it, confirmed the damage, and restored every file from backup plus git checkout. The source tree is clean. XcodeGen is effectively abandoned here: the pbxproj is the source of truth and project.yml is stale.

So the two new files get added to project.pbxproj by hand — a PBXFileReference and PBXBuildFile each, an entry in the Services group, and an entry in the target's PBXSourcesBuildPhase. Then xcodebuild -scheme SnapSparrow -configuration Debug build to verify.

Reconciling project.yml is worth doing and is not part of this task. It is a live trap for anyone who runs xcodegen expecting it to work.

Final review

Pros

  • One decode path. ImageNormalization removes duplication and changes nothing behaviorally, so it cannot regress paste.
  • Accepted types are a single constant; HEIC and TIFF are one line away.
  • Batch undo is correct from the start. Imports are multi-file in a way paste is not.
  • Drop order is deterministic, avoiding the defect the previous drop code had.
  • Generalizing addImageFromData reduces the four-way duplication rather than adding a fourth copy.
  • No entitlement, signing, or distribution change.

Cons

  • Two insertion behaviors persist, by decision. ⌘O appends; a drop onto the canvas inserts after the active slide. That mirrors how paste already behaves from those surfaces, but the two import routes do land images in different places while compose is open.
  • ImageNormalization re-encodes an already-PNG file, exactly as paste does. A 4MB screenshot round-trips through NSBitmapImageRep for no gain. Inherited deliberately so import and paste produce byte-identical storage.
  • Hand-editing the pbxproj is more error-prone than regenerating it would be, if regenerating worked.

Edge cases this plan does not solve

Follow-ups left on the table

  1. The strip toolbar could carry the picker beside its Paste button. The strip is the surface where images get gathered. Out of scope as specified, easy to add.
  2. HEIC and TIFF — one line in acceptedTypes plus one in acceptedExtensions.
  3. Reconcile project.yml so xcodegen is usable again, or delete it so nobody trusts it.
  4. A compound undo case carrying insertion indices, which would fix redo-after-insert for .addImage and .addImages together.
84/100
Confidence

The ingestion architecture converges cleanly and the service boundary is obvious, so steps 1–5 and 7–8 are high-confidence. The remaining doubt is almost entirely step 6 — whether an extra 26pt breaks the .minimal tier at 900pt cannot be predicted from reading, only from building and resizing the window — plus the ⌘O-versus-drop insertion split, which is a design question rather than an implementation one.