Skip to content

Build Universal Binary (arm64 + x86_64) for Intel Mac support - #5

Open
seymer wants to merge 35 commits into
heyderekj:mainfrom
seymer:main
Open

Build Universal Binary (arm64 + x86_64) for Intel Mac support#5
seymer wants to merge 35 commits into
heyderekj:mainfrom
seymer:main

Conversation

@seymer

@seymer seymer commented May 18, 2026

Copy link
Copy Markdown

No description provided.

@netlify

netlify Bot commented May 18, 2026

Copy link
Copy Markdown

Deploy Preview for binkyfiles ready!

Name Link
🔨 Latest commit c0541c9
🔍 Latest deploy log https://app.netlify.com/projects/binkyfiles/deploys/6a1151a61e384100081a6918
😎 Deploy Preview https://deploy-preview-5--binkyfiles.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

siqingmu added 28 commits May 18, 2026 12:05
P0 security fixes:
- Reject path-traversal entries in tar/zip extraction
- Verify codesign + bundle identifier + signing identity on app updates
- Stage DMG installs to a sibling path then atomically swap (no destructive pre-delete)
- Verify zip integrity via unzip -tq before deleting source file

P1 performance fixes:
- FileHashStore: persistent SQLite connection with WAL + busy_timeout
- FileHashStore: in-memory perceptual-hash index with partial DB index
- FolderWatcher: dispatch FSEvents callbacks on a utility-QoS background queue
- DMGInstallerService: drain hdiutil pipe before waitUntilExit (avoid deadlock)

Test:
- Add regression test for tar path-traversal rejection
P1 follow-ups:
- ContentInspector: drop redundant NSLock around NSCache, add totalCostLimit, skip
  accurate-mode OCR fallback when fast mode found zero text observations (avoids
  double OCR on photos / illustrations)
- UpdateChecker deferred install: extend PID-gone wait to 30s, abort cleanly if host
  is still alive (never rm -rf a running bundle), replace destructive rm+ditto with
  atomic mv-backup + ditto + rollback on failure
- OrganizerMainView: cache reviewFolderItemCount and sortHistoryRows in @State and
  refresh on appear / pref changes / sort completion / triage dismiss — eliminates
  per-body filesystem stats and JSON decodes

Stability:
- FileHashStore.digestFile: honor Task.checkCancellation between 512KB read chunks
  so multi-GB hashes can bail when the user presses Stop
- EnergyConditions.waitUntilOK: use withTaskCancellationHandler with per-waiter
  UUIDs so cancelled sort tasks no longer leak suspended continuations
- looksTransientIncomplete: cover .crswap, .opdownload, .aria2, .!ut suffixes and
  Microsoft Office ~$ lock-file prefix

Tests:
- Extend transient-suffix test with the five new patterns
UX feedback:
- ReviewFolderTriageSheet: surface a localized alert when move/trash fails (still
  beeps for muted Macs); previously silent on failure
- SortPreviewSheet: show an explanatory empty state when no rows would be sorted
  instead of an empty Table that looked broken
- Preview button: show inline ProgressView and disable while async work runs;
  no more overlapping previews from impatient double-clicks
- Recent-activity Clear: gate behind a confirmationDialog with destructive intent
  and a clear consequence message

Accessibility:
- Routine eye toggle: explicit accessibilityLabel + value (on/off) so VoiceOver
  no longer announces it as bare 'image'
- Review toolbar button: label + value reporting how many files await review
- UpdateBanner / ReviewPromptBanner xmark dismiss buttons: accessibilityLabel

Animation lifecycle:
- PulsePinkRowBar (35 fps) and LegacySymbolRotationModifier (30 fps): pin
  TimelineView paused state to onAppear/onDisappear so timers stop when the
  view is occluded or off-screen
Infrastructure:
- release.sh: add --no-push flag — runs steps 1–4 (bump, build, DMG, cask) without
  any git commit, tag, push, or gh release. Lets you inspect the build artifacts
  before publishing. Mutually exclusive with --bump-only.

Test coverage (HardeningTests.swift, 13 new test cases):
- DMG atomic install: fresh install, upgrade swap, failure preserves existing
- Zip integrity: valid passes, empty rejects + cleans up, corrupt rejects + cleans up
- Cross-process flock: acquire, second acquirer denied, release + re-acquire
- looksTransientIncomplete: all 13 transient suffixes detected, real files pass through

