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.
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:
| Routine | Behavior | Reached from |
|---|---|---|
AppDelegate.addImageFromDataSnapSparrowApp.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.pasteImageFromClipboardFlowSceneView.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.
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.
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.
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.
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.
SnapSparrowApp.swift
addImagesFromData(_:) — the body of addImageFromData without the
per-image undo record, then one .addImages and one saveImmediately.
addImageFromData is refactored to call it with a single element, so there is one
implementation rather than a fourth copy of the six-step sequence.handleImport() — picker → load → add → toast "Imported 4 images", or
"Imported 3 of 4 — couldn't read logo.png". Cancelling is a silent no-op.installKeyboardMonitor() beside the existing ⌘V branch, with the same
responder is NSTextView bail-out.FlowSceneView.swift
importImages(urls:) — inserts sequentially from activeIndex + 1,
mirroring pasteImageFromClipboard, caches each bitmap into imageCache,
records one .addImages, animates to the first new slide..onDrop(of: [.fileURL], isTargeted:) on the root VStack.
Drop order is preserved by resolving providers into an index-keyed array and
awaiting them as a group before any insertion. The deleted CaptureModalView.handleDrop
interleaved DispatchQueue.main.async completions and lost order; this does not repeat
that..onKeyPress for ⌘O here — the AppDelegate monitor already fires
for this window, and a second handler would open two panels.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.
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.
The committed project has drifted from project.yml, and
xcodegen generate silently discards real configuration:
| File | What 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.xcscheme | Deleted 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.
ImageNormalization removes duplication and changes nothing
behaviorally, so it cannot regress paste.addImageFromData reduces the four-way duplication rather than
adding a fourth copy.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..addImage —
drop, undo, redo moves the images to the end of the grid. Fixing it means teaching five existing
undo cases to carry insertion indices.ClipboardMonitor hashes content; manual paste has
never deduped, and import will not either. Importing the same file twice writes two copies.load(urls:) for perceptible time. Under ten files this is imperceptible; I am not
adding async progress until it matters..text. Position within the filmstrip
is ignored.acceptedTypes plus one in
acceptedExtensions.project.yml so xcodegen is usable again,
or delete it so nobody trusts it..addImage and .addImages together.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.