diff --git a/.cursorrules b/.cursorrules index 66f74523..9adee0e8 100644 --- a/.cursorrules +++ b/.cursorrules @@ -54,7 +54,7 @@ Keep these four files consistent when architecture or agent guidance changes. - Do not assume behavior of `airlift` / `csv2notion_neo` beyond CLI usage in `DatabaseUploader` (and Dropbox setup in `DropboxSetupModel`). - Do not break Share Destination scripting (`Resources/OSAScriptingDefinition.sdef` + Obj‑C) or Workflow Extension disk/DNC contracts. - The app warns if not run from `/Applications`; do not remove that check (extension opens that path). -- SwiftUI `.alert` / `.confirmationDialog` icons: chain `.appDialogIcon()` after every dialog. `MarkerDataAppIcon` is `@MainActor` (`DialogIcon.swift`). Main-app Dock / About / alerts use Icon Composer `Marker-Data.icon` (`ASSETCATALOG_COMPILER_APPICON_NAME` = Marker-Data). Workflow Extension **header** loads the icon from the containing `Marker Data.app` (walk `appex` → `PlugIns` → `Contents` → `.app`); the appex’s `applicationIconImage` is its own `AppIcon.appiconset` (or Final Cut Pro). Do not flatten the layer PNG into `AppIconSingle`. Do not replace the Workflow Extension bundle/plugin `AppIcon.appiconset`. Uninstaller uses `applicationIconImage`. +- SwiftUI `.alert` / `.confirmationDialog` icons: chain `.appDialogIcon()` after every dialog in new code — but it wraps `.dialogIcon(_:)` and propagates via the environment, so an existing single trailing call already covers a whole chain (`ConfigurationSettingsView`: four dialogs, one call — not a bug). `MarkerDataAppIcon` is `@MainActor` (`DialogIcon.swift`). AppKit `NSAlert` needs `MarkerDataAppIcon.alertImage` — Pagemaker's WebView JavaScript panels are the only `NSAlert`s and must be built by `PagemakerUIDelegate.makeAlert(message:)` (no generic "Alert" / "Confirm" title; `.informational`, message as `messageText`). Main-app Dock / About / alerts use Icon Composer `Marker-Data.icon` (`ASSETCATALOG_COMPILER_APPICON_NAME` = Marker-Data). Workflow Extension **header** loads the icon from the containing `Marker Data.app` (walk `appex` → `PlugIns` → `Contents` → `.app`); the appex’s `applicationIconImage` is its own `AppIcon.appiconset` (or Final Cut Pro). Do not flatten the layer PNG into `AppIconSingle`. Do not replace the Workflow Extension bundle/plugin `AppIcon.appiconset`. Uninstaller uses `applicationIconImage`. - Database models use property name `plaform` — keep consistent unless intentionally migrating. - If adding new on-disk paths the app uses, update `MarkerDataUninstaller` cleanup paths too. - Do not upload Queue jobs using only absolute `ExtractInfo.jsonURL` when a same-named JSON exists in the dropped/scanned folder — use `manifestURL`. @@ -62,6 +62,8 @@ Keep these four files consistent when architecture or agent guidance changes. - `applyTaskAppearance` is `@MainActor` — always `await` it from `ColorPaletteRenderer`. - FCP timeline drops onto the Dock icon are often pasteboard-only; Extract panel drop is the supported timeline path. - Do not use `Color.accentColor` / `.accent` for shared chrome hosted in Final Cut Pro — use `Color.markerAccent`. +- `ColorExtension.swift` overrides `Color ==` with a 0.1-per-channel tolerance that shadows SwiftUI's exact operator app-wide and decides `SettingsStore` equality (so small colour edits do not mark a configuration dirty). It is not transitive — never put `Color` or `SettingsStore` in a `Set`, key a dictionary on them, or dedupe an array. Do not delete the tolerance without also fixing `Color.hex`, which truncates; see **GUARDRAILS.md → Signs**. +- Pagemaker's JavaScript `confirm` panel is unreachable in-app (gated behind `isMacOSSafari()` in bundled `Pagemaker.html`; WKWebView's user agent has no `Safari` token). Keep the delegate correct; do not try to verify it by clicking. - Do not delete a second `DialogIcon.swift in Sources` (or other shared files) from `project.pbxproj` — that is two-target membership, not a duplicate file. - Do not force-unwrap `UTType("com.apple.finalcutpro.xml")` / `.xmld` — resolve with `UTType(identifier) ?? UTType(importedAs:identifier, conformingTo:)` and keep `UTImportedTypeDeclarations` in `Marker-Data-Info.plist`. First Extract layout evaluates these types; FCP may be absent (App Preview). Do not split the Share Destination **Asset Description File** document type. - File menu custom items replace `.newItem` so system Close stays last — do not empty `.newItem` and do not add a second Close. @@ -73,5 +75,5 @@ Keep these four files consistent when architecture or agent guidance changes. - File menu still replaces `.newItem` with app actions; system Close stays last (no second Close; do not empty `.newItem`). - Drop overlays still work on main-app Extract / Roles / Queue **and** Workflow Extension Extract + Roles; queue still finds/uploads `extract_info.json` folders via `manifestURL`. - Skipped / no-media swatch finishes as “Extract done” (not “Analysing swatch done”). -- New alerts **and** confirmation dialogs use `.appDialogIcon()`; Workflow Extension header still shows the main-app Icon Composer icon (not the appex `AppIcon.appiconset`); settings changes include version + migration + wiring as required above. +- New alerts **and** confirmation dialogs use `.appDialogIcon()` (`NSAlert` → `MarkerDataAppIcon.alertImage`); Workflow Extension header still shows the main-app Icon Composer icon (not the appex `AppIcon.appiconset`); settings changes include version + migration + wiring as required above. - Update `AGENT.md` / `ARCHITECTURE.md` / `GUARDRAILS.md` / `.cursorrules` when behavior changes. diff --git a/AGENT.md b/AGENT.md index 76d762d1..73181f74 100644 --- a/AGENT.md +++ b/AGENT.md @@ -72,7 +72,7 @@ For deeper module/data-flow detail, see **`ARCHITECTURE.md`**. For hard always/n | Info.plist | `Source/Marker Data/Marker-Data-Info.plist` — Sparkle, Share Destination document types (**Asset Description File** name is a contract), imported FCPXML UTIs | | Drop overlay (Extract / Roles / Queue + WE) | `Views/Components/DropTargetOverlay.swift` (also Workflow Extension Compile Sources) | | Workflow Extension UI | `Source/Marker Data/Workflow Extension/WorkflowExtensionView.swift` — Extract + Roles tabs; **two** Compile Sources rows (main app + appex — do not delete the main-app membership). `WorkflowExtensionViewController.swift` is appex-only. | -| Color helpers (`markerAccent`, `heroGradient`) | `Utilities/Extensions/ColorExtension.swift` | +| Color helpers + `Color` persistence | `Utilities/Extensions/ColorExtension.swift` — `markerAccent`, `heroGradient`, `init(hex:)` / `hex`, `Codable` conformance (how `fontColor` / `strokeColor` reach `preferences.json`), and a tolerant `static func ==` that shadows SwiftUI’s exact operator (see **GUARDRAILS.md → Signs**) | | Queue UI | `Views/Detail Views/QueueView.swift` | | Queue scan/upload | `Models/Queue/QueueModel.swift` | | Queue row + local-first manifest | `Models/Queue/QueueInstance.swift` (`manifestURL`) | @@ -85,7 +85,7 @@ For deeper module/data-flow detail, see **`ARCHITECTURE.md`**. For hard always/n | Settings migrations | `Source/Marker Data/Marker Data/Models/Settings/SettingsVersioningManager.swift` | | Canonical disk paths | `Source/Marker Data/Marker Data/Utilities/Extensions/URLExtension.swift` | | Notification names | `Source/Marker Data/Marker Data/Utilities/Extensions/NotificationNameExtension.swift` | -| App / dialog icon | `Views/Extensions/DialogIcon.swift` — `@MainActor` `MarkerDataAppIcon` + `.appDialogIcon()`; Icon Composer `Marker-Data.icon` | +| App / dialog icon | `Views/Extensions/DialogIcon.swift` — `@MainActor` `MarkerDataAppIcon` + `.appDialogIcon()` (SwiftUI) / `alertImage` (AppKit `NSAlert`); Icon Composer `Marker-Data.icon` | | About | `Views/Detail Views/AboutView.swift` — `MarkerDataAppIcon.image()` at 200×200 | | Workflow Extension header icon | Same `MarkerDataAppIcon.image()` at 100×100; in the appex, resolve from containing `Marker Data.app` | | Apple Event open | `FCP Share Destination/OpenEventHandler.swift` — `kAEOpen` → `.openFile`; re-register on `.FCPShareStart` (main queue) | @@ -251,8 +251,10 @@ Also: getter for `colorSwatchSettings` **forces `enableSwatch = false` when extr - **Resolution (`displayIcon`):** 1. If running as `.appex`: load from the containing `Marker Data.app` (`appex` → `PlugIns` → `Contents` → `.app`): `Bundle.image(forResource: "Marker-Data")`, else `NSWorkspace.icon(forFile:)`. Do **not** use the appex’s `applicationIconImage` (that is `AppIcon.appiconset`, or Final Cut Pro). 2. Else: `NSImage(named: "Marker-Data")`, then `NSApplication.shared.applicationIconImage`. -- **Surfaces:** About `MarkerDataAppIcon.image()` **200×200**; Workflow Extension header **100×100**; alerts **and** confirmation dialogs `.appDialogIcon()`. +- **Surfaces:** About `MarkerDataAppIcon.image()` **200×200**; Workflow Extension header **100×100**; alerts **and** confirmation dialogs `.appDialogIcon()`; AppKit `NSAlert` `MarkerDataAppIcon.alertImage`. - **Main-app `.alert` / `.confirmationDialog` (chain `.appDialogIcon()`):** `Marker_DataApp` (library folders); `ContentView` (install location); `ExtractView` (extract + upload, ×2); `ConfigurationSettingsView` (unsaved-switch, unsaved-add, and delete confirmations + alert); `DatabaseSettingsView` (remove + duplicate alerts, ×2, plus delete-profile confirmation); `CreateDBProfileSheet`; `DropboxSetupView`; `InstallShareDestinationView`. +- `.appDialogIcon()` wraps SwiftUI `.dialogIcon(_:)` and **propagates through the environment**: one trailing call covers every dialog earlier in the same modifier chain. `ConfigurationSettingsView` deliberately has four dialogs and one call; `DatabaseSettingsView` has three calls (redundant, harmless). Add one per dialog in new code, but do not treat an existing single trailing call as a missing icon. +- **AppKit `NSAlert`:** only Pagemaker’s JavaScript panels. Build them through `PagemakerUIDelegate.makeAlert(message:)` — it applies `MarkerDataAppIcon.alertImage`, forces `.informational`, and puts the message in `messageText` (no generic “Alert” / “Confirm” title). Do not add bare `NSAlert()`s elsewhere; use SwiftUI `.alert` + `.appDialogIcon()`. - Uninstaller is a **separate target**: `UninstallerView` uses `NSApplication.shared.applicationIconImage`, not `MarkerDataAppIcon`. - Do **not** change the Workflow Extension’s bundle/plugin icon (`ASSETCATALOG_COMPILER_APPICON_NAME` = AppIcon, existing `AppIcon.appiconset`). - Workflow Extension Compile Sources must include `DialogIcon.swift` (header UI). Keep Extract/Roles `TabView` `.padding(.bottom, 40)` so `overlayHelpButton` does not sit on the drop zone. @@ -309,7 +311,7 @@ Exact overlay strings and always/never rules: **`GUARDRAILS.md`**. - **Queue relocated folders:** never upload only `ExtractInfo.jsonURL` — use `manifestURL` (see Signs in `GUARDRAILS.md`). - **Progress `reset()` / wrong swatch label:** never `reset()` before swatch; never retitle to “Analysing swatch” until render will run. Skipped swatch must finish as **“Extract done”**, not **“Analysing swatch done”**. `await applyTaskAppearance` from `ColorPaletteRenderer` (MainActor). - **FCP timeline → Dock:** often pasteboard-only; Extract panel drop is the supported UI path for timelines. -- **Alert / confirmation icons:** chain `.appDialogIcon()` after every `.alert` and `.confirmationDialog`. `MarkerDataAppIcon` is `@MainActor`; source is compiled `Marker-Data.icon`. Do not flatten the layer PNG into `AppIconSingle`. In the Workflow Extension header, load from the containing `Marker Data.app` — not the appex `AppIcon.appiconset`. +- **Alert / confirmation icons:** chain `.appDialogIcon()` after every `.alert` and `.confirmationDialog`; AppKit `NSAlert` needs `MarkerDataAppIcon.alertImage` instead (it does not inherit the Icon Composer icon on its own). `MarkerDataAppIcon` is `@MainActor`; source is compiled `Marker-Data.icon`. Do not flatten the layer PNG into `AppIconSingle`. In the Workflow Extension header, load from the containing `Marker Data.app` — not the appex `AppIcon.appiconset`. - **Dual Sparkle controllers:** both `Marker_DataApp` and `ApplicationDelegate` construct `SPUStandardUpdaterController`; update-available UI relies on `ApplicationDelegate.bestValidUpdate(...)` posting `.updateAvailable`. Don’t “simplify” without understanding that path. - **`OpenEventHandler`:** must re-register on `.FCPShareStart`; registration should stay on the main queue (see comments in that file). - **Shared `pbxproj` rows:** do not remove a second `DialogIcon.swift in Sources` (or other shared files) — the file is compiled into two targets. @@ -317,6 +319,8 @@ Exact overlay strings and always/never rules: **`GUARDRAILS.md`**. - **WE cache folder:** the extension does not `mkdir` Movies cache; a first-time drop can fail if the main app has never created `~/Movies/Marker Data Cache/`. - **FCPXML `UTType` force-unwrap:** `UTType("com.apple.finalcutpro.xml")!` traps on first Extract layout when Final Cut Pro is not installed (App Preview / machines without FCP). Resolve with `UTType(identifier) ?? UTType(importedAs:identifier, conformingTo:)` and keep `UTImportedTypeDeclarations` in `Marker-Data-Info.plist`. Do not split the Share Destination **Asset Description File** document type. - **File menu Close last:** custom File items replace `.newItem` (`FileCommands`). Do not empty `.newItem` (that puts system Close first) and do not add a second Close. +- **Pagemaker `confirm` is unreachable in-app:** the only `confirm(` in bundled `Resources/Pagemaker.html` sits behind `isMacOSSafari()`, and WKWebView’s default user agent has no `Safari` token. Keep the delegate correct, but you cannot exercise it from the UI — and `update_pagemaker.yml` can change that HTML upstream. +- **Tolerant `Color ==`:** `ColorExtension.swift` overrides `Color ==` with a 0.1-per-channel tolerance that shadows SwiftUI’s and drives `SettingsStore` equality, so small colour edits do not mark a configuration dirty. It is not transitive — never put `Color` or `SettingsStore` in a `Set`, key a dictionary on them, or dedupe an array. Full reasoning in **GUARDRAILS.md → Signs**. ## Definition of done for most changes - App builds **unsigned** Debug **and** Release for **arm64** (`CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO`; Workflow Extension still embeds). @@ -327,7 +331,7 @@ Exact overlay strings and always/never rules: **`GUARDRAILS.md`**. - Drop overlays still work on main-app Extract / Roles / Queue **and** Workflow Extension Extract + Roles. - Queue scan/upload still works for folders containing `extract_info.json`, including **moved/copied** folders (`manifestURL`). - Skipped / no-media swatch finishes as **“Extract done”** (not **“Analysing swatch done”**). -- Any new `.alert` or `.confirmationDialog` uses `.appDialogIcon()` (`MarkerDataAppIcon` from compiled `Marker-Data.icon`). +- Any new `.alert` or `.confirmationDialog` uses `.appDialogIcon()` (`MarkerDataAppIcon` from compiled `Marker-Data.icon`); any `NSAlert` goes through `PagemakerUIDelegate.makeAlert(message:)` / `MarkerDataAppIcon.alertImage`. - Workflow Extension header still shows the main-app Icon Composer icon (containing `Marker Data.app`); bundle/plugin `AppIcon.appiconset` unchanged. - If settings changed: version bumped + migration case + UI wired + export bridge if needed. - If behavior/architecture changed: update `AGENT.md`, `ARCHITECTURE.md`, `GUARDRAILS.md`, and `.cursorrules`. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 1b6b48c1..f24edb7c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -261,6 +261,7 @@ Migrations operate on `[String: Any]` — not Codable — so renames and nested - **Unique names:** `configurationNameExists` checks in-memory list **and** on-disk file; collisions → `ConfigurationSaveError.nameAlreadyExists`. Illegal names: empty or `"Default"`. - Rename = duplicate-as-new + remove-old (`ConfigurationsViewModel.rename`); same-name rename is a no-op; sheets dismiss only on success. - `unsavedChanges`: Default compares to `defaults()`; named configs compare to on-disk file. +- **Colour caveat:** `SettingsStore` gets synthesized `Equatable`, and its two `Color` properties (`fontColor`, `strokeColor`) are compared by the app’s **own** `Color ==` in `ColorExtension.swift`, which is tolerant (`isEqual(to:tolerance: 0.1)`) and shadows SwiftUI’s exact operator. Colour edits smaller than ~25/255 per channel therefore leave `unsavedChanges` false, disabling ⌘S / ⌘Z, even though the `$store` sink has already written `preferences.json`. See **GUARDRAILS.md → Signs** for the reason (truncating `Color.hex`) and the correct fix. - Optional ⌘1…⌘9 shortcuts via `@UserDefaultsArray("configurationShortcuts")`. #### Bridge to extraction (`markersExtractorSettings`) @@ -354,6 +355,8 @@ Pipeline (`performExtraction`): - If DB profile selected: `DatabaseUploader.uploadToDatabase(jsonManifestPath, profile)` 4. Aggregate `ExportExitStatus` + `ExtractionFailure[]`; notifications; optional Finder/Pagemaker open +Failure recording: `extractAndUpdateProgress` **rethrows** the extractor error rather than appending, so each file contributes at most one `.failedToExtract` entry — appended by the task group `catch`, with the real message. `ExtractError.exportResultisNil` is a safety net that no longer fires. The upload block still runs after a failed extract, so a file can also contribute a `.failedToUpload` entry (`missingJsonFile`); `ExtractionFailure.id` is the URL, so those two rows share an identity in `FailedExtractionsView`. + Cancellation: `cancelAll()` cancels extraction `Task` and terminates upload `Process`es. External file gate: if open/Workflow Extension arrives without a valid export folder, set `externalFileRecieved` / `externalFileURL` and wait for `processExternalFile` after the user picks a destination. @@ -479,6 +482,7 @@ Settings model: `ColorSwatchSettingsModel` (nested under SettingsStore, Codable) - Setting a DB profile forces the MarkersExtractor export format for that platform so a JSON manifest exists - Validation: unique profile names; must not collide with extract-only format display names - Property spelling **`plaform`** is intentional in current code — preserve when editing +- `duplicateProfile(profileName:)` appends `" copy"` and runs `addProfile(saveToDisk: true)` synchronously on the main actor, so `DatabaseValidationError.nameAlreadyExists` reaches `DatabaseSettingsView`’s “Failed to duplicate profile” alert. Duplicating the same profile twice is refused by design (matching `ConfigurationsViewModel.duplicateConfiguration`, which also uses a flat `" copy"`); neither panel auto-increments. | Model | Notable fields | |-------|----------------| @@ -502,10 +506,12 @@ Notable modules under `Views/`: | Components | `DropTargetOverlay` (main app + WE), `FCPXMLDropModifier`, `HelpButton` / `OverlayHelpButton`, shared controls | | Extensions | **`MarkerDataAppIcon` / `.appDialogIcon()`** (`DialogIcon.swift`, `@MainActor`) — About, Workflow Extension header, alerts and confirmation dialogs; see **App icon and dialogs**. Also `ApplyPickerSizing`, `OptionalKeyboardShortcut`. | | Other | `FailedExtractionsView` (truncate + `.help()` tooltips; min ~640×240) | -| Pagemaker | `PagemakerView` WebView + `PagemakerPDFExportHandler` (JS → Swift PDF via `NSSavePanel`) | +| Pagemaker | `PagemakerView` WebView + `PagemakerPDFExportHandler` (JS → Swift PDF via `NSSavePanel`) + `PagemakerUIDelegate` (JavaScript `alert` / `confirm` / folder picker) | Install-location warning: on appear, if not under `/Applications` and `@AppStorage("ignoreInstallLocation")` is false, show alert (with `.appDialogIcon()`). +Pagemaker JavaScript panels are the app’s only `NSAlert`s. `PagemakerUIDelegate.makeAlert(message:)` is the single constructor: it sets `MarkerDataAppIcon.alertImage`, forces `.informational` (`NSAlert` defaults to `.warning`), and uses the JavaScript string as `messageText` — splitting at the first blank line so a trailing paragraph becomes `informativeText`. There is no generic “Alert” / “Confirm” / “Prompt” title. `present(_:)` sheets on `NSApp.keyWindow` and falls back to `runModal()`; `confirm` returns `.alertFirstButtonReturn`, so Cancel reaches JavaScript as `false`. + Drop overlay copy and invariants: **`GUARDRAILS.md`**. ```mermaid @@ -663,7 +669,8 @@ flowchart TB MD["MarkerDataAppIcon.displayIcon"] ABOUT["AboutView 200×200"] WEH["WE header 100×100"] - ALERTS[".appDialogIcon"] + ALERTS["dialogImage
.appDialogIcon (SwiftUI)"] + NSA["alertImage
PagemakerUIDelegate.makeAlert (AppKit)"] DOC --> MD CAT -.->|not artwork source| MD @@ -671,6 +678,9 @@ flowchart TB MD --> ABOUT MD --> WEH MD --> ALERTS + MD --> NSA + ALERTS -->|environment: one call covers a chain| DLG[".alert / .confirmationDialog"] + NSA --> JS["JS alert / confirm / prompt"] ``` **Resolution:** @@ -685,7 +695,10 @@ flowchart TB | Dock | `ASSETCATALOG_COMPILER_APPICON_NAME` = Marker-Data (`Marker-Data.icon`, not inside `Assets.xcassets`) | | About | `MarkerDataAppIcon.image()` — size **200×200** only in `AboutView` | | Workflow Extension header | `MarkerDataAppIcon.image()` — **100×100** in `WorkflowExtensionView` | -| `.alert` / `.confirmationDialog` | `.appDialogIcon()` after every dialog. Call sites: `Marker_DataApp`, `ContentView`, `ExtractView` (×2), `ConfigurationSettingsView` (3 confirmations + alert), `DatabaseSettingsView` (×2 alerts + delete confirmation), `CreateDBProfileSheet`, `DropboxSetupView`, `InstallShareDestinationView`. Uninstaller uses `applicationIconImage`, not this helper. | +| `.alert` / `.confirmationDialog` | `.appDialogIcon()` — wraps SwiftUI `.dialogIcon(_:)`, which propagates through the **environment**, so one call at the end of a modifier chain covers every dialog attached beneath it (see note below). Views with dialogs: `Marker_DataApp`, `ContentView`, `ExtractView` (2 alerts, 2 calls), `ConfigurationSettingsView` (3 confirmations + 1 alert, **1** trailing call), `DatabaseSettingsView` (2 alerts + delete confirmation, 3 calls), `CreateDBProfileSheet`, `DropboxSetupView`, `InstallShareDestinationView`. Uninstaller uses `applicationIconImage`, not this helper. | +| AppKit `NSAlert` | `MarkerDataAppIcon.alertImage` — the AppKit counterpart of `.appDialogIcon()`. Only call site is `PagemakerUIDelegate.makeAlert(message:)`, which serves the three `WKUIDelegate` JavaScript panels (alert / confirm / prompt). The fourth `webView(_:runOpenPanelWith:…)` delegate method is an `NSOpenPanel` folder picker, not an `NSAlert`. | + +**Why the call counts differ.** `.appDialogIcon()` is not per-dialog bookkeeping — it sets an environment value, so a single trailing call covers every `.alert` / `.confirmationDialog` earlier in the same modifier chain. `ConfigurationSettingsView` therefore needs only **one** call for its four dialogs, and `DatabaseSettingsView`’s three calls are redundant but harmless. A view with dialogs and **zero** `.appDialogIcon()` anywhere in its chain is the only broken case. Guidance elsewhere says “chain it after every dialog” because that is the safe habit for new code, not because one-per-dialog is required. `Marker-Data.icon` lives in `Source/Marker Data/Marker Data/` beside the catalog. Workflow Extension `ASSETCATALOG_COMPILER_APPICON_NAME` remains **AppIcon** (existing PNG `AppIcon.appiconset`) until that plugin icon is updated separately. @@ -795,7 +808,7 @@ Source/Marker Data/Marker Data/ Install View/, Objective-C Code/, OpenEventHandler.swift Pagemaker/ # PagemakerView, PDF export handler, UIDelegate, WebViewStateManager Utilities/ - Extensions/ # URL, Color (markerAccent, heroGradient), UTType (`UTType.fcpxml` / `.fcpxmld`, never `!`), NotificationName, … + Extensions/ # URL, Color (markerAccent, heroGradient, hex, Codable, tolerant `==` — see Signs), UTType (`UTType.fcpxml` / `.fcpxmld`, never `!`), NotificationName, … Shell/, Notifications/, Other/ # FCPXMLIntake, TextClippingReader, LibraryFolders, FileWatcher, … Resources/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 94edb7a3..ef0c06d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +### 2.1.2 (26) + +**🎉 Released:** +- 10th September 2026 + +**🐞 Bug Fix:** +- Pagemaker alerts now show the app icon instead of a generic “Alert” title. +- Duplicating a database profile now reports why it failed. +- Failed extractions now appear once in Failed Tasks, with the actual error. + +--- + ### 2.1.1 (25) **🎉 Released:** diff --git a/GUARDRAILS.md b/GUARDRAILS.md index 16977868..af6db90e 100644 --- a/GUARDRAILS.md +++ b/GUARDRAILS.md @@ -19,7 +19,7 @@ Read with **`AGENT.md`** (how to change things), **`ARCHITECTURE.md`** (how it w |------|------| | **Settings** | Bump `SettingsStore.version`, add a dict migration `case`, wire UI, and pass export-related fields through `markersExtractorSettings(fcpxmlFileUrl:)`. The Workflow Extension compiles `SettingsStore` and decodes prefs **without** running migrations — main app must launch after a schema bump. | | **Config names** | Throw `ConfigurationSaveError.nameAlreadyExists` on add / rename / duplicate collisions — never overwrite `{name}.json` silently. | -| **Alerts** | Chain `.appDialogIcon()` after every `.alert` / `.confirmationDialog` (`DialogIcon.swift` → `@MainActor` `MarkerDataAppIcon` from compiled `Marker-Data.icon`). Uninstaller is a separate target and uses `applicationIconImage`. | +| **Alerts** | Chain `.appDialogIcon()` after every `.alert` / `.confirmationDialog` (`DialogIcon.swift` → `@MainActor` `MarkerDataAppIcon` from compiled `Marker-Data.icon`). It is environment-propagating, so an existing single trailing call already covers a whole chain — see **Signs**. AppKit `NSAlert` uses the counterpart `MarkerDataAppIcon.alertImage`; Pagemaker’s JavaScript panels are the only ones, and must be built by `PagemakerUIDelegate.makeAlert(message:)`. Uninstaller is a separate target and uses `applicationIconImage`. | | **Extract intake** | Route Finder files, text clippings, and Final Cut Pro pasteboard drops through `FCPXMLIntake` → `ExtractionModel.receiveFiles` / `receiveItemProviders`. | | **Drop overlays** | Use shared `DropTargetOverlay` on main-app Extract / Roles / Queue **and** Workflow Extension Extract + Roles. Shared files have **two** Compile Sources memberships in `project.pbxproj` (one file, two targets) — never delete one row. Extension membership includes overlay + `ColorExtension` + `DialogIcon.swift` + `HelpButton` / `OverlayHelpButton` plus Roles/settings files listed in **ARCHITECTURE.md**. | | **Shared WE chrome color** | Use `Color.markerAccent` (system indigo) for overlay borders and other shared chrome hosted in FCP — not `Color.accentColor`. | @@ -90,6 +90,12 @@ Shared component: `Views/Components/DropTargetOverlay.swift` (main app + Workflo 15. **Workflow Extension does not migrate settings.** `RolesManager` JSON-decodes `SettingsStore` with no `SettingsVersioningManager`. Open the main app after a schema bump. 16. **FCPXML `UTType` force-unwrap** — `UTType("com.apple.finalcutpro.xml")!` traps on Extract first layout when Final Cut Pro is absent (App Preview / review Macs). Use `importedAs` fallback and Info.plist imported types. Do not copy the old Marker Data force-unwrap. 17. **File menu Close last:** custom File items replace `.newItem`. Emptying `.newItem` makes system Close the first File item; do not duplicate Close. +18. **`Task { try await MainActor.run { … } }` inside a `throws` method swallows the error.** The task result is discarded, so the method always returns normally and the caller’s `catch` is dead. `DatabaseManager` is already `@MainActor` — call directly and let the error propagate. `duplicateProfile` was fixed this way; `setActiveProfile` and `loadProfilesFromDisk` still use the wrapper but throw nothing, so they are harmless. Do not reintroduce the pattern for anything that validates. +19. **Record an extract failure once.** `extractAndUpdateProgress` must rethrow the `MarkersExtractor` error, not append to `failedTasks` — appending there and then tripping the `exportResultisNil` guard produced two entries for one file, the second reading “Failed to get export result”, and duplicate `ExtractionFailure.id` (the URL) in `FailedExtractionsView`. The task group `catch` is the single recording point. +20. **`NSAlert` does not pick up the Icon Composer icon**, and its `alertStyle` defaults to `.warning`. Pagemaker’s WebView JavaScript panels are the app’s only `NSAlert`s — route them through `PagemakerUIDelegate.makeAlert(message:)` (sets `MarkerDataAppIcon.alertImage`, `.informational`, message as `messageText`). Never re-add generic “Alert” / “Confirm” / “Prompt” titles; SwiftUI dialogs elsewhere have no such title. Keep `confirm` returning `.alertFirstButtonReturn` so Cancel reaches JavaScript as `false`. +21. **`Color ==` is tolerant, app-wide, and not transitive.** `ColorExtension.swift` defines `static func == (Color, Color)` delegating to `isEqual(to:tolerance: 0.1)`; Swift prefers the same-module declaration, so this shadows SwiftUI's exact `==` for **every** `Color` comparison compiled into the main app — including synthesized `Equatable` on `SettingsStore` (verified). Consequences: a colour tweak under ~25/255 in all three channels leaves `SettingsContainer.checkForUnsavedChanges()` false, so the Configurations “Changed” badge and ⌘S / ⌘Z stay disabled even though `preferences.json` was already updated; and `0.0 == 0.1`, `0.1 == 0.2`, `0.0 != 0.2` breaks `Equatable` transitivity, so never put `Color` (or `SettingsStore`) in a `Set`, use it as a dictionary key, or dedupe an array of them. The tolerance exists because `Color.hex` **truncates** (`Int(components.red * 255.0)`): 179 of 256 sampled colours drift one step through encode/decode, which under exact equality would mark the store permanently dirty. Fix properly by rounding in `hex` and comparing `lhs.hex == rhs.hex` — measured zero round-trip failures — not by lowering the tolerance alone. Note `hex` also feeds `imageLabelFontColor` / `imageLabelFontStrokeColor`, so the drift reaches rendered labels. +22. **Pagemaker’s `confirm` panel is unreachable in-app — do not “verify” it by clicking.** The only `confirm(` in bundled `Resources/Pagemaker.html` is gated behind `isMacOSSafari()`, and WKWebView’s default user agent carries no `Safari` token, so the branch never runs inside Marker Data. `webView(_:runJavaScriptConfirmPanelWithMessage:…)` must still return `.alertFirstButtonReturn` correctly (Sign 20), but that contract cannot be exercised from the UI — reason about it, don’t test-and-conclude. Two things would make it live: setting `applicationNameForUserAgent` to include `Safari`, or an upstream Pagemaker change, since `update_pagemaker.yml` refreshes that HTML outside this repo’s review. +23. **`.appDialogIcon()` propagates through the environment.** It wraps SwiftUI `.dialogIcon(_:)`, so one trailing call covers every `.alert` / `.confirmationDialog` earlier in the same modifier chain. `ConfigurationSettingsView` has four dialogs and **one** call — that is correct, not a missing icon; `DatabaseSettingsView`’s three calls are redundant but harmless. Only a dialog-bearing chain with **no** call anywhere is broken. Keep adding one per dialog in new code (safe habit), but do not “fix” an existing single trailing call. --- @@ -103,7 +109,7 @@ Shared component: `Views/Components/DropTargetOverlay.swift` (main app + Workflo - [ ] Drop overlays appear on main-app Extract / Roles / Queue **and** Workflow Extension Extract + Roles with the copy above. - [ ] Queue finds `extract_info.json` folders and uploads via `manifestURL` (relocated folders work). - [ ] No-media / skipped swatch finishes as **“Extract done”** (not **“Analysing swatch done”**); `ColorPaletteRenderer.render` → `Bool` drives finish path. -- [ ] New alerts **and** confirmation dialogs use `.appDialogIcon()` (`MarkerDataAppIcon` from compiled `Marker-Data.icon`). +- [ ] New alerts **and** confirmation dialogs use `.appDialogIcon()` (`MarkerDataAppIcon` from compiled `Marker-Data.icon`); any `NSAlert` uses `MarkerDataAppIcon.alertImage` via `PagemakerUIDelegate.makeAlert(message:)`. - [ ] App icon still Icon Composer `Marker-Data.icon` for the **main app** (not inside `Assets.xcassets`; do not flatten layer PNG into `AppIconSingle`). Workflow Extension **header** uses the containing `Marker Data.app` icon; bundle/plugin icon stays `AppIcon.appiconset`. - [ ] Settings changes include version + migration + UI + export bridge when required. - [ ] New on-disk paths are reflected in the Uninstaller. diff --git a/Source/Marker Data/Marker Data/Models/Database/DatabaseManager.swift b/Source/Marker Data/Marker Data/Models/Database/DatabaseManager.swift index 2276f4d1..fc5ef7fe 100644 --- a/Source/Marker Data/Marker Data/Models/Database/DatabaseManager.swift +++ b/Source/Marker Data/Marker Data/Models/Database/DatabaseManager.swift @@ -92,21 +92,17 @@ class DatabaseManager: ObservableObject { } func duplicateProfile(profileName: String) throws { - Task { - try await MainActor.run { - guard let profile = self.profiles.first(where: { $0.name == profileName }) else { - throw DatabaseProfileDuplicationError.noProfileFound - } - - guard let duplicate = profile.copy() else { - throw DatabaseProfileDuplicationError.failedToDeepCopy - } - - duplicate.name += " copy" - - try self.addProfile(duplicate, saveToDisk: true) - } + guard let profile = self.profiles.first(where: { $0.name == profileName }) else { + throw DatabaseProfileDuplicationError.noProfileFound } + + guard let duplicate = profile.copy() else { + throw DatabaseProfileDuplicationError.failedToDeepCopy + } + + duplicate.name += " copy" + + try self.addProfile(duplicate, saveToDisk: true) } /// Saves a profile to disk diff --git a/Source/Marker Data/Marker Data/Models/Errors/DatabaseErrors.swift b/Source/Marker Data/Marker Data/Models/Errors/DatabaseErrors.swift index 829bc234..3453b634 100644 --- a/Source/Marker Data/Marker Data/Models/Errors/DatabaseErrors.swift +++ b/Source/Marker Data/Marker Data/Models/Errors/DatabaseErrors.swift @@ -109,3 +109,14 @@ enum DatabaseProfileDuplicationError: Error { case noProfileFound case failedToDeepCopy } + +extension DatabaseProfileDuplicationError: LocalizedError { + public var errorDescription: String? { + switch self { + case .noProfileFound: + "Could not find the selected profile" + case .failedToDeepCopy: + "Failed to copy the profile" + } + } +} diff --git a/Source/Marker Data/Marker Data/Models/Extract/Extraction Model/ExtractionModel.swift b/Source/Marker Data/Marker Data/Models/Extract/Extraction Model/ExtractionModel.swift index ea1324a1..9ae7b350 100644 --- a/Source/Marker Data/Marker Data/Models/Extract/Extraction Model/ExtractionModel.swift +++ b/Source/Marker Data/Marker Data/Models/Extract/Extraction Model/ExtractionModel.swift @@ -134,17 +134,23 @@ final class ExtractionModel: ObservableObject, Sendable { } var exportResult: ExportResult? = nil + var extractionError: Error? = nil do { // Do extraction exportResult = try await extractor.extract() } catch { Self.logger.error("Failed to extract: \(error.localizedDescription)") - failedTasks.append(ExtractionFailure(url: url, exitStatus: .failedToExtract, errorMessage: error.localizedDescription)) + extractionError = error } observation.invalidate() + // Rethrow so the task group records this failure once, with the real message + if let extractionError { + throw extractionError + } + guard let exportResult = exportResult else { throw ExtractError.exportResultisNil } diff --git a/Source/Marker Data/Marker Data/Pagemaker/PagemakerUIDelegate.swift b/Source/Marker Data/Marker Data/Pagemaker/PagemakerUIDelegate.swift index 1b69e417..1c7814f0 100644 --- a/Source/Marker Data/Marker Data/Pagemaker/PagemakerUIDelegate.swift +++ b/Source/Marker Data/Marker Data/Pagemaker/PagemakerUIDelegate.swift @@ -29,7 +29,11 @@ class PagemakerUIDelegate: NSObject, WKUIDelegate { panel.message = "Select a folder extracted from Marker Data" panel.prompt = "Select Folder" - let response = await panel.beginSheetModal(for: NSApp.keyWindow!) + let response: NSApplication.ModalResponse = if let window = NSApp.keyWindow { + await panel.beginSheetModal(for: window) + } else { + await panel.begin() + } return response == .OK ? panel.urls : nil } @@ -40,13 +44,10 @@ class PagemakerUIDelegate: NSObject, WKUIDelegate { runJavaScriptAlertPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, ) async { - let alert = NSAlert() - alert.messageText = "Alert" - alert.informativeText = message - alert.alertStyle = .informational + let alert = makeAlert(message: message) alert.addButton(withTitle: "OK") - await alert.beginSheetModal(for: NSApp.keyWindow!) + await present(alert) } // Handle JavaScript confirm dialogs @@ -55,15 +56,11 @@ class PagemakerUIDelegate: NSObject, WKUIDelegate { runJavaScriptConfirmPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo ) async -> Bool { - let alert = NSAlert() - alert.messageText = "Confirm" - alert.informativeText = message - alert.alertStyle = .warning + let alert = makeAlert(message: message) alert.addButton(withTitle: "OK") alert.addButton(withTitle: "Cancel") - await alert.beginSheetModal(for: NSApp.keyWindow!) - return true + return await present(alert) == .alertFirstButtonReturn } // Handle JavaScript prompt dialogs @@ -73,10 +70,7 @@ class PagemakerUIDelegate: NSObject, WKUIDelegate { defaultText: String?, initiatedByFrame frame: WKFrameInfo ) async -> String? { - let alert = NSAlert() - alert.messageText = "Prompt" - alert.informativeText = prompt - alert.alertStyle = .informational + let alert = makeAlert(message: prompt) alert.addButton(withTitle: "OK") alert.addButton(withTitle: "Cancel") @@ -86,7 +80,34 @@ class PagemakerUIDelegate: NSObject, WKUIDelegate { alert.accessoryView = input - let response = await alert.beginSheetModal(for: NSApp.keyWindow!) + let response = await present(alert) return response == .alertFirstButtonReturn ? input.stringValue : nil } + + /// Matches the app's SwiftUI dialogs: Icon Composer app icon, the JavaScript + /// message as the title, and any paragraph after a blank line as detail. + private func makeAlert(message: String) -> NSAlert { + let paragraphs = message + .trimmingCharacters(in: .whitespacesAndNewlines) + .components(separatedBy: "\n\n") + + let alert = NSAlert() + alert.icon = MarkerDataAppIcon.alertImage + // NSAlert defaults to .warning; the app's SwiftUI dialogs are informational. + alert.alertStyle = .informational + alert.messageText = paragraphs.first.flatMap { $0.isEmpty ? nil : $0 } ?? Bundle.main.appName + alert.informativeText = paragraphs.dropFirst().joined(separator: "\n\n") + + return alert + } + + /// Falls back to app-modal when no window is key (background app, open menu). + @discardableResult + private func present(_ alert: NSAlert) async -> NSApplication.ModalResponse { + guard let window = NSApp.keyWindow else { + return alert.runModal() + } + + return await alert.beginSheetModal(for: window) + } } diff --git a/Source/Marker Data/Marker Data/Views/Detail Views/Database/DatabaseSettingsView.swift b/Source/Marker Data/Marker Data/Views/Detail Views/Database/DatabaseSettingsView.swift index 376e4f4d..8ef05218 100644 --- a/Source/Marker Data/Marker Data/Views/Detail Views/Database/DatabaseSettingsView.swift +++ b/Source/Marker Data/Marker Data/Views/Detail Views/Database/DatabaseSettingsView.swift @@ -20,6 +20,7 @@ struct DatabaseSettingsView: View { @State var showProfileRemoveAlert = false @State var showDuplicationAlert = false + @State var duplicationErrorMessage = "" @State var showDeleteConfirm = false @@ -46,7 +47,9 @@ struct DatabaseSettingsView: View { } .alert("Failed to remove profile", isPresented: $showProfileRemoveAlert) { } .appDialogIcon() - .alert("Failed to duplicate profile", isPresented: $showDuplicationAlert) { } + .alert("Failed to duplicate profile", isPresented: $showDuplicationAlert) { } message: { + Text(duplicationErrorMessage) + } .appDialogIcon() .confirmationDialog("Delete profile \(selection?.quoted ?? "")? This action cannot be undone.", isPresented: $showDeleteConfirm) { Button("Delete", role: .destructive) { @@ -117,6 +120,7 @@ struct DatabaseSettingsView: View { do { try databaseManager.duplicateProfile(profileName: profileName) } catch { + duplicationErrorMessage = error.localizedDescription showDuplicationAlert = true } } diff --git a/Source/Marker Data/Marker Data/Views/Extensions/DialogIcon.swift b/Source/Marker Data/Marker Data/Views/Extensions/DialogIcon.swift index 9e6e4176..575e3bd7 100644 --- a/Source/Marker Data/Marker Data/Views/Extensions/DialogIcon.swift +++ b/Source/Marker Data/Marker Data/Views/Extensions/DialogIcon.swift @@ -55,6 +55,11 @@ enum MarkerDataAppIcon { Image(nsImage: displayIcon) } + /// AppKit counterpart of `.appDialogIcon()` for `NSAlert` (Pagemaker WebView panels). + static var alertImage: NSImage { + displayIcon + } + /// Unsized icon content. Callers apply layout (About 200×200, Workflow Extension 100×100). static func image() -> some View { Image(nsImage: displayIcon)