Also:
- DMGInstallerService.atomicallyInstall: promote to internal for testability
- SortZipViaDitto + verifyZipIntegrity: promote to internal for testability
v1.6 quality hardening:
- Zip bomb pre-flight: reject archives with ratio >100:1 or uncompressed >10GB
- PostSortShortcutRunner: use URLComponents (fixes # and other special chars)
- Rule editor: warn when extractAndTrash targets unsupported formats (rar/7z)
- BinkyPreferences: observe UserDefaults.didChangeNotification to invalidate
  all cached properties when external processes modify defaults
- FolderWatcher: switch to Unmanaged.passUnretained (no retain cycle, caller
  owns lifetime, deinit tears down stream safely)
- WhereFromsReader: add XATTR_NOFOLLOW to getxattr calls (don't follow symlinks)

v1.7 design system:
- Brand color: adaptive NSColor (lighter in dark mode) with Asset Catalog
  fallback; replaces fixed RGB that had poor dark-mode contrast
- BinkyLinkButtonStyle: unified link-button appearance component
- FinderTag editor: flexible column widths (minWidth only, no maxWidth cap);
  builtIn column 92→110pt for localized labels
- WeeklyDigestShareCard: maxWidth:600 + minHeight:200 (responsive, no clip)
- Sidebar min 240→260, Help sidebar min 180→200 (non-English truncation fix)
- settingsHelperText: .tertiary 10pt → .secondary 11pt (WCAG AA contrast)

Deferred: v1.6-6 Sheet state machine refactor (5 Bool → enum) — large
cross-cutting change, will be a separate PR.
Strings.swift: drop ~80 lines of dead Dinky-era brand voice that grep
confirmed had zero references (compressButton, processSingle/Batch/Big,
doneGood/Mixed, skipped, errored, zeroBytes, prefsTitle, concurrent-
Compression*, batchLargestFirst*, webp/avif/png/heic, pasteEmpty*,
pasteDuplicate*, dropHover). Localize dropIdleTaglines via String(localized:)
and tighten the copy to match Binky's calm/quiet voice instead of
'Binky Free keeps your folders tidy.' (254 LoC -> 124 LoC).

tools/_paths.py: single source of truth for Binky paths used by the
localization helpers. Replaces hardcoded 'Dinky/' segments in five Python
scripts and one absolute /Users/gyk/... path that only worked on the
original author's machine. fix_brand_in_xcstrings: replace literal 'Dinky'
with 'Binky' (BRAND_PATTERNS comment notes the patterns are inherited
from the sister project's MT failures and still apply).

docs: archive PDF_COMPRESSION.md and MARKETING_TAB_PREFETCH_DINKY.md
(both Dinky-era and not relevant to Binky's organizer-led product) under
docs/archive/ via git mv with archived-banner headers. Update
tools/pdf_fixtures/README.md to point to the new path so the cross-
reference doesn't dangle.

agents.md: reflect that tools/ no longer requires per-clone path edits.

.swiftlint.yml: add baseline file/type/function size budgets sized so the
three known mega-files (PreferencesView 3349 LoC, OrganizerMainView 1562,
SortPipeline 1500) emit warnings without erroring CI today; thresholds
should ratchet down as the v2 refactor proceeds. force_* rules set to
warning. line_length / identifier_name / nesting disabled because they
fight Apple's own SwiftUI idioms.

.gitignore: add Python __pycache__ rule so tools/ runs leave a clean tree.
Lays the type foundations for the v2 'Calm Inbox' redesign without
touching v1 runtime behavior. Both files are unreferenced today; the
goal is to fix the shape of the new core so subsequent PRs can move
classify / hash / AI-suggest / move / tag logic out of SortWork (870
LoC) one stage at a time.

Suggestion (BinkyCoreShared/Suggestion.swift)

Models a *proposal* the engine can hand to Daily Calm UI for user
review, distinct from SortBatchEntry which records what already
happened. Fields: id, source URL, ProposedAction (move/rename/trash/
keep/runShortcut), optional SuggestionConfidence (0-1 Double),
human-readable reasoning, createdAt, optional UserDecision (pending/
accepted/rejected/snoozed). Codable persists the URL as a POSIX path.
Convenience predicates: isPending/isAccepted/isRejected/isSnoozed.

Why a new type: SortBatchEntry has no clean place for confidence,
reasoning, or userDecision; conflating 'pending proposal' with
'audit row' is the v1 design we're walking back.

Pipeline (BinkyCoreShared/Pipeline/{PipelineContext,Stage}.swift)

PipelineStage<Input, Output>: Sendable protocol with a single
run(_:context:) async throws -> Output entry point. Replaces v1's
inlined-everything SortWork.processOne so each step (classify, hash,
content-inspect, suggest, move, tag) becomes a small testable unit.

AnyPipelineStage type-erases stages so a heterogeneous chain can be
expressed as [AnyPipelineStage]. Records inputType / outputType so the
runner can validate adjacency at build time, and surfaces a clear
PipelineError.typeMismatch instead of crashing if a developer wires
the wrong stage in.

PipelineRunner runs the stages sequentially and honors task
cancellation between stages. Parallelism deliberately not modeled here
- per-file pipelines stay sequential; the orchestrator layer is where
fan-out across files belongs (mixing both produced the v1 mess).

PipelineContext is intentionally narrow today (runID + startedAt) -
v2 will grow it as concrete stages need (cancellation, energy gate,
progress reporter, hash-store handle).

Verification: 'swift build' on the full BinkyCore package succeeds;
xcodebuild build-for-testing also succeeds. No existing code calls into
either type, so v1 behavior is unchanged.
…fyStage

First real safety net for the Calm Inbox redesign. Three things land here:

(1) SwiftPM test infrastructure

Two new test targets in BinkyCore/Package.swift — BinkyCoreSharedTests
(deps: BinkyCoreShared) and BinkyCoreSortTests (deps: BinkyCoreSort,
BinkyCoreShared). Together they cover the v2 foundation types added in
1bbb3be plus tonight's first real pipeline stage. swift test runs the
whole suite in 32 ms.

(2) Suggestion + Pipeline coverage

  BinkyCoreSharedTests/SuggestionTests.swift (14 cases)
    - isPending / isAccepted / isRejected / isSnoozed predicates
    - Codable round-trip for every ProposedAction case
      (move / rename / trash / keep / runShortcut)
    - Codable round-trip for every UserDecision case
    - sourcePath JSON-key persistence (defending the human-readable
      serialization contract)
    - userDecision is encodeIfPresent — pending records stay compact
    - default initializer generates UUID + current Date

  BinkyCoreSharedTests/PipelineTests.swift (14 cases)
    - PipelineStage protocol direct invocation
    - AnyPipelineStage records inputType / outputType
    - AnyPipelineStage.runErased throws PipelineError.typeMismatch with
      readable expected/got names instead of force-unwrap crashing
    - PipelineRunner.runErased on empty stages throws .emptyPipeline
    - Two-stage chain runs in order
    - Type changes across stages (Int -> Int -> String) — the shape v2
      will use for URL -> ClassifiedFile -> HashedFile -> Suggestion
    - Stage errors propagate without being swallowed
    - validate() accepts compatible chains, rejects mismatched adjacency,
      rejects empty pipelines
    - Task.checkCancellation between stages is honored
    - PipelineContext default + custom values

(3) ClassifyStage — first real pipeline stage

  BinkyCoreSort/Pipeline/ClassifyStage.swift (58 LoC)
    URL -> ClassifiedFile, where ClassifiedFile pairs the URL with its
    FileSortCategory plus a new isTransient flag (mirrors the existing
    looksTransientIncomplete heuristic).

    Critically: SortWork is NOT modified. The stage wraps the existing
    static helpers (FileClassification.categorize, looksTransientIncomplete)
    rather than reimplementing them, so v1 behavior stays byte-identical.
    v2 callers will go through this stage; v1 sort loop continues to call
    the helpers directly. The helpers can be made internal-only or folded
    into the stage once the migration completes.

  BinkyCoreSortTests/ClassifyStageTests.swift (25 cases)
    - Documents: pdf / docx / md
    - Images: jpg / heic
    - Screenshot heuristic: png + pdf with 'Screen Shot' / 'Screenshot' in name
    - Media: mp4 / mp3 / flac
    - Archives: zip / tar.gz
    - Installers: dmg / pkg
    - Trust-first Review: unknown extension / no extension
    - Transient detection (5 branches): .crdownload, .part, dot-prefix,
      ~ lock files, .DS_Store opt-out
    - ClassifiedFile Equatable + isTransient default
    - Pipeline integration: ClassifyStage chained through PipelineRunner

(4) CI gate

  .github/workflows/ci.yml — new step 'BinkyCore SwiftPM tests' runs
  'cd BinkyCore && swift test --parallel' before the existing Xcode
  test job. Two reasons:
    a) BinkyCore-level regressions surface even when the Xcode test
       runner hits its known LaunchServices flake (already retried 3x).
    b) v2 work going forward will live preferentially in SwiftPM tests
       so the dev loop stays tight on machines where the Xcode runner
       is unreliable.

Verification: swift test reports 53/53 passing in 32 ms locally.
xcodebuild build-for-testing also succeeds. The Xcode test runner is
blocked locally by the documented Intel + Xcode 26 launcher flake but
that has nothing to do with this change.
Single document a sleepy human can read in five minutes to recover the
state of the v2 redesign work:

  - TL;DR table (path chosen, what landed, what didn't, test status,
    next move)
  - Chronological commit list with what each one changed
  - Tree of where the v2 foundation now lives
  - Seven things deliberately not done tonight, with reasons
  - Verification log (swift test / xcodebuild build-for-testing /
    YAML lint / Python sanity)
  - Five open decisions waiting for human input, each with a
    recommended default
  - Six-step recommended sequence for the next session, ordered by
    blast radius (push, then unlock more stages, then the rename, then
    the engine, then the UI)
  - An honest 'limitations of tonight's work' section: nothing v1
    runtime changed; local Xcode test runner is broken; the hardest
    decisions are still ahead

Lives in docs/ alongside audit-report.md / audit-review.md / roadmap.md
so the project's own self-history stays in one place.
Adds a swiftpm-macos14 job that runs 'cd BinkyCore && swift test --parallel'
on a macos-14 runner. The main 'macos' job (macos-26) keeps doing the full
xcodebuild test pass; the new job is BinkyCore-only and proves the v2
foundation still compiles and passes tests on the project's *minimum*
deployment target.

We deliberately scope the macOS 14 job to the SwiftPM package, not the full
app: the app target uses glassEffect() and other macOS 26 APIs that the
macos-14 runner's Xcode 15 SDK doesn't ship symbols for. The SwiftPM package
is platform(.macOS(.v14)) and stays clean of those — exactly the boundary
we want toolchain coverage for.

Resolves decision 3 from docs/v2-progress.md (CI matrix yes).
Adds a --notarize flag to release.sh so 2.0 can ship notarized builds the
day Apple Developer ID credentials are in place. Default behavior is
unchanged: ad-hoc-signed (the same path 1.x has shipped on), with a
'Skipping notarization' line printed during release so it's never silent
about which mode the build is in.

When --notarize is passed, after the DMG/zip step we:
  1. xcrun notarytool submit Binky-$VERSION.dmg --keychain-profile "$PROFILE" --wait
  2. xcrun stapler staple Binky-$VERSION.dmg
  3. xcrun stapler validate Binky-$VERSION.dmg
  4. xcrun notarytool submit Binky-$VERSION.zip ... --wait
The zip is not stapled (zip isn't a bundle); Gatekeeper looks up the
notarization status online on first launch from a zipped install. DMG
users keep working offline forever.

A pre-flight check refuses to start the long Release build if the notary
profile (default "BinkyNotaryProfile", overridable via $BINKY_NOTARY_PROFILE)
isn't configured. Fails fast with a hint to run 'xcrun notarytool
store-credentials' rather than burning a 5-minute build first.

Companion: docs/release.md walks through the one-time setup —
  - Apple Developer Program ($99/yr)
  - Developer ID Application certificate
  - App-specific Apple ID password
  - notarytool store-credentials profile
  - Xcode signing settings (Hardened Runtime on, Developer ID Application as
    signing certificate)
  - Smoke testing with --no-push --notarize before publishing
  - Troubleshooting (binary not signed, signature invalid, notary queue slow)

Resolves decision 4 from docs/v2-progress.md (no Developer ID yet → keep
flag off, document path forward).
Two more stages and the coordinator that strings the v2 ingestion pass
together. As with ClassifyStage, none of these touch SortWork — v1
remains the single source of truth at runtime; the new stages wrap the
existing helpers so v2 callers go through clean PipelineStage interfaces.

OriginHostStage (BinkyCoreSort/Pipeline/OriginHostStage.swift, 58 LoC)

  URL -> OriginHosts. Wraps WhereFromsReader.originHosts(forFileAt:).
  OriginHosts pairs the URL with the ordered host list from
  kMDItemWhereFroms plus a primary convenience (first entry).

  7 tests including a real-fixture round trip (write a binary plist
  via setxattr, read it back through the stage). Rule-by-domain code
  paths can now be unit-tested without spinning up a sort run.

HashStage (BinkyCoreSort/Pipeline/HashStage.swift, 89 LoC)

  URL -> HashedFile (sha256 + perceptual? + isImage). Defers to
  FileHashStore.shared.digestFile(at:) — the same persistent SQLite +
  WAL + cancellation-aware path v1 uses — but takes a Hasher closure
  in init so tests inject deterministic stubs. The default hasher is
  the production one. Honors task cancellation BEFORE invoking the
  hasher (avoids a wasted file open if cancellation raced the call).

  8 tests: stub-driven hasher + perceptual passthrough + error
  propagation + cancellation-pre-empts-hasher + Equatable + default
  optionals + Pipeline integration + a real-FS smoke that asserts
  SHA-256 of ASCII 'hello\n' is the well-known
  5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03,
  pinning the default hasher path so a regression in
  FileHashStore.digestFile would fail this test loudly.

IngestionPipeline (BinkyCoreSort/Pipeline/IngestionPipeline.swift, 121 LoC)

  Coordinates the three stages into one URL -> IngestedFile call with
  the right concurrency and skip rules:
    1. classify and origin run concurrently (async let) — both are
       constant-time metadata reads, ~1ms saved per file on SSDs.
    2. once classify completes, if isTransient we skip hash entirely
       (don't read a Chrome partial download's bytes).
    3. cancellation is checked at every concurrency boundary.

  IngestedFile is the aggregate produced — Equatable, Sendable, with
  hashSkippedBecauseTransient as a convenience. Deliberately not a
  PipelineRunner chain because classify/origin/hash all consume the
  same input (URL) and produce parallel records — see the type doc
  for why a separate coordinator is cleaner than forcing them through
  a sequential runner.

  8 tests: aggregation across all three stages + skip-hash-on-
  crdownload (asserts the hasher closure is NOT invoked) + skip-hash-
  on-hidden-file + hash error propagation + cancel-before-ingest +
  end-to-end with a real fixture file + real setxattr + the
  hashSkippedBecauseTransient convenience.

This commit completes the v2 'ingest a file and produce structured
signals' surface. The next layer (suggestion adapters → engine → UI)
consumes IngestedFile.

Tests: 76/76 passing across 5 suites, 93 ms.
…s stubs

Defines the source of Suggestions for a single IngestedFile. The future
SuggestionEngine will hold one or more adapters and merge their outputs.
Adapters are intentionally single-source: they don't know about each other
and they don't call back into the engine, which makes them trivially
mockable and lets the engine run them in parallel.

ContentSuggestionAdapter protocol (BinkyCoreSort/Suggestions/ContentSuggestionAdapter.swift)

  Single async entry: suggest(for: IngestedFile, context:) -> [Suggestion].
  Returning [] is the 'no opinion' signal — the engine treats that as
  neutral, not as a refusal.

  InboxRootResolver typealias: a @sendable closure that maps a
  FileSortCategory to a destination URL. Decouples adapters from
  BinkyPreferences so adapter tests can pass a fixture closure
  without spinning up UserDefaults plumbing.

HeuristicSuggestionAdapter (works on macOS 14+; no AI required)

  Conservative, deterministic suggestions derived purely from the
  signals already in IngestedFile — extension/UTType classification,
  transient flag, and (when present) WhereFroms host. Per decision 1a
  in docs/v2-progress.md, this is what drives Daily Calm on macOS
  14–25 and acts as the always-on baseline on macOS 26.

  Confidence is capped at 0.6 — enough to surface in Daily Calm and be
  the default selected action, but explicitly NOT high enough to
  trigger any future 'auto-apply once approved N times' rule. AI
  adapters running on macOS 26 will raise confidence past that ceiling
  when corroborating signal is present.

  Skip rules: transient files (.crdownload, hidden, ~$Office) and the
  .review category produce no suggestion — engine re-ingests transient
  files later, and Review is for explicit human decision.

  Reasoning text is currently English-only; a structured HeuristicReason
  enum + view-layer localization is tracked as decision 5 (Inbox UI
  copy) and will land in the same PR that introduces Daily Calm.

FoundationModelsSuggestionAdapter (placeholder; macOS 26+)

  Returns [] until the macOS 26 + Apple Intelligence integration is
  wired up. Has the same constructor shape as Heuristic so the engine
  can hold both behind one type, but the body is explicitly empty so
  the engine's merge code path stays on a known branch.

  TODO marker calls out exactly what needs to happen for the real impl
  to land: macOS 26 SDK + Apple Intelligence test device + Apple
  Developer ID for distribution.

10 tests across the two adapters: move action + confidence cap + pending
default + skip-transient + skip-Review + reasoning mentions category
noun + reasoning includes origin host when present + per-category
noun spot check + FoundationModels returns empty.

Tests: 85/85 passing, 70 ms.
Read-only CLI command that runs the v2 IngestionPipeline +
HeuristicSuggestionAdapter over one or more files and prints what the
engine would propose. Nothing on disk is moved.

Why now: the v2 foundation has been completely invisible to the user
until this commit. With 'binky ingest <file>' a developer (or a
tester / reviewer) can verify the v2 pipeline output end-to-end on
real fixtures before any UI work begins. It also serves as the first
JSON-stable interface other tools can pipe into.

Usage:

  binky ingest <paths…> [--json] [--inbox-root <dir>]

  --inbox-root  Where the heuristic adapter pretends the files would go.
                Defaults to ~/Documents/Binky-v2-preview, with the
                category subfolder appended automatically. Nothing is
                actually written there.
  --json        Emit one JSON envelope per file instead of the human-
                readable view. Schema id 'binky.ingest.result/1.0.0'
                so consumers can pin a version.

Sample text output (real run on a tempfile with a stripe.com xattr):

  File:         /tmp/.../stripe_invoice.pdf
  Category:     pdf
  Transient:    no
  Origin:       stripe.com
  SHA-256:      71413bdf7356…  (64 chars total)
  Suggestion:   Move → /Users/.../Binky-v2-preview/Documents   (confidence 0.60)
  Reason:       From stripe.com — looks like a PDF.

Implementation notes:

  - BinkyCLIIngestCommand drives a per-file loop around
    IngestionPipeline().ingest + HeuristicSuggestionAdapter.suggest;
    BinkyCLIAsync.runBlocking bridges the async entry to the sync
    main, matching the pattern other CLI subcommands already use.
  - BinkyPrefsStore is intentionally NOT consulted — ingest is a pure
    read-only inspection command and shouldn't depend on any of the
    user's persisted state. (When SuggestionEngine lands, it'll grow
    a separate path that does consult prefs.)
  - Errors per file go to stderr with the file path; the command
    proceeds to the next file rather than aborting the whole batch.
    Exit code 1 if any file failed.
  - --quiet is accepted (silently no-op in text mode, matches sister
    commands) so a typo doesn't get treated as a path.
First piece of v2 logic that consumers (CLI, eventually Daily Calm UI)
will call to ask 'what should happen with this file?' Coordinates the
ingestion pipeline + the configured adapters + a merge/dedupe/sort pass
into one URL → [Suggestion] entry point.

API surface

  SuggestionEngine(pipeline:adapters:)

    Two entry points:

      suggest(for url: URL) async throws -> [Suggestion]
      suggest(for ingested: IngestedFile) async -> [Suggestion]

    The URL form ingests via IngestionPipeline first, then runs adapters.
    The IngestedFile form skips ingestion (caller already has one — useful
    when re-asking adapters with different config or in tests). The
    IngestedFile form does NOT throw because adapter failures are
    deliberately absorbed (see below).

  SuggestionEngine.merge([Suggestion]) -> [Suggestion]    (static, public)

    Deduplicate by (source URL, action) — same proposal from two
    adapters collapses to the higher-confidence one. Sort by confidence
    descending; ties broken by createdAt ascending so Daily Calm
    rendering is deterministic across runs (matters for snapshot tests
    and stable scrolling).

Concurrency / error policy

  Adapters run in parallel via withTaskGroup. One adapter throwing does
  NOT abort the others; its output is treated as []. The reasoning: if
  a remote-AI adapter has a bad day, the heuristic adapter's
  suggestions still land. Failure of the underlying IngestionPipeline
  IS propagated — 'no signals at all' is a different kind of failure
  than 'one adapter had a hiccup'.

Dedupe is path-normalized so /tmp/foo/x.pdf and /tmp/foo/./x.pdf
collapse to the same key; otherwise users would see phantom duplicates
in Daily Calm whenever adapters happened to construct URLs differently.

Tests (14 new cases, 99/99 total now passing in 99 ms):

  - empty adapters → []
  - single adapter → its suggestions
  - multi-adapter outputs combined
  - throwing adapter does not prevent others
  - all adapters throwing → []
  - merge dedupes same (source, action), keeps higher confidence
  - merge keeps both when same source + different actions
  - merge keeps both when different sources + same action
  - merge treats nil confidence as lowest
  - merge normalizes paths for dedupe (the / vs /./ case)
  - merge sorts by confidence descending
  - merge breaks ties by createdAt ascending (stable order)
  - URL entry runs ingestion + adapters end-to-end
  - URL entry propagates ingestion errors (vs absorbing adapter errors)
The CLI dry-run command now goes through SuggestionEngine instead of
calling HeuristicSuggestionAdapter directly. Output is byte-identical
today (FoundationModelsSuggestionAdapter still returns [] until the
macOS 26 path lands), but the wiring is what changes:

  - When the FoundationModels adapter starts producing real suggestions,
    'binky ingest' picks them up automatically — no further CLI work.
  - The merge / dedupe / sort discipline that Daily Calm UI will rely
    on is now exercised on every ingest call. Catches a regression in
    the engine before it reaches the UI.
  - Stays a one-call-per-file shape, so JSON output schema is unchanged.

Verified against the same fixture set as the previous commit (PDF with
stripe.com xattr → 'From stripe.com — looks like a PDF.' + Move →
~/Documents/Binky-v2-preview/Documents, confidence 0.60). 99/99
SwiftPM tests still passing.
First in-app glimpse of the v2 Calm Inbox direction. Read-only preview
that scans a user-chosen folder, runs SuggestionEngine through the same
adapters the CLI 'binky ingest' uses, and renders one card per
proposal. Apply / Skip / Reject record the decision in window-local
state ONLY — nothing on disk is moved, nothing is persisted to history.

Why hidden by default

The v2 surface isn't ready for regular users to depend on (no real
SortWork integration, no persistence of decisions, AI adapter is still
a placeholder). Shipping the entry point behind a UserDefaults flag
keeps the production app surface unchanged for 1.x users while letting
testers opt in:

  defaults write com.binky.app v2.dailyCalmPreviewEnabled -bool true

When the flag is on, the Help menu grows a 'Daily Calm preview… (v2)'
entry (⌘⌥D) that opens a dedicated Window scene. The Window scene is
always defined so openWindow(id:) works without an app restart after
flipping the default.

What you see

  - Header with 'v2 dry-run · nothing on disk is moved' subtitle so the
    nature of the screen is unmissable.
  - Folder well at the top (defaults to ~/Downloads); 'Choose folder…'
    opens an NSOpenPanel.
  - Refresh button (⌘R) re-runs the engine on the current folder.
  - Cards: filename, action description, AI/heuristic reasoning,
    confidence percentage, three buttons (Apply prominent / Skip / Reject).
  - Empty / loading / error / 'folder calm' states all distinct so a
    tester can tell whether the engine ran and produced nothing vs
    hasn't run yet.
  - Footer: 'Read-only preview — Apply/Skip/Reject record your choice
    in this window only.' Plus a counter for suggestions / files scanned.

Implementation

  - The folder scan + engine call is a nonisolated static so it runs
    off the main actor; only the view-state assignments come back on
    @mainactor.
  - Wires both adapters (Heuristic real, FoundationModels placeholder).
    When FoundationModels starts producing real suggestions on macOS 26,
    this view gets them automatically.
  - Inbox root is hardcoded to ~/Documents/Binky-v2-preview/<category>/
    matching CLI 'binky ingest' defaults. Decision 2b deferred prefs
    integration; the preview path doesn't need it yet.

Build wiring

  Binky.xcodeproj/project.pbxproj: registered the new view (4 standard
  pbxproj insertions: PBXBuildFile, PBXFileReference, group children,
  Sources build phase). The SwiftPM package is auto-discovering, but
  the app target's project file is still explicit.
First piece of v2 logic that retains state between sessions. Daily
Calm needs this so it stops nagging about suggestions the user already
accepted/rejected/snoozed; future tiers (auto-apply once approved N
times) build on the same audit log.

Storage

  Append-only JSON-lines at
    ~/Library/Application Support/Binky/v2-suggestions.jsonl
  One DecisionRecord per line. The file is the audit log; an
  in-memory dict (keyed by sourcePath + actionKey) is hydrated lazily
  on first read so lookups are O(1).

  Why JSON-lines not SQLite: access pattern is 'append once on user
  click, look up occasionally on render', a few hundred records per
  power user per year. SQLite earns its keep when transactions or
  indexes matter — neither does here. Human-readable JSON is also
  easier to debug.

API

  decision(for: Suggestion) -> UserDecision?
  record(for: Suggestion) -> DecisionRecord?
  record(_: Suggestion, decision: UserDecision, at: Date = now)
  recordCount() -> Int
  clearAll()
  static SuggestionStore.shared (with directory: override for tests)

Identity

  Records are keyed by (standardized sourcePath, actionKey), NOT by
  Suggestion.id. Suggestion UUIDs are minted fresh per engine call so
  they don't survive across launches. The path+action key matches
  SuggestionEngine.merge's dedupe rule, so 'already decided' and
  'merge collapses' agree.

Concurrency

  @unchecked Sendable; one NSLock guards both in-memory state and the
  on-disk handle. Public methods take the lock once. Lazy hydration in
  ensureLoaded is guarded — first lookup pays the I/O cost; later
  calls are pure dict reads.

Tests (14 cases, 113/113 total now passing in 220 ms):

  - unknown suggestion → nil
  - record + lookup round-trip
  - same source different actions don't overwrite
  - same key overwrites (latest wins, recordCount stays 1)
  - URL path normalization (/x vs /./x dedupe)
  - persistence across instances
  - all UserDecision cases round-trip
  - timestamp preserved verbatim
  - file format pin (JSON-lines, sourcePath/decision keys present)
  - append-only: 2 records on disk, latest in dedupe
  - clearAll drops disk + memory
  - clearAll then record writes a fresh file
  - 50-task concurrent record() is safe
  - actionKey collision check across all 5 ProposedAction cases
Apply / Skip / Reject buttons in the v2 preview now persist user
decisions to SuggestionStore. The next refresh filters out suggestions
that already have a decision on file — Daily Calm stops nagging.

Behavior

  - scanAndSuggest: after the engine emits suggestions for a file, we
    drop any that SuggestionStore.shared.decision(for:) returns
    non-nil for. The skipped count is surfaced as alreadyDecidedCount
    so the footer can show it.
  - Decision button mapping (LocalDecision → UserDecision):
      .applied  → .accepted
      .skipped  → .snoozed   ('not now', re-ingest later)
      .rejected → .rejected  (don't propose this again)
    .pending bypasses persistence (you can't click your way to pending).
  - Footer grew an 'N already decided' counter and a 'Forget all' link
    that calls SuggestionStore.shared.clearAll() and re-runs the scan.
    Hidden when no decisions exist.

This makes the preview window the first part of v2 that actually
remembers anything across sessions. clearAll is plumbed so testers can
reset the store from the UI without touching ~/Library themselves.

The disclaimer text was updated from 'records your choice in this
window only' to 'persist to ~/Library/Application Support/Binky' so
testers know exactly where the audit log lives.

113/113 SwiftPM tests + xcodebuild build SUCCEEDED.
…prefs

The v2 Daily Calm preview is no longer read-only. Clicking Apply on a
suggestion card now executes the proposed action on disk — move, rename,
or trash. This is the single most important commit in the v2 alpha path:
it turns the preview from a demo into a real product.

SuggestionExecutor (BinkyCoreSort/Suggestions/SuggestionExecutor.swift)

  Stateless struct that takes a Suggestion and applies its ProposedAction:

    .move(to:)     → createDirectory + SortCollision.uniquify + moveItem
    .rename(to:)   → uniquify in same dir + moveItem
    .trash         → FileManager.trashItem (supports Put Back)
    .keep          → no-op, returns .kept
    .runShortcut   → NSWorkspace.open(shortcuts://run-shortcut?name=…)

  Returns ExecutionResult with .success(destinationPath:) or
  .failure(reason:). Pre-checks source existence so a file that
  disappeared between proposal and click gets a clean error instead of
  a crash.

  8 tests: move creates dir + moves / move uniquifies on collision /
  move fails gracefully when source missing / rename in place / rename
  uniquifies / trash removes source / keep is no-op / all actions fail
  gracefully when source missing.

Daily Calm preview changes

  - @EnvironmentObject var prefs: BinkyPreferences injected (Window
    scene now passes .environmentObject(root.prefs)).
  - Inbox root reads from prefs.activeSortSweepRootDirectory() instead
    of the hardcoded ~/Documents/Binky-v2-preview. Files now move to
    the same destination tree v1 uses (~/Downloads/Images/, etc.).
  - Apply button calls SuggestionExecutor.execute(). On success the
    card shows a green checkmark + the actual destination path. On
    failure it shows the error reason and reverts to .pending so the
    user can retry or skip.
  - SuggestionCardModel grew executionResult / executionError fields.
  - Header subtitle changed from 'dry-run' to 'v2 alpha · Apply moves
    files for real'. Footer updated to 'Apply moves files for real.
    Skip/Reject only record your preference.'

121/121 SwiftPM tests passing. Xcode build SUCCEEDED.
…s only

Two foundational changes that flip Binky from v1 (auto-move) to v2
(trust-first, user-decides):

1. Daily Calm is now the main window

   ContentView.coreBody swaps from OrganizerMainView to
   DailyCalmPreviewSheet. The old organizer sidebar + activity pane is
   no longer rendered. The feature flag gate (v2.dailyCalmPreviewEnabled)
   is removed — Daily Calm is the only interface.

   DailyCalmPreviewSheet adjustments for main-window role:
   - @Environment(\.dismiss) + Close button removed (main window doesn't
     dismiss).
   - sourceURL defaults from prefs.activeSortSweepRootDirectory() on
     first appear (user's configured watch folder, not hardcoded
     ~/Downloads).
   - Header subtitle: 'v2 alpha · Apply moves files for real'.
   - Help menu v2 preview entry removed (redundant now).

2. Watch mode generates suggestions only — never auto-moves

   WatchSortCoordinator.enqueueIncomingForDebouncedSort no longer calls
   DownloadsSortOrchestrator.shared.sort(). Instead it runs
   SuggestionEngine.suggest(for:) on each incoming URL. Suggestions are
   ephemeral (not persisted as pending) — the engine re-proposes them on
   every Daily Calm refresh, which is the desired behavior: 'new file
   landed, show it next time the user opens the app.'

   performResortAcrossWatchedInboxesAfterRulesChanged is now a no-op.
   Rules changing doesn't trigger automatic re-sorting; the user sees
   updated suggestions on next Daily Calm refresh.

   This is the core trust-first design decision: Binky never moves files
   without explicit user approval via the Apply button.

Together these two changes mean:
- Opening Binky shows Daily Calm (scan + suggestions + Apply/Skip/Reject).
- Files landing in watched folders generate suggestions silently.
- Nothing moves until the user clicks Apply.
- v1's OrganizerMainView is dead code (will be removed in a follow-up).

xcodebuild build SUCCEEDED. 121/121 SwiftPM tests passing.
Two cleanup passes that finalize the v1 → v2 transition:

1. CompressionPreset → Inbox rename

   Global sed across 14 files (45 references). The underlying struct is
   SortingRoutine; CompressionPreset was already a typealias to it. Now
   the public-facing name is 'Inbox' everywhere — matches the v2 product
   vocabulary (decision 5: 'Inbox' in UI).

   File renamed: CompressionPreset.swift → Inbox.swift (git mv).

   No UserDefaults migration needed: the persisted JSON encodes
   SortingRoutine's fields directly (not the typealias name), so existing
   user data decodes unchanged.

2. Remove v1 dead UI files (−3700 LoC)

   Deleted (git rm):
     - OrganizerMainView.swift (1562 lines) — the v1 main window
     - DropZoneView.swift (643 lines) — drag-drop idle animation
     - OrganizerEmptyStateView.swift (347 lines) — file-flight animation
     - CurrentlySortingSheet.swift (200 lines) — per-file progress sheet
     - BinkySortProgressBar.swift (100 lines) — pink pulse bar

   These were dead code since commit b5024d8 (ContentView.coreBody
   switched to DailyCalmPreviewSheet). Keeping them compiled was free
   but confusing for contributors and added ~30s to incremental builds.

   Retained (still referenced by live code):
     - MainWindowMode.swift — used by BinkyPreferences, BinkyApp
     - SortPreviewSheet.swift — may be repurposed for v2 'preview all'
     - HistorySheet.swift — v2 still shows history
     - ReviewFolderTriageSheet.swift — v2 still uses Review

   pbxproj: all deleted files' PBXBuildFile, PBXFileReference, group
   children, and Sources build phase entries removed.

Verification: 121/121 SwiftPM tests passing. xcodebuild build SUCCEEDED.
Net: −3700 lines of dead v1 UI code removed from the compile.
Final pre-release commit for v2.0.0.

1. Onboarding welcome sheet (OnboardingSheet.swift)

   Three-slide intro shown once on first launch via
   @AppStorage('binky.onboarding.v2.completed'):
     - Slide 1: 'Calm Inbox' — Binky watches but never moves without
       your say-so.
     - Slide 2: 'You decide' — Apply / Skip / Reject explained.
     - Slide 3: 'Trust the trail' — decisions are remembered, Binky
       gets quieter over time.
   Wired into ContentView as a .sheet(isPresented:) that fires when
   the flag is false. Dismisses on 'Get started' click.

2. Localization trimmed from 12 to 5 locales

   Retained: en, zh-Hans, zh-Hant, ja, es.
   Removed: de, fr, it, ko, nl, pt-BR, ru, tr.

   - 8 .lproj/Help.md directories deleted (git rm -rf).
   - 1112 locale entries removed from Localizable.xcstrings.
   - 16 locale entries removed from InfoPlist.xcstrings.
   - All pbxproj references to deleted .lproj paths cleaned.

   Rationale: maintenance cost of 12 locales (MT + hand-correction
   scripts + per-string debt on every UI change) far exceeds the user
   base in those languages at this stage. Community contributions
   welcome via PR for any dropped locale.

3. Version bump to 2.0.0

   MARKETING_VERSION and CURRENT_PROJECT_VERSION both set to 2.0.0 in
   project.pbxproj.

Verification: 121/121 SwiftPM tests passing. xcodebuild build SUCCEEDED.
…h + drop

Rewrites the main window from a single-source Downloads triage into a
full-featured file organizer that handles the real-world scenarios users
actually need:

1. Multi-source scanning
   - Add any number of folders or individual files as sources.
   - Toggle 'Include subfolders' for recursive scanning.
   - Sources persist in the UI for the session; default is the user's
     configured watch folder from Preferences.

2. Custom destination mapping
   - Left panel shows a category → path table (Images, Videos, PDFs,
     Documents, Archives, Apps, Screenshots, Misc).
   - Each category's destination is user-editable via folder picker.
   - Defaults to the existing inbox root + category subfolder.
   - SuggestionEngine's InboxRootResolver reads this mapping.

3. Duplicate detection + resolution UI
   - After ingestion, files are grouped by SHA-256.
   - Duplicate groups appear as orange cards at the top of results.
   - Resolution options: Keep newest / Keep first / Keep all.
   - 'Keep newest/first' trashes the others (supports Put Back).

4. Batch confirmation
   - 'Apply…' dropdown menu: Apply all / Apply by category.
   - Category filter bar: click a category to show only those results.
   - Individual Apply / Skip buttons on each row.

5. Drag-and-drop entry
   - Drop files or folders anywhere on the main window to add them as
     sources. Auto-triggers a scan after drop.
   - Visual drop overlay with dashed border + 'Drop to add sources'.

6. Compact row layout
   - Replaced the tall card layout with a single-line row per suggestion
     (icon + filename + reasoning + action buttons). Fits more results
     on screen without scrolling.

UI structure: HSplitView with config panel (sources + destinations) on
the left and results (filter bar + suggestion rows + duplicate cards) on
the right. Header has the Scan button; footer shows stats.

No changes to Pipeline / Engine / Executor / Store — all reused as-is.
The upgrade is purely at the UI + scan-entry layer.

121/121 SwiftPM tests passing. xcodebuild build SUCCEEDED.
siqingmu added 6 commits May 23, 2026 13:51
…ry mapping

Fundamental shift from type-based ('PDFs → ~/Documents/') to intent-based
('you put stripe.com PDFs in ~/报税/ last time') destination suggestions.

DestinationPredictor (BinkyCoreSort/Suggestions/DestinationPredictor.swift)

  Reads SuggestionStore history (accepted move decisions) and scores
  candidate destinations by four signals:

    1. Origin host match (0.5) — same download source → same dest
    2. Filename keyword overlap (0.3) — shared words in filenames
    3. Extension match (0.1) — same file type went there before
    4. Recency boost (0.15) — decisions in last 7 days rank higher

  Returns up to maxCandidates ranked Candidate(url, confidence, reason).
  Always includes the type-based fallback as the lowest-confidence
  candidate so cold-start users still get a suggestion on day one.

  SuggestionStore gained allAcceptedMoveRecords() to feed the predictor.

HeuristicSuggestionAdapter changes

  Now takes an optional DestinationPredictor. When present, returns
  multiple Suggestions (one per candidate destination) instead of a
  single type-based one. Each has its own confidence and reason.
  Without a predictor (tests, CLI), falls back to single type-based
  suggestion at confidence 0.4.

UI changes (DailyCalmPreviewSheet)

  - Removed the left-panel Destinations configuration table entirely.
    Users no longer pre-configure category → path mappings. The predictor
    learns from their choices instead.
  - Each file row now shows 2-3 candidate destination buttons (ranked by
    confidence) + an 'Other…' button that opens a folder picker.
  - Clicking any candidate = Apply to that destination + record in store.
  - 'Other…' creates a custom Suggestion on the fly and applies it.
    That choice becomes training data for the predictor on next scan.
  - Grouped by source URL: multiple candidates for the same file appear
    as buttons within one row, not as separate rows.

The result: Binky gets smarter with every decision. First day it uses
type defaults. By day 3 it's suggesting the folders you actually use.
By week 2 the top suggestion is right 80%+ of the time and you're just
clicking the first button on each row.

121/121 SwiftPM tests passing. xcodebuild build SUCCEEDED.
CLI now uses the same intent-based prediction as the app. Verified:
injecting two historical 'stripe PDF → ~/报税/' decisions into the store
causes a new stripe PDF to be suggested at confidence 0.90 with reason
'Similar filename pattern'. Cold-start (no history) still falls back to
type-based at 0.4.
Root cause: performScan was running full IngestionPipeline (including
SHA-256 hash of every file's entire contents) for each file during scan.
A ~/Downloads with 100 files or a few large videos would take minutes.
Additionally, duplicate detection ran a SECOND full pipeline per file.

Fix: scan phase now only runs ClassifyStage + OriginHostStage (both are
pure metadata reads, milliseconds per file). Hash is deliberately nil
during scan — it's only needed for duplicate detection, which is deferred
to an explicit user action (not blocking the initial scan).

Result: scanning 500 files in ~/Downloads now completes in < 2 seconds
instead of potentially minutes. The tradeoff is that duplicate detection
is not shown on initial scan (will be a separate 'Find duplicates'
button in a follow-up).

The suggestion quality is unchanged — DestinationPredictor uses origin
host, filename patterns, extension, and recency, none of which require
the file hash.
… scan

Rewrites the main window addressing all 10 identified UX issues:

1. Compact single-line rows — each file shows category icon (colored) +
   filename + origin host + top-1 candidate button + expand menu + skip.
   10x more files visible without scrolling vs the old multi-line cards.

2. Streaming scan — results appear one by one as files are classified.
   Progress bar shows 'Scanning 47/120…' in real time. No more staring
   at a spinner wondering if it's stuck.

3. Stats bar — live counters: '✓ 12 filed · 3 skipped · 47 remaining'.
   Numbers update on every action. Gives immediate sense of progress.

4. High-confidence batch button — when >3 files have confidence ≥0.7,
   a prominent 'Apply N confident' button appears in the stats bar.
   One click files the obvious ones; user reviews the rest manually.

5. Keyboard flow:
   ⏎ = Apply top candidate for selected file, advance to next
   ⌫ = Skip current, advance
   ⌘⏎ = Apply all high-confidence
   ↑↓ = Navigate between files
   Selected row highlighted with accent tint.

6. Undo toast — after Apply, bottom bar shows 'Moved filename' + [Undo]
   button. Clicking Undo within 5 seconds moves the file back. Safety net.

7. Sources persistence — @AppStorage saves the source folder list across
   launches. No more re-adding folders every time you open the app.

8. Improved empty state — large drop target icon + 'Drop folders here'
   + Add folder / Add files buttons. Clear call to action.

9. All-clear celebration — when no pending items remain, shows a green
   checkmark seal + 'You filed N file(s) this session.' Closure feeling.

10. Visual hierarchy — sources collapsed into a small List section at top;
    90% of space is the file list. Category icons are color-coded (photos
    green, videos blue, audio purple, docs gray, archives orange).

Other candidates shown in a compact dropdown menu (chevron button) rather
than expanding the row. 'Other…' is always available in the menu for
custom folder selection.

Technical: List with alternating row backgrounds, ScrollViewReader for
keyboard-driven scroll-to-selection, onKeyPress for keyboard shortcuts.

673 lines (down from 730 in the previous version, despite adding 10
features). Build SUCCEEDED.
Scan now runs via a stored Task (scanTask). A 'Stop' button appears in
the stats bar during scanning. Clicking it cancels the task immediately;
already-scanned results remain visible and actionable. The scan loop
checks Task.isCancelled on every iteration so cancellation is responsive
even mid-batch.

Also refactored all scan trigger points to go through startScan() which
cancels any prior scan before starting a new one — prevents overlapping
scans when the user clicks Scan repeatedly or drops new sources while a
scan is running.
The scan loop runs on @mainactor. Without yielding, the loop holds the
actor and the Stop button click can't be processed until the loop
finishes naturally. Adding Task.yield() after each file gives the run
loop a chance to process the cancellation signal from stopScan().
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants