From 07e8adc3728feaf4254b161eb00f1d3bdf072fa2 Mon Sep 17 00:00:00 2001 From: showxu <10173746+showxu@users.noreply.github.com> Date: Sun, 4 Oct 2026 19:29:14 +0800 Subject: [PATCH 1/2] Improve native target fidelity and framework validation Preserve Notes and Reminders content and lifecycle data, improve native Maps collections and modern Calendar/Notifications contracts, and correct result fidelity in file, document, mail and system targets. Add multi-input framework normalization and scoped native fixture validation with matching architecture, user guidance and skill updates. Keep Maps new-place saving explicitly awaiting native command validation. This checkpoint preserves remaining app alignment work for refactoring. Validation: 1111 default Swift Testing tests in 74 suites; 30 release-tool tests; public content/link checks and diff whitespace checks passed. Native opt-in tests were not enabled for this checkpoint. --- .gitignore | 2 + .../Architecture/Calendar/Architecture.md | 40 +- .../Architecture/Calendar/CapabilityList.md | 11 +- Documentation/Architecture/CapabilityList.md | 10 +- Documentation/Architecture/CliContract.md | 8 +- .../Architecture/Clipboard/Architecture.md | 58 +- .../Architecture/Clipboard/CapabilityList.md | 23 +- .../Architecture/Intelligence/Architecture.md | 30 +- .../Intelligence/CapabilityList.md | 2 +- .../Architecture/Keynote/Architecture.md | 46 +- .../Architecture/Keynote/CapabilityList.md | 23 +- .../Architecture/Mail/Architecture.md | 4 + .../Architecture/Maps/Architecture.md | 118 +- .../Architecture/Maps/CapabilityList.md | 58 +- .../Architecture/Notes/Architecture.md | 58 +- .../Notifications/Architecture.md | 55 +- .../Notifications/CapabilityList.md | 15 +- .../Architecture/Numbers/Architecture.md | 9 + .../Architecture/Numbers/CapabilityList.md | 2 +- .../Architecture/PermissionAndWording.md | 5 +- .../Architecture/Reminders/Architecture.md | 53 +- .../Architecture/Reminders/CapabilityList.md | 10 +- .../Architecture/RepositoryIdentity.md | 25 +- .../Architecture/TCC/CapabilityList.md | 2 +- Documentation/Architecture/TargetFirstCli.md | 13 +- .../TargetImplementationMechanisms.md | 6 +- .../Reference/Calendar/DeveloperGuide.md | 45 + Documentation/Reference/Calendar/UserGuide.md | 76 + .../Reference/Clipboard/DeveloperGuide.md | 59 +- .../Reference/Clipboard/UserGuide.md | 98 +- .../Reference/FrameworkGeneration.md | 115 ++ .../FrameworkInputManifest.schema.json | 49 + .../Reference/Intelligence/DeveloperGuide.md | 15 + .../Reference/Intelligence/UserGuide.md | 20 +- .../Reference/Keynote/DeveloperGuide.md | 22 +- Documentation/Reference/Keynote/UserGuide.md | 46 +- Documentation/Reference/Mail/UserGuide.md | 8 + .../Reference/Maps/DeveloperGuide.md | 74 + Documentation/Reference/Maps/UserGuide.md | 158 +- .../NativeFixtureManifest.schema.json | 56 + .../Reference/NativeFixtureValidation.md | 128 ++ .../Reference/Notes/DeveloperGuide.md | 63 +- Documentation/Reference/Notes/UserGuide.md | 50 +- .../Reference/Notifications/DeveloperGuide.md | 35 +- .../Reference/Notifications/UserGuide.md | 69 +- Documentation/Reference/Numbers/UserGuide.md | 13 +- Documentation/Reference/README.md | 2 + Documentation/Reference/ReleaseGuide.md | 12 + .../Reference/Reminders/DeveloperGuide.md | 87 ++ .../Reference/Reminders/UserGuide.md | 66 +- Documentation/Reference/TCC/UserGuide.md | 5 + Package.swift | 13 + README.md | 5 +- Scripts/framework-normalize-full-dump | 1197 ++++++++++++++++ ...otes-private-framework-normalize-full-dump | 745 +--------- Scripts/package-release | 9 +- Scripts/reminderkit-normalize-full-dump | 565 +------- Scripts/verify-release | 1 + Sources/AppleCLI/Info.plist | 18 + Sources/AppleMCPAdapter/AppleMCPAdapter.swift | 41 +- Sources/CalendarCLI/Backends.swift | 16 +- .../CalendarCollectionCommands.swift | 92 ++ Sources/CalendarCLI/CalendarCollections.swift | 135 ++ Sources/CalendarCLI/Command.swift | 161 ++- Sources/CalendarCLI/Commands.swift | 54 +- Sources/CalendarCLI/Models.swift | 143 +- Sources/CalendarCLI/Protocols.swift | 8 +- Sources/CalendarCLI/Recurrence.swift | 277 ++++ Sources/CalendarCLI/Support.swift | 384 ++--- Sources/ClipboardCLI/Backends.swift | 293 +++- Sources/ClipboardCLI/Command.swift | 204 +-- Sources/ClipboardCLI/Commands.swift | 203 ++- Sources/ClipboardCLI/Models.swift | 74 +- Sources/ClipboardCLI/Protocols.swift | 20 +- Sources/ClipboardCLI/Support.swift | 202 ++- Sources/FinderCLI/Backends.swift | 11 +- Sources/IntelligenceCLI/Backends.swift | 336 +++-- Sources/IntelligenceCLI/CacheWrite.swift | 40 + Sources/IntelligenceCLI/CountryCache.swift | 304 ++++ Sources/IntelligenceCLI/Wording.swift | 18 + Sources/KeynoteCLI/Backends.swift | 155 +- Sources/KeynoteCLI/Command.swift | 57 +- Sources/KeynoteCLI/Commands.swift | 27 +- Sources/KeynoteCLI/Models.swift | 117 +- Sources/KeynoteCLI/NativeContent.swift | 247 ++++ Sources/KeynoteCLI/Protocols.swift | 1 + Sources/KeynoteCLI/Support.swift | 128 +- Sources/MailCLI/Backends.swift | 83 +- Sources/MailCLI/Command.swift | 7 + Sources/MailCLI/Models.swift | 3 + Sources/MailCLI/Support.swift | 15 +- Sources/MapsCLI/Backends.swift | 223 ++- Sources/MapsCLI/CollectionCommands.swift | 147 ++ Sources/MapsCLI/CollectionMutations.swift | 164 +++ Sources/MapsCLI/Command.swift | 101 +- Sources/MapsCLI/Commands.swift | 351 ++++- Sources/MapsCLI/Models.swift | 160 ++- Sources/MapsCLI/NativeCallback.swift | 69 + Sources/MapsCLI/NativeCollectionChanges.swift | 103 ++ Sources/MapsCLI/NativeProjection.swift | 137 ++ Sources/MapsCLI/NativeSavedPlace.swift | 183 +++ Sources/MapsCLI/NativeStore.swift | 318 ++++ Sources/MapsCLI/Protocols.swift | 39 +- Sources/MapsCLI/SavedCollectionChanges.swift | 313 ++++ Sources/MapsCLI/SavedPlaceCreation.swift | 115 ++ Sources/MapsCLI/SavedPlaceModels.swift | 104 ++ Sources/MapsCLI/SavedPlaces.swift | 140 ++ Sources/MapsCLI/Support.swift | 182 ++- .../Commands/NotesCommandAccounts.swift | 2 +- .../NotesCommandAttachmentAudio.swift | 2 +- .../NotesCommandAttachmentSearch.swift | 4 +- .../Commands/NotesCommandAttachments.swift | 6 +- .../Commands/NotesCommandBodyFormatting.swift | 94 +- .../Commands/NotesCommandBodyMath.swift | 8 +- .../Commands/NotesCommandBodyTables.swift | 17 +- .../Commands/NotesCommandCollaboration.swift | 4 +- .../Commands/NotesCommandFolders.swift | 2 +- .../Commands/NotesCommandImportExport.swift | 6 +- .../Commands/NotesCommandLifecycle.swift | 16 +- .../NotesCLI/Commands/NotesCommandLinks.swift | 4 +- .../Commands/NotesCommandReadSearch.swift | 4 +- .../Commands/NotesCommandSecurity.swift | 2 +- .../Commands/NotesCommandSettings.swift | 2 +- .../NotesCommandSmartFolderReasoning.swift | 72 +- .../Commands/NotesCommandSmartFolders.swift | 6 +- .../NotesCLI/Commands/NotesCommandState.swift | 8 +- .../Commands/NotesCommandSupport.swift | 4 +- .../NotesCLI/Commands/NotesCommandTags.swift | 2 +- .../Commands/NotesCommandWorkflowAudits.swift | 6 +- .../NotesManagedAccessorProbe.swift | 14 + .../Diagnostics/NotesRuntimeMethod.swift | 74 + Sources/NotesCLI/Models/Models.swift | 200 +-- .../NotesCLI/Operations/NotesBodyEdit.swift | 32 + .../Operations/NotesImplementation.swift | 6 + .../Operations/NotesInlineCoverage.swift | 144 ++ .../Operations/NotesInlineParagraphs.swift | 65 + .../Operations/NotesManagedObjectLookup.swift | 16 + .../Operations/NotesMutationVerifier.swift | 407 +++--- .../Operations/NotesNativeContext.swift | 71 + .../Operations/NotesNativeInlineStyle.swift | 85 ++ .../Operations/NotesParagraphStructure.swift | 73 + .../Operations/NotesPlainTextBody.swift | 12 + Sources/NotesCLI/Operations/NotesReader.swift | 435 +++--- .../Operations/NotesStyledParagraphs.swift | 30 + .../NotesCLI/Operations/NotesTitleEdit.swift | 56 + Sources/NotesCLI/Operations/NotesWriter.swift | 971 +++++-------- Sources/NotesCLI/Operations/Protocols.swift | 9 + .../NotesCLI/SQLiteReader/SQLiteReader.swift | 4 +- Sources/NotificationsCLI/Backends.swift | 274 +++- Sources/NotificationsCLI/Command.swift | 108 +- Sources/NotificationsCLI/Commands.swift | 176 ++- Sources/NotificationsCLI/Models.swift | 97 +- Sources/NotificationsCLI/Protocols.swift | 11 +- Sources/NotificationsCLI/Support.swift | 52 +- Sources/NumbersCLI/Backends.swift | 185 ++- Sources/NumbersCLI/CellValue.swift | 91 ++ Sources/NumbersCLI/Command.swift | 50 +- Sources/NumbersCLI/Models.swift | 25 +- Sources/RemindersCLI/Commands/Command.swift | 2 + Sources/RemindersCLI/Commands/Commands.swift | 4 +- .../RemindersCommand+ListDispatch.swift | 8 +- .../RemindersCommand+NotesDispatch.swift | 65 + ...emindersCommand+RichMetadataDispatch.swift | 4 +- .../RemindersCommand+SmartListIdentity.swift | 29 +- .../RemindersCommand+TemplateDispatch.swift | 23 + .../RemindersTarget+CommandTree.swift | 28 +- .../Commands/RemindersTarget+Notes.swift | 66 + .../RemindersTarget+TargetOptions.swift | 15 + .../Models/ReminderListModels.swift | 8 + .../Models/ReminderNotesModels.swift | 49 + .../Models/ReminderStoreDebugModels.swift | 3 + .../ReminderKit/ReminderKitCoreHelpers.swift | 83 +- .../ReminderKit/ReminderKitNativeStore.swift | 39 + .../ReminderKitRuntimeMethod.swift | 74 + .../ReminderKit/ReminderNotesFormatting.swift | 201 +++ .../ReminderSmartListFilterEncoder.swift | 15 +- .../ReminderKit/ReminderSmartListQuery.swift | 163 +++ .../ReminderSmartListWriter+ChangeItems.swift | 14 + .../ReminderSmartListWriter+Matching.swift | 54 - .../ReminderSmartListWriter+Preflight.swift | 16 - .../ReminderSmartListWriter+Resolution.swift | 66 +- .../ReminderSmartListWriter+Store.swift | 33 +- .../ReminderKit/ReminderSmartListWriter.swift | 37 +- .../RemindersCommand+Assignments.swift | 6 +- .../RemindersCommand+Attachments.swift | 6 +- .../RemindersCommand+ListGroups.swift | 6 +- .../RemindersCommand+ListMetadata.swift | 8 +- .../RemindersCommand+MessagingPerson.swift | 6 +- .../ReminderKit/RemindersCommand+Notes.swift | 68 + .../RemindersCommand+ReminderKitCore.swift | 25 +- .../RemindersCommand+ReminderReads.swift | 44 - .../ReminderKit/RemindersCommand+Repeat.swift | 6 +- .../RemindersCommand+RichMetadata.swift | 6 +- .../RemindersCommand+Sections.swift | 12 +- ...RemindersCommand+SmartListOperations.swift | 169 +-- .../RemindersCommand+SmartListReads.swift | 154 ++ .../RemindersCommand+SubtaskOperations.swift | 117 +- .../RemindersCommand+Subtasks.swift | 41 +- .../ReminderKit/RemindersCommand+Tags.swift | 10 +- .../RemindersCommand+TemplateContents.swift | 78 +- ...ndersCommand+TemplateItemRichContent.swift | 33 +- .../ReminderKit/RemindersCommand+Urgent.swift | 6 +- .../RemindersCommand+VisibleURL.swift | 6 +- .../RemindersSQLiteReader+Debug.swift | 1 + .../RemindersSQLiteReader+Enrichment.swift | 32 +- .../RemindersSQLiteReader+ListState.swift | 51 +- ...emindersSQLiteReader+ReminderQueries.swift | 54 + .../RemindersSQLiteReader+SubtaskState.swift | 7 +- Sources/TCCCLI/Framework.swift | 6 +- Sources/TCCCLI/Models.swift | 16 +- Sources/Utility/CLIPermissionWording.swift | 3 - .../AppleCLITests/AppleMCPAdapterTests.swift | 432 +++--- Tests/AppleCLITests/CLIExecutableTests.swift | 59 +- .../AppleCLITests/EventKitCommandTests.swift | 259 +++- Tests/AppleCLITests/FinderCommandTests.swift | 37 + .../FrameworkNormalizationTests.swift | 513 +++++++ .../IWorkDocumentCommandTests.swift | 77 +- .../IntelligenceCommandTests.swift | 58 +- .../IntelligenceCountryArchiveFixture.swift | 103 ++ ...ntelligenceCountryCacheReadonlyTests.swift | 25 + .../IntelligenceCountryCacheTests.swift | 321 +++++ Tests/AppleCLITests/KeynoteContentTests.swift | 359 +++++ Tests/AppleCLITests/MailBackendTests.swift | 93 ++ Tests/AppleCLITests/MapsCommandTests.swift | 576 +++++++- .../AppleCLITests/NativeFixtureSupport.swift | 340 +++++ .../NativeFixtureSupportTests.swift | 370 +++++ .../NativeMutationFixtureTests.swift | 408 ++++++ .../NativeNotesBodyEvidence.swift | 42 + .../NativeNotesBodyEvidenceTests.swift | 74 + .../NativeRuntimeMethodTests.swift | 148 ++ Tests/AppleCLITests/NotesBodyEditTests.swift | 50 + .../NotesBodyFormatReadbackTests.swift | 292 ++++ Tests/AppleCLITests/NotesCommandTests.swift | 1274 ++++++++++++----- .../NotesInlineParagraphTests.swift | 97 ++ .../NotesLifecycleReadbackTests.swift | 110 ++ .../NotesManagedAccessorProbeTests.swift | 69 + .../NotesNativeInlineStyleTests.swift | 54 + .../NotesParagraphStructureTests.swift | 100 ++ Tests/AppleCLITests/NotesReaderTests.swift | 56 + .../NotesStyledParagraphTests.swift | 131 ++ Tests/AppleCLITests/NotesTitleEditTests.swift | 95 ++ .../AppleCLITests/NumbersCellValueTests.swift | 138 ++ Tests/AppleCLITests/NumbersCommandTests.swift | 122 +- .../ReminderKitReadonlyTests.swift | 28 + .../AppleCLITests/RemindersCommandTests.swift | 398 +++++ .../SystemDomainCommandTests.swift | 617 +++++++- Tests/AppleCLITests/TCCCommandTests.swift | 19 +- Tests/ReleaseTools/test_archive_documents.py | 39 +- skills/reminder-creator/SKILL.md | 12 +- 249 files changed, 20552 insertions(+), 4878 deletions(-) create mode 100644 Documentation/Reference/FrameworkGeneration.md create mode 100644 Documentation/Reference/FrameworkInputManifest.schema.json create mode 100644 Documentation/Reference/NativeFixtureManifest.schema.json create mode 100644 Documentation/Reference/NativeFixtureValidation.md create mode 100755 Scripts/framework-normalize-full-dump create mode 100644 Sources/AppleCLI/Info.plist create mode 100644 Sources/CalendarCLI/CalendarCollectionCommands.swift create mode 100644 Sources/CalendarCLI/CalendarCollections.swift create mode 100644 Sources/CalendarCLI/Recurrence.swift create mode 100644 Sources/IntelligenceCLI/CacheWrite.swift create mode 100644 Sources/IntelligenceCLI/CountryCache.swift create mode 100644 Sources/KeynoteCLI/NativeContent.swift create mode 100644 Sources/MapsCLI/CollectionCommands.swift create mode 100644 Sources/MapsCLI/CollectionMutations.swift create mode 100644 Sources/MapsCLI/NativeCallback.swift create mode 100644 Sources/MapsCLI/NativeCollectionChanges.swift create mode 100644 Sources/MapsCLI/NativeProjection.swift create mode 100644 Sources/MapsCLI/NativeSavedPlace.swift create mode 100644 Sources/MapsCLI/NativeStore.swift create mode 100644 Sources/MapsCLI/SavedCollectionChanges.swift create mode 100644 Sources/MapsCLI/SavedPlaceCreation.swift create mode 100644 Sources/MapsCLI/SavedPlaceModels.swift create mode 100644 Sources/MapsCLI/SavedPlaces.swift create mode 100644 Sources/NotesCLI/Diagnostics/NotesRuntimeMethod.swift create mode 100644 Sources/NotesCLI/Operations/NotesBodyEdit.swift create mode 100644 Sources/NotesCLI/Operations/NotesInlineCoverage.swift create mode 100644 Sources/NotesCLI/Operations/NotesInlineParagraphs.swift create mode 100644 Sources/NotesCLI/Operations/NotesManagedObjectLookup.swift create mode 100644 Sources/NotesCLI/Operations/NotesNativeContext.swift create mode 100644 Sources/NotesCLI/Operations/NotesNativeInlineStyle.swift create mode 100644 Sources/NotesCLI/Operations/NotesParagraphStructure.swift create mode 100644 Sources/NotesCLI/Operations/NotesPlainTextBody.swift create mode 100644 Sources/NotesCLI/Operations/NotesStyledParagraphs.swift create mode 100644 Sources/NotesCLI/Operations/NotesTitleEdit.swift create mode 100644 Sources/NumbersCLI/CellValue.swift create mode 100644 Sources/RemindersCLI/Commands/RemindersCommand+NotesDispatch.swift create mode 100644 Sources/RemindersCLI/Commands/RemindersTarget+Notes.swift create mode 100644 Sources/RemindersCLI/Models/ReminderNotesModels.swift create mode 100644 Sources/RemindersCLI/ReminderKit/ReminderKitNativeStore.swift create mode 100644 Sources/RemindersCLI/ReminderKit/ReminderKitRuntimeMethod.swift create mode 100644 Sources/RemindersCLI/ReminderKit/ReminderNotesFormatting.swift create mode 100644 Sources/RemindersCLI/ReminderKit/ReminderSmartListQuery.swift create mode 100644 Sources/RemindersCLI/ReminderKit/RemindersCommand+Notes.swift create mode 100644 Sources/RemindersCLI/ReminderKit/RemindersCommand+SmartListReads.swift create mode 100644 Tests/AppleCLITests/FrameworkNormalizationTests.swift create mode 100644 Tests/AppleCLITests/IntelligenceCountryArchiveFixture.swift create mode 100644 Tests/AppleCLITests/IntelligenceCountryCacheReadonlyTests.swift create mode 100644 Tests/AppleCLITests/IntelligenceCountryCacheTests.swift create mode 100644 Tests/AppleCLITests/KeynoteContentTests.swift create mode 100644 Tests/AppleCLITests/MailBackendTests.swift create mode 100644 Tests/AppleCLITests/NativeFixtureSupport.swift create mode 100644 Tests/AppleCLITests/NativeFixtureSupportTests.swift create mode 100644 Tests/AppleCLITests/NativeMutationFixtureTests.swift create mode 100644 Tests/AppleCLITests/NativeNotesBodyEvidence.swift create mode 100644 Tests/AppleCLITests/NativeNotesBodyEvidenceTests.swift create mode 100644 Tests/AppleCLITests/NativeRuntimeMethodTests.swift create mode 100644 Tests/AppleCLITests/NotesBodyEditTests.swift create mode 100644 Tests/AppleCLITests/NotesBodyFormatReadbackTests.swift create mode 100644 Tests/AppleCLITests/NotesInlineParagraphTests.swift create mode 100644 Tests/AppleCLITests/NotesLifecycleReadbackTests.swift create mode 100644 Tests/AppleCLITests/NotesNativeInlineStyleTests.swift create mode 100644 Tests/AppleCLITests/NotesParagraphStructureTests.swift create mode 100644 Tests/AppleCLITests/NotesStyledParagraphTests.swift create mode 100644 Tests/AppleCLITests/NotesTitleEditTests.swift create mode 100644 Tests/AppleCLITests/NumbersCellValueTests.swift create mode 100644 Tests/AppleCLITests/ReminderKitReadonlyTests.swift diff --git a/.gitignore b/.gitignore index 22a6ffe..4471aae 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ .DS_Store /.agent/ /.build +__pycache__/ +*.pyc /tmp/ /Packages xcuserdata/ diff --git a/Documentation/Architecture/Calendar/Architecture.md b/Documentation/Architecture/Calendar/Architecture.md index 461e763..48dcb33 100644 --- a/Documentation/Architecture/Calendar/Architecture.md +++ b/Documentation/Architecture/Calendar/Architecture.md @@ -15,11 +15,45 @@ broad private Calendar implementation mechanism. EventKit is the source of authority for accepted calendar and event identity, authorization, recurrence, alarms, attendee metadata, and mutations. +Read and mutation workflows require full Calendar access. Mutations resolve +calendars and current events before writing; write-only access cannot supply +those identities. `apple` embeds its Calendar access purpose descriptions from +`Sources/AppleCLI/Info.plist` through the package's executable linker settings. +`calendar doctor` checks this metadata without requesting authorization. + ## Implementation Mechanisms -Calendar uses EventKit for calendars, event list/search/read, occurrences, -availability, statistics, iCalendar export planning, and safety-gated -event create/update/delete. +Calendar uses EventKit for source/account reads, calendar list/read and +create/update/delete, event list/search/read, occurrences, availability, +statistics, iCalendar export planning, and event create/update/delete. + +Calendar collection mutations use explicit source or calendar IDs. Source +identity is fixed when a calendar is created. Calendar updates modify only +requested title/color fields; immutable calendar attributes are separate from +permission to modify its events. Update/delete re-resolve and compare the +calendar record before writing. Successful saves require matching fields from +a fresh EventKit store; deletion requires absence there. Provider save failures +retain their diagnostics and require checking current state before retrying. + +Event summaries and details expose all EventKit recurrence rules in +`recurrenceRules`; `recurrence` contains the first rule. Each rule retains +weekday ordinals, month/day/week selectors, set positions, recurrence-calendar +identity and the native first weekday. A zero first weekday means unspecified. +Custom recurrence input replaces the event's rules; other field updates leave +them intact. Invalid frequency/selector combinations are rejected before native +construction. The full EventKit initializer must retain the requested conditions +in its getters before the rule is attached to an event. + +iCalendar export accepts non-recurring events. Bounded EventKit queries return +expanded occurrences without complete series masters and exception data. +Recurring or detached rows therefore return an unsupported result before an +artifact is written. Series export requires complete original occurrence, +exception, UID and time-zone data. + +All-day export uses the observed event time zone, or the current macOS time +zone when EventKit supplies none. Its date-only end remains exclusive across +daylight-saving changes. Summaries/details retain the observed time-zone +identifier; timed export currently represents UTC instants. ## Validation diff --git a/Documentation/Architecture/Calendar/CapabilityList.md b/Documentation/Architecture/Calendar/CapabilityList.md index fa8918d..f2c9745 100644 --- a/Documentation/Architecture/Calendar/CapabilityList.md +++ b/Documentation/Architecture/Calendar/CapabilityList.md @@ -9,13 +9,16 @@ contract. | Capability | CLI surface | Implementation mechanism | Gate / verifier / gap | | --- | --- | --- | --- | -| Calendar reads | `calendars list` | EventKit | Read-only; authorization failures are explicit. | -| Event reads | `events list/search/read/occurrences/stats`, `availability check` | EventKit | Date ranges and limits bound output. | -| Event export | `events export` | EventKit plus file output | Supports `--dry-run`; execution requires `--allow-artifact-action`. | -| Event mutation | `events create/update/delete` | EventKit | Supports `--dry-run`; execution uses current event identity and attendee metadata. | +| Source/account reads | `sources list/read` | EventKit | Native source IDs, type, delegation state and event-calendar membership; `read` requires an ID. | +| Calendar reads | `calendars list/read` | EventKit | Full Calendar access; optional source-ID filtering, explicit-limit truncation and native source/type/attribute permissions/color metadata. | +| Calendar mutation | `calendars create/update/delete` | EventKit | Creation requires source ID; update/delete require calendar ID. Preview before writing; immutable attribute changes/deletion rejected; unchanged requests skip saves. Saves and deletion use fresh-store verification. Updates support title/color and preserve other projected fields. Provider restrictions remain explicit. | +| Event reads | `events list/search/read/occurrences/stats`, `availability check` | EventKit | Date ranges and limits bound output; invalid or noncanonical date-only values are rejected. Summaries/details retain all recurrence rules and their custom conditions. | +| Event export | `events export` | EventKit plus file output | Non-recurring events; supports `--dry-run` and requires `--allow-artifact-action` for execution. Recurring/detached rows require complete series and exception data and currently return unsupported before writing. | +| Event mutation | `events create/update/delete` | EventKit | Supports `--dry-run`; full Calendar access is required to resolve calendar and event identity. Custom weekday/month/week/year-day/set-position conditions are validated and checked against native rule getters; provider persistence requires controlled native validation. | ## Rejected / Gated - Occurrence-scoped mutation. +- Complete recurring-series iCalendar export. - Attendee invite administration beyond accepted metadata behavior. - Broad calendar-server administration. diff --git a/Documentation/Architecture/CapabilityList.md b/Documentation/Architecture/CapabilityList.md index ade7e86..eac174f 100644 --- a/Documentation/Architecture/CapabilityList.md +++ b/Documentation/Architecture/CapabilityList.md @@ -26,22 +26,22 @@ Keep detailed target design out of this file. Target-specific truth belongs in | Target | Accepted capability summary | Detailed capability list | | --- | --- | --- | | `notes` | Notes accounts, folders, Smart Folder metadata/criteria/matching-note reads plus single-tag create/update/rename/delete, tag listing/search/membership/rename/delete, attachment metadata/add/remove/export, link metadata/backlinks/resolution, web/app/file URL link add/update/remove, note-to-note link add/update/remove, paragraph note-link add/update/remove, visible-note PDF/Markdown/HTML/RTF/RTFD export, body structure, checklist add/set/set-all/convert/convert-range/reorder/indent/delete, single ordinary list item reorder/indent/delete, note state, list/search/read, safety-gated text note mutations, private-framework readiness checks, and capability diagnostics for rich Notes workflows. | [Notes](Notes/CapabilityList.md) | -| `calendar` | EventKit calendars, event reads/searches/occurrences/stats/availability/export, and safety-gated event create/update/delete. | [Calendar](Calendar/CapabilityList.md) | +| `calendar` | EventKit source/account and calendar reads, calendar lifecycle, event reads/searches/occurrences/stats/availability/export, and event create/update/delete. | [Calendar](Calendar/CapabilityList.md) | | `reminders` | ReminderKit reads/writes, rich Reminders metadata, Smart Lists, list organization, and read-only SQLite verifier/doctor evidence. | [Reminders](Reminders/CapabilityList.md) | | `contacts` | Contacts search/read/duplicates/groups, vCard import/export, contact mutations, and group membership changes. | [Contacts](Contacts/CapabilityList.md) | | `mail` | Mail accounts/mailboxes/message reads, bounded body preview/search, draft/reply/forward/send, and mailbox mutations. | [Mail](Mail/CapabilityList.md) | | `messages` | Local Messages conversation/message reads and safety-gated sends. | [Messages](Messages/CapabilityList.md) | -| `maps` | Place search/read, directions preview, and safety-gated Maps open actions. | [Maps](Maps/CapabilityList.md) | +| `maps` | Native address/POI search, place detail, saved favorite reads, collection lifecycle and existing member links, route calculation, ETA, directions links and safety-gated Maps open. | [Maps](Maps/CapabilityList.md) | | `finder` | Path-bounded file metadata, open/reveal, move/trash/delete, tags, and bounded file text writes. | [Finder](Finder/CapabilityList.md) | | `numbers` | `.numbers` document metadata, sheet/table reads, table export, single-cell text write, open, and QuickLook/package export. | [Numbers](Numbers/CapabilityList.md) | | `pages` | `.pages` document metadata, open, and QuickLook/package export. | [Pages](Pages/CapabilityList.md) | -| `keynote` | `.key` presentation metadata, slide listing/export, open, and QuickLook/package export. | [Keynote](Keynote/CapabilityList.md) | +| `keynote` | `.key` file/package metadata, native slide reads/PDF export, cached previews, open and package copy. | [Keynote](Keynote/CapabilityList.md) | | `facetime` | Contact resolution, call preparation, and safety-gated FaceTime call start. | [FaceTime](FaceTime/CapabilityList.md) | | `safari` | Safari windows/tabs/pages/profile reads, selected browser actions, Reading List, gated page/extension actions, and Tab Group diagnostics. | [Safari](Safari/CapabilityList.md) | | `photos` | Photos library/media/album/folder reads, import/export/report, metadata workflows, slideshow/actions, spotlight, and gated hooks. | [Photos](Photos/CapabilityList.md) | | `print` | Printer/job inspection plus safety-gated print submission and cancellation. | [Print](Print/CapabilityList.md) | -| `clipboard` | Pasteboard type/read plus safety-gated write/clear. | [Clipboard](Clipboard/CapabilityList.md) | -| `notifications` | Notification preview and safety-gated send for notifications created by this tool. | [Notifications](Notifications/CapabilityList.md) | +| `clipboard` | Bounded text and ordered typed items, verified conditional replacement, current-device writes and clear. | [Clipboard](Clipboard/CapabilityList.md) | +| `notifications` | Preview, per-app settings, explicit authorization, callback-confirmed submission and scoped pending/delivered queries and removal. | [Notifications](Notifications/CapabilityList.md) | | `intelligence` | Apple Intelligence local-cache support/doctor/verify and risk-flag-gated enablement/recovery/service workflows. | [Intelligence](Intelligence/CapabilityList.md) | | `tcc` | TCC service/identity/database diagnostics, access preflight/request, reset, and gated private diagnostics. | [TCC](TCC/CapabilityList.md) | diff --git a/Documentation/Architecture/CliContract.md b/Documentation/Architecture/CliContract.md index a5a827c..2e54891 100644 --- a/Documentation/Architecture/CliContract.md +++ b/Documentation/Architecture/CliContract.md @@ -92,15 +92,17 @@ Secondary resources remain explicit, such as `apple calendar events list`, forward-draft/send/move/archive/delete. - `messages`: conversations/messages search/read, iMessage send, existing-chat conversation send, and explicit-recipient send-many. -- `maps`: place search/read, coordinate-aware directions, and open-in-Maps. +- `maps`: place search/read, saved favorite reads, collection lifecycle and existing + member links, route calculation and ETA, + coordinate-aware directions links, and open-in-Maps. - `finder`: file listing, reveal/open, metadata/tag/search, move/trash/delete, create-only text write, and single-file overwrite. - `numbers`: document/sheet/table read, table CSV/TSV export, single-cell table text write, document open, and QuickLook PDF/thumbnail/package export. - `pages`: document read/open/export, including QuickLook PDF/thumbnail/package export. -- `keynote`: presentation/slide read, slide image export, open, and QuickLook - PDF/thumbnail/package export. +- `keynote`: file/package metadata, native slide reads and PDF export, cached + preview reads/exports, open, and package copy. - `facetime`: contact/call preparation and gated call initiation. - `safari`: windows/tabs/current/read, profile, snapshot window, and Tab Group snapshot reads including snapshot window mappings, bounded page text/source reads, state-action diff --git a/Documentation/Architecture/Clipboard/Architecture.md b/Documentation/Architecture/Clipboard/Architecture.md index 4a4d3a2..05276a9 100644 --- a/Documentation/Architecture/Clipboard/Architecture.md +++ b/Documentation/Architecture/Clipboard/Architecture.md @@ -1,26 +1,64 @@ # Clipboard Architecture -`clipboard` owns local pasteboard workflows under `apple clipboard`. +`clipboard` owns the current macOS pasteboard under `apple clipboard`. ## Capability Maturity Current level: `L0 Public Framework / SDEF / AppleScript` -Rationale: Clipboard currently uses NSPasteboard, a public system pasteboard -mechanism, for accepted read, write, and clear behavior. It does not currently -rely on a private pasteboard implementation mechanism. +Clipboard uses the public NSPasteboard and NSPasteboardItem APIs. Typed item +reads and replacement preserve item order, declared format order and raw bytes. +The target's capability list states the supported surface. ## Source Authority -NSPasteboard defines the accepted pasteboard type, read, write, and clear -behavior. +[NSPasteboard](https://developer.apple.com/documentation/appkit/nspasteboard) +and [NSPasteboardItem](https://developer.apple.com/documentation/appkit/nspasteboarditem) +define the pasteboard's ownership, representations and native conversions. ## Implementation Mechanisms -Clipboard uses NSPasteboard. It is a narrow system-domain target, not a broad -clipboard history or cross-device clipboard automation surface. +The backend uses the general pasteboard for CLI commands. It retains a +pasteboard name rather than sharing native item objects between operations. +Native items used for writes are fresh and unbound. + +`types` returns metadata and an ownership counter. Legacy `read` retains +NSPasteboard's text selection and multi-item text joining behavior. `items read` +returns individual items, their native format order and base64 data. Unavailable +promised data stays unavailable; a missing representation is not empty data. + +Byte caps bound accepted and returned raw representation data after AppKit +fetches it. AppKit obtains each complete representation from its provider; +these caps do not bound that provider's allocation or response time. + +Replacement validates the entire payload before clearing the pasteboard. +Formats advertised by NSFilePromiseReceiver are rejected: their data contains +transfer metadata that requires a live provider to create the promised files. +Raw reads can inspect those bytes but do not accept or fulfill a file promise. +Write results require successful native writes, the requested item order, +format order and bytes, and unchanged ownership during readback. AppKit may +add compatibility formats. Complete identical payloads skip writing; RTF's +additional native plain-text formats permit a skip only when their decoded +text agrees with the RTF. Other extra formats require replacement. + +Writes support `--current-host-only` through NSPasteboard's +`prepareForNewContents(with: .currentHostOnly)`. Contents options have no public +getter, so an explicit restriction always obtains new ownership, including +when the data is unchanged. Byte equality alone cannot verify that option. + +The ownership counter and `--if-change-count` guard observable ownership +changes. NSPasteboard has no atomic compare-and-swap operation; an owner can +also supply promised data without changing that counter. These checks do not +provide an atomic content snapshot or reserve the pasteboard. + +An unverified replacement reports an error with possible mutation, and does +not restore an old snapshot over a newer owner. Empty clear requests preserve +the counter and return `changed: false`. Programmatic reads follow the app's +macOS pasteboard access setting. ## Validation -Use command help and system-domain command tests. Detailed command status lives -in `CapabilityList.md`. +The SystemDomainCommandTests suite owns Clipboard contract regressions and +the opt-in native workflow. It uses a unique pasteboard, verifies native rich +text/image/URL consumers and checks empty cleanup before releasing it. +Detailed operating instructions live in the Developer Guide. diff --git a/Documentation/Architecture/Clipboard/CapabilityList.md b/Documentation/Architecture/Clipboard/CapabilityList.md index edaa47a..5644291 100644 --- a/Documentation/Architecture/Clipboard/CapabilityList.md +++ b/Documentation/Architecture/Clipboard/CapabilityList.md @@ -2,16 +2,33 @@ ## Source Authority -Clipboard capabilities are accepted from NSPasteboard behavior. +Clipboard capabilities follow public NSPasteboard and NSPasteboardItem behavior. ## Supported Capabilities | Capability | CLI surface | Implementation mechanism | Gate / verifier / gap | | --- | --- | --- | --- | -| Type/read | `types`, `read` | NSPasteboard | Sensitive bounded read. | -| Mutation | `write`, `clear` | NSPasteboard | Supports `--dry-run`; execution requires `--allow-persistent-action`. | +| Metadata | `types` | NSPasteboard | Type union and `changeCount`; no content output. | +| Text | `read [--type] [--max-bytes]` | NSPasteboard | Explicit sensitive read; UTF-8 byte cap; rejects changed ownership or unavailable declared text. | +| Typed items | `items read [--type] [--limit] [--max-bytes]` | NSPasteboardItem | Ordered items and representations, base64 bytes, unavailable data, total count and explicit filtered/truncated state. | +| Text replacement | `write --text` | Fresh NSPasteboardItem | Explicit empty text supported; size preflight, unchanged-state check and native readback. | +| Typed replacement | `items write --input` | Fresh NSPasteboardItems | Complete JSON payload/envelope; preserves declared order and bytes, reports native failures. Truncated, filtered, unavailable or file-promise snapshots cannot be replayed. | +| Device scope | `--current-host-only` on writes | NSPasteboard.ContentsOptions | Applies the current-device restriction when claiming new contents; renews ownership even for identical data. | +| Clear | `clear` | NSPasteboard | Empty no-op and native empty readback. | +| Conditional replacement | `--if-change-count` on writes/clear | Ownership counter | Refuses stale preconditions; does not reserve the pasteboard or provide atomic compare-and-swap. | +| Diagnostics | `doctor` | Metadata and macOS access behavior | Does not request content; exposes configured programmatic-read denial when available. | + +All mutations support `--dry-run`; execution requires +`--allow-persistent-action`. Raw data defaults to 1 MiB with a 64 MiB ceiling. +Item reads default to 50 with a ceiling of 500. Oversized content produces an +error, not a partial successful representation. ## Rejected / Gated - Clipboard history. - Cross-device clipboard automation. +- Live file-promise transfer and drag-session ownership. + +Consumers retain their own permission requirements for referenced file URLs. +Unique-pasteboard native validation does not prove general-pasteboard privacy +access or compatibility on other macOS versions. diff --git a/Documentation/Architecture/Intelligence/Architecture.md b/Documentation/Architecture/Intelligence/Architecture.md index 43948fb..720e3c0 100644 --- a/Documentation/Architecture/Intelligence/Architecture.md +++ b/Documentation/Architecture/Intelligence/Architecture.md @@ -35,6 +35,10 @@ framework as the broad production implementation mechanism. Foundation plist reads/writes, backup state, bounded subprocess calls, LaunchDaemon plist installation, local preflight checks, and rollback confinement. +- `Sources/IntelligenceCLI/CountryCache.swift` owns bounded archive graph validation, + selective country changes, and structural preservation verification. +- `Sources/IntelligenceCLI/CacheWrite.swift` owns prepared cache snapshots and + write/readback checks. - `Sources/IntelligenceCLI/Models.swift` owns typed target models: patch scopes, cache files, domains, mechanisms, state manifests, verification results, and `typed operation result`. @@ -87,7 +91,7 @@ implemented mechanisms are: - comprehensive patch: extends answer patching with known GREYMATTER/CALCIUM status input values; - country cache rewrite: optionally rewrites `countryd` alpha-2 country-code - strings; + fields on the active combined estimate; - recompute and service persistence: refresh `eligibilityd` through bounded Swift-owned command paths. @@ -126,9 +130,27 @@ Feature version landmarks and product boundaries: - Answer and comprehensive patch scopes are typed `IntelligencePatchRule` values written with `PropertyListSerialization`. - Country cache rewriting uses `--eligibility-country`, validates a - two-letter country code, rewrites all uppercase alpha-2 country-code string - values in the `countryd` plist, reports `countryd` as the touched subsystem, - and emits an iPhone Mirroring pairing warning. + two-letter country code, and follows `$top.root` to the active + `CombinedEstimate`. Supported input is an `NSKeyedArchiver` archive with + archive version `100000`, `RDCachedData` version `5`, an array of `RDEstimate` + records, and native UID references. Missing, empty, cyclic, dangling, or + unsupported structures are refused before backup, unlocking, or cache writes. + Copies of changed estimates, their collection, and the root are appended; + only `$top.root` switches to the copied branch. Existing objects, historical + estimates, local observations, timestamps, priorities, unrelated values, and + other top-level references retain their content and references. A matching + active country preserves the country archive bytes. The result reports + `countryd` as the touched subsystem and an iPhone Mirroring pairing warning. +- Enablement prepares every requested cache change before writing, backs up the + captured bytes, checks for changes since preflight, and reads back both each + write and the completed group. Country readback compares the entire archive + graph as well as the active country. On failure, automatic recovery restores + only files whose current bytes still match this operation's write. Concurrent + replacement is retained and reported as unconfirmed with a backup for manual + recovery. Separate cache files do not form an atomic system transaction; + daemons can replace them again after verification. +- Manual rollback validates all backup digests before unlocking or restoring, + and confirms restored bytes by readback after atomic replacement. - Reset uses known cache paths and optional `launchctl kickstart`, not broad deletion. - Recompute uses a fixed `lldb` command sequence only with explicit debug-attach diff --git a/Documentation/Architecture/Intelligence/CapabilityList.md b/Documentation/Architecture/Intelligence/CapabilityList.md index 787fa60..caf146e 100644 --- a/Documentation/Architecture/Intelligence/CapabilityList.md +++ b/Documentation/Architecture/Intelligence/CapabilityList.md @@ -11,7 +11,7 @@ capability truth for `apple intelligence`. | Capability | CLI surface | Implementation mechanism | Gate / verifier / gap | | --- | --- | --- | --- | | Support and diagnosis | `support`, `doctor`, `verify` | Foundation plist reads and bounded host fact checks | Read-only diagnostics. | -| Enablement | `enable` | Foundation plist writes to local eligibility cache | Requires explicit `--allow-system-cache-write`; not `DryRun` payload-based. | +| Enablement | `enable` | Foundation plist writes to local eligibility cache | Requires explicit `--allow-system-cache-write`; all inputs prepared before writes, stale snapshot checks and group readback. Optional country changes require a supported archive and preserve historical/shared objects. Cache verification does not prove service readiness. | | Cache and recovery | `reset-cache`, `rollback`, `unlock` | Bounded file/cache/service operations | Uses target-specific risk flags. | | Recompute and service | `recompute`, `service install/uninstall` | Bounded subprocess and LaunchDaemon workflows | Requires explicit debug/service risk flags. | diff --git a/Documentation/Architecture/Keynote/Architecture.md b/Documentation/Architecture/Keynote/Architecture.md index e493d25..cc29470 100644 --- a/Documentation/Architecture/Keynote/Architecture.md +++ b/Documentation/Architecture/Keynote/Architecture.md @@ -1,27 +1,53 @@ # Keynote Architecture -`keynote` owns path-bounded `.key` presentation workflows under +`keynote` owns path-bounded `.key` file and package workflows under `apple keynote`. ## Capability Maturity Current level: `L0 Public Framework / SDEF / AppleScript` -Rationale: Keynote currently uses package/FileManager metadata, QuickLook -slide and export mechanisms, and presentation open actions. It does not -currently rely on a private Keynote implementation mechanism. +The target uses FileManager metadata, Keynote scripting, QuickLook cache +inspection, and NSWorkspace open actions. It has no private framework path. ## Source Authority -The `.key` package shape, QuickLook slide output, and accepted presentation -open/export behavior define the current target contract. +Keynote owns slide content, ordering, skipped state, presenter notes and PDF +rendering. Filesystem metadata describes the saved presentation. QuickLook +artifacts describe cached previews. ## Implementation Mechanisms -Keynote uses package/FileManager metadata, QuickLook-backed slide listing and -export, presentation open actions, and QuickLook PDF/thumbnail/package export. +Native slide reads use the document ID and ordered slide collection exposed by +Keynote's scripting dictionary. Reads return default title/body text and plain +presenter notes, including skipped slides. `totalSlideCount` and `truncated` +describe the native collection and requested limit. Missing or invalid native +content produces an error. + +Keynote exposes no persistent slide ID through this mechanism. Each response +has a new `snapshotID`; slide IDs have `identityKind: snapshot_position` and +identify positions only within that response. They cannot identify a slide +across edits, reordering or later reads. + +Operations distinguish `live_document` from `opened_file`. Already-open +documents are borrowed without saving or closing. Only documents opened and +identified as belonging to the operation are closed, with saving disabled. + +PDF export invokes Keynote with individual slides, skipped slides included, +and build stages disabled. The artifact must be a readable, unencrypted PDF +with one page per native slide. It is published without replacing an existing +destination. `verification` covers that artifact validation, rather than rich +content equivalence. Native failures and timeouts do not fall back to caches. + +`previews list/export`, `preview-pdf` and `thumbnail` inspect or copy available +QuickLook cache artifacts. Preview order and IDs describe cache entries. +`package` copies a package presentation. These paths do not require Automation. ## Validation -Use document path validation and iWork command tests. Detailed command status -lives in `CapabilityList.md`. +Swift Testing covers typed native responses, cache independence, selection +limits, snapshot identity, native failures, PDF validation and destination +preservation. An opt-in dictionary test compiles scripts against installed +Keynote. Native workflow validation requires controlled presentations and +Automation permission; default tests and compilation do not prove rendering, +rich formatting preservation or multi-version behavior. diff --git a/Documentation/Architecture/Keynote/CapabilityList.md b/Documentation/Architecture/Keynote/CapabilityList.md index 4e689d1..017e72f 100644 --- a/Documentation/Architecture/Keynote/CapabilityList.md +++ b/Documentation/Architecture/Keynote/CapabilityList.md @@ -2,19 +2,24 @@ ## Source Authority -Keynote capabilities are accepted from `.key` package metadata and QuickLook -behavior. +Keynote's scripting dictionary owns native slide and PDF behavior. File and +package metadata and QuickLook cache inspection have separate authority. ## Supported Capabilities | Capability | CLI surface | Implementation mechanism | Gate / verifier / gap | | --- | --- | --- | --- | -| Presentation reads | `presentations list/search/read` | File/package metadata | Path-bounded read. | -| Slide reads/export | `slides list/export` | QuickLook-backed slide output | Read-only for listing; export supports `--dry-run` and execution requires `--allow-artifact-action`. | -| Presentation open/export | `presentations open/export` | Presentation open action and QuickLook/package export | Supports `--dry-run`; open execution requires `--allow-external-dispatch` and export execution requires `--allow-artifact-action`. | +| Presentation metadata | `presentations list/search/read` | FileManager, file and package `.key` paths | Path-bounded; metadata does not extract slide content. | +| Slide reads | `slides list` | Keynote scripting | Automation required; ordered default title/body, skipped state and plain presenter notes; bounded response with snapshot-position IDs. Native workflow coverage requires controlled fixtures. | +| Cached image previews | `previews list/export` | QuickLook cache entries | No relationship to native slide count or order; export requires `--allow-artifact-action`. | +| Native PDF export | `presentations export --format pdf` | Keynote export | `--allow-artifact-action`; readable PDF and page-count validation before publication; native rendering coverage requires controlled fixtures. | +| Cached PDF/thumbnail copy | `presentations export --format preview-pdf/thumbnail` | QuickLook cache | Explicit cache source; cache may be absent or stale; `--allow-artifact-action`. | +| Package copy | `presentations export --format package` | FileManager package copy | Package source and fresh destination required; `--allow-artifact-action`. | +| Presentation open | `presentations open` | NSWorkspace | `--allow-external-dispatch`; reports submitted, rather than app readiness. | -## Rejected / Gated +## Gated Capabilities -- Rich app-native extraction. -- Presentation writes. -- Deeper Keynote automation until separately proven. +- Rich text, arbitrary slide objects, tables, media and layout extraction. +- Native image, PowerPoint and other export formats. +- Presentation and slide writes. +- Native workflow and version coverage beyond validated fixtures. diff --git a/Documentation/Architecture/Mail/Architecture.md b/Documentation/Architecture/Mail/Architecture.md index eb43018..afefc50 100644 --- a/Documentation/Architecture/Mail/Architecture.md +++ b/Documentation/Architecture/Mail/Architecture.md @@ -21,6 +21,10 @@ actions. Mail uses target-local structured Mail.app scripting. Persistent `.emlx` or full-text indexing is not part of the accepted implementation mechanism. +Send success reports Mail's accepted submission through `sent.submitted`. +Rejected or unconfirmed submissions return a structured error; Mail owns +delivery tracking. Body preview limits apply to UTF-8 bytes. + ## Validation Use `apple mail doctor --json`, bounded message commands, and Mail command diff --git a/Documentation/Architecture/Maps/Architecture.md b/Documentation/Architecture/Maps/Architecture.md index 84a6630..88e245f 100644 --- a/Documentation/Architecture/Maps/Architecture.md +++ b/Documentation/Architecture/Maps/Architecture.md @@ -1,28 +1,120 @@ # Maps Architecture -`maps` owns place lookup, directions preview, and Maps open actions under -`apple maps`. +`maps` owns place search and detail lookup, saved favorite reads, collection +reads and changes, collection membership, route and ETA requests, directions link +previews, and Maps open actions under `apple maps`. ## Capability Maturity -Current level: `L0 Public Framework / SDEF / AppleScript` +Current level: `L1 Private Framework Increment` -Rationale: Maps currently uses CoreLocation-style lookup behavior and -validated `maps:` URL external actions. It does not currently rely on a private -Maps implementation mechanism. +Rationale: MapsSync provides verified native favorite, collection and member +reads, collection creation/update/deletion and links between collections and +existing saved members. Public mechanisms provide place and routing services, +reverse geocoding and validated Maps URL actions. New saved-place creation has +an implemented command path awaiting native end-to-end validation. Favorite +changes and history remain incomplete; private mechanisms do not own +most Maps capabilities. ## Source Authority -CoreLocation-backed lookup behavior and validated Maps URLs define the accepted -CLI contract. +The typed MapsCLI command tree owns selectors, limits, transport modes, result +projection and external-action policy. Native service responses own place +details, route metrics, geometry, instructions and ETA. The current user's native +Maps store owns saved identities, names, visibility, positions, dates and +collection membership. ## Implementation Mechanisms -Maps uses CoreLocation-style place/direction resolution and validated `maps:` -URL external actions. Structured Maps.app scripting is not a current -implementation mechanism. +`MKLocalSearch` searches addresses and points of interest. An explicit region +biases search; it is not a radius filter. Native place identifiers are exposed +when the service supplies them on macOS 15 or newer; `MKMapItemRequest` reads +those identifiers. Coordinate snapshots have derived IDs and cannot be fetched +by ID. Coordinate lookup uses `CLGeocoder` reverse geocoding. + +`MKDirections` calculates driving, walking and cycling routes, or ETA for those +modes and transit. Both endpoints must be explicit. Query endpoints must +resolve to exactly one place; ambiguous results require the caller to select +coordinates. The returned overall mode must match the requested mode. Native +departure and arrival times are mutually exclusive. + +MapKit requests use a monotonic deadline and cancel on timeout. Synchronous commands +keep the main run loop available for native callbacks. Search and detail reads +have a 10-second deadline; routing shares a 25-second deadline across endpoint +resolution and the route or ETA request. Missing callbacks and service failures +remain errors. + +Saved reads use the native MapsSync default store and typed favorite, collection +or collection-item request. +Query options bound the native fetch to the CLI limit plus one, allowing an +accurate `hasMore` flag without a total-count claim. The default limit is 20; +valid limits are 1...100. `--offset` accepts 0...1,000,000. Lists sort by native +position and UUID; `nextOffset` is present when another batch exists. These are +live store reads, so concurrent changes can affect separate pages. Results keep +hidden favorite entries and optional fields. + +Favorites, collections and collection items use `maps-favorite:`, +`maps-collection:` and `maps-collection-item:` UUID namespaces, distinct from +service place IDs. Favorite and collection reads select the typed native UUID; +missing or duplicate identities remain errors. Member queries first require the +exact parent collection, then filter its native relationship. An absent parent +does not become a successful empty member list. + +Collection records preserve the native reported place count independently of the +bounded member batch. Place members expose available names, address, coordinates, +category and note. Transit members retain a lossless string line identifier. +Unknown member classes are unsupported, rather than discarded. + +Collection changes use the same default-store owner. Creation accepts an optional +collection UUID and otherwise generates one before execution. A matching existing +UUID and requested fields return `changed: false`; different content at that UUID +is refused. Positions are nonnegative native indices; creation appends after the +last native position by default. Updates change only supplied title, description +or position fields. The built-in Favorites guide's metadata and deletion remain +owned by Maps; member links can still be changed. + +Update, delete and membership commands resolve exact UUIDs and recheck the selected +records before writing. Relationship snapshots use typed native store queries, +with a maximum of 10,000 results per relationship; larger scopes are refused. +Changes commit through native save/delete callbacks and then verify fresh store +reads. Metadata updates preserve cover data and membership. Member add/remove +operates on an existing `maps-collection-item:` identity, preserving its fields, +cover data and other collection links. Removing a link does not delete the item. +Deleting a collection preserves members still belonging to another collection; +Maps owns the lifetime of members with no remaining collection. + +Mutation commands support `--dry-run` without invoking native save/delete. +Unchanged updates and repeated links return `changed: false` without saving. +Write callbacks and verification share a 20-second deadline. A write whose outcome +cannot be confirmed returns an error with `mutation_outcome: unverified`; callers +must read the selected identity before retrying. + +Saved-place access uses runtime class and ABI checks, including the complete +extended encoding of the native fetch callback. Unsupported interfaces fail with +`backend_unavailable`. Store creation and fetching share a 10-second callback +deadline. MapsSync exposes no request cancellation here; late callbacks are +discarded. Read commands do not invoke native save, delete, reset or edit methods. + +`directions preview` constructs a URL without contacting a service. `open` +dispatches a validated Maps URL through NSWorkspace after its external-action +gate. Native reads use explicit input and do not request the device's current +location. Network and regional service availability affect results. ## Validation -Use command help and Maps command tests. Detailed command status lives in -`CapabilityList.md`. +`collections places create` accepts a native service place ID or explicit +coordinates, an optional recoverable member UUID, custom name and note. Its +backend uses MapsSync's saved-place storage conversion and checks native fields, +storage and relationships after saving. Command input and dry-run behavior have +deterministic coverage; native end-to-end validation of this command remains +pending. Mechanism prototypes do not establish command-level persistence or +supported OS coverage. + +Command tests cover input, projection, callback delivery, timeout cancellation, +mode fidelity, saved identities, mutation inputs, dry-run separation and +external-action gates. Native network and saved-store workflows are checked +separately from deterministic package tests. Controlled collection workflows +verify cover preservation, shared member links, repeated operations and deletion, +followed by independent cleanup and unchanged existing records. Saved-store access +is verified on the current development OS; full Maps view refresh and another OS +remain unverified. Detailed command status lives in `CapabilityList.md`. diff --git a/Documentation/Architecture/Maps/CapabilityList.md b/Documentation/Architecture/Maps/CapabilityList.md index ea2d7b8..c1b06b1 100644 --- a/Documentation/Architecture/Maps/CapabilityList.md +++ b/Documentation/Architecture/Maps/CapabilityList.md @@ -2,18 +2,62 @@ ## Source Authority -Maps capabilities are accepted from place/direction lookup behavior and -validated Maps URL actions. +MapsCLI owns the command contract; native Maps services supply place, route and +ETA data, and MapsSync supplies saved favorites, collections and membership from +the current Maps store. ## Supported Capabilities | Capability | CLI surface | Implementation mechanism | Gate / verifier / gap | | --- | --- | --- | --- | -| Place lookup | `places search`, `places read` | CoreLocation-backed lookup | Read-only. | -| Directions | `directions preview` | Coordinate/query-aware preview planning | Read-only preview. | +| Place search | `places search` | MKLocalSearch address/POI search, optional region bias | Read-only; limit 1...50; reports service result count and truncation. | +| Place detail | `places read` | Coordinate reverse geocoding or MKMapItemRequest | Native `maps-item:` ID lookup requires macOS 15+; snapshot IDs cannot be fetched. Available details include address, phone, website, category and time zone. | +| Saved favorite reads | `favorites list/read` | MapsSync default store and native favorite request | Native UUID selection, hidden entries, positions and available names/address/coordinates/dates. List limit 1...100, default 20; offset, `hasMore` and `nextOffset`. Runtime ABI compatibility is required. | +| Saved collections | `collections list/read` | MapsSync native collection request | UUID selection, title/description/image URL, position, dates and native reported place count; bounded live pagination. | +| Collection membership | `collections places list` | Parent UUID lookup and native collection relationship predicate | Requires an existing exact parent; bounded pagination. Place details/notes and lossless transit line IDs; unknown member types fail. The returned batch does not replace the native reported count. | +| Collection creation | `collections create` | MapsSync native collection constructor and save callback | Required title; optional description, UUID and nonnegative position. Matching requested fields at the same UUID return `changed: false`; different content is refused. Fresh read verifies persistence. Supports `--dry-run`. | +| Collection metadata | `collections update` | Native title/description/position setters and save callback | Exact UUID and current record recheck; only supplied fields change. Cover and members remain intact. `--clear-description` removes a description. Supports `--dry-run`. | +| Collection deletion | `collections delete` | Native collection delete callback | Exact UUID and current record recheck; verifies absence and preserves shared members' other collection links. Supports `--dry-run`. | +| Existing member links | `collections places add/remove` | Native collection addPlace/removePlace and save callback | Requires collection and existing `maps-collection-item:` UUIDs. Verifies both relationships, member fields and cover preservation. Repeated operations do not save. Supports `--dry-run`. | +| Route calculation | `directions calculate` | MKDirections routes, instructions, metrics and geometry | Requests driving/walking/cycling; rejects a response reporting another mode. Explicit endpoints; queries must resolve uniquely. Alternative routes are optional; output cap 1...10, default 3. | +| Travel estimate | `directions eta` | MKDirections ETA | Driving/walking/cycling/transit; distance, duration and native departure/arrival estimates. | +| Link planning | `directions preview` | Coordinate/query-aware URL construction | No network request or application dispatch. Driving/walking/transit link modes. | | Open action | `open` | Validated Maps URL external action | Supports `--dry-run`; execution requires `--allow-external-dispatch`. | -## Rejected / Gated +## Capabilities Awaiting Native Validation -- Raw Maps.app scripting. -- Unvalidated external URL opens. +`collections places create` implements new place saving by `--place maps-item:` +or explicit `--latitude` and `--longitude`. `--id` selects the collection; +`--item` optionally supplies a recoverable member UUID. Custom names and notes +are literal text, and the command supports `--dry-run`. Input and dry-run tests +do not establish native end-to-end persistence; that validation is pending. + +## Boundaries + +- Transit route steps and geometry are unavailable through the public routing + API; transit ETA is supported. +- Transit-member creation, favorite changes, collection cover + editing, app sort modes, visited places, curated guides, offline maps and sharing + state are not implemented. A native position index is not an app sort mode. +- Maps owns metadata and deletion of its built-in Favorites guide. Collection + member links remain available. Deleting a custom collection preserves shared + members; no lifetime guarantee is made for members with no remaining collection. +- Writes recheck selected records and require complete native relationship + snapshots of at most 10,000 results each. Unverified write outcomes remain + errors; the caller must read the selected identity before retrying. +- Favorite reads include hidden native entries. A missing name, address or + coordinate remains absent; it is not reconstructed through a place search. +- Saved identifiers cannot be used as service place identifiers. `hasMore` + describes a bounded saved batch; it is not a total count. Pages use position and + UUID ordering, and reflect the live store rather than a retained snapshot. +- Nonempty place membership, collection lifecycle and shared member changes have + current-host native verification. Full Maps view refresh and another OS remain + unverified. Transit member projection has guarded native interfaces; its full + current-host workflow remains unverified. +- Each route includes at most 2,000 steps and 20,000 polyline points, with native + counts and truncation flags. A truncated polyline is a prefix, not a complete + route geometry. +- Search service counts describe the returned batch, not the total number of + places in an area. Search does not paginate the service. +- Network reads do not open Maps or request current-location authorization. + External opens require the explicit dispatch gate. diff --git a/Documentation/Architecture/Notes/Architecture.md b/Documentation/Architecture/Notes/Architecture.md index 0aa554e..e2069a8 100644 --- a/Documentation/Architecture/Notes/Architecture.md +++ b/Documentation/Architecture/Notes/Architecture.md @@ -1288,7 +1288,63 @@ privacy-safe color/font-hash counts, checklist indentation levels, checklist/table/math/link/attachment counts, and rich-state flags without printing note bodies, titles, raw paragraph UUIDs, paragraph titles, raw attributed content, raw paragraph style data, raw colors, raw font objects, or -private color/font objects. `body surfaces` is the promoted special-surface +private color/font objects. Structure counts, collections, and native rich-state +flags preserve unavailable readback as optional values. Known empty data reports +zero counts and empty collections; independent plain-text, link, attachment, +and state metadata remains available when successfully read. Missing table, +math-result, or outline readback cannot prove an empty selector list. Required +verification checks with unavailable evidence prevent a mutation from being +reported as verified. Inline verification binds the complete attributed body's +string hash and whole-body UTF-16 positions to an independent paragraph/text +occurrence readback. Format/font/color evidence must cover the requested range +continuously; attribute-run splits do not change the selection's identity. +Inline and selected-text link selectors resolve actual UTF-16 text paragraphs, +including ordinary paragraphs without native style UUIDs. Their ordinals include +the title. A matching native anchor retains its hash; other paragraph evidence +uses a note-and-text snapshot hash. Reader-owned selection readback provides the +paragraph's range as well as the selected occurrence, and the verifier requires +containment in that range. Paragraph links and structural operations continue +to use persistent native anchors. +Structural writers resolve complete physical paragraphs and merge attribute +fragments with the same paragraph identity. Existing checklist and ordinary-list +item ordinals count items in their own family; conversion ordinals count native +paragraph anchors. A hash shared by multiple physical paragraphs is ambiguous +for single-item edits and reordering. Checklist creation and conversion use +native checklist styles and preserve paragraph identity and indentation during +conversion. +Inline mutations convert between native model attributes and presentation +attributes through `ICTTTextController`. Each operation merges only the model +keys it owns, preserving paragraph identities, timestamps, attachments, and +unrelated formats, then persists note data before the context save. Inline +readback uses the same native conversion on an independently read snapshot. +Unavailable conversion omits inline evidence while preserving separately read +structure and content metadata. +The CLI title is the complete title paragraph. When Notes metadata contains an +ellipsized display title, the reader recovers the complete paragraph only when +the native range, truncation flag, and metadata fragment agree. Protected or +unavailable content retains its metadata title. Native title ranges can describe +a shortened display fragment; writes extend that range only within the matching +complete title paragraph. Plain body readback excludes the proven title and its +single separator even when the native title-less getter returns the full text. +Title-only updates replace only title characters, +preserving its terminator and the attributed body. The title must be one +non-empty paragraph. Title regeneration, note data persistence, and context +save precede readback; repeating the same title performs no save and reports +`changed=false`. Plain body updates replace the content after the native title +terminator and preserve the header attributes and native paragraph identity. +An update supplying title and body validates both fields before editing, then +saves them in one editing session. Plain body replacement clears the old body +formatting. Create, update, and append preserve the supplied body text, +including leading and trailing whitespace; an empty update clears the body. +Create and update titles require one non-empty paragraph. +`richTextSHA256` hashes the attributed string's text, excluding formatting +attributes. Heading, list, checklist, checklist completion, block-quote, and +style counts summarize physical UTF-16 paragraphs. Inline formatting fragments +and equivalent attribute aliases do not create additional paragraphs. Missing +or contradictory style flags make the affected counters unavailable; a missing +checklist completion flag does not imply an open item. `paragraphStyleRunCount` +counts attributed-text spans containing recognized paragraph style evidence. +`body surfaces` is the promoted special-surface accounting slice: it reports table, math-result, collapsible-section, and collapsed-section counts plus supported/gated read and mutation families, verifies the accounting through private body structure readback, reports diff --git a/Documentation/Architecture/Notifications/Architecture.md b/Documentation/Architecture/Notifications/Architecture.md index 303705a..9bd533c 100644 --- a/Documentation/Architecture/Notifications/Architecture.md +++ b/Documentation/Architecture/Notifications/Architecture.md @@ -1,27 +1,60 @@ # Notifications Architecture -`notifications` owns notification preview and send workflows for notifications -created by this tool. +`notifications` owns notification preview, settings, explicit authorization, +submission and pending/delivered management for notifications created by this tool. ## Capability Maturity Current level: `L0 Public Framework / SDEF / AppleScript` -Rationale: Notifications currently uses a target-local delivery path for this -tool's notifications. It does not currently rely on a broad private -notification history or system notification implementation mechanism. +Notifications uses public UserNotifications APIs scoped to Apple CLI. +Authorization and submission are distinct from on-screen presentation. ## Source Authority -The target-local notification delivery behavior defines the accepted command -surface. +[UserNotifications](https://developer.apple.com/documentation/usernotifications) +defines application authorization, request submission and local triggers. ## Implementation Mechanisms -Notifications uses the target-local legacy CLI delivery path. Direct broad -UserNotifications probing is not part of the unbundled SwiftPM CLI process. +The `apple` executable embeds its application identity, +`org.computer-mcp.apple-cli`, in its Info.plist. The backend checks that identity +before obtaining UNUserNotificationCenter. The adapter invokes the same CLI. + +`settings` and `doctor` query this application's native settings without +requesting authorization. Only `permissions request` asks the system for +alert, sound and badge authorization; execution requires +`--allow-persistent-action`. Denial is reported in the authorization result. + +`send` requires `--allow-external-dispatch` and a native authorized or +provisional state. It does not request authorization automatically. A successful +add callback establishes `submitted`; a native rejection returns an error. +An add timeout reports possible mutation and the request ID, because a late +completion may still accept the request. + +Request IDs use the `apple-cli:` namespace. Omitted IDs are generated at +execution; provided IDs replace the same native request according to the +framework contract. Optional delays create one-time native time-interval +triggers. Focus, presentation settings and suppression remain system behavior. + +Pending and delivered queries use native callbacks and expose only IDs in this +tool's namespace. Lists are sorted by ID and cap returned summaries; exact reads +include title, subtitle, body and native trigger information. Delivered entries +include the native delivery date and cover entries still in Notification Center. + +`pending cancel` and `delivered remove` require `--allow-persistent-action`. +They remove only the selected ID and query the same collection again. Results +separate `attempted`, `wasPresent` and `verifiedAbsent`; an absent ID is a no-op. +A failed readback or an entry that remains returns an error with possible-mutation +details. The pending absence check does not establish whether the request fired +during cancellation, and IDs do not provide atomic comparison against a concurrent +replacement. Removal does not establish user-read status. ## Validation -Use system-domain command tests and executable contract validation. Detailed -command status lives in `CapabilityList.md`. +Use the existing SystemDomainCommandTests and executable metadata tests. +The callback regression checks native error mapping, first completion and +unknown timeouts. Native-object projection covers content, namespace and triggers; +real executable reads cover settings and scoped collections. Authorized scheduling, +request persistence after CLI exit, nonempty cancellation and presentation require +separate native verification. Detailed command status lives in `CapabilityList.md`. diff --git a/Documentation/Architecture/Notifications/CapabilityList.md b/Documentation/Architecture/Notifications/CapabilityList.md index 7a71ef1..dbabf13 100644 --- a/Documentation/Architecture/Notifications/CapabilityList.md +++ b/Documentation/Architecture/Notifications/CapabilityList.md @@ -9,9 +9,22 @@ Notifications capabilities are accepted for notifications created by this tool. | Capability | CLI surface | Implementation mechanism | Gate / verifier / gap | | --- | --- | --- | --- | | Preview | `preview` | Target-local notification preview | Read-only preview. | -| Send | `send` | Target-local notification delivery | Supports `--dry-run`; execution requires `--allow-external-dispatch`. | +| Settings | `settings` | UNUserNotificationCenter | This application's authorization and alert/sound/badge/center/lock-screen settings; no authorization request. | +| Authorization | `permissions request` | Native authorization callback | Supports `--dry-run`; execution requires `--allow-persistent-action` and the person's system choice. | +| Send | `send` | Native add callback | Supports `--dry-run`; requires `--allow-external-dispatch` and authorization. Native rejection is an error; successful submission does not prove presentation. | +| Identity and delay | `--id`, `--delay-seconds` on preview/send | Native request ID and one-time trigger | IDs use `apple-cli:`; optional delay is 1...604800 seconds. Authorized persistence and delivery need native verification. | +| Pending queries | `pending list`, `pending read` | Native pending-request callback | Own namespace; ID-sorted summaries with default limit 50 and maximum 500; exact reads include content and trigger fields. | +| Pending cancellation | `pending cancel` | Exact-ID removal and fresh pending readback | `--dry-run` or `--allow-persistent-action`; absent IDs make no removal call. Absence does not prove the notification did not fire. | +| Notification Center queries | `delivered list`, `delivered read` | Native delivered-notification callback | Own namespace and entries still in the center; includes delivery date, not read status or complete history. | +| Notification Center removal | `delivered remove` | Exact-ID removal and fresh delivered readback | `--dry-run` or `--allow-persistent-action`; reports attempted/previous presence/verified absence. Nonempty removal requires native verification. | +| Diagnostics | `doctor` | Native per-app settings and embedded identity | Queries this application; does not request authorization. | ## Rejected / Gated - Global notification history. - Broad system notification automation. +- Creation with richer triggers, actions, sounds, badges and attachments. + +The current-host native proof covers identity, settings, empty scoped collections +and an unauthorized submission rejection with cold absence. Authorized scheduling, +CLI-exit persistence, nonempty cancellation/removal and presentation remain unverified. diff --git a/Documentation/Architecture/Numbers/Architecture.md b/Documentation/Architecture/Numbers/Architecture.md index 29570b9..b89dd3b 100644 --- a/Documentation/Architecture/Numbers/Architecture.md +++ b/Documentation/Architecture/Numbers/Architecture.md @@ -23,6 +23,15 @@ Numbers uses package/FileManager metadata, QuickLook export, and target-local Numbers.app structured scripting for sheet/table reads and single-cell text writes. +Cell write comparison uses the native value type, actual value, and formula. +Formatted text is presentation evidence. A write is skipped only for identical +literal text with a confirmed absence of a formula. After a write, the command +reads the saved cell again and checks its identity, value, type, and formula. + +Path-based writes require ownership of the document's open/save lifecycle. +The writer refuses a document already open in Numbers, including one resolved +by the app to an existing document, to protect pending edits. + ## Validation Use document path validation, iWork command tests, and Numbers-specific tests. diff --git a/Documentation/Architecture/Numbers/CapabilityList.md b/Documentation/Architecture/Numbers/CapabilityList.md index 8b8569a..0ce6528 100644 --- a/Documentation/Architecture/Numbers/CapabilityList.md +++ b/Documentation/Architecture/Numbers/CapabilityList.md @@ -12,7 +12,7 @@ and proven Numbers.app sheet/table scripting. | Document reads | `documents list/search/read` | File/package metadata | Path-bounded read. | | Sheet/table reads | `sheets list`, `tables read` | Numbers.app structured scripting | Bounded read. | | Export/open | `documents open/export`, `tables export` | QuickLook/package export and app action | Supports `--dry-run`; open execution requires `--allow-external-dispatch` and export execution requires `--allow-artifact-action`. | -| Cell write | `tables set-cell` | Numbers.app structured scripting | Supports `--dry-run`; execution requires `--allow-persistent-action`. | +| Cell write | `tables set-cell` | Numbers.app structured scripting | Supports `--dry-run`; execution requires `--allow-persistent-action` and a closed source document. Compares actual text/type/formula and verifies saved readback. | ## Rejected / Gated diff --git a/Documentation/Architecture/PermissionAndWording.md b/Documentation/Architecture/PermissionAndWording.md index 6ab4ea9..799de87 100644 --- a/Documentation/Architecture/PermissionAndWording.md +++ b/Documentation/Architecture/PermissionAndWording.md @@ -18,8 +18,9 @@ Each target owns the permission semantics for its backend: - Automation-backed targets own their app automation failures and guidance. - File and database backed targets own Full Disk Access diagnostics for the exact resource they try to read. -- `notifications` owns the local notification surface and must not claim a - reliable global notification-permission probe from the unbundled SwiftPM CLI. +- `notifications` owns its application identity, native per-app settings and + explicit authorization request. Submission results do not establish system + presentation or global notification state. - `tcc` owns TCC service catalog lookup, current-process identity diagnostics, read-only TCC database inspection, official reset, explicit public preflight/request helpers, and gated private database/framework diagnostics. diff --git a/Documentation/Architecture/Reminders/Architecture.md b/Documentation/Architecture/Reminders/Architecture.md index 16b48ac..621995b 100644 --- a/Documentation/Architecture/Reminders/Architecture.md +++ b/Documentation/Architecture/Reminders/Architecture.md @@ -35,6 +35,31 @@ The Reminders capability baseline is defined in this order: 5. The read-only Reminders `SQLiteReader` provides enrichment, diagnostics, and verifier evidence. +Reminder and list existence and identity come from ReminderKit. SQLite +enrichment matches native identifiers and fills missing metadata; retained +store rows belong to diagnostics. +Saved template item listing uses a bounded read-only index to discover IDs +under the selected native template and account. Every returned item is fetched +through ReminderKit and checked for saved-item entity, template, and account +membership. An unavailable or ambiguous index fails the read. + +Custom Smart List storage supplies the list identity, account identity, type, +and filter data. Creation and conversion retain the new change item's ID. +Rule verification reloads that exact native identity and compares its account +and requested JSON rules. Rule updates preserve the list's other fields and +return without saving when the rules already match. Deletion verifies native +absence. Unconfirmed post-save verification reports possible mutation and +inspection guidance. + +Smart List membership reads use ReminderKit's custom Smart List data-view +invocation with the selected native Smart List and account storages. The +native view evaluates its saved rules; the CLI decodes its property-list +result, checks the requested list identity, and fetches the full reminder +objects by their native IDs. Contextual subtasks are flattened and deduplicated +by identity. Output retains physical list and parent relationships. Completion, +date, and search filters run before the output limit. Unavailable invocation +methods or unknown result shapes fail the read. + EventKit and the Reminders.app SDEF remain useful reference evidence for Apple model boundaries. Production Reminders reads and writes use ReminderKit. @@ -54,7 +79,7 @@ capability accounting lives in `CapabilityList.md`. writes through `ReminderKit` / `ReminderKitInternal` calls. Files are split by product capability: reminder core, list metadata, list groups, Smart Lists, repeat rules, visible URL, tags, sections, subtasks, attachments, - assignments, urgent state, Messaging-person triggers, field verification, and + assignments, rich notes, urgent state, Messaging-person triggers, field verification, and preservation verification. Smart List writing keeps operation entry points, change-item configuration, resolution, conversion, store/save helpers, matching, preflight checks, and criteria encoding in separate target-local @@ -109,6 +134,8 @@ Command names remain semantic: - Use `apple reminders create`, `update`, `complete`, `uncomplete`, bulk completion, `delete`, and `cleanup-completed` for reminder mutation. - Use `apple reminders list`, `search`, and `read` for normal reminder reads. +- Use `apple reminders notes read`, `notes format`, and `notes list-style` + for attributed notes and selective formatting changes. - Use `apple reminders lists list`, `lists create`, `lists delete`, `lists update`, `lists reorder`, `lists groups ...`, and `lists smart ...` for list organization intent. @@ -120,7 +147,7 @@ Command names remain semantic: `templates` is the collection entry, matching Reminders.app's View Templates flow; `--template` selects the one template to mutate in the stateless CLI. Template sections support direct list/add/rename/delete/reorder. Template - items support direct add/update/delete on saved reminders, including title, + items support direct list/read/add/update/delete on saved reminders, including title, notes, visible URL, due date, priority, repeat, location, absolute alarm, flag, tags, section membership, file/image attachments, and one-level subtasks. Current-user or execution-context fields such as urgent, early @@ -135,6 +162,28 @@ Command names remain semantic: ## Preservation Invariant +Notes formatting reads native attributed text and selects literal text in +UTF-16 ranges. Repeated text requires an explicit one-based occurrence; +omitting text selects all notes. Inline changes preserve other attributes and +unselected content. List styles apply to complete selected paragraphs: +bulleted, dashed, and numbered styles preserve existing paragraph metadata, +while plain clears the selected paragraph's list layout. Changes save once +through the native reminder change item and require a fresh attributed-text +readback plus unchanged reminder fields, except the modification timestamp. +An identical request does not save. Unconfirmed saves report possible mutation +and require inspecting notes before retrying. + +Subtask verification binds the child, parent, and list by identity. A successful +fresh ReminderKit lookup and readable store relationship evidence are required; +parent titles do not identify the relationship. Moving to the current parent +preserves the reminder and reports `changed: false`. + +Promotion adds the existing reminder change item to its original list's +top-level membership in the same save request. Saved template items use their +template's list representation for this membership change. Verification +requires that the child still exists in its list or template and has no parent +relationship. + Commands that move, recreate, merge, extract, or reorganize reminders preserve known rich reminder state. This includes visible URLs, assignments, attachments, tags, sections, subtask relationships, recurrence, alarms, and list metadata. diff --git a/Documentation/Architecture/Reminders/CapabilityList.md b/Documentation/Architecture/Reminders/CapabilityList.md index 9b385f5..227a234 100644 --- a/Documentation/Architecture/Reminders/CapabilityList.md +++ b/Documentation/Architecture/Reminders/CapabilityList.md @@ -43,6 +43,7 @@ accepted production implementation mechanism is ReminderKit. | Capability | CLI support | Command | Implementation mechanism | Verifier | Gate | Notes | | --- | --- | --- | --- | --- | --- | --- | | Reminder title, notes, due date, priority, completion, and list membership | supported | `create`; `update`; `complete`; `complete --completed-at`; `uncomplete`; `delete`; `cleanup-completed`; `list`; `search`; `read` | ReminderKit reminder/list change items | ReminderKit readback plus optional read-only SQLite diagnostics | `DryRun` payload for mutations / bounded-read for reads | Core reminder identity and list identity come from ReminderKit. `--completed-at` preserves explicit historical completion timestamps. | +| Rich notes | supported | `notes read`; `notes format --format bold\|italic\|underline\|strikethrough --state on\|off`; `notes list-style --style plain\|bulleted\|dashed\|numbered` | ReminderKit attributed notes and reminder change items | Fresh attributed-text and unchanged reminder-field readback | bounded-read / `DryRun` payload | Literal text and one-based occurrence select inline ranges; list styles affect whole selected paragraphs. Plain clears their list layout. Existing `--notes` supplies plain text. | | Recurrence, location, early reminder, and absolute alarm triggers | supported | `create/update --repeat`; `--location`; `--early-reminder-minutes-before`; `--alarm-at` and clear variants | ReminderKit recurrence and alarm contexts | ReminderKit readback and read-only SQLite recurrence evidence where needed | `DryRun` payload / read-only verified | Covers hourly, daily, weekly, monthly, yearly, weekday positions, selected month days/months, and BYSETPOS-style set positions. | | Standard lists and list appearance | supported | `lists list`; `lists icons list`; `lists create`; `lists delete`; `lists update --title/--color/--icon` | ReminderKit list/account change items plus target-local native badge token catalog | ReminderKit readback, read-only list evidence, color hex readback, and badge-token catalog tests | `DryRun` payload / bounded-read | Color input accepts names or hex values and normal reads emit `color` as hex plus `hasColor`; icon input is semantic and validated against the native badge token catalog before write. | | Visible URL/link-card and file/image attachments | supported | `create --url`; `update --url`; `--clear-url`; `attachments add/remove` | ReminderKit attachment/link context | Read-only SQLite attachment/link evidence | `DryRun` payload / read-only verified | Normal reads expose visible link and attachment metadata when verifier evidence maps to reminder identity. | @@ -51,8 +52,8 @@ accepted production implementation mechanism is ReminderKit. | Subtasks | supported | `subtasks create/move/promote`; normal reads expose hierarchy when evidence exists | ReminderKit reminder/subtask context | Read-only parent/child evidence | `DryRun` payload / read-only verified | Same-list and cross-list reparenting verify final parent evidence after save. | | Shopping list type and automatic grocery metadata | supported | `lists update --type standard|shopping`; list reads and doctor list/SQLite evidence | ReminderKit list grocery context | Read-only list type and grocery evidence | `DryRun` payload / read-only verified | Account and locale availability resolve on the local Reminders.app account. | | List type, pin, sort, order, and groups | supported | `lists update --pinned/--sort/--show-large-attachments`; `lists reorder`; `lists groups create/rename/delete/move-list/remove-list` | ReminderKit list/account/appearance/group contexts | Read-only list metadata and group parent/child evidence | bounded-read / `DryRun` payload / read-only verified | Covers pinned lists, sorting style, large attachment display, sidebar order, and list groups. | -| List templates | supported | `templates list`; `templates save`; `templates create-list`; `templates update`; `templates replace`; `templates sections list/add/rename/delete/reorder`; `templates items add/update/delete`; `templates items attachments add/remove`; `templates items subtasks create/move/promote`; `templates delete` | ReminderKit template/account save requests, template section contexts, template list representation, saved reminder change items, attachment context, and subtask context | ReminderKit template/list readback and generated-list smoke verification where needed | bounded-read / `DryRun` payload | Saves a source list as an official Reminders template, creates new lists from saved templates, edits template title/appearance, replaces template content from a list, directly edits template sections, directly edits saved template items by `REMCDSavedReminder` ID, edits template item attachments and one-level subtasks, and deletes templates. Template items support instantiable content and structure: title, notes, visible URL, due date, priority, repeat, location, absolute alarm, flag, tags, section membership, file/image attachments, and one-level subtasks. Current-user or execution-context fields such as urgent, early reminders, Messaging person, and shared assignment remain normal reminder fields. | -| Smart Lists | supported for bounded criteria | `lists smart create/update/delete/convert` | ReminderKit Smart List, list, and reminder change items | Read-only Smart List type and filter evidence | `DryRun` payload / read-only verified | Supported criteria include bounded tag, priority, flag, date, and boolean selectors. | +| List templates | supported | `templates list`; `templates save`; `templates create-list`; `templates update`; `templates replace`; `templates sections list/add/rename/delete/reorder`; `templates items list/read/add/update/delete`; `templates items attachments add/remove`; `templates items subtasks create/move/promote`; `templates delete` | ReminderKit template/account save requests, template section contexts, template list representation, saved reminder change items, attachment context, and subtask context | ReminderKit template/list readback and generated-list smoke verification where needed | bounded-read / `DryRun` payload | Saves a source list as an official Reminders template, creates new lists from saved templates, edits template title/appearance, replaces template content from a list, directly edits template sections, lists and reads saved template items, directly edits them by `REMCDSavedReminder` ID, edits template item attachments and one-level subtasks, and deletes templates. Template items support instantiable content and structure: title, notes, visible URL, due date, priority, repeat, location, absolute alarm, flag, tags, section membership, file/image attachments, and one-level subtasks. Current-user or execution-context fields such as urgent, early reminders, Messaging person, and shared assignment remain normal reminder fields. | +| Smart Lists | supported for bounded criteria and native membership reads | `list` / `search --list`; `lists smart create/update/delete/convert` | ReminderKit custom Smart List data-view invocation, storage, and change items | Native membership IDs and exact rule readback | bounded-read / `DryRun` payload / native readback | Saved rules evaluate in Reminders. Reads preserve physical lists and contextual subtasks without duplicating IDs. Criteria cover bounded tag, priority, flag, and date selectors. | | Shared assignment | supported when sharee evidence is resolvable | `assignments assign/unassign`; normal reads expose assignment evidence | ReminderKit assignment context plus read-only sharee resolver | Read-only assignment/sharee evidence | `DryRun` payload / read-only verified | Assignment validation uses shared-list sharee evidence for the selected account. | | Flagged, urgent, and Messaging-person reminders | supported | `update --flagged`; `update --urgent`; `update --messaging-person`; clear variants | ReminderKit reminder, urgent alarm, and contact-handle contexts | Read-only SQLite evidence | `DryRun` payload / read-only verified | Messaging-person display-name selectors require exactly one Contacts match. | @@ -82,9 +83,10 @@ accepted production implementation mechanism is ReminderKit. | List lifecycle | `lists create`; `lists delete` | ReminderKit list/account save requests | `DryRun` payload | Create and delete bind source/list identity and verify readback. | | List appearance metadata | `lists icons list`; `lists update --title`; `--color`; `--icon` | ReminderKit list change items plus native badge catalog | `bounded-read` / `DryRun` payload | The CLI accepts color names or hex values, reads colors back as hex, enumerates and validates native Reminders badge tokens for `--icon`, then writes ReminderKit list metadata. | | List type, pin, sort, order, and groups | `lists update --type`; `--pinned`; `--sort`; `--show-large-attachments`; `lists reorder`; `lists groups create/rename/delete/move-list/remove-list` | ReminderKit list/account/appearance/group contexts | `DryRun` payload / `read-only verified` | Covers grocery/list type, pin, sort, large attachment display, list order, and group hierarchy. | -| List templates | `templates list`; `templates save`; `templates create-list`; `templates update`; `templates replace`; `templates sections list/add/rename/delete/reorder`; `templates items add/update/delete`; `templates items attachments add/remove`; `templates items subtasks create/move/promote`; `templates delete` | ReminderKit template/account contexts, template section contexts, template list representation, saved reminder change items, attachment context, and subtask context | `bounded-read` / `DryRun` payload / `ReminderKit readback` | Template save binds the source list and template title. Template create-list binds the saved template and destination list title. Template replace binds the old template, replacement source list, and final title. Template sections and items mutate saved template internals directly without requiring a source draft list. Template item generated-list smoke covers visible URL, due date, repeat, location, absolute alarm, priority, flag, tags, section membership, file/image attachments, and one-level subtasks. | -| Smart Lists | `lists smart create/update/delete/convert`; Smart List reads | ReminderKit custom Smart List, list, and reminder change items plus read-only verifier | `bounded-read` / `DryRun` payload / `read-only verified` | Reads may expose `listType=smart`, `smartListType`, and bounded filter evidence. | +| List templates | `templates list`; `templates save`; `templates create-list`; `templates update`; `templates replace`; `templates sections list/add/rename/delete/reorder`; `templates items list/read/add/update/delete`; `templates items attachments add/remove`; `templates items subtasks create/move/promote`; `templates delete` | ReminderKit template/account contexts, template section contexts, template list representation, saved reminder change items, attachment context, and subtask context | `bounded-read` / `DryRun` payload / `ReminderKit readback` | Template save binds the source list and template title. Template create-list binds the saved template and destination list title. Template replace binds the old template, replacement source list, and final title. Template item list/read exposes saved identities and fields; listing uses a bounded account/template index with native item validation. Template sections and items mutate saved template internals directly without requiring a source draft list. Template item generated-list smoke covers visible URL, due date, repeat, location, absolute alarm, priority, flag, tags, section membership, file/image attachments, and one-level subtasks. | +| Smart Lists | `list` / `search --list`; `lists smart create/update/delete/convert`; Smart List metadata reads | ReminderKit custom Smart List data-view invocation, storage, and change items | `bounded-read` / `DryRun` payload / `native readback` | Native membership reads flatten contextual subtasks and deduplicate IDs, retain physical list relationships, and filter before limiting output. Metadata carries native list and account identities. Rule changes verify exact identity, account, and requested JSON filter; identical updates return `changed=false` without saving, and deletion verifies native absence. | | Reminder core fields | `create`; `update --title`; `--notes`; `--due`; `--priority`; `complete`; `complete --completed-at`; `uncomplete`; `delete`; `cleanup-completed` | ReminderKit reminder/list save requests | `DryRun` payload for mutations / `bounded-read` | SQLite diagnostics expose bounded row evidence for explanation and preservation checks, including completion timestamps. | +| Rich notes | `notes read`; `notes format`; `notes list-style` | ReminderKit attributed text, native format helpers, and reminder save requests | `bounded-read` / `DryRun` payload | Read exposes UTF-16 runs, inline format flags, links, and recognized list styles. Changes preserve text and other reminder fields; repeated requests do not save. | | Repeat, location, early reminders, alarms | `create/update --repeat`; `--repeat-days-of-week`; `--repeat-weekday-positions`; `--repeat-days-of-month`; `--repeat-months-of-year`; `--repeat-set-positions`; `--location`; `--early-reminder-minutes-before`; `--alarm-at`; clear variants | ReminderKit recurrence, structured location, due-date delta, and alarm contexts | `DryRun` payload / `read-only verified` | ReminderKit covers hourly recurrence, daily/weekly/monthly/yearly rules, weekday week-number positions, selected month days/months, BYSETPOS-style set positions, geofence alarms, relative early reminders, and explicit alarms. | | Visible URL / link card | `create --url`; `update --url`; `--clear-url`; normal read `url` | ReminderKit attachment/link context + read-only verifier | `DryRun` payload / `read-only verified` | Production write/clear reports success after read-only SQLite verification. | | Flagged | `update --flagged true|false`; normal read `isFlagged` | ReminderKit reminder change items + read-only verifier | `DryRun` payload | Read enrichment and verification use SQLite flag evidence where available. | diff --git a/Documentation/Architecture/RepositoryIdentity.md b/Documentation/Architecture/RepositoryIdentity.md index 72183cd..c4cc149 100644 --- a/Documentation/Architecture/RepositoryIdentity.md +++ b/Documentation/Architecture/RepositoryIdentity.md @@ -19,6 +19,10 @@ apple [options] The optional adapter executable is `apple-cli-mcp`. It is an adapter over the canonical CLI contract and does not own behavior. +The `apple` executable embeds the application identity +`org.computer-mcp.apple-cli`. Notifications uses that identity for its per-app +native authorization and request namespace. + ## Target Catalog The accepted target catalog is: @@ -77,8 +81,9 @@ Implemented target capabilities: add/update/remove/file URL add/update/remove/note link add/update/remove/paragraph note-link add/remove/body structure/note state/list/search/read plus safety-gated create/import markdown/update/append/delete/tag membership. -- `calendar`: EventKit read/search/occurrences/availability/statistics plus - safety-gated iCalendar export and event create/update/delete with +- `calendar`: EventKit source/account and calendar reads, calendar lifecycle, + event read/search/occurrences/availability/statistics plus iCalendar export + and event create/update/delete with attendee metadata, relative/absolute alarms, and recurrence rules. - `reminders`: ReminderKit read/search, reminder/list lifecycle mutations, visible URL/link cards, file/image attachments, tags, sections, subtasks, @@ -94,8 +99,10 @@ Implemented target capabilities: - `messages`: read-only local Messages database conversations/messages list/search/read plus safety-gated iMessage send, existing-chat conversation send, and explicit-recipient send-many. -- `maps`: CoreLocation-backed place search/read, coordinate-aware directions - preview, and safety-gated Maps open. +- `maps`: native MapKit address/POI search and place detail, driving/walking/cycling + routes, transit-capable ETA, MapsSync saved favorite reads, collection lifecycle + and existing member links, directions link previews + and safety-gated Maps open. - `finder`: path-validated item list/search/metadata plus safety-gated open/reveal/tag/move/trash/delete/write-text/overwrite-text. Delete and overwrite-text are limited to one regular file; write-text is create-only. @@ -104,9 +111,8 @@ Implemented target capabilities: document open, and QuickLook PDF/thumbnail/package export. - `pages`: path-bounded document metadata, safety-gated document open, and QuickLook PDF/thumbnail/package export. -- `keynote`: path-bounded presentation metadata, QuickLook-backed slide list, - safety-gated slide image export, presentation open, and QuickLook - PDF/thumbnail/package export. +- `keynote`: path-bounded file/package metadata, native slide reads and PDF + export, explicit cached preview list/export, presentation open and package copy. - `facetime`: Contacts-backed contact resolve, call prepare, and safety-gated call start. - `safari`: Safari.app windows/tabs/page reads, profile, snapshot window, and @@ -124,8 +130,9 @@ Implemented target capabilities: submit/cancel. - `clipboard`: NSPasteboard type/read/write/clear with DryRun preview for mutation. -- `notifications`: local notification preview/send for notifications created by - this tool, with DryRun preview for send. +- `notifications`: local notification preview, settings, explicit authorization, + callback-confirmed submission and scoped pending/delivered management for this + tool, with DryRun previews for authorization, send and exact-ID removal. - `intelligence`: Apple Intelligence workflows, currently implemented as a Swift-owned local-cache path. The current backend uses macOS eligibility cache files and `eligibilityd`. It includes diff --git a/Documentation/Architecture/TCC/CapabilityList.md b/Documentation/Architecture/TCC/CapabilityList.md index 092596a..388b2a1 100644 --- a/Documentation/Architecture/TCC/CapabilityList.md +++ b/Documentation/Architecture/TCC/CapabilityList.md @@ -12,7 +12,7 @@ public API, and gated private diagnostic behavior. | Service and identity reads | `services list/read`, `identity read/resolve` | Service catalog and code-signing/LaunchServices reads | Read-only. | | Database diagnostics | `database info/digest`, `records list/read` | Read-only SQLite inspection | Read-only; no silent grant. | | Access recovery | `access preflight/request`, `reset` | Public permission APIs and `tccutil` | Explicit risk flags. | -| Private diagnostics | `framework probe/add/reset` | Gated private TCC.framework diagnostics | Explicit allow flags and target-local gates. | +| Private diagnostics | `framework probe/add/reset` | Gated private TCC.framework diagnostics | Explicit allow flags and target-local gates; write results report an attempted call with unverified effect. | ## Rejected / Gated diff --git a/Documentation/Architecture/TargetFirstCli.md b/Documentation/Architecture/TargetFirstCli.md index d4df05f..eaee8ea 100644 --- a/Documentation/Architecture/TargetFirstCli.md +++ b/Documentation/Architecture/TargetFirstCli.md @@ -30,7 +30,7 @@ Non-primary resources stay explicit: - `apple mail messages read` - `apple finder items search` - `apple numbers tables read` -- `apple keynote slides export` +- `apple keynote presentations export` - `apple print jobs submit` ## Current Structure @@ -117,8 +117,9 @@ design. explicit bounded body preview, reply/forward preview, draft, reply-draft/forward-draft, send, move, archive, and delete. - `messages` owns local Messages read/search and safety-gated send flows. -- `maps` owns place search/read, query or coordinate directions preview, and - safety-gated `maps:` URL open. +- `maps` owns native place search/read, saved favorite reads, collection lifecycle + and existing member links, route and ETA requests, + query or coordinate directions link previews, and safety-gated `maps:` URL open. - `finder` owns path-bounded Finder/app-bound file workflows. - `numbers`, `pages`, and `keynote` own path-bounded iWork document workflows, QuickLook/package export paths, and the focused iWork reads or writes already @@ -135,9 +136,9 @@ design. slideshow/spotlight, and strong-gated Swift eval hooks or post-commands. - `print` owns local printer/job inspection and print submit/cancel. - `clipboard` owns pasteboard type/read/write/clear. -- `notifications` owns this tool's notification preview/send surface through a - target-local legacy CLI delivery backend; direct `UserNotifications` probing - is disabled for the unbundled SwiftPM CLI process. +- `notifications` owns this tool's preview, authorization, submission and scoped + pending/delivered management through UserNotifications. The CLI embeds its + application identity; authorization requests and removals have explicit gates. - `intelligence` owns Apple Intelligence workflows as a Swift target-local implementation. Its current production line is a local-cache path; the current backend uses macOS eligibility cache files and `eligibilityd`. It diff --git a/Documentation/Architecture/TargetImplementationMechanisms.md b/Documentation/Architecture/TargetImplementationMechanisms.md index eef8678..736b901 100644 --- a/Documentation/Architecture/TargetImplementationMechanisms.md +++ b/Documentation/Architecture/TargetImplementationMechanisms.md @@ -33,17 +33,17 @@ and links to the target owner. | `contacts` | Contacts.framework for people, groups, labels, vCard import/export, and authorization. | [Contacts](Contacts/Architecture.md) | | `mail` | Mail.app structured scripting for accounts, mailboxes, messages, drafts, sends, and mailbox actions. | [Mail](Mail/Architecture.md) | | `messages` | Read-only Messages database inspection for reads; Messages.app structured send automation for sends. | [Messages](Messages/Architecture.md) | -| `maps` | CoreLocation-backed place lookup and validated Maps URL external actions. | [Maps](Maps/Architecture.md) | +| `maps` | MapKit search/place IDs/routes/ETA, CoreLocation reverse geocoding, MapsSync saved reads and collection/member changes with native verification, and validated Maps URL external actions. | [Maps](Maps/Architecture.md) | | `finder` | FileManager/file-resource APIs, NSWorkspace/Finder-bound external actions, and file tag/resource metadata APIs. | [Finder](Finder/Architecture.md) | | `numbers` | File/package metadata, QuickLook export, and Numbers.app structured scripting for sheet/table/cell behavior. | [Numbers](Numbers/Architecture.md) | | `pages` | File/package metadata and QuickLook export/open mechanisms. | [Pages](Pages/Architecture.md) | -| `keynote` | File/package metadata, QuickLook slide/package export, and presentation open mechanisms. | [Keynote](Keynote/Architecture.md) | +| `keynote` | File/package metadata, Keynote native slide/PDF scripting, explicit cached previews, package copy and presentation open mechanisms. | [Keynote](Keynote/Architecture.md) | | `facetime` | Contacts.framework resolution and FaceTime URL external actions. | [FaceTime](FaceTime/Architecture.md) | | `safari` | Safari.app structured scripting plus read-only `SafariTabs.db` snapshots for profiles, windows, and Tab Groups. | [Safari](Safari/Architecture.md) | | `photos` | Read-only Photos SQLite snapshots, Photos.app structured scripting, file/ImageIO export helpers, optional explicit exiftool, and gated Swift/shell hooks. | [Photos](Photos/Architecture.md) | | `print` | CUPS inspection and bounded `lp`/`cancel` subprocess actions. | [Print](Print/Architecture.md) | | `clipboard` | NSPasteboard. | [Clipboard](Clipboard/Architecture.md) | -| `notifications` | Target-local notification preview/delivery mechanisms for this tool's notifications. | [Notifications](Notifications/Architecture.md) | +| `notifications` | Public UserNotifications callbacks, scoped collections and exact-ID removal/readback using the embedded CLI identity. | [Notifications](Notifications/Architecture.md) | | `intelligence` | Foundation plist APIs plus bounded subprocess calls for local eligibility/cache/service workflows. | [Intelligence](Intelligence/Architecture.md) | | `tcc` | TCC service catalog, read-only SQLite inspection, `tccutil`, public permission APIs, and gated private TCC.framework diagnostics. | [TCC](TCC/Architecture.md) | diff --git a/Documentation/Reference/Calendar/DeveloperGuide.md b/Documentation/Reference/Calendar/DeveloperGuide.md index e5e54ae..d6e8288 100644 --- a/Documentation/Reference/Calendar/DeveloperGuide.md +++ b/Documentation/Reference/Calendar/DeveloperGuide.md @@ -8,3 +8,48 @@ swift test --filter EventKit Run `apple calendar doctor --json` when diagnosing EventKit authorization or local calendar availability. + +`Package.swift` embeds `Sources/AppleCLI/Info.plist` into the `apple` executable's +`__TEXT,__info_plist` section. It provides both the full-access Calendar purpose +description and the legacy description used on macOS 13. The executable tests +check that `calendar doctor` can read these descriptions from the running +product; the same suite runs against installed and unpacked release binaries. + +The EventKit command suite retains its command coverage and checks invalid +date-only inputs, UTF-8 iCalendar line folding and exact unfolding, and rejection +of write-only access when mutation identity requires reads. + +`CalendarCollections.swift` owns EventKit source/calendar collection reads and +mutations. It keeps every saved calendar and its source in one store, then uses +a fresh store to verify persistence. Source IDs are required at creation; +update/delete compare the resolved calendar record with the command's selected +record. `isImmutable` governs calendar attributes and deletion independently of +`allowsContentModifications`, which governs event content. Missing source or +attribute-permission evidence prevents collection mutations. + +Collection command arguments use typed required IDs/source/title declarations. +The MCP catalog derives those requirements from the same CLI help. Tests reuse +the EventKit command suite for source identity/filtering/truncation, collection +lifecycle/no-op behavior, and the distinction between attribute and event +permissions. Provider persistence and recurrence behavior need controlled native +validation in addition to these command tests. + +`Recurrence.swift` owns recurrence parsing, frequency/selector validation, +native construction, projection and rule text. It uses the full +`EKRecurrenceRule` initializer and compares requested conditions with native +getters. EventKit may ignore incompatible selectors or raise Objective-C +exceptions for invalid weekday ordinals, so validation precedes construction. +Monthly weekday ordinals are limited to five; weekly rules require unnumbered +weekdays. Yearly numbered weekdays cannot be combined with week-number filters. + +The original EventKit suite checks weekly, monthly and yearly rule objects +directly, including native reconstruction, and rejects invalid conditions. Its +existing create/export tests cover custom CLI arguments and refusal to produce a +recurring artifact from expanded rows. These checks do not prove provider saves +or complete recurring-series export. Native saving, occurrence selection, +exceptions and time-zone fidelity require their own controlled evidence. + +All-day serializer tests use explicit time zones, including a two-day Los +Angeles interval spanning daylight-saving time that lasts 47 hours. The output +must retain both local dates and the exclusive end. These are serializer +regressions, separate from Calendar app persistence evidence. diff --git a/Documentation/Reference/Calendar/UserGuide.md b/Documentation/Reference/Calendar/UserGuide.md index 1b27752..0ac9917 100644 --- a/Documentation/Reference/Calendar/UserGuide.md +++ b/Documentation/Reference/Calendar/UserGuide.md @@ -2,20 +2,96 @@ Use `apple calendar --help` and subcommand help as the first reference. +Calendar reads and mutations require full Calendar access in macOS. Write-only +access cannot resolve the selected calendar or existing events. Run +`apple calendar doctor --json` to check authorization and the executable's access +purpose descriptions without requesting access. + +Date-only inputs use `YYYY-MM-DD` in the local time zone and must name a real +Gregorian calendar day. A date-only `--to` includes that day by ending the range +at the next local day. Use an ISO-8601 date-time with an offset for an explicit +instant. + Common reads: ```bash +apple calendar sources list --json +apple calendar sources read --id SOURCE_ID --json apple calendar calendars list --json +apple calendar calendars list --source SOURCE_ID --json +apple calendar calendars read --id CALENDAR_ID --json apple calendar events list --from 2026-01-01 --to 2026-01-31 --json apple calendar events read --id EVENT_ID --json apple calendar availability check --from 2026-01-01T09:00:00Z --to 2026-01-01T10:00:00Z --json ``` +Sources represent Calendar accounts. Use their IDs to select an account when +creating a calendar. Calendar reads include the source ID, type, attribute +permissions and color when available. An explicit `--limit` on source/calendar +lists sets `truncated` when it omits rows. IDs can change after a full sync; +refresh the list if an ID is no longer found. + +Calendar creation and property changes: + +```bash +apple calendar calendars create --source SOURCE_ID --title "Work" --color '#4A90E2' --dry-run --json +apple calendar calendars update --id CALENDAR_ID --title "Projects" --dry-run --json +apple calendar calendars update --id CALENDAR_ID --color '#4A90E2' --dry-run --json +apple calendar calendars delete --id CALENDAR_ID --dry-run --json +``` + +Remove `--dry-run` to execute. Update requires a title, color, or both; an +unchanged request returns `changed: false`. Colors use `#RRGGBB` or `#RRGGBBAA` +and read back as `#RRGGBBAA`. A calendar's source cannot be changed through an +update. Deletion removes the calendar and its contents. Account providers may +restrict creation or deletion, and immutable calendars cannot be renamed, +recolored or deleted even when they allow event writes. Requests that match the +current name/color still return an unchanged result without saving. Check current state +before retrying a save or deletion that failed. + Event export and mutations use the DryRun safety flow: ```bash apple calendar events create --calendar Work --title "Review" --start 2026-01-01T09:00:00Z --end 2026-01-01T10:00:00Z --dry-run --json ``` +Custom repeats can specify numbered weekdays or select the last matching day: + +```bash +apple calendar events create --calendar Work --title "Monthly review" --start 2027-03-12T09:00:00Z --end 2027-03-12T10:00:00Z --recurrence-frequency monthly --recurrence-by-day 2FR --recurrence-count 6 --dry-run --json +apple calendar events create --calendar Work --title "Month end" --start 2027-03-31T09:00:00Z --end 2027-03-31T10:00:00Z --recurrence-frequency monthly --recurrence-by-day MO,TU,WE,TH,FR --recurrence-by-set-pos=-1 --dry-run --json +``` + +| Option | Meaning | +| --- | --- | +| `--recurrence-by-day` | `SU,MO,TU,WE,TH,FR,SA`; monthly/yearly rules also accept ordinals such as `2FR` or `-1MO`. Weekly rules require unnumbered weekdays. | +| `--recurrence-by-month-day` | Monthly days `1...31` or `-31...-1`, counted from the end. | +| `--recurrence-by-month` | Months `1...12` for yearly rules. | +| `--recurrence-by-week-no` | Yearly weeks `1...53` or `-53...-1`. Cannot combine with numbered weekdays. | +| `--recurrence-by-year-day` | Yearly days `1...366` or `-366...-1`. | +| `--recurrence-by-set-pos` | Select positions within another condition's matches; `-1` selects the last. Values range from `-366...-1` or `1...366`. | + +List values use commas. Zero is invalid for numeric selectors. Monthly weekday +ordinals range from `-5...-1` or `1...5`; yearly ordinals range from `-53...-1` or +`1...53`. Supply `--recurrence-frequency` with any recurrence options. A count +and an end date cannot be combined. Updating recurrence replaces the existing +rules; omitting recurrence options preserves them. + +Event summaries and details include all observed `recurrenceRules`, with the +first also available as `recurrence`. Rules include custom conditions, their +calendar identifier and first weekday (`1` is Sunday, `7` is Saturday, and `0` +means unspecified). + +`events export` currently writes non-recurring events. A query containing a +repeating event or an individually changed occurrence returns unsupported +without creating a file. Complete series and exception export is still +unavailable. + +All-day export preserves the event's local calendar dates and exclusive end; +it uses the current macOS time zone when the event has no explicit zone. Timed +events export UTC instants. Event reads include the observed time-zone +identifier when available; full original-zone and floating-time export remain +unavailable. + The detailed capability boundary lives in `../../Architecture/Calendar/CapabilityList.md`. diff --git a/Documentation/Reference/Clipboard/DeveloperGuide.md b/Documentation/Reference/Clipboard/DeveloperGuide.md index 7be81fa..efba4bb 100644 --- a/Documentation/Reference/Clipboard/DeveloperGuide.md +++ b/Documentation/Reference/Clipboard/DeveloperGuide.md @@ -1,10 +1,61 @@ # Clipboard Developer Guide -Use package-local tests for Clipboard changes: +Use the existing SystemDomainCommandTests suite for command behavior: ```bash -swift test --filter SystemDomain +swift test --filter SystemDomainCommandTests ``` -Clipboard reads can expose sensitive local data; keep output explicit and -bounded by command policy. +The native workflow is explicitly enabled: + +```bash +APPLE_CLI_RUN_CLIPBOARD_INTEGRATION_TESTS=1 \ + swift test --filter clipboardNativeReplacementPreservesItemsAndVerifiesChanges +``` + +It creates a unique named pasteboard and temporary files, runs the production +backend, verifies ordered representations and native RTF/image/URL consumers, +and exercises byte bounds, no-op writes, stale counters and data providers. +Cleanup clears the owned board, checks empty state and requests its global +release. General-pasteboard content is not read or written by this workflow. + +ClipboardCLI owns payload validation, byte/item bounds, native failure +handling and mutation verification. MCP discovers its typed command tree +through CLI help. Keep tests that inspect production behavior in the owning +suite; avoid separate implementations of native conversion rules in fakes. + +Raw representations are obtained as complete AppKit Data values before the +byte cap is checked. Retain checks after provider calls: promised data can +be unavailable, or a provider can change ownership while fulfilling it. +`changeCount` tracks ownership, not every provider update. + +Replacement uses fresh, unbound NSPasteboardItems. Verify all requested bytes +and ordering after native writes. Native compatibility formats can be added +by the system; pre-write no-op decisions account for RTF-derived text only +after comparing its decoded text. Read snapshots include those generated +formats. + +Input types follow [Apple's UTI syntax](https://developer.apple.com/library/archive/documentation/FileManagement/Conceptual/understanding_utis/understand_utis_conc/understand_utis_conc.html) and AppKit item validation. Previews prepare fresh in-memory items with the same validation used before replacement. + +Input JSON is read from a bounded regular file and checked for file changes. +Its encoded cap is `4 × ceil(maxBytes / 3) + 2 MiB` for base64 data and JSON +metadata. Decoded representations still share the raw byte cap. +Snapshot-only metadata is not an input mutation instruction. + +Use [NSPasteboard's current-device contents option](https://developer.apple.com/documentation/appkit/nspasteboard/contentsoptions/currenthostonly) +for `--current-host-only`. An explicit restriction requires +`prepareForNewContents(with:)`, even when bytes match, because the public API +cannot read back the active contents options. Preserve the existing counter +precondition and content verification around that ownership claim. + +File-promise formats come from NSFilePromiseReceiver's public +`readableDraggedTypes`. Reject them during complete input validation before +obtaining ownership. [Apple's file-promise API](https://developer.apple.com/documentation/appkit/supporting-drag-and-drop-through-file-promises) +requires provider and receiver lifecycles and asynchronous file creation; +serializing their metadata does not supply that behavior. Raw item reads do +not call `receivePromisedFiles(atDestination:options:operationQueue:reader:)`. + +The framework provides no atomic conditional replacement. Preserve a newer +owner on verification failures; report possible mutation rather than restoring +an old snapshot. Native tests on unique boards do not validate the general +pasteboard's per-app privacy setting or another macOS release. diff --git a/Documentation/Reference/Clipboard/UserGuide.md b/Documentation/Reference/Clipboard/UserGuide.md index ba7313a..e6823a9 100644 --- a/Documentation/Reference/Clipboard/UserGuide.md +++ b/Documentation/Reference/Clipboard/UserGuide.md @@ -2,19 +2,107 @@ Use `apple clipboard --help` and subcommand help as the first reference. -Common reads: +Inspect types and the current ownership counter without returning content: ```bash apple clipboard types --json -apple clipboard read --json +apple clipboard doctor --json ``` -Writes and clear use the DryRun safety flow: +Read plain text, or inspect rich content with item boundaries preserved: + +```bash +apple clipboard read --max-bytes 1048576 --json +apple clipboard items read --limit 50 --max-bytes 1048576 --json +apple clipboard items read --type public.png --json +``` + +`read` uses the native text behavior, which can join text from multiple items. +`items read` returns each item's native representations. `dataBase64` contains +the exact data for a type; an omitted value means the declared data could not +be obtained. `ordinal` is the item's original one-based position. + +Reads are sensitive. The default byte cap is 1 MiB and the maximum is 64 MiB, +counting raw data across returned representations, including native derived +formats. The item limit defaults to 50 and cannot exceed 500. An exceeded byte +cap fails the read. Item limits report `truncated`; a type filter reports +`filtered`. macOS may ask for pasteboard access according to the app's +Paste from Other Apps setting. + +Preview a plain-text replacement: ```bash apple clipboard write --text "hello" --dry-run --json +apple clipboard write --text "hello" --allow-persistent-action --json +``` + +Add `--current-host-only` to either write command to keep its new contents +on this device: + +```bash +apple clipboard write --text "hello" --current-host-only \ + --allow-persistent-action --json +``` + +An explicit `--text ""` writes an empty text item. `clear` removes all items: + +```bash apple clipboard clear --dry-run --json +apple clipboard clear --allow-persistent-action --json ``` -The detailed capability boundary lives in -`../../Architecture/Clipboard/CapabilityList.md`. +Save a complete typed snapshot, then preview or restore it: + +```bash +apple clipboard items read --limit 500 --json > clipboard.json +apple clipboard items write --input clipboard.json --dry-run --json +apple clipboard items write --input clipboard.json --allow-persistent-action --json +``` + +The input accepts the complete read envelope or a plain payload: + +```json +{ + "items": [ + { + "representations": [ + {"type": "public.utf8-plain-text", "dataBase64": "aGVsbG8="} + ] + } + ] +} +``` + +Input item and representation order is preserved. Read ordinals are metadata. +Every representation needs a unique UTI type and valid base64 data. Truncated, +filtered and unavailable snapshots are rejected. Empty item arrays use `clear`. +Input must be a regular JSON file; symlinks are rejected. There are at most +500 items and 256 representations per item. Preview output includes counts +and hashes, not clipboard content. + +File-promise snapshots return `unsupported_operation` on replacement, including +in previews. Their metadata depends on a running provider that creates files +when a transfer is accepted. Reading the raw data does not receive those files. + +Use `--if-change-count` with the counter from `types` or `items read` to refuse +a stale replacement: + +```bash +apple clipboard items write --input clipboard.json \ + --if-change-count 42 --allow-persistent-action --json +``` + +Replace `42` with the observed counter. The precondition does not reserve the +clipboard or make the operation atomic. A preview does not claim ownership. + +Successful writes return `changed` and the verified `changeCount`. Identical +complete requests and empty clears skip writing. With `--current-host-only`, +each write renews ownership to apply the restriction even for identical data. +The contents option cannot be recovered from a raw snapshot. AppKit may generate +additional plain-text formats for RTF. If a write cannot be verified, its +error may report `mutation_may_have_occurred`; reread the current clipboard +before deciding whether to retry. Referenced file URLs preserve their URL +data; consumers still need access to those files. + +The capability boundary lives in the +[Clipboard Capability List](../../Architecture/Clipboard/CapabilityList.md). diff --git a/Documentation/Reference/FrameworkGeneration.md b/Documentation/Reference/FrameworkGeneration.md new file mode 100644 index 0000000..a24caee --- /dev/null +++ b/Documentation/Reference/FrameworkGeneration.md @@ -0,0 +1,115 @@ +# Framework Header Generation + +Notes and Reminders use generated Clang modules with the frameworks' official +names. Their generation commands share an Objective-C declaration merger and +retain separate module configurations: + +```bash +Scripts/notes-private-framework-normalize-full-dump --dump-root --sources-root +Scripts/reminderkit-normalize-full-dump --dump-root --sources-root +``` + +Notes generates `NotesSupport`, `NotesHTML`, `NotesShared`, `NotesUI`, +`NotesEditor`, and `NotesPreviewKit`. Reminders generates `ReminderKit` and +`ReminderKitInternal`. Each module receives an umbrella header, module map, and +linking shim. Input headers use `///*.h`. + +## Versioned Inputs + +Use exactly one of `--dump-root` and `--input-manifest`. A versioned manifest +follows [FrameworkInputManifest.schema.json](FrameworkInputManifest.schema.json): + +```bash +Scripts/notes-private-framework-normalize-full-dump \ + --input-manifest \ + --sources-root \ + --report +``` + +`--sdk-root` optionally selects the validation SDK. Otherwise the generator +uses the selected Xcode macOS SDK. + +Each manifest input has a unique `id`, a `dump_root` relative to the manifest +or an absolute input path, a recorded macOS version, OS build, and architecture +(`arm64` or `x86_64`). Record the extraction source, SDK version, and app version +when known. Each configured module has either: + +- `presence: "present"` with a complete map of header filenames to their SHA-256 + digests; extra, missing, or changed headers are refused. +- `presence: "absent"` without headers; the module input directory must be + absent. This describes the supplied dump, rather than proving absence of a + framework from an OS. + +Every output module needs at least one present input. A single dump does not +infer version, build, architecture, or app metadata from the generation host. + +## Merge and Validation + +Declarations are identified by module, class, protocol, or category; members +are identified by property name or scoped Objective-C selector. Matching +declarations are deduplicated and compatible additional members are combined. +Method argument names do not define a selector or signature. Superclass, +return/parameter type, property ownership, accessors, mutability, protocol +requirement, and selector conflicts retain their source identities and block +generation. Categories are checked against the class's effective selectors. +For comparison, explicit default getter/setter names are equivalent to their +omission, and `strong` and `retain` describe the same ownership. Source +declarations and origins remain in the output and report. Ownership semantics +follow [Clang's ARC specification](https://clang.llvm.org/docs/AutomaticReferenceCounting.html#property-declarations); +accessor defaults follow [Apple's declared property rules](https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/ObjectiveC/Chapters/ocProperties.html). +Conflicting property metadata within a single declaration also requires +reconciliation. A property and a method with the same accessor but different +object types remain conflicting; the generator does not choose a declaration +based on its position or erase the discrepancy to `id`. + +The report lists each declaration and member's observed inputs and the input +header digests. `availability_basis: "observed_input_presence"` records evidence +from supplied dumps. It does not establish an Apple API introduction version. +Members excluded by import normalization are reported with their origins. +Normalized headers remain an import surface; they do not independently prove +runtime ABI or behavior of every declaration. + +Before publication, the generator typechecks imports of every configured +module for each recorded architecture. Without recorded architecture metadata, +it checks the generation host's architecture. The compiler uses the selected +SDK and a macOS 13 deployment target. These checks do not prove old-OS framework +loadability, execution on Intel, or target operation behavior. Runtime +requirements and native verification remain owned by the CLI target. + +## Output Ownership and Recovery + +Generation prepares the complete output tree beside `sources-root`, preserving +other targets' files. Parsing, digest, conflict, or import failures preserve the +existing output group. A successful generation publishes the prepared tree +through one directory exchange, with snapshots before and after exchange to +detect concurrent edits. Run generation while builds and source edits are idle. + +Generated paths and metadata must not be symlinks. Generation metadata is +stored under `/.framework-normalization/.json`. An +explicit `--report` destination must be outside the output tree. On a failed +exchange recovery, retain the workspace identified in the diagnostic before +manual recovery. A diagnostic report write failure after successful publication +produces a warning; the validated output group remains published. + +Inputs are read-only regular files with bounded sizes. Diagnostic reports must +also be outside every input dump tree and cannot replace the input manifest or +an input header reached through a symbolic link. Boundaries are checked against +resolved ancestors even when an output or report does not yet exist. Dangling +ancestor links and links at owned generated paths are refused. + +Keep local dumps, machine paths, and exploratory reports in temporary execution +state. Promote only reviewed import surfaces and portable provenance appropriate +to the repository. Normal package builds use the saved headers and do not run +class dumps or the normalizer. + +## Tests + +```bash +swift test --filter FrameworkNormalizationTests +``` + +Swift Testing uses synthetic declarations and temporary output trees. It +covers both profiles, missing inputs, signature conflicts, observed version +presence, order independence, protocol/category merging, digest validation, +compiler rejection, and output preservation. It does not access app stores or +invoke app mutations. diff --git a/Documentation/Reference/FrameworkInputManifest.schema.json b/Documentation/Reference/FrameworkInputManifest.schema.json new file mode 100644 index 0000000..24fab47 --- /dev/null +++ b/Documentation/Reference/FrameworkInputManifest.schema.json @@ -0,0 +1,49 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "apple-cli framework declaration input manifest", + "type": "object", + "required": ["schema_version", "inputs"], + "properties": { + "schema_version": {"const": 1}, + "inputs": { + "type": "array", "minItems": 1, "maxItems": 64, + "items": { + "type": "object", + "required": ["id", "dump_root", "macos_version", "os_build", "architecture", "modules"], + "properties": { + "id": {"type": "string", "pattern": "^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$"}, + "dump_root": {"type": "string", "minLength": 1}, + "source_kind": {"type": "string"}, + "macos_version": {"type": "string", "pattern": "^[0-9]+(?:\\.[0-9]+){1,2}$"}, + "os_build": {"type": "string", "minLength": 1}, + "architecture": {"enum": ["arm64", "x86_64"]}, + "sdk_version": {"type": "string"}, + "app_version": {"type": "string"}, + "modules": { + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "type": "object", "required": ["presence", "headers"], + "properties": { + "presence": {"const": "present"}, + "headers": { + "type": "object", "minProperties": 1, + "propertyNames": {"pattern": "^[^/\\\\]+\\.h$"}, + "additionalProperties": {"type": "string", "pattern": "^[0-9a-f]{64}$"} + } + } + }, + { + "type": "object", "required": ["presence"], + "properties": {"presence": {"const": "absent"}}, + "not": {"required": ["headers"]} + } + ] + } + } + } + } + } + } +} diff --git a/Documentation/Reference/Intelligence/DeveloperGuide.md b/Documentation/Reference/Intelligence/DeveloperGuide.md index 80c976f..e0938e1 100644 --- a/Documentation/Reference/Intelligence/DeveloperGuide.md +++ b/Documentation/Reference/Intelligence/DeveloperGuide.md @@ -8,3 +8,18 @@ swift test --filter Intelligence Risk-flagged commands must keep explicit `--allow-*` semantics for fixed-risk local cache mechanisms. + +Country-cache regressions use synthetic keyed archives with native UIDs, shared +active/history/local references, and unrelated country-like strings. They +verify complete object preservation, no-op bytes, rejection before backup or +unlock, write/readback failures, concurrent replacement, and exact rollback. + +A separate explicit opt-in validates the host archive using copies in memory: + +```bash +APPLE_CLI_RUN_INTELLIGENCE_COUNTRY_READONLY=1 swift test --filter IntelligenceCountryCacheReadonlyTests +``` + +This test reads the system country cache without writing it. It does not prove +country daemon acceptance, service availability, or model readiness. Default +package tests do not read that host cache. diff --git a/Documentation/Reference/Intelligence/UserGuide.md b/Documentation/Reference/Intelligence/UserGuide.md index ae6572c..6cb270e 100644 --- a/Documentation/Reference/Intelligence/UserGuide.md +++ b/Documentation/Reference/Intelligence/UserGuide.md @@ -23,7 +23,7 @@ version or implementation mechanism uses the same route. | --- | --- | --- | | `--patch-scope answer` | Sets known `os_eligibility_answer_t` cache values to the eligible answer. | Modern local-cache path. This is the smallest patch and is useful when the target cache files exist. | | `--patch-scope comprehensive` | Applies answer values plus known GREYMATTER/CALCIUM status input values. | Modern local-cache path and the default enablement scope. | -| `--eligibility-country CC` | Rewrites uppercase alpha-2 country-code strings in `countryCodeCache.plist`. | Optional country-cache path for releases where `countryd` contributes to location-gated behavior. | +| `--eligibility-country CC` | Changes country fields on the active combined estimate in a supported country archive. | Optional country-cache path for releases where `countryd` contributes to location-gated behavior. | | `recompute` | Performs a bounded `lldb` attach to `eligibilityd` and recomputes. | Optional refresh path when plist/cache values are present but the daemon has not picked them up. | | `service install` | Installs this CLI's LaunchDaemon to run the Swift CLI recompute command. | Optional persistence for recompute only; not part of default enablement. | | macOS beta-only region spoof or feature-flag experiments | Not implemented in the production CLI. | Out of current scope until there is a source-owned, testable design. | @@ -113,9 +113,21 @@ debug attach. Changing the eligibility country cache is optional. Use it only when you need to force the cached eligibility country, usually to `US`, for location-gated features such as ChatGPT integration, Apple News, or international Maps. -The command rewrites uppercase two-letter country-code strings in -`countryCodeCache.plist`; `countryd` remains an internal touched subsystem, not -a user-facing option. +The command changes the active combined estimate in `countryCodeCache.plist`. +Historical estimates, local observations, timestamps, priorities and unrelated +values are preserved, including shared references. It accepts archive version +`100000` with cached-data version `5`; missing or unsupported country archives +are refused before any eligibility cache is changed, even with `--create-missing`. +The country action reports changed estimates and verified archive readback. +This confirms cache contents; app access, service checks and model downloads +still require separate verification. + +Enablement checks for concurrent cache changes. If a write or readback fails, +it restores only bytes still attributable to this operation. An unconfirmed +restore preserves the current file: inspect the action receipt and backup +before using manual rollback. Backup creation alone does not count as a cache +change. Manual rollback validates every backup digest before unlocking or +restoring any file, then uses atomic writes and exact readback checks. If you use iPhone Mirroring, pair the iPhone with the Mac before changing the eligibility country. diff --git a/Documentation/Reference/Keynote/DeveloperGuide.md b/Documentation/Reference/Keynote/DeveloperGuide.md index f3f33a3..843c3d1 100644 --- a/Documentation/Reference/Keynote/DeveloperGuide.md +++ b/Documentation/Reference/Keynote/DeveloperGuide.md @@ -1,10 +1,24 @@ # Keynote Developer Guide -Use package-local tests for Keynote changes: +Use package-local Swift Testing for Keynote changes: ```bash -swift test --filter IWork +swift test --filter 'KeynoteContentTests|IWorkDocumentCommandTests' ``` -Keep presentation paths explicit and richer Keynote writes gated until -separately proven. +The installed scripting dictionary can be checked independently: + +```bash +APPLE_CLI_VALIDATE_IWORK_SDEF=1 swift test --filter KeynoteContentTests.keynoteScriptsCompileAgainstInstalledDictionary +``` + +That check compiles the scripts without executing document reads or exports. +Native acceptance needs controlled presentations with known slide order, +skipped state, default text, presenter notes and PDF page counts. Test both +single-file and package forms, reordered slides, absent/stale caches, borrowed +unsaved documents, source preservation and Automation failures. + +Keep typed decoding strict: incomplete rows, missing required metadata and +unknown scalar types must fail. Optional native text remains unknown when +unavailable. Snapshot-position identities must not become persistent selectors. +Export verification must precede publication and preserve existing destinations. diff --git a/Documentation/Reference/Keynote/UserGuide.md b/Documentation/Reference/Keynote/UserGuide.md index 92cd6be..b0e3008 100644 --- a/Documentation/Reference/Keynote/UserGuide.md +++ b/Documentation/Reference/Keynote/UserGuide.md @@ -2,19 +2,55 @@ Use `apple keynote --help` and subcommand help as the first reference. -Common reads: +Read metadata or native slides: ```bash apple keynote presentations list --path . --json apple keynote presentations read --path Deck.key --json -apple keynote slides list --path Deck.key --json +apple keynote slides list --path Deck.key --limit 50 --json ``` -Open and export use the DryRun safety flow: +File and package presentations are accepted. Native slide reads require Keynote +and Automation permission for the process running `apple`. They include skipped +slides, default title/body text and plain presenter notes. `totalSlideCount` +reports all slides; `truncated` reports whether the limit shortened the result. +An unavailable native read returns an error. + +`documentID` identifies the open Keynote document. Slide `id` values identify +positions within one `snapshotID`; `identityKind` is `snapshot_position`. Read +again after editing or reordering. These IDs cannot track a slide between reads. +`readSource` indicates whether the operation borrowed a `live_document` or +opened the saved file as `opened_file`. Borrowed documents keep their unsaved +edits and remain open. + +Export a native PDF, including skipped slides with one page per slide: + +```bash +apple keynote presentations export --path Deck.key --to Deck.pdf --format pdf --dry-run --json +apple keynote presentations export --path Deck.key --to Deck.pdf --format pdf --allow-artifact-action --json +``` + +A dry run validates paths and formats without rendering. Execution requires +Automation and a fresh destination. The result includes document identity, +source, page count, bytes and SHA-256. Artifact verification checks PDF validity +and page count. If a native operation times out, inspect the reported staging +path before retrying. `residualArtifactPaths` reports staging artifacts when +publication succeeds but cleanup cannot remove them. + +Inspect or copy available cache images independently: ```bash -apple keynote presentations export --path Deck.key --output Deck.pdf --format pdf --dry-run --json +apple keynote previews list --path Deck.key --json +apple keynote previews export --path Deck.key --to DeckPreviews --format images --dry-run --json +apple keynote previews export --path Deck.key --to DeckPreviews --format images --allow-artifact-action --json ``` -The detailed capability boundary lives in +Preview order describes cache files. Missing previews do not imply an empty +presentation. These commands inspect package caches; they do not extract caches +from single-file presentations. `presentations export --format preview-pdf` +copies the cached PDF, `thumbnail` copies the cached JPEG, and `package` copies +a package presentation. Cached artifacts may be absent or stale. + +Open actions require `--allow-external-dispatch`; exports require +`--allow-artifact-action`. The capability boundary lives in `../../Architecture/Keynote/CapabilityList.md`. diff --git a/Documentation/Reference/Mail/UserGuide.md b/Documentation/Reference/Mail/UserGuide.md index 61946a3..f56a2a1 100644 --- a/Documentation/Reference/Mail/UserGuide.md +++ b/Documentation/Reference/Mail/UserGuide.md @@ -19,3 +19,11 @@ apple mail messages draft --to user@example.com --subject "Hi" --body "Hello" -- The detailed capability boundary lives in `../../Architecture/Mail/CapabilityList.md`. + +Body previews respect `--max-bytes` in UTF-8 and preserve complete characters. +`truncated` indicates that additional body content was omitted. + +Successful send results contain `sent.submitted: true` after Mail accepts the +request. Mail owns delivery tracking. Rejected or unconfirmed submissions return +an error with `submission_status` set to `rejected` or `unknown`; inspect Mail's +outgoing messages and drafts before retrying. diff --git a/Documentation/Reference/Maps/DeveloperGuide.md b/Documentation/Reference/Maps/DeveloperGuide.md index 9bac0b9..3789c68 100644 --- a/Documentation/Reference/Maps/DeveloperGuide.md +++ b/Documentation/Reference/Maps/DeveloperGuide.md @@ -7,3 +7,77 @@ swift test --filter Maps ``` Keep URL opening validation target-local; execution requires `--allow-external-dispatch`. + +MapKit callbacks arrive on the main queue. Keep the synchronous command's main +run loop runnable and cancel the native request when its deadline expires. +The synchronous backend is called from the CLI's synchronous entrypoint or a +worker with the main queue available. It must not block an active MainActor job. +Search uses an optional region bias and preserves native result ordering. +Report the returned batch count separately from the CLI cap. + +Route endpoint queries must have exactly one candidate. Keep requested and +returned transport modes consistent; transit supports ETA only. MapKit route +distances use meters and durations use seconds. Project native steps and +polyline coordinates with counts and explicit truncation flags. + +Native place ID lookup is guarded at macOS 15; modern location/address +projection is guarded at macOS 26. Earlier runtimes use placemark projection. +These guards do not replace validation on the intended OS. Package tests use +native objects and controlled callbacks without issuing network requests; +current-host network verification is a separate check. `doctor` checks local +readiness and does not verify regional service connectivity. + +Saved reads belong to `MapsSyncSavedPlacesBackend`. Use the native default-store +factory and the designated query-options initializer. Bound native fetches to +`limit + 1`, preserve native UUIDs and optional values, and derive `hasMore` from +that returned batch. Sort by position and UUID and validate the native range's +offset/limit values, rather than object identity. Hidden favorite records remain +part of the projection. Read selectors use UUID predicates; member reads validate +the exact parent and its relationship. Missing parents and empty collections have +distinct outcomes. +Do not replace unreadable fields with empty strings or zero coordinates. + +MapsSync's fetch method uses a Swift extended Objective-C encoding, which the +runtime argument-count parser splits incorrectly. Validate the complete expected +encoding before making that call. Guard other constructors and getters by their +ABI. Store and fetch callbacks share a deadline; late callbacks are closed out, +and no native cancellation method is available on this request. Deterministic +command tests use a configured reader; native store identity and fields require +a separate real CLI/MCP read. + +Collection writes also belong to `MapsSyncSavedPlacesBackend`. Validate mutation +inputs and the native constructor/setter/save/delete ABIs before changing objects. +Creation assigns the requested UUID and verifies it before save. Repeated creation +with matching requested fields, unchanged metadata and existing desired links +return without saving. The built-in Favorites guide's metadata and deletion are +protected independently of member links. + +Recheck exact selected records before write. Compare text by UTF-8 bytes so Swift's +canonical Unicode equality cannot hide a changed native value. Take complete +relationship snapshots through typed store queries, rather than cached wrapper +collections. Fetch at most 10,001 results and refuse the operation if the extra +result indicates a scope larger than 10,000. Raw cover data, member fields and +other parents must survive the intended change. Save the parent and item after +the parent's addPlace/removePlace operation; collection deletion invokes native +delete on the selected collection only. + +Store creation, snapshots, save/delete callback and fresh verification share a +20-second write deadline. Verify changed fields and relationships after save; +verify parent absence and shared members' remaining links after delete. Native +callbacks have no cancellation method here. Post-submission failures retain +`mutation_outcome: unverified`; a timeout does not prove that a write was undone. + +New place creation uses the MapsSync-owned `MSCollectionPlaceItem.strippedMapItemWith:` +conversion of a native GEO item. Service place IDs remain opaque and distinct +from saved UUIDs and numeric native place identifiers. Explicit coordinates are +saved as coordinate points. The creation path shares a 25-second deadline across +source lookup, store operations and verification, checking full native storage +semantics and unknown-field bytes alongside fields, cover and relationships. +Input and dry-run regression tests cover the command; its native end-to-end +workflow remains unverified. + +Controlled current-host workflows verify nonempty place membership, collection +creation/update/deletion, shared member links and cover preservation. They compare +existing records, independently read native persistence and clean up exact owned +UUIDs. Full Maps view refresh, transit-member workflows and another OS remain +unverified. diff --git a/Documentation/Reference/Maps/UserGuide.md b/Documentation/Reference/Maps/UserGuide.md index c1b1e35..75f0701 100644 --- a/Documentation/Reference/Maps/UserGuide.md +++ b/Documentation/Reference/Maps/UserGuide.md @@ -2,19 +2,163 @@ Use `apple maps --help` and subcommand help as the first reference. -Common reads and previews: +Search addresses or points of interest. An explicit region biases results; +it does not impose a distance filter. Search returns at most 10 places by +default, with `--limit` from 1 to 50: + +```bash +apple maps places search --query "coffee" --kind poi --json +apple maps places search --query "三里屯" --kind all \ + --region-latitude 39.9338 --region-longitude 116.4552 \ + --region-span-meters 10000 --limit 5 --json +apple maps places read --latitude 39.9338 --longitude 116.4552 --json +``` + +On macOS 15 or newer, a result may include a native `maps-item:` ID. Use that +returned ID for `places read --id`. A derived `maps-place:` coordinate snapshot +ID cannot be looked up. Details depend on the service and can include address, +phone, website, category and time zone. Reads require network access and regional +coverage; they do not request your current location. + +Read favorites saved in the current user's Maps store: + +```bash +apple maps favorites list --limit 20 --json +``` + +The default limit is 20; `--limit` accepts 1...100. Results include native +`maps-favorite:` IDs, visibility (`hidden`), positions and available names, +addresses, coordinates and dates. Hidden native entries are included and may +have no place details. Missing details remain absent. Results keep native batch +order by position and UUID. When `hasMore` is true, pass the returned `nextOffset` +as `--offset` to retrieve another batch. Offset accepts 0...1,000,000. Separate +pages reflect current data; concurrent changes can affect their contents. +Favorite IDs are distinct from `maps-item:` place IDs. + +Read one favorite using the ID returned by its list: + +```bash +apple maps favorites read --id 'maps-favorite:' --json +``` + +Replace `` with the corresponding list's UUID. Read your saved +collections and the places belonging to one collection: + +```bash +apple maps collections list --limit 20 --offset 0 --json +apple maps collections read --id 'maps-collection:' --json +apple maps collections places list --id 'maps-collection:' \ + --limit 20 --offset 0 --json +``` + +Collections include available title, description, image URL and dates, plus +position and `reportedPlaceCount` from Maps. Member results include the selected +collection and native member IDs. Place members include available names, address, +coordinates, category and note; transit members have a string line ID. Missing +details remain absent. A missing parent collection returns `not_found`. +`reportedPlaceCount` is independent of the returned member batch. Both lists use +the same limit/offset rules. These read commands do not edit saved data. + +Create a custom collection, previewing the request first: + +```bash +apple maps collections create --title "Weekend" --description "Places to visit" \ + --dry-run --json +apple maps collections create --title "Weekend" --description "Places to visit" --json +``` + +Use the returned `collection.id` in later commands. To reuse the preview's +generated identity, pass `normalizedArguments.id` as `--id` when creating. A retry with the same +UUID and requested fields returns `changed: false`; different content at that +UUID is refused. Each creation without `--id` generates a new UUID. `--position` +accepts a nonnegative native index; creation appends by default. + +Update only the fields you supply, or delete the selected custom collection: + +```bash +apple maps collections update --id 'maps-collection:' \ + --title "Weekend plans" --position 2 --json +apple maps collections update --id 'maps-collection:' \ + --clear-description --json +apple maps collections delete --id 'maps-collection:' --dry-run --json +``` + +`--description` and `--clear-description` cannot be combined. Metadata updates +preserve the collection's cover and members. The built-in Favorites guide's +metadata and deletion are managed by Maps. + +Link an existing saved member, taking its `maps-collection-item:` ID from a +collection's member list: + +```bash +apple maps collections places add --id 'maps-collection:' \ + --item 'maps-collection-item:' --json +apple maps collections places remove --id 'maps-collection:' \ + --item 'maps-collection-item:' --json +``` + +These commands change a link; they do not create a place from a search result or +favorite ID. Removing a link preserves the saved member and its other collection +links. Deleting a collection preserves members shared with another collection; +Maps determines what happens to members with no remaining collection. + +New place saving is exposed by `collections places create --help`, using either +a native `--place maps-item:` ID or explicit latitude and longitude, with optional +custom name, note and recoverable `--item` UUID. Native end-to-end validation of +this command is pending; it is not part of the verified saved-place workflows. + +Collection mutation commands accept `--dry-run`; update, delete and member previews +read the selected records without saving. Remove `--dry-run` to execute an explicit +request. Unchanged updates and repeated add/remove requests return `changed: false`. +Writes refuse stale selected records or relationship scopes exceeding 10,000 +results. If an error reports `mutation_outcome: unverified`, read the selected +collection and member list before retrying: the native write may have occurred. + +Saved-store access depends on compatible MapsSync interfaces and the current +user's Maps state. An unavailable interface or failed native read returns +`backend_unavailable`; it does not return an empty successful list. + +Calculate a route between explicit coordinates, using coordinates returned by +search for the desired places: + +```bash +apple maps directions calculate \ + --from-latitude 39.9338 --from-longitude 116.4552 \ + --to-latitude 39.9419 --to-longitude 116.4552 \ + --mode walking --json +apple maps directions eta \ + --from-latitude 39.9338 --from-longitude 116.4552 \ + --to-latitude 39.9419 --to-longitude 116.4552 \ + --mode transit --json +``` + +`calculate` accepts driving, walking and cycling requests where the service +supports them. A response reporting another mode is rejected with +`backend_unavailable`; it is not labelled as the requested mode. It returns distances in +meters, travel times in seconds, route steps, advisory notices, toll/highway +indicators and latitude/longitude geometry. `--alternatives` asks for alternate +routes; `--limit` caps returned routes from 1 to 10, default 3. Steps and geometry +carry counts and truncation flags; a truncated geometry does not represent the +whole route. `eta` also supports transit and reports native departure and arrival +estimates. These are estimates, not live navigation or transit timetables. + +Both commands accept query endpoints through `--from` and `--to`, but require +each query to resolve uniquely. If a query is ambiguous, search first and choose +explicit coordinates. `--departure` or `--arrival` accepts ISO 8601 with a time +zone; supply only one. Service availability and mode coverage vary by region. + +Create a directions link without making a network request or opening Maps: ```bash -apple maps places search --query "coffee" --json -apple maps places read --id PLACE_ID --json apple maps directions preview --from "Cupertino" --to "San Francisco" --json ``` -Opening Maps uses the DryRun safety flow: +Opening Maps uses a `maps:` URL from a place result or directions preview: ```bash -apple maps open --query "Apple Park" --dry-run --json +apple maps open --url 'maps:?q=Cupertino' --dry-run --json +apple maps open --url 'maps:?q=Cupertino' --allow-external-dispatch --json ``` -The detailed capability boundary lives in -`../../Architecture/Maps/CapabilityList.md`. +`submitted` reports the application's dispatch result. The detailed capability +boundary lives in [Maps Capability List](../../Architecture/Maps/CapabilityList.md). diff --git a/Documentation/Reference/NativeFixtureManifest.schema.json b/Documentation/Reference/NativeFixtureManifest.schema.json new file mode 100644 index 0000000..37d8706 --- /dev/null +++ b/Documentation/Reference/NativeFixtureManifest.schema.json @@ -0,0 +1,56 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Native App Mutation Fixture Manifest", + "type": "object", + "required": ["schemaVersion", "runID", "host", "evidenceDirectory"], + "anyOf": [{"required": ["notes"]}, {"required": ["reminders"]}], + "properties": { + "schemaVersion": {"const": 1}, + "runID": {"type": "string", "format": "uuid"}, + "host": { + "type": "object", + "required": ["userID", "osVersion", "osBuild", "architecture"], + "properties": { + "userID": {"type": "integer", "minimum": 0, "maximum": 4294967295}, + "osVersion": {"type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"}, + "osBuild": {"type": "string", "minLength": 1}, + "architecture": {"enum": ["arm64", "x86_64"]} + } + }, + "evidenceDirectory": { + "type": "string", + "pattern": "^/", + "description": "Canonical absolute path whose final component equals the lowercase run UUID." + }, + "notes": { + "allOf": [ + {"$ref": "#/$defs/scope"}, + {"properties": { + "accountID": {"pattern": "^x-coredata://"}, + "containerID": {"pattern": "^x-coredata://"} + }} + ] + }, + "reminders": { + "allOf": [ + {"$ref": "#/$defs/scope"}, + {"properties": { + "accountID": {"pattern": "^x-apple-reminderkit://"}, + "containerID": {"pattern": "^x-apple-reminderkit://"} + }} + ] + } + }, + "$defs": { + "scope": { + "type": "object", + "required": ["accountID", "containerID", "appVersion", "appBuild"], + "properties": { + "accountID": {"type": "string", "minLength": 1}, + "containerID": {"type": "string", "minLength": 1}, + "appVersion": {"type": "string", "minLength": 1}, + "appBuild": {"type": "string", "minLength": 1} + } + } + } +} diff --git a/Documentation/Reference/NativeFixtureValidation.md b/Documentation/Reference/NativeFixtureValidation.md new file mode 100644 index 0000000..cde39fe --- /dev/null +++ b/Documentation/Reference/NativeFixtureValidation.md @@ -0,0 +1,128 @@ +# Native Fixture Validation + +Default Swift Testing checks use controlled backends and temporary data. +`NativeMutationFixtureTests` provides separate, opt-in Notes and Reminders +workflows against configured app data. A skipped test provides no native +mutation evidence. + +## Configure the Test Environment + +Use the macOS user and account selected for native validation. Prepare a +dedicated, empty Notes folder or standard Reminders list for each run. Generate a fresh UUID, and +name the container `Apple CLI Fixture `. + +The Notes folder must support editing and must be unshared, writable, ordinary, +and have no child folders. Default, system, trash, and smart folders are +refused. The Reminders list must be writable, standard, and unshared. Container +identity, name, and ownership are checked again before object mutations and +cleanup. Preparation and removal of the test containers belong to the test +environment; the suite creates and cleans its own note or reminder. + +Read account and container identities through the package CLI: + +```bash +.build/debug/apple notes accounts list --json +.build/debug/apple notes folders list --account ACCOUNT_ID --json +.build/debug/apple reminders lists list --json +``` + +Use the exact IDs returned by the app. Notes IDs use `x-coredata://`; Reminders +IDs use `x-apple-reminderkit://`. Names, default selectors, and private-list +identifiers cannot select a mutation fixture. + +## Fixture Manifest + +Store a JSON manifest outside published product content. The +[manifest schema](NativeFixtureManifest.schema.json) describes its fields. + +| Field | Required value | +| --- | --- | +| `schemaVersion` | Integer `1` | +| `runID` | Fresh UUID for this run | +| `host.userID` | Numeric UID of the test user | +| `host.osVersion` | OS version as `major.minor.patch` | +| `host.osBuild` | Exact OS build | +| `host.architecture` | `arm64` or `x86_64` | +| `evidenceDirectory` | Absolute canonical path ending in the lowercase run UUID; its parent must exist | +| `notes.accountID`, `notes.containerID` | Exact test account and folder IDs when running Notes | +| `reminders.accountID`, `reminders.containerID` | Exact test source and list IDs when running Reminders | +| `.appVersion`, `.appBuild` | Exact installed app version and build | + +Obtain UID and OS metadata with `id -u`, `sw_vers`, and `uname -m`. App version +and build are `CFBundleShortVersionString` and `CFBundleVersion` from the +installed app's `Info.plist`. An app or OS update requires fresh metadata. +The active target's scope must be present; the other scope may be omitted. +The manifest must be a regular file no larger than 64 KiB. + +The suite creates private run and target directories. An existing target run +directory is refused, including a previous failed run. Keep its evidence and +inspect any remaining objects before preparing a new UUID and container name. + +## Run an Individual Workflow + +Build the package executables and tests from the repository root: + +```bash +xcrun swift build --build-tests --force-resolved-versions --jobs 2 +``` + +Set `APPLE_CLI_NATIVE_FIXTURE_MANIFEST` to the absolute path of the prepared +manifest. Enable and select only the workflow being validated: + +```bash +APPLE_CLI_RUN_NOTES_MUTATION_TESTS=1 \ + xcrun swift test --skip-build --force-resolved-versions \ + --filter notesSaveFormatAndColdReadback + +APPLE_CLI_RUN_REMINDERS_MUTATION_TESTS=1 \ + xcrun swift test --skip-build --force-resolved-versions \ + --filter remindersCompletionHistoryAndColdReadback +``` + +Both workflows are serialized within their suite. Run one test process at a +time against the app environment. Each CLI invocation starts a new process, +has a 20-second deadline, and bounds output to 2 MiB. The suite invokes the +repository's `.build/debug/apple`; it does not select an executable from `PATH` +or the manifest. + +Notes creates a marked note, applies bold and strikethrough to a Unicode +selection, checks an idempotent retry, compares plain content and identity +after cold reads, and checks structure and native HTML export. It also renames +the note, verifies preserved content, formatting and paragraph identities, +and checks that a repeated title edit is a no-op. Reminders +creates a marked reminder, completes it with a historical timestamp, retries, +and explicitly corrects the timestamp while checking unrelated fields. +These are specific workflow checks; broader target and OS coverage requires +additional validation. + +## Evidence and Recovery + +Each target directory contains a manifest snapshot, container snapshots, +created object, before/after records, operation receipts, and `ledger.json`. +Command receipts retain bounded stdout and stderr, including failed command +results. Notes also retains the native HTML artifact. Snapshot files are written +exclusively. Evidence includes app content and exact IDs; keep it local to the +test environment. + +The ledger records intent before creation and stores the returned object ID +before subsequent writes. Cleanup checks the recorded ID, title marker, and +current container membership. A failed creation response can be reconciled +only to a unique object matching the recorded intent in the dedicated +container. A controlled rename records the pending title before the command; +only that object's exact old or pending title remains owned until cold readback +confirms the transition. Other renamed, moved, ambiguous, or foreign objects stop cleanup. + +Notes cleanup moves the owned note to Recently Deleted and purges that exact +ID after matching its title. Purge readback must return an empty exact-ID +result or the matching `not_found` error and exit code. Permission failures, +invalid output, and timeouts leave cleanup unverified. Reminders deletion +requires cold confirmation that the ID has left its dedicated list and that +ordinary exact-ID reads return the matching `not_found` result. + +Validation failures stop the workflow and still enter cleanup. The ledger +retains operation and cleanup failures separately, with error codes and +diagnostic hashes. Objects are marked cleaned only after the applicable +readback succeeds. If the process stops or cleanup fails, inspect the ledger +and current native state, then recover only the recorded objects through the +owning CLI. Keep any unresolved object as a reported residual; do not reuse +the old run directory or infer success from a submitted operation. diff --git a/Documentation/Reference/Notes/DeveloperGuide.md b/Documentation/Reference/Notes/DeveloperGuide.md index 18b095a..7fe4668 100644 --- a/Documentation/Reference/Notes/DeveloperGuide.md +++ b/Documentation/Reference/Notes/DeveloperGuide.md @@ -39,6 +39,11 @@ Scripts/notes-private-framework-normalize-full-dump \ --sources-root Sources ``` +The command also accepts `--input-manifest` for digest-checked multi-version +inputs. Its shared merger validates declarations and module imports before +publishing the complete output group. See [Framework Header Generation](../FrameworkGeneration.md) +for input metadata, conflict reports, observed availability, and recovery. + The generated surfaces live under: - `Sources/NotesSupport/include/` @@ -98,6 +103,32 @@ context or persistent store; required deferred accessors retain a readiness warning until operation-context verification. See [Apple's managed-object documentation](https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/CoreData/LifeofaManagedObject.html). +The reader and writer share context bootstrap requirements. The shared-context, +start, and initializer signatures are checked immediately before their typed +calls; unused fallback methods do not gate an existing shared context. Writer +bootstrap and context-save helpers check the context and managed-context save +signatures, and saves recheck the actual receiver. The common note-save helper +also checks the NotesUI `ICNote.save` void method before calling it. + +A single-note `read` performs its fetch, date-model checks, accessor signature +checks, and value projection on the owning managed-object context's queue. +Date attributes must match the actual entity model. An object declaration or +an installed model alone does not establish that an accessor works on that +object. These requirements cover the listed entry points; other capability +families retain their own implementation and validation requirements. + +Explicit host read validation runs separately from the default fixture suite: + +```bash +APPLE_CLI_RUN_NOTES_INTEGRATION_TESTS=1 swift test --filter NotesReaderTests +``` + +This suite needs an existing unprotected note with date metadata. It exercises +real model-backed date and plain-text reads and inspects save signatures +without saving. Write preservation requires a controlled mutation fixture and +save/reopen verification. Runtime checks run after process loading; the +[Release Guide](../ReleaseGuide.md) describes startup dependencies. + Current `doctor` includes `notes_store`, a read-only check for the Notes group container, `NoteStore.sqlite`, SQLite schema counts, entity row counts, WAL/SHM presence, and Notes index-state files. This evidence is diagnostic/verifier @@ -2754,6 +2785,16 @@ counts, and rich-state flags. It must not print note body text, note title, raw attributed content, raw paragraph style data, raw colors, raw font objects, private color/font objects, table cell text, or checklist item text. +Structure counts, collections, and rich-state flags preserve unknown readback +as optional values. Missing evidence omits the corresponding fields from JSON; +a successfully read empty body produces zero counts and empty collections. +Plain-text, link, attachment, and native state metadata are independently +retained when successfully read. Outline counts require an available controller +for nonempty paragraphs. Missing table/math/outline readback reports backend +unavailability rather than an empty selector list. Surface accounting and +mutation verification require available evidence; Smart Folder checklist, +mention, and math reasons retain unknown evidence instead of proving absence. + Rich body special-surface accounting, collapsible-section state mutation, and collapsible-section create/update through paragraph style are promoted for table/math/collapsible accounting and collapsible state/content-boundary @@ -3078,10 +3119,18 @@ attributed content. Inline format/color/highlight/font writes use text range inside a paragraph selected by paragraph hash or ordinal. The command layer hashes selected text, color input, and font-family evidence for dry-run/result output; point size is reported as a normalized number. The -verifier checks selected-text hash/count, target inline run readback, -font-hash readback for font mutations, changed/no-op reporting, -paragraph-anchor order preservation, body byte-count/hash preservation, and note -identity/title/folder/account preservation. +verifier independently reselects the paragraph and literal text occurrence from +attributed readback, then compares text hash/count, whole-body UTF-16 position, +occurrence, and rich-string snapshot identity with writer evidence. Format, +font, and color readback uses continuous interval coverage across attribute-run +splits. Partial coverage requires a change for either enabling or disabling; +zero or partial coverage cannot verify enabling the requested format. The +verifier also checks changed/no-op reporting, paragraph-anchor order, +body byte-count/hash preservation, and note identity/title/folder/account. +`richTextSHA256` hashes the attributed body's complete string, excluding its +formatting attributes; run and paragraph `utf16Location`/`utf16Length` values +refer to that whole string. Missing position, snapshot, or independent selection +evidence prevents verified success. Richer table-format operations beyond supported import/copy/convert-to-text and row/column insert/delete/move/copy/clear/format, broader rich formatting surfaces, and @@ -3505,6 +3554,12 @@ the user explicitly asks to change it. ## Validation +The opt-in `notesSaveFormatAndColdReadback` workflow creates a marked note in +a configured, dedicated test folder and records cold reads, format checks, +HTML export, and exact-ID cleanup. See [Native Fixture Validation](../NativeFixtureValidation.md) +for the manifest, mutation switch, evidence, and recovery procedure. Its +default skip provides no native write evidence. + Use package-local checks for Notes changes: ```bash diff --git a/Documentation/Reference/Notes/UserGuide.md b/Documentation/Reference/Notes/UserGuide.md index 74c48c2..298ab65 100644 --- a/Documentation/Reference/Notes/UserGuide.md +++ b/Documentation/Reference/Notes/UserGuide.md @@ -2279,10 +2279,37 @@ indentation levels, checklist/table/math/link/attachment counts, and rich-state `body list set-style`, `body list reorder`, `body list indent`, or `body list delete`. Use `paragraphAnchors[].ordinal` with `body checklist convert-range` and `body list convert-range`. + +For inline format, color, highlight, font, and selected-text link insertion, +`--ordinal` selects the actual text paragraph, counting the title as paragraph +1. This includes ordinary body paragraphs without a persistent anchor. +`--paragraph` accepts a native anchor hash or the text snapshot hash returned +in an inline operation's paragraph evidence. Text snapshot hashes identify a +range in that note's current text and must be selected again after text edits. +Persistent native anchor hashes continue to select paragraph links and +structural paragraph operations. + +Commands selecting an existing checklist or ordinary-list item use an ordinal +within that item family. Conversion commands use `paragraphAnchors[].ordinal`. +Inline font or color changes do not add items or change those ordinals. +Structural edits apply to the complete selected paragraph. Item edits and +reordering return `ambiguous_identity` when a paragraph hash matches multiple +paragraphs; select the intended item by ordinal. + It does not print note body text, note title, raw attributed content, raw paragraph UUIDs, paragraph titles, raw paragraph style data, raw colors, raw font objects, or private color/font objects. +Counts, collections, and rich-state flags are omitted when their readback is +unavailable. Zero counts and empty collections mean the data was read and found +empty. Available plain-text, link, attachment, and state metadata remains in +the summary. `body surfaces` marks missing counts as unavailable and does not +verify them; table/math/outline listing reports an error when its source cannot +be read. Smart Folder reasons retain unknown body evidence, including criteria +that exclude checklists or mentions. If required evidence is unavailable after +a mutation, the command reports verification failure; read the note before +retrying. + `body surfaces` is the higher-level special-surface accounting view: ```bash @@ -2571,9 +2598,13 @@ and a point size from 1 through 288. If the selected text appears more than once in the selected paragraph, add `--occurrence N`. Dry-run and result output hash the selected text, colors, and font-family evidence; they do not print selected text, raw attributed content, raw colors, raw font objects, or private color/font -objects. Execution verifies target run readback, font-hash readback for font -changes, paragraph-anchor order preservation, body byte-count/hash preservation, -and note identity/title/folder/account preservation. +objects. Text selection is literal. Execution independently locates the requested +occurrence and verifies that the whole selected range has the requested format, +font, or color, even when other attributes split it into several runs. It also +checks paragraph-anchor order, body byte-count/hash preservation, and note +identity/title/folder/account. Missing selection or formatting readback prevents +verified success. Structure run/paragraph positions use UTF-16 offsets in the +whole attributed body; `richTextSHA256` hashes that body's text. `body paragraph style` changes one non-list, non-checklist, non-block-quote paragraph to an Apple Notes paragraph style. Prefer the paragraph hash from @@ -3648,6 +3679,19 @@ apple notes tags delete --tags Urgent,Review --allow-destructive-selection --jso apple notes delete --id NOTE_ID --dry-run --json ``` +`update --title` changes only the title and preserves the existing attributed +body and its paragraph separator. The title must be one non-empty paragraph. +For readable notes, the CLI reports the complete title paragraph when native +evidence resolves Notes' shortened display title. +Submitting the current title again returns `changed: false` without saving the +note. An explicit `update --body` replaces the plain-text body; use the rich +content replacement commands for formatted content. + +Create, update, and append keep the supplied body text exactly, including +leading and trailing whitespace. `update --body ""` clears the body while +preserving the title and its formatting. Supplying both `--title` and `--body` +updates them together. Append requires a non-empty body. + `quick-note create` creates one persisted Quick Note/system-paper note through the private note writer in an explicit folder. Execution verifies ordinary note readback plus private note-state `isSystemPaper` readback. Quick Note UI launch diff --git a/Documentation/Reference/Notifications/DeveloperGuide.md b/Documentation/Reference/Notifications/DeveloperGuide.md index 69fc793..38f64c9 100644 --- a/Documentation/Reference/Notifications/DeveloperGuide.md +++ b/Documentation/Reference/Notifications/DeveloperGuide.md @@ -3,7 +3,40 @@ Use package-local tests for Notifications changes: ```bash -swift test --filter SystemDomain +swift test --filter 'SystemDomainCommandTests|CLIExecutableTests|AppleMCPAdapterTests' ``` This target is limited to notifications created by this tool. + +AppleCLI owns the embedded executable Info.plist. Keep the backend's expected +bundle identity aligned with it and verify the actual built executable after +linking. A test-host bundle has a separate identity and must not accidentally +use the production notification namespace. + +Use native callback results for submission and authorization. Preserve the +first completion, bound waits and retain possible-mutation details on timeout. +Do not infer rejected submission from a timeout or silently retry with a new ID. +Settings callbacks are read-only; permission requests are explicit mutations. + +Keep API native error domain/code in diagnostics without echoing localized +descriptions that may include selected content. The ordinary suite exercises +production mapping and CLI behavior; it does not grant notification permission. + +Reuse the shared `--limit` option. Scope native pending/delivered callbacks to +the request namespace before sorting and output limits. Preserve exact content, +time-interval/calendar trigger fields and native delivery dates; summaries omit +the body. Normalize IDs once semantically, with an idempotent 500-byte bound +including the namespace. + +Removal APIs have no completion callback. Preflight the exact ID, remove only +that ID and query the selected collection again. Report attempted/presence/absence +separately; a readback failure must retain possible-mutation details. An absent +pending request may have fired, and concurrent ID replacement has no atomic +comparison guarantee. Never clear the application's entire collection. + +For native scheduling verification, use fresh `apple-cli:` IDs and one-time +delays long enough for independent readback and cancellation before firing. +Record exact IDs and payloads before submission, and verify cleanup from a +separate process. Confirm that a second owned request is unchanged. Authorized +scheduling, nonempty removal and presentation are distinct evidence; empty-query +and no-op proofs do not replace them. Their current gaps live in the capability list. diff --git a/Documentation/Reference/Notifications/UserGuide.md b/Documentation/Reference/Notifications/UserGuide.md index 456749e..95dfd9a 100644 --- a/Documentation/Reference/Notifications/UserGuide.md +++ b/Documentation/Reference/Notifications/UserGuide.md @@ -9,5 +9,72 @@ apple notifications preview --title "Build" --body "Done" --json apple notifications send --title "Build" --body "Done" --dry-run --json ``` +Check this tool's native authorization: + +```bash +apple notifications settings --json +apple notifications doctor --json +``` + +Ask for authorization explicitly: + +```bash +apple notifications permissions request --dry-run --json +apple notifications permissions request --allow-persistent-action --json +``` + +The system may present a permission prompt for **Apple CLI**. Denied settings +can be changed in System Settings. Preview and diagnostics do not request +permission; sending does not request it automatically. + +After authorization, submit immediately or with a one-time delay: + +```bash +apple notifications send --title "Build" --body "Done" \ + --id build-result --delay-seconds 60 --allow-external-dispatch --json +``` + +The returned ID is `apple-cli:build-result`. Reusing an ID replaces that native +request. Omit `--id` to generate a new ID for each submission. Delays accept +whole seconds from 1 to 604800; omitted delays submit immediately. +Normalized IDs, including `apple-cli:`, accept at most 500 UTF-8 bytes. + +`submitted: true` means the native service accepted the request. Focus and +system presentation settings affect whether it appears. Native rejection is +an error. If an add times out, `mutation_may_have_occurred` means the result is +unknown; keep the reported ID when diagnosing the request. + +Inspect requests waiting for their trigger: + +```bash +apple notifications pending list --limit 50 --json +apple notifications pending read --id build-result --json +apple notifications pending cancel --id build-result --dry-run --json +apple notifications pending cancel --id build-result --allow-persistent-action --json +``` + +Lists return summaries sorted by ID; `--limit` accepts 1...500 and defaults to 50. +Exact reads include title, subtitle, body and trigger information. These queries +do not request notification permission. A missing read returns `not_found`. + +Inspect or remove this tool's entries still in Notification Center: + +```bash +apple notifications delivered list --json +apple notifications delivered read --id build-result --json +apple notifications delivered remove --id build-result --allow-persistent-action --json +``` + +Removal results distinguish `attempted`, `wasPresent` and `verifiedAbsent`. +An already absent ID is a no-op. After a removal call, the tool queries the +selected collection again; failed verification reports an error and possible +mutation. No other IDs are removed. + +A request can fire while cancellation is in progress. Pending absence therefore +does not prove it was never displayed; delivered entries can be inspected +separately. Delivered entries do not establish user-read status or complete +notification history. Reusing the same ID concurrently can change which native +request an exact-ID removal affects. + The detailed capability boundary lives in -`../../Architecture/Notifications/CapabilityList.md`. +[Notifications Capability List](../../Architecture/Notifications/CapabilityList.md). diff --git a/Documentation/Reference/Numbers/UserGuide.md b/Documentation/Reference/Numbers/UserGuide.md index 75e6691..618c1bd 100644 --- a/Documentation/Reference/Numbers/UserGuide.md +++ b/Documentation/Reference/Numbers/UserGuide.md @@ -14,8 +14,19 @@ apple numbers tables read --path Budget.numbers --sheet Sheet1 --table Table1 -- Open/export/cell writes use the DryRun safety flow: ```bash -apple numbers tables set-cell --path Budget.numbers --sheet Sheet1 --table Table1 --cell A1 --value "42" --dry-run --json +apple numbers tables set-cell --path Budget.numbers --sheet Sheet1 --table Table1 --row 1 --column 1 --value "Quarterly total" --dry-run --json ``` +Cell writes set literal text. Close the source document in Numbers before +executing a write so that the command owns opening, saving, and closing it. +Execution requires `--allow-persistent-action`. + +Equal displayed text does not skip a write when the actual value is numeric, +a date, a boolean, or a formula. A write includes `verified: true` after saved +readback confirms the requested literal text and no formula. A no-op is based +on the current native cell value. If +readback cannot confirm the result, the command reports an error with +`mutation_may_have_occurred: "true"`; inspect the document before retrying. + The detailed capability boundary lives in `../../Architecture/Numbers/CapabilityList.md`. diff --git a/Documentation/Reference/README.md b/Documentation/Reference/README.md index b45f7b7..fbcdb68 100644 --- a/Documentation/Reference/README.md +++ b/Documentation/Reference/README.md @@ -15,6 +15,8 @@ repository reference, and keep current architecture rules in - [MCP Setup](AppleCLIUserGuide.md#mcp-setup) - [Safety Gates](SafetyGates.md) - [Release Guide](ReleaseGuide.md) +- [Framework Header Generation](FrameworkGeneration.md) +- [Native Fixture Validation](NativeFixtureValidation.md) ## Target References diff --git a/Documentation/Reference/ReleaseGuide.md b/Documentation/Reference/ReleaseGuide.md index 19ed7c7..4ff1857 100644 --- a/Documentation/Reference/ReleaseGuide.md +++ b/Documentation/Reference/ReleaseGuide.md @@ -14,6 +14,13 @@ change between system versions. Inspect the packaged provenance for the exact tested OS, architecture, toolchain and SDK. Intel and older macOS versions require separate build and runtime evidence. +The `apple` executable currently loads all six Notes frameworks, `ReminderKit` +and `ReminderKitInternal` as required libraries at startup. A missing framework +can prevent every command, including `doctor`, from starting. The MCP service +forwards commands to this executable, so forwarding also requires its startup +dependencies. Selector checks run after the system loader and do not establish +framework loadability on another OS. + Successful help, version and MCP forwarding checks establish executable startup and the CLI contract. They do not establish every app capability, account, permission or private selector. Run the relevant target's `doctor` and read its @@ -77,6 +84,11 @@ This integration suite reads local Notes data and may need the host's existing permissions and account configuration. It does not replace default tests or authorize a mutation. +Notes and Reminders also have separate, opt-in mutation workflows requiring a +dedicated native fixture manifest. See [Native Fixture Validation](NativeFixtureValidation.md) +for scope, evidence, and recovery. Default skips and successful host reads do +not establish native mutation or multi-OS support. + ## GitHub Release Publication Before selecting a tag, update the canonical version and its Changelog entry diff --git a/Documentation/Reference/Reminders/DeveloperGuide.md b/Documentation/Reference/Reminders/DeveloperGuide.md index 7441c20..b0902fc 100644 --- a/Documentation/Reference/Reminders/DeveloperGuide.md +++ b/Documentation/Reference/Reminders/DeveloperGuide.md @@ -6,8 +6,47 @@ This guide is for maintaining and validating the `apple reminders` target. It covers package checks, doctor commands, and fixture-backed Swift Testing validation. Ordinary usage lives in `UserGuide.md`. +## Framework Header Generation + +`Scripts/reminderkit-normalize-full-dump` generates the official `ReminderKit` +and `ReminderKitInternal` module surfaces from one dump or a digest-checked +versioned input manifest. It shares declaration merging and complete output +publication with the Notes generation command. See [Framework Header Generation](../FrameworkGeneration.md) +for conflicts, import checks, observed availability, and recovery. Runtime +requirements and ReminderKit save behavior remain owned by this target. + +## Runtime Entry Requirements + +All default store factories, save-request factories, and synchronous saves +use target-owned entry points. They check method scope, return type, and +argument encodings before typed dispatch. Save-request creation checks save +availability before the request can be modified, and saving checks the actual +receiver again. Core account and default-list/account reads also check their +consumed signatures. Existing feature-specific validation and error handling +continue to own their operation behavior. + +Explicit host read validation is available separately: + +```bash +APPLE_CLI_RUN_REMINDERKIT_READONLY_TESTS=1 swift test --filter ReminderKitReadonlyTests +``` + +It requires configured accounts and an existing list. It initializes a real +store, reads accounts/lists, and inspects save signatures without creating a +save request or committing changes. These reads and metadata checks establish +entry-point evidence; mutation preservation and older OS compatibility need +separate validation. Runtime requirements apply after framework loading; see +the [Release Guide](../ReleaseGuide.md) for startup dependencies. + ## Default Validation +The opt-in `remindersCompletionHistoryAndColdReadback` workflow creates a +marked reminder in a configured, dedicated test list and checks historical +completion, retry preservation, cold reads, and exact-ID cleanup. See +[Native Fixture Validation](../NativeFixtureValidation.md) for the manifest, +mutation switch, evidence, and recovery procedure. Its default skip provides +no native write evidence. + Run package-local checks for Reminders changes: ```bash @@ -29,8 +68,55 @@ availability, and read-access access for accounts, lists, reminders, Smart Lists, and sections. `doctor store`, `doctor item`, and `doctor list` expose bounded verifier evidence through read-only SQLite access. +`templates items list` discovers saved item IDs in the read-only local index, +bound to the exact template and account, then fetches each item through +ReminderKit. It checks native entity and membership before producing output. +The index query excludes deleted rows and fails when unavailable or ambiguous. +`templates items read` uses the native saved-item fetch directly; ID parsing +keeps ordinary reminder and saved-item entities distinct. These boundaries are +covered in the existing `RemindersCommandTests` suite. + +Custom Smart List reads use `REMSmartList.storage` for identity, account, +metadata, and filter data. Runtime signature checks run on the actual storage +receiver. SQLite list enrichment supplies missing metadata only after an exact +native identifier match. Rule readback compares the freshly fetched native +identity, account, custom type, and complete JSON filter. Update allocates no +save request when the filter already matches and changes only the requested +rules. These boundaries are covered in `RemindersCommandTests` with a temporary +SQLite store and mismatched native readback values; persistence checks use +owned native fixtures. + +Smart membership reads belong to `ReminderSmartListQuery`. The fixed +`REMRemindersListDataView_CustomSmartListInvocation` constructor is resolved by +runtime name and checked before dispatch, avoiding a strong reference to that +query class. Store invocation and result getters check their actual receiver +signatures. Parameters use binary property lists with the Smart List UUID, +native enum encodings, completion visibility, and subtask fetching. Storages +are bound to the native Smart List, account, and parent group when referenced. +The result must identify the requested Smart List and provide a reminder model. +The decoder accepts contextual child/root duplication, preserves first-seen +identity, and rejects foreign entities, invalid UUIDs, cycles, unknown shapes, +and results beyond its byte or hierarchy bounds. Full reminder objects provide +the physical list and parent fields; common query filters and the output limit +apply afterward. The existing suite covers the decoder boundaries, while owned +native fixtures establish actual membership and field preservation. + ## Fixture-Backed Swift Testing Validation +Rich notes behavior belongs to `RemindersCommand+Notes`, with range selection, +native attribute edits, and semantic projection in `ReminderNotesFormatting`. +Consumed native selectors are checked against actual receiver signatures. +List-style changes copy paragraph styles before editing; plain uses the native +paragraph-style removal operation. Post-save validation compares the complete +attributed text and reminder fields, allowing only the modification timestamp +to differ. Identical attributed text returns without saving. An unconfirmed +readback reports possible mutation and inspection guidance. + +The existing `RemindersCommandTests` suite covers these production helpers +with in-memory native attributes, literal Unicode selection, untouched ranges, +links, paragraph metadata, and no-op requests. Persistence and cold reads use +dedicated native fixtures as described in [Native Fixture Validation](../NativeFixtureValidation.md). + Reminders validation belongs in Swift Testing. Default tests use fixture-backed data, temporary files, and ReminderKit implementation guardrails to cover command parsing, JSON envelopes, `DryRun` payloads, safety gates, ReminderKit @@ -46,6 +132,7 @@ The behavior families that must remain covered by Swift Testing include: against the target-local native Reminders badge token catalog exposed by `lists icons list` - ReminderKit reminder flag set and clear +- attributed notes reads, inline formats, and paragraph list styles - hourly repeat set and clear - visible URL create/set and clear - item tag set, add/remove, clear, tag rename, and tag delete diff --git a/Documentation/Reference/Reminders/UserGuide.md b/Documentation/Reference/Reminders/UserGuide.md index efaad8d..e8204d7 100644 --- a/Documentation/Reference/Reminders/UserGuide.md +++ b/Documentation/Reference/Reminders/UserGuide.md @@ -112,6 +112,8 @@ apple reminders templates sections add --template "Trip Checklist" --title "Docu apple reminders templates sections rename --template "Trip Checklist" --section "Documents" --title "Entry Documents" --dry-run --json apple reminders templates sections reorder --template "Trip Checklist" --section "Entry Documents" --before "Packing" --dry-run --json apple reminders templates sections delete --template "Trip Checklist" --section "Entry Documents" --dry-run --json +apple reminders templates items list --template "Trip Checklist" --limit 20 --json +apple reminders templates items read --id TEMPLATE_ITEM_ID --json apple reminders templates items add --template "Trip Checklist" --title "Passport" --notes "Check expiration date" --dry-run --json apple reminders templates items add --template "Trip Checklist" --title "Flight check-in" --due 2026-07-01T09:00:00Z --alarm-at 2026-06-30T20:00:00Z --section "Travel Day" --dry-run --json apple reminders templates items update --id TEMPLATE_ITEM_ID --title "Passport and visa" --dry-run --json @@ -128,10 +130,11 @@ apple reminders templates delete --template "Trip Checklist" --dry-run --json `templates create-list` creates a new Reminders list from a saved template. `templates update` edits template title and appearance fields. `templates replace` replaces template content from a normal Reminders list. `templates -sections` edits saved template sections directly. `templates items` edits saved -template reminder items directly; `items add` returns the `REMCDSavedReminder` -ID used by later `items update`, `items attachments`, `items subtasks`, and -`items delete` calls. Template item add and +sections` edits saved template sections directly. `templates items list` reads +saved items, including subtasks, with a default limit +of 50. `items read` reads one saved item. The item IDs from `items list` and +`items add` can be used by `items read`, `items update`, `items attachments`, +`items subtasks`, and `items delete`. Template item add and update support title, notes, visible URL, due date, priority, repeat, location, absolute alarm, flag, tags, and section membership. Template item attachments support file/image add and remove. Template item subtasks support one-level @@ -156,8 +159,29 @@ apple reminders lists smart convert --list LIST_ID_OR_TITLE --dry-run --json apple reminders lists smart delete --list LIST_ID_OR_TITLE --dry-run --json ``` -Supported criteria use bounded semantic selectors such as tags, priority, flag, -date, and boolean state. +Combine criteria with whitespace inside a quoted value and choose `--match all` +or `--match any`. Specify each filter once: use comma-separated tags or +priorities, and `date-range:YYYY-MM-DD..YYYY-MM-DD` for a date range. +Absolute dates must be valid `YYYY-MM-DD` calendar dates; a range's start must +be on or before its end. Flag and any-tag filters accept `true`. + +Read or search a custom Smart List by native ID or unique title: + +```bash +apple reminders list --list SMART_LIST_ID --status all --limit 50 --json +apple reminders search --list SMART_LIST_ID --query "Travel" --limit 20 --json +``` + +These commands use the saved rules in Reminders. Results keep each reminder's +physical `listId` and `parentReminderId`. A matching parent can bring contextual +subtasks that do not independently match the rules. Each reminder appears once, +even when it also matches on its own. Status, date, and search filters apply +before the limit. With no list selector, reads cover physical lists once. + +`lists list` includes native Smart List IDs and account IDs. Creation and +updates check the saved rules against the request. Repeating the same rules +returns `changed: false`. If verification is unconfirmed, inspect the list +before retrying: the save may already have occurred. ## Reminder Fields And Triggers @@ -202,6 +226,36 @@ apple reminders update --id REMINDER_ID --early-reminder-minutes-before 10,30 -- apple reminders update --id REMINDER_ID --clear-alarms --clear-early-reminders --dry-run --json ``` +## Notes Formatting + +Read structured notes, including plain text, UTF-16 formatting ranges, links, +and recognized paragraph list styles: + +```bash +apple reminders notes read --id REMINDER_ID --json +``` + +Set one inline format with `--format bold|italic|underline|strikethrough` and +`--state on|off`. `--text` selects literal text; repeated matches require a +one-based `--occurrence`. Omit `--text` to select all notes. + +```bash +apple reminders notes format --id REMINDER_ID --text "Bring passport" --format bold --state on --dry-run --json +apple reminders notes format --id REMINDER_ID --text "Check" --occurrence 2 --format underline --state off --dry-run --json +apple reminders notes list-style --id REMINDER_ID --text "Packing" --style bulleted --dry-run --json +``` + +List styles are `plain`, `bulleted`, `dashed`, and `numbered`. They apply to the +whole paragraphs containing the selected text. Plain clears those paragraphs' +list layout. Inline formatting preserves other formatting and links; notes +formatting preserves the text and other reminder fields. Empty notes cannot be +formatted. Repeat requests that already match return `changed: false`. + +Create/update `--notes` supplies a plain-text replacement. Use the formatting +commands when changing only appearance. Preview with `--dry-run`, then omit it +to apply the same request. If a save reports unconfirmed verification, inspect +notes before retrying. + ## Tags, Sections, Subtasks, Attachments, And Assignments Tag update intent is shaped as normal reminder update options. Tag rename/delete diff --git a/Documentation/Reference/TCC/UserGuide.md b/Documentation/Reference/TCC/UserGuide.md index 0afd95a..3f96cde 100644 --- a/Documentation/Reference/TCC/UserGuide.md +++ b/Documentation/Reference/TCC/UserGuide.md @@ -19,3 +19,8 @@ apple tcc reset Reminders com.example.App --allow-tcc-reset --json The detailed capability boundary lives in `../../Architecture/TCC/CapabilityList.md`. + +Private framework write results report `attempted: true` and +`verification: "unverified"` when the native call returns. `changed` is omitted +until the permission effect can be independently confirmed. Inspect the +permission state for the same app identity after a framework operation. diff --git a/Package.swift b/Package.swift index a7649a3..fbf0e45 100644 --- a/Package.swift +++ b/Package.swift @@ -1,8 +1,13 @@ // swift-tools-version: 6.3 // The swift-tools-version declares the minimum version of Swift required to build this package. +import Foundation import PackageDescription +let appleInfoPlistPath = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .appendingPathComponent("Sources/AppleCLI/Info.plist").path + let argumentParser: Target.Dependency = .product( name: "ArgumentParser", package: "swift-argument-parser" @@ -232,6 +237,14 @@ let package = Package( "PrintCLI", "TCCCLI", argumentParser, + ], + exclude: ["Info.plist"], + linkerSettings: [ + .unsafeFlags( + [ + "-Xlinker", "-sectcreate", "-Xlinker", "__TEXT", "-Xlinker", "__info_plist", + "-Xlinker", appleInfoPlistPath, + ], .when(platforms: [.macOS])) ] ), .executableTarget( diff --git a/README.md b/README.md index 804de60..4b1dee2 100644 --- a/README.md +++ b/README.md @@ -148,8 +148,9 @@ Choose a target to open its user guide: | Documents | [`pages`](Documentation/Reference/Pages/UserGuide.md) · [`numbers`](Documentation/Reference/Numbers/UserGuide.md) · [`keynote`](Documentation/Reference/Keynote/UserGuide.md) | | System utilities | [`print`](Documentation/Reference/Print/UserGuide.md) · [`clipboard`](Documentation/Reference/Clipboard/UserGuide.md) · [`notifications`](Documentation/Reference/Notifications/UserGuide.md) · [`intelligence`](Documentation/Reference/Intelligence/UserGuide.md) · [`tcc`](Documentation/Reference/TCC/UserGuide.md) | -Coverage varies by target. Pages and Keynote focus on metadata and export; -Numbers supports reads, exports and single-cell writes. Safari Tab Group +Coverage varies by target. Pages focuses on metadata and export; Keynote +supports native slide reads and PDF export alongside cached previews. Numbers +supports reads, exports and single-cell writes. Safari Tab Group mutations and some Notes media workflows return an explicit unsupported result. Read the [Capability List](Documentation/Architecture/CapabilityList.md) for the supported scope of each target. diff --git a/Scripts/framework-normalize-full-dump b/Scripts/framework-normalize-full-dump new file mode 100755 index 0000000..41784de --- /dev/null +++ b/Scripts/framework-normalize-full-dump @@ -0,0 +1,1197 @@ +#!/usr/bin/xcrun swift + +import CryptoKit +import Darwin +import Foundation + +struct ModuleConfig { + let name: String + let imports: [String] + let definesSwiftObject: Bool + let importsModuleNames: [String] +} + +struct ParsedModule { + var classes: [String: String] = [:] + var protocols: [String: String] = [:] + var categories: [(base: String, text: String)] = [] + var forwardClasses: Set = [] + var forwardProtocols: Set = [] + var structNames: Set = [] + var externalSupers: Set = [] + var externalCategoryBases: Set = [] + var orderedClasses: [String] = [] +} + +let foundationClasses: Set = [ + "NSArray", "NSMutableArray", "NSDictionary", "NSMutableDictionary", "NSSet", + "NSMutableSet", "NSOrderedSet", "NSMutableOrderedSet", "NSString", + "NSMutableString", "NSData", "NSMutableData", "NSDate", "NSDateComponents", + "NSDateInterval", "NSURL", "NSURLComponents", "NSUUID", "NSNumber", "NSError", + "NSCache", "NSObject", "NSValue", "NSCalendar", "NSTimeZone", "NSLocale", "NSUserDefaults", + "NSAttributedString", "NSMutableAttributedString", "NSCharacterSet", + "NSISO8601DateFormatter", "NSFileManager", "NSFileVersion", "NSIndexSet", + "NSIndexPath", "NSOperation", "NSOperationQueue", "NSProgress", "NSPredicate", + "NSSortDescriptor", "NSBundle", "NSFormatter", "NSMeasurement", "NSUnitDuration", + "NSUndoManager", "NSPersonNameComponents", "NSRegularExpression", +] + +let importedExternalClasses: Set = foundationClasses.union([ + "INIntent", "INIntentResponse", "INObject", "INObjectResolutionResult", "CKAllowedSharingOptions", "ACAccount", "AVAsset", "AVAudioEngine", "AVURLAsset", "CALayer", "CKAsset", + "CKDatabaseOperation", "CKFetchDatabaseChangesOperation", "CKFetchRecordZoneChangesOperation", + "CKFetchRecordsOperation", "CKModifyRecordsOperation", "CKOperation", "CKOperationGroup", + "CKRecord", "CKRecordID", "CKRecordZoneID", "CKServerChangeToken", "CKShare", + "CKShareParticipant", "CLLocation", "CNContact", "CNContactStore", "CSSearchableItem", + "CSSearchableItemAttributeSet", "LPLinkMetadata", "MKMapItem", "NSAlert", "NSAppearance", + "NSApplication", "NSArrayController", "NSBezierPath", "NSButton", "NSCollectionView", + "NSColor", "NSControl", "NSFont", "NSGestureRecognizer", "NSImage", "NSImageView", + "NSLayoutConstraint", "NSLayoutManager", "NSManagedObject", "NSManagedObjectContext", + "NSManagedObjectID", "NSManagedObjectModel", "NSMenu", "NSMenuItem", "NSOpenPanel", + "NSCoreDataCoreSpotlightDelegate", "NSParagraphStyle", "NSPersistentContainer", "NSPersistentStoreCoordinator", "NSPopover", + "NSPrintPanel", "NSScreen", "NSScrollView", "NSTableView", "NSTextAttachment", + "NSTextContainer", "NSTextContentManager", "NSTextContentStorage", "NSTextField", + "NSTextLayoutManager", "NSTextStorage", "NSTextView", "NSTokenField", "NSToolbar", + "NSTouchBar", "NSView", "NSViewController", "NSWindow", "PDFDocument", "PKDrawing", + "QLPreviewPanel", "TUTranscriptionModelAvailability", "WKWebView", "WKUserContentController", +]) + +let importedExternalClassPrefixes: [String] = [ + "AV", "CA", "CK", "CL", "CN", "CS", "LP", "MK", "NS", "PDF", "PK", "QL", "TU", "UN", "VN", "WK", +] + +let forcedExternalClassStubs: Set = [ + "NSTokenAttachmentCell", "PKTextAttachmentDrawingView", "PKTitleQuery", +] + +let importedExternalProtocols: Set = [ + "MLFeatureProvider", "AVAudioPlayerDelegate", "CLLocationManagerDelegate", "CNKeyDescriptor", + "HTMLConverterProtocol", "MKAnnotation", "NSAlertDelegate", "NSMachPortDelegate", + "NSAccessibility", "NSAccessibilityButton", "NSAccessibilityElement", "NSAccessibilityGroup", + "NSAccessibilityImage", "NSAccessibilityRow", "NSAccessibilityTable", + "NSCollectionViewDataSource", "NSCollectionViewDelegate", "NSControlTextEditingDelegate", + "NSFetchedResultsControllerDelegate", "NSFilePromiseProviderDelegate", + "NSGestureRecognizerDelegate", "NSLayoutManagerDelegate", "NSMenuDelegate", + "NSMenuItemValidation", "NSOpenSavePanelDelegate", "NSPasteboardWriting", + "NSPopoverDelegate", "NSPortDelegate", "NSPrintPanelAccessorizing", + "NSSharingServiceDelegate", "NSSharingServicePickerDelegate", "NSTableViewDataSource", + "NSTableViewDelegate", "NSTextAttachmentCell", "NSTextContentManagerDelegate", + "NSTextContentStorageDelegate", "NSTextDelegate", "NSTextFieldDelegate", + "NSTextFinderClient", "NSTextLayoutManagerDelegate", "NSTextLocation", + "NSTextStorageDelegate", "NSTextViewDelegate", + "NSTextViewportRenderingSurface", "NSTokenFieldDelegate", "NSTouchBarDelegate", + "NSTouchBarProvider", "NSURLDownloadDelegate", "NSURLSessionDownloadDelegate", + "NSUserActivityDelegate", "NSUserInterfaceValidations", + "NSWindowRestoration", "OS_dispatch_queue", "PKDrawingUUID", + "WKNavigationDelegate", "WKScriptMessageHandler", + "WKUIDelegate", "WKURLSchemeHandler", +] + +func stderr(_ message: String) { + FileHandle.standardError.write(Data((message + "\n").utf8)) +} + +func firstCapture(_ text: String, pattern: String) -> String? { + guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil } + let ns = text as NSString + let range = NSRange(location: 0, length: ns.length) + guard let match = regex.firstMatch(in: text, range: range), + match.numberOfRanges > 1, + match.range(at: 1).location != NSNotFound + else { + return nil + } + return ns.substring(with: match.range(at: 1)) +} + +func replaceRegex(_ text: String, pattern: String, with replacement: String) -> String { + guard let regex = try? NSRegularExpression(pattern: pattern) else { return text } + let ns = text as NSString + return regex.stringByReplacingMatches( + in: text, + range: NSRange(location: 0, length: ns.length), + withTemplate: replacement + ) +} + +func isIdentifier(_ value: String) -> Bool { + firstCapture(value, pattern: #"^([A-Za-z_][A-Za-z0-9_]*)$"#) != nil +} + +func matchingBraceEnd(in text: NSString, openBraceLocation: Int) -> Int? { + var depth = 0 + var index = openBraceLocation + while index < text.length { + let scalar = text.character(at: index) + if scalar == Character("{").utf16.first! { + depth += 1 + } else if scalar == Character("}").utf16.first! { + depth -= 1 + if depth == 0 { + return index + 1 + } + } + index += 1 + } + return nil +} + +func replaceCPPStructs(_ input: String) -> String { + var text = input + let prefixes = ["struct basic_string", "struct vector", "struct map", "struct pair"] + for prefix in prefixes { + while let prefixRange = text.range(of: prefix) { + let ns = text as NSString + let start = ns.range(of: prefix).location + let searchRange = NSRange(location: start, length: ns.length - start) + let brace = ns.range(of: "{", range: searchRange) + guard brace.location != NSNotFound, + let end = matchingBraceEnd(in: ns, openBraceLocation: brace.location) + else { + text.replaceSubrange(prefixRange, with: "void *") + continue + } + let before = ns.substring(to: start) + let after = ns.substring(from: end) + text = before + "void *" + after + } + } + return text +} + +func stripInlineStructs(_ input: String, structNames: inout Set) -> String { + var text = replaceCPPStructs(input) + guard let regex = try? NSRegularExpression(pattern: #"\bstruct\s+([A-Za-z_][A-Za-z0-9_]*)\s*\{"#) else { + return text + } + + while true { + let ns = text as NSString + let range = NSRange(location: 0, length: ns.length) + guard let match = regex.firstMatch(in: text, range: range) else { return text } + let name = ns.substring(with: match.range(at: 1)) + let openBrace = match.range.location + match.range.length - 1 + guard let end = matchingBraceEnd(in: ns, openBraceLocation: openBrace) else { + return text + } + structNames.insert(name) + let before = ns.substring(to: match.range.location) + let after = ns.substring(from: end) + text = before + "struct \(name)" + after + } +} + +func headerFiles(in directory: URL) throws -> [URL] { + let contents = try FileManager.default.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) + return contents.filter { $0.pathExtension == "h" }.sorted { $0.lastPathComponent < $1.lastPathComponent } +} + +func lineIsImporterNoise(_ line: String) -> Bool { + if line.contains("@?<") || line.contains("v48@0:8") { + return true + } + if line.contains("out inout") || line.contains("$__lazy_storage_$_") { + return true + } + if line.contains(" -> ") { + return true + } + if line.contains("Swift.") || line.contains("SwiftUI.") || line.contains("Combine.") { + return true + } + if line.contains("CoreGraphics.") || line.contains("__C.") || line.contains("etwork.") { + return true + } + if line.contains("tesShared.") || line.contains("tesSupport.") || line.contains("tesUI.") + || line.contains("tesEditor.") || line.contains("tesPreviewKit.") + { + return true + } + if line == "@protocol v@?;" { + return true + } + return false +} + +func normalizeLine(_ input: String, structNames: inout Set) -> String? { + var line = input.trimmingCharacters(in: .whitespacesAndNewlines) + guard !line.isEmpty else { return "" } + if line.hasPrefix("//") || line.hasPrefix("#") || line.hasPrefix("@import") || line.hasPrefix("#include") { + return nil + } + if lineIsImporterNoise(line) { + return nil + } + line = stripInlineStructs(line, structNames: &structNames) + line = replaceRegex( + line, + pattern: #"NSObject\s*\*"#, + with: "NSObject *" + ) + line = replaceRegex( + line, + pattern: #"\bstruct\s+CGColor\s*\*"#, + with: "CGColorRef" + ) + line = line.replacingOccurrences( + of: "@property (retain, nonatomic) CGColorRef", + with: "@property (nonatomic) CGColorRef" + ) + line = line.replacingOccurrences( + of: "@property (strong, nonatomic) CGColorRef", + with: "@property (nonatomic) CGColorRef" + ) + line = replaceRegex( + line, + pattern: #"(\([^)]*\))\s*(nil|Nil|YES|NO|NULL)\b"#, + with: "$1value" + ) + if lineIsImporterNoise(line) { + return nil + } + return line +} + +func isImportedExternalClass(_ name: String, importedKnownTypes: Set) -> Bool { + if forcedExternalClassStubs.contains(name) { + return false + } + if importedExternalClasses.contains(name) || importedKnownTypes.contains(name) { + return true + } + return importedExternalClassPrefixes.contains { name.hasPrefix($0) } +} + +func writeModule( + _ parsed: ParsedModule, + config: ModuleConfig, + sourcesRoot: URL +) throws { + let moduleDirectory = sourcesRoot.appendingPathComponent(config.name, isDirectory: true) + let includeDirectory = moduleDirectory + .appendingPathComponent("include", isDirectory: true) + try FileManager.default.createDirectory(at: includeDirectory, withIntermediateDirectories: true) + + var lines: [String] = [ + "// Normalized full dump import surface for \(config.name).", + "// Input observations: .framework-normalization/\(profileName).json.", + "#ifndef \(config.name)_h", + "#define \(config.name)_h", + "", + "#import ", + ] + lines.append(contentsOf: config.imports) + lines.append("") + + if config.definesSwiftObject { + lines.append("@interface _TtCs12_SwiftObject : NSObject") + lines.append("@end") + lines.append("") + } + + for name in parsed.externalSupers.union(parsed.externalCategoryBases).sorted() + where name != "NSObject" && name != "_TtCs12_SwiftObject" { + lines.append("@interface \(name) : NSObject") + lines.append("@end") + } + lines.append("") + + for name in parsed.structNames.sorted() { + lines.append("struct \(name);") + } + lines.append("") + + let forwardClasses = parsed.forwardClasses + .filter { $0 != "NSObject" && $0 != "_TtCs12_SwiftObject" } + .sorted() + for chunkStart in stride(from: 0, to: forwardClasses.count, by: 12) { + let chunkEnd = min(chunkStart + 12, forwardClasses.count) + lines.append("@class " + forwardClasses[chunkStart.. Options { + let arguments = Array(CommandLine.arguments.dropFirst()) + if arguments.contains("--help") || arguments.contains("-h") { + print("Usage: framework-normalize-full-dump --profile notes|reminders (--dump-root PATH | --input-manifest FILE) --sources-root PATH [--report FILE] [--sdk-root PATH]") + exit(0) + } + var values: [String: String] = [:] + var index = 0 + while index < arguments.count { + let key = arguments[index] + guard ["--profile", "--dump-root", "--input-manifest", "--sources-root", "--report", "--sdk-root"].contains(key), + index + 1 < arguments.count, values[key] == nil else { throw GenerationError("Invalid or repeated option: \(key)") } + values[key] = arguments[index + 1] + index += 2 + } + guard let profile = values["--profile"], ["notes", "reminders"].contains(profile), + let output = values["--sources-root"], !output.isEmpty, + (values["--dump-root"] == nil) != (values["--input-manifest"] == nil) + else { throw GenerationError("Choose a profile, exactly one input mode, and --sources-root.") } + func url(_ key: String) throws -> URL? { + guard let path = values[key] else { return nil } + return try resolvedPath(URL(fileURLWithPath: path)) + } + return Options(profile: profile, dumpRoot: try url("--dump-root"), manifest: try url("--input-manifest"), + sourcesRoot: try resolvedPath(URL(fileURLWithPath: output)), + report: try url("--report"), sdkRoot: try url("--sdk-root")) +} + +struct InputManifest: Decodable { + let schema_version: Int + let inputs: [InputSource] +} + +struct InputSource: Codable { + let id: String + let dump_root: String + let source_kind: String? + let macos_version: String? + let os_build: String? + let architecture: String? + let sdk_version: String? + let app_version: String? + let modules: [String: ModuleInput]? +} + +struct ModuleInput: Codable { + let presence: String + let headers: [String: String]? +} + +struct Origin: Codable, Hashable { + let source_id: String + let module: String + let header: String + let line: Int +} + +struct HeaderInput { + let url: URL + let text: String + let source: InputSource + let module: String +} + +struct Member { + let id: String + let signature: String + let declaration: String + let text: String + let optional: Bool + let methods: [String: String] + let origin: Origin +} + +struct Declaration { + let kind: String + let name: String + let category: String? + let superclass: String? + let protocols: Set + let origin: Origin + var members: [Member] + var identity: String { [kind, name, category ?? ""].joined(separator: ":") } +} + +struct ParsedInput { + var declarations: [Declaration] = [] + var forwardClasses: Set = [] + var forwardProtocols: Set = [] + var structNames: Set = [] + var excludedMembers: [[String: Any]] = [] +} + +func digest(_ data: Data) -> String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() +} + +func canonical(_ text: String) -> String { + let regex = try! NSRegularExpression(pattern: #"[A-Za-z_][A-Za-z_0-9]*|[0-9]+|[^\s]"#) + let ns = text as NSString + return regex.matches(in: text, range: NSRange(location: 0, length: ns.length)) + .map { ns.substring(with: $0.range) }.joined(separator: " ") +} + +func parenthesized(_ text: String, at start: String.Index) throws -> (body: String, end: String.Index) { + guard start < text.endIndex, text[start] == "(" else { throw GenerationError("Expected a parenthesized Objective-C type.") } + var depth = 0 + var cursor = start + while cursor < text.endIndex { + if text[cursor] == "(" { depth += 1 } + if text[cursor] == ")" { + depth -= 1 + if depth == 0 { return (String(text[text.index(after: start).. (id: String, signature: String) { + guard let scope = text.first, scope == "+" || scope == "-" else { throw GenerationError("Invalid method scope.") } + var cursor = text.index(after: text.startIndex) + func spaces() { while cursor < text.endIndex && text[cursor].isWhitespace { cursor = text.index(after: cursor) } } + spaces() + let returnType = try parenthesized(text, at: cursor) + cursor = returnType.end + var selector = "" + var parameters: [String] = [] + while cursor < text.endIndex { + spaces() + let start = cursor + while cursor < text.endIndex && (text[cursor].isLetter || text[cursor].isNumber || text[cursor] == "_") { + cursor = text.index(after: cursor) + } + guard start != cursor else { break } + let piece = String(text[start.. Member { + if raw.hasPrefix("-") || raw.hasPrefix("+") { + let method = try methodShape(raw) + return Member(id: method.id, signature: "\(optional):\(method.signature)", declaration: raw, text: normalized, + optional: optional, methods: [method.id: method.signature], origin: origin) + } + guard raw.hasPrefix("@property") else { throw GenerationError("Unsupported declaration member at \(origin.header):\(origin.line).") } + var remainder = String(raw.dropFirst("@property".count)).trimmingCharacters(in: .whitespaces) + var attributes: Set = [] + if remainder.hasPrefix("(") { + let parsed = try parenthesized(remainder, at: remainder.startIndex) + attributes = Set(parsed.body.split(separator: ",").map { $0.filter { !$0.isWhitespace } }) + remainder = String(remainder[parsed.end...]).trimmingCharacters(in: .whitespaces) + } + guard let name = firstCapture(remainder, pattern: #"\b([A-Za-z_][A-Za-z_0-9]*)\s*;$"#), + let range = remainder.range(of: name, options: .backwards) + else { throw GenerationError("Unsupported property at \(origin.header):\(origin.line).") } + let type = canonical(String(remainder[.. ParsedInput { + var parsed = ParsedInput() + var current: Declaration? + var ivars = 0 + var optional = false + var pending = "" + var pendingLine = 0 + let lines = try withoutComments(input.text) + .replacingOccurrences(of: "\r\n", with: "\n").components(separatedBy: .newlines) + for (offset, rawLine) in lines.enumerated() { + let raw = rawLine.trimmingCharacters(in: .whitespacesAndNewlines) + if ivars > 0 { + ivars += raw.filter { $0 == "{" }.count - raw.filter { $0 == "}" }.count + continue + } + if raw.isEmpty || raw.hasPrefix("//") || raw.hasPrefix("#") || raw.hasPrefix("@import") { continue } + if let names = firstCapture(raw, pattern: #"^@class\s+(.+);$"#) { + parsed.forwardClasses.formUnion(names.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter(isIdentifier)) + continue + } + if let names = firstCapture(raw, pattern: #"^@protocol\s+(.+);$"#) { + parsed.forwardProtocols.formUnion(names.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter(isIdentifier)) + continue + } + if raw.hasPrefix("@interface") || raw.hasPrefix("@protocol") { + guard current == nil, pending.isEmpty else { throw GenerationError("Unclosed declaration in \(input.url.lastPathComponent):\(offset + 1).") } + guard let name = firstCapture(raw, pattern: #"^@(?:interface|protocol)\s+([A-Za-z_][A-Za-z_0-9]*)"#) else { + throw GenerationError("Invalid declaration in \(input.url.lastPathComponent):\(offset + 1).") + } + let isProtocol = raw.hasPrefix("@protocol") + let category = isProtocol ? nil : firstCapture(raw, pattern: #"^@interface\s+\w+\s*\(([^)]*)\)"#) + let head = String(raw.prefix { $0 != "{" }) + let superclass = isProtocol || category != nil ? nil : firstCapture(head, pattern: #":\s*([A-Za-z_][A-Za-z_0-9]*)"#) + let adopted = firstCapture(head, pattern: #"<([^>]+)>"#)?.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) } ?? [] + current = Declaration(kind: isProtocol ? "protocol" : category != nil ? "category" : "class", name: name, + category: category, superclass: superclass, protocols: Set(adopted), + origin: Origin(source_id: input.source.id, module: input.module, header: input.url.lastPathComponent, line: offset + 1), members: []) + ivars = raw.filter { $0 == "{" }.count - raw.filter { $0 == "}" }.count + optional = false + continue + } + if raw == "@end" { + guard let declaration = current, pending.isEmpty else { throw GenerationError("Unclosed member or unmatched @end in \(input.url.lastPathComponent).") } + if declaration.kind != "protocol" || !importedExternalProtocols.contains(declaration.name) { parsed.declarations.append(declaration) } + current = nil + continue + } + guard current != nil else { continue } + if raw == "@optional" { optional = true; continue } + if raw == "@required" { optional = false; continue } + if raw.hasPrefix("@synthesize") || raw.hasPrefix("@dynamic") { continue } + if pending.isEmpty { pendingLine = offset + 1 } + pending += (pending.isEmpty ? "" : " ") + raw + guard pending.hasSuffix(";") else { continue } + let statement = pending + pending = "" + if let normalized = normalizeLine(statement, structNames: &parsed.structNames), !normalized.isEmpty { + let origin = Origin(source_id: input.source.id, module: input.module, header: input.url.lastPathComponent, line: pendingLine) + current?.members.append(try parseMember(statement, normalized: normalized, optional: optional, origin: origin)) + } else { + parsed.excludedMembers.append(["source_id": input.source.id, "module": input.module, + "header": input.url.lastPathComponent, "line": pendingLine, "declaration": statement, + "reason": "not_importable_by_normalization"]) + } + } + guard current == nil, pending.isEmpty, ivars == 0 else { throw GenerationError("Unclosed declaration in \(input.url.lastPathComponent).") } + return parsed +} + +func withoutComments(_ text: String) throws -> String { + let bytes = Array(text.utf8) + var result = bytes + var index = 0 + var quote: UInt8? + while index < bytes.count { + if let currentQuote = quote { + if bytes[index] == 92 { index += 2; continue } + if bytes[index] == currentQuote { quote = nil } + index += 1 + continue + } + if bytes[index] == 34 || bytes[index] == 39 { quote = bytes[index]; index += 1; continue } + if index + 1 < bytes.count, bytes[index] == 47, bytes[index + 1] == 47 { + while index < bytes.count && bytes[index] != 10 && bytes[index] != 13 { result[index] = 32; index += 1 } + } else if index + 1 < bytes.count, bytes[index] == 47, bytes[index + 1] == 42 { + result[index] = 32; result[index + 1] = 32; index += 2 + var closed = false + while index < bytes.count { + if index + 1 < bytes.count, bytes[index] == 42, bytes[index + 1] == 47 { + result[index] = 32; result[index + 1] = 32; index += 2; closed = true; break + } + if bytes[index] != 10 && bytes[index] != 13 { result[index] = 32 } + index += 1 + } + guard closed else { throw GenerationError("Unclosed header comment.") } + } else { index += 1 } + } + return String(decoding: result, as: UTF8.self) +} + +func originObject(_ origin: Origin) -> [String: Any] { + ["source_id": origin.source_id, "module": origin.module, "header": origin.header, "line": origin.line] +} + +func memberVariant(_ member: Member) -> [String: Any] { + ["signature": member.signature, "declaration": member.declaration, + "import_declaration": member.text, "origin": originObject(member.origin)] +} + +func mergeModule( + _ inputs: [ParsedInput], module: String, importedKnownTypes: Set, conflicts: inout [[String: Any]], + observations: inout [[String: Any]] +) throws -> ParsedModule { + var parsed = ParsedModule() + for input in inputs { + parsed.forwardClasses.formUnion(input.forwardClasses) + parsed.forwardProtocols.formUnion(input.forwardProtocols) + parsed.structNames.formUnion(input.structNames) + } + let grouped = Dictionary(grouping: inputs.flatMap(\.declarations), by: \.identity) + var effectiveMethods: [String: [(String, Origin)]] = [:] + var selectorScopes: [String: [String: Set]] = [:] + for identity in grouped.keys.sorted() { + let versions = grouped[identity]! + let declaration = versions[0] + let superclasses = Set(versions.map { $0.superclass ?? "" }) + if superclasses.count > 1 { + conflicts.append(["kind": "superclass", "module": module, "identity": identity, + "variants": versions.map { ["signature": $0.superclass ?? "", "origin": originObject($0.origin)] }]) + } + let protocols = versions.reduce(into: Set()) { $0.formUnion($1.protocols) }.sorted() + let members = Dictionary(grouping: versions.flatMap(\.members), by: \.id) + var lines: [String] = [] + if declaration.kind == "protocol" { lines.append("@protocol \(declaration.name)") } + else if declaration.kind == "category" { lines.append("@interface \(declaration.name) (\(declaration.category ?? ""))") } + else { lines.append("@interface \(declaration.name)" + (declaration.superclass.map { " : " + $0 } ?? "")) } + if !protocols.isEmpty { lines[0] += " <" + protocols.joined(separator: ", ") + ">" } + var wasOptional = false + var memberObservations: [[String: Any]] = [] + for memberID in members.keys.sorted() { + let variants = members[memberID]! + let signatures = Set(variants.map(\.signature)) + if signatures.count > 1 { + conflicts.append(["kind": "member", "module": module, "identity": identity + ":" + memberID, + "variants": variants.map(memberVariant)]) + } + let chosen = variants.min { $0.text < $1.text }! + if declaration.kind == "protocol", chosen.optional != wasOptional { + lines.append(chosen.optional ? "@optional" : "@required") + wasOptional = chosen.optional + } + lines.append(chosen.text) + let owner = declaration.kind == "protocol" ? "protocol:" + declaration.name : "class:" + declaration.name + for variant in variants { + for (methodID, signature) in variant.methods { + effectiveMethods[owner + ":" + methodID, default: []].append((signature, variant.origin)) + let parts = methodID.split(separator: ":", maxSplits: 2).map(String.init) + if parts.count == 3 { + let key = owner + ":" + parts[2] + selectorScopes[key, default: [:]][variant.origin.source_id, default: []].insert(parts[1]) + } + } + } + memberObservations.append(["identity": memberID, "signatures": signatures.sorted(), + "observed_in": Array(Set(variants.map { $0.origin.source_id })).sorted(), + "variants": variants.sorted { ($0.origin.source_id, $0.origin.header, $0.origin.line) < ($1.origin.source_id, $1.origin.header, $1.origin.line) }.map(memberVariant), + "origins": variants.map { originObject($0.origin) }.sorted { ($0["source_id"] as! String, $0["header"] as! String, $0["line"] as! Int) < ($1["source_id"] as! String, $1["header"] as! String, $1["line"] as! Int) }]) + } + lines.append("@end") + let text = lines.joined(separator: "\n") + "\n" + switch declaration.kind { + case "class": parsed.classes[declaration.name] = text + case "protocol": parsed.protocols[declaration.name] = text + default: parsed.categories.append((base: declaration.name, text: text)) + } + observations.append(["module": module, "identity": identity, "superclasses": superclasses.sorted(), + "protocols": protocols, "observed_in": Array(Set(versions.map { $0.origin.source_id })).sorted(), + "members": memberObservations]) + } + for key in effectiveMethods.keys.sorted() { + let variants = effectiveMethods[key]! + if Set(variants.map(\.0)).count > 1 { + conflicts.append(["kind": "selector", "module": module, "identity": key, + "variants": variants.map { ["signature": $0.0, "origin": originObject($0.1)] }]) + } + } + for key in selectorScopes.keys.sorted() { + let bySource = selectorScopes[key]! + let variants = Set(bySource.values.map { $0.sorted().joined() }) + if variants.count > 1, !bySource.values.contains(where: { $0.count > 1 }) { + conflicts.append(["kind": "method_scope", "module": module, "identity": key, "source_scopes": bySource.mapValues { $0.sorted() }]) + } + } + parsed.forwardClasses.formUnion(parsed.classes.keys) + parsed.forwardProtocols.formUnion(parsed.protocols.keys) + let externalSupers = Set(parsed.classes.values.compactMap { firstCapture($0.components(separatedBy: .newlines)[0], pattern: #":\s*([A-Za-z_]\w*)"#) }) + let externalCategories = Set(parsed.categories.map(\.base)) + parsed.externalSupers = externalSupers.filter { !parsed.classes.keys.contains($0) && $0 != "_TtCs12_SwiftObject" && !isImportedExternalClass($0, importedKnownTypes: importedKnownTypes) } + parsed.externalCategoryBases = externalCategories.filter { !parsed.classes.keys.contains($0) && $0 != "_TtCs12_SwiftObject" && !isImportedExternalClass($0, importedKnownTypes: importedKnownTypes) } + var state: [String: Int] = [:] + func visit(_ name: String) throws { + if state[name] == 2 { return } + guard state[name] != 1 else { throw GenerationError("Cyclic class inheritance in \(module): \(name)") } + state[name] = 1 + if let superclass = firstCapture(parsed.classes[name]!.components(separatedBy: .newlines)[0], pattern: #":\s*([A-Za-z_]\w*)"#), parsed.classes[superclass] != nil { + try visit(superclass) + } + state[name] = 2 + parsed.orderedClasses.append(name) + } + for name in parsed.classes.keys.sorted() { try visit(name) } + return parsed +} + +func readInputs(_ options: Options, modules: [ModuleConfig]) throws -> (sources: [InputSource], headers: [String: [HeaderInput]], records: [[String: Any]]) { + let manager = FileManager.default + let sources: [InputSource] + let base: URL + if let manifest = options.manifest { + guard options.report != manifest else { throw GenerationError("Diagnostic report must not replace the input manifest.") } + let data = try readInputData(manifest, limit: 4 * 1_024 * 1_024) + let decoded = try JSONDecoder().decode(InputManifest.self, from: data) + guard decoded.schema_version == 1, !decoded.inputs.isEmpty, decoded.inputs.count <= 64 else { throw GenerationError("Unsupported or empty input manifest.") } + sources = decoded.inputs + base = manifest.deletingLastPathComponent() + } else { + sources = [InputSource(id: "single", dump_root: options.dumpRoot!.path, source_kind: nil, + macos_version: nil, os_build: nil, architecture: nil, sdk_version: nil, app_version: nil, modules: nil)] + base = URL(fileURLWithPath: manager.currentDirectoryPath) + } + guard Set(sources.map(\.id)).count == sources.count else { throw GenerationError("Duplicate input source identity.") } + var headers: [String: [HeaderInput]] = [:] + var records: [[String: Any]] = [] + var totalBytes = 0 + for source in sources.sorted(by: { $0.id < $1.id }) { + guard source.id.range(of: #"^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$"#, options: .regularExpression) != nil, + !source.dump_root.isEmpty else { throw GenerationError("Invalid input source identity or dump root.") } + if options.manifest != nil { + guard let version = source.macos_version, version.range(of: #"^\d+(?:\.\d+){1,2}$"#, options: .regularExpression) != nil, + let build = source.os_build, !build.isEmpty, + source.architecture == "arm64" || source.architecture == "x86_64", source.modules != nil + else { throw GenerationError("Versioned inputs require macos_version, os_build, architecture, and module digests.") } + } + let dumpRoot = try resolvedPath(URL(fileURLWithPath: source.dump_root, relativeTo: base)) + guard !isInside(options.sourcesRoot, dumpRoot), !isInside(dumpRoot, options.sourcesRoot) else { + throw GenerationError("Input and output trees must be separate.") + } + if let report = options.report, isInside(report, dumpRoot) { + throw GenerationError("Diagnostic report must be outside every input dump tree.") + } + for config in modules { + let directory = dumpRoot.appendingPathComponent("\(config.name)/\(config.name)") + let expected = source.modules?[config.name] + if options.manifest != nil && expected == nil { throw GenerationError("Missing module input descriptor: \(source.id)/\(config.name)") } + if expected?.presence == "absent" { + guard expected?.headers == nil, !manager.fileExists(atPath: directory.path) else { throw GenerationError("Absent module descriptor has headers or an existing directory.") } + records.append(["source_id": source.id, "module": config.name, "presence": "absent"]) + continue + } + if let expected, expected.presence != "present" { throw GenerationError("Invalid module presence: \(source.id)/\(config.name)") } + guard manager.fileExists(atPath: directory.path) else { throw GenerationError("Missing dump directory: \(source.id)/\(config.name)") } + let files = try headerFiles(in: directory) + guard !files.isEmpty, files.count <= 20_000 else { throw GenerationError("Empty or oversized module input: \(source.id)/\(config.name)") } + if let expected { + guard let hashes = expected.headers, Set(hashes.keys) == Set(files.map(\.lastPathComponent)) else { throw GenerationError("Header set differs from manifest: \(source.id)/\(config.name)") } + } + for file in files { + let resolved = file.resolvingSymlinksInPath() + guard !isInside(resolved, options.sourcesRoot), options.report != resolved else { + throw GenerationError("Input header must be separate from generated output and diagnostic report: \(source.id)/\(config.name)/\(file.lastPathComponent)") + } + let data = try readInputData(resolved, limit: 8 * 1_024 * 1_024) + totalBytes += data.count + guard data.count <= 8 * 1_024 * 1_024, totalBytes <= 256 * 1_024 * 1_024, + let text = String(data: data, encoding: .utf8) else { throw GenerationError("Invalid or oversized header: \(source.id)/\(config.name)/\(file.lastPathComponent)") } + let hash = digest(data) + if let expected, expected.headers?[file.lastPathComponent] != hash { + throw GenerationError("Header digest differs from manifest: \(source.id)/\(config.name)/\(file.lastPathComponent)") + } + headers[config.name, default: []].append(HeaderInput(url: file, text: text, source: source, module: config.name)) + records.append(["source_id": source.id, "module": config.name, "header": file.lastPathComponent, "sha256": hash, "presence": "present"]) + } + } + } + for module in modules where headers[module.name] == nil { throw GenerationError("No observed declarations for required module: \(module.name)") } + return (sources, headers, records) +} + +func readInputData(_ file: URL, limit: Int) throws -> Data { + let values = try file.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]) + guard values.isRegularFile == true, let size = values.fileSize, size <= limit else { + throw GenerationError("Input must be a regular file within its size limit: \(file.lastPathComponent)") + } + let handle = try FileHandle(forReadingFrom: file) + defer { try? handle.close() } + var data = Data() + while data.count <= limit { + guard let chunk = try handle.read(upToCount: min(65_536, limit + 1 - data.count)), !chunk.isEmpty else { break } + data.append(chunk) + } + guard data.count <= limit else { throw GenerationError("Input exceeds its size limit: \(file.lastPathComponent)") } + return data +} + +func isInside(_ path: URL, _ root: URL) -> Bool { + root.path == "/" || path.path == root.path || path.path.hasPrefix(root.path + "/") +} + +func isSymbolicLink(_ path: URL) -> Bool { + (try? FileManager.default.destinationOfSymbolicLink(atPath: path.path)) != nil +} + +func resolvedPath(_ path: URL) throws -> URL { + var ancestor = path.standardizedFileURL + var missing: [String] = [] + while !FileManager.default.fileExists(atPath: ancestor.path) { + guard !isSymbolicLink(ancestor) else { throw GenerationError("Path has a dangling symbolic link: \(ancestor.lastPathComponent)") } + let parent = ancestor.deletingLastPathComponent() + guard parent.path != ancestor.path else { throw GenerationError("Cannot resolve path ancestry.") } + missing.append(ancestor.lastPathComponent) + ancestor = parent + } + var result = ancestor.resolvingSymlinksInPath() + for component in missing.reversed() { result.appendPathComponent(component) } + return result +} + +func command(_ arguments: [String], scratch: URL) throws -> String { + let log = scratch.appendingPathComponent("command-\(UUID().uuidString).log") + guard FileManager.default.createFile(atPath: log.path, contents: nil) else { + throw GenerationError("Could not create a compiler diagnostic file.") + } + let handle = try FileHandle(forWritingTo: log) + defer { try? handle.close(); try? FileManager.default.removeItem(at: log) } + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun") + process.arguments = arguments + process.standardOutput = handle + process.standardError = handle + let finished = DispatchSemaphore(value: 0) + process.terminationHandler = { _ in finished.signal() } + try process.run() + if finished.wait(timeout: .now() + 90) == .timedOut { + kill(process.processIdentifier, SIGKILL) + process.waitUntilExit() + throw GenerationError("Compiler command exceeded its deadline.") + } + let data = try Data(contentsOf: log) + guard process.terminationStatus == 0 else { + let output = String(decoding: data.prefix(32_768), as: UTF8.self) + .replacingOccurrences(of: scratch.path, with: "") + throw GenerationError("Compiler validation failed: \(output)") + } + return String(decoding: data, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines) +} + +func snapshot(_ root: URL) throws -> [String: String] { + let manager = FileManager.default + guard manager.fileExists(atPath: root.path) else { return [:] } + guard let enumerator = manager.enumerator(atPath: root.path) else { + throw GenerationError("Cannot enumerate output tree.") + } + var result: [String: String] = [:] + var total = 0 + // Relative enumeration keys remain stable across Foundation's filesystem path aliases. + for case let key as String in enumerator { + guard result.count < 50_000 else { throw GenerationError("Output tree exceeds its entry limit.") } + let file = URL(fileURLWithPath: root.path).appendingPathComponent(key) + let values = try file.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey, .isRegularFileKey, .fileSizeKey]) + let permissions = (try manager.attributesOfItem(atPath: file.path)[.posixPermissions] as? NSNumber)?.stringValue ?? "" + if values.isSymbolicLink == true { + result[key] = "link:" + (try manager.destinationOfSymbolicLink(atPath: file.path)) + enumerator.skipDescendants() + } else if values.isDirectory == true { result[key] = "directory:" + permissions } + else if values.isRegularFile == true { + guard (values.fileSize ?? 0) <= 1_024 * 1_024 * 1_024 - total else { + throw GenerationError("Output tree exceeds its byte limit.") + } + let data = try Data(contentsOf: file) + total += data.count + guard total <= 1_024 * 1_024 * 1_024 else { throw GenerationError("Output tree exceeds its byte limit.") } + result[key] = "file:" + permissions + ":" + digest(data) + } else { throw GenerationError("Output tree contains a special file.") } + } + return result +} + +func ensureOwnedPaths(_ root: URL, modules: [ModuleConfig]) throws { + guard root.path != "/", root.path != FileManager.default.homeDirectoryForCurrentUser.path else { + throw GenerationError("Choose a source or generated-output directory.") + } + for config in modules { + for suffix in [config.name, "\(config.name)/include", "\(config.name)/include/\(config.name).h", "\(config.name)/include/module.modulemap", "\(config.name)/shim.c"] { + let file = root.appendingPathComponent(suffix) + if isSymbolicLink(file) { + throw GenerationError("Generated output paths must not be symbolic links: \(suffix)") + } + } + } + let metadata = root.appendingPathComponent(".framework-normalization") + if isSymbolicLink(metadata) { + throw GenerationError("Generation metadata directory must not be a symbolic link.") + } + let receipt = metadata.appendingPathComponent("\(profileName).json") + if isSymbolicLink(receipt) { + throw GenerationError("Generation metadata file must not be a symbolic link.") + } +} + +var profileName = "" + +func generate(_ options: Options) throws { + profileName = options.profile + let modules = options.profile == "notes" ? notesModules : remindersModules + let manager = FileManager.default + try ensureOwnedPaths(options.sourcesRoot, modules: modules) + if let report = options.report, isInside(report, options.sourcesRoot) { + throw GenerationError("Explicit diagnostic report must be outside the generated output tree.") + } + let input = try readInputs(options, modules: modules) + var conflicts: [[String: Any]] = [] + var observations: [[String: Any]] = [] + var parsedByModule: [String: ParsedModule] = [:] + var excludedMembers: [[String: Any]] = [] + for config in modules { + let imported = config.importsModuleNames.reduce(into: Set()) { names, module in + names.formUnion(parsedByModule[module]?.classes.keys ?? Dictionary().keys) + names.formUnion(parsedByModule[module]?.protocols.keys ?? Dictionary().keys) + } + let parsed = try input.headers[config.name]!.map(parseHeader) + excludedMembers += parsed.flatMap(\.excludedMembers) + parsedByModule[config.name] = try mergeModule(parsed, module: config.name, importedKnownTypes: imported, + conflicts: &conflicts, observations: &observations) + } + let sourceRecords: [[String: Any]] = input.sources.sorted { $0.id < $1.id }.map { source in + var record: [String: Any] = ["id": source.id] + if let value = source.source_kind { record["source_kind"] = value } + if let value = source.macos_version { record["macos_version"] = value } + if let value = source.os_build { record["os_build"] = value } + if let value = source.architecture { record["architecture"] = value } + if let value = source.sdk_version { record["sdk_version"] = value } + if let value = source.app_version { record["app_version"] = value } + return record + } + var report: [String: Any] = ["schema_version": 1, "generator": "apple-cli-framework-normalizer", + "profile": options.profile, "sources": sourceRecords, "input_headers": input.records, + "declarations": observations, "excluded_members": excludedMembers, "conflicts": conflicts, "availability_basis": "observed_input_presence", + "runtime_compatibility": "unverified", "status": conflicts.isEmpty ? "prepared" : "conflicted"] + func saveDiagnostic() throws { + guard let path = options.report else { return } + try manager.createDirectory(at: path.deletingLastPathComponent(), withIntermediateDirectories: true) + try JSONSerialization.data(withJSONObject: report, options: [.prettyPrinted, .sortedKeys]) + .write(to: path, options: .atomic) + } + guard conflicts.isEmpty else { + try saveDiagnostic() + throw GenerationError("Found \(conflicts.count) declaration conflicts; outputs were preserved. Use --report for both source signatures.") + } + + let before = try snapshot(options.sourcesRoot) + let parent = options.sourcesRoot.deletingLastPathComponent() + try manager.createDirectory(at: parent, withIntermediateDirectories: true) + let workspace = parent.appendingPathComponent(".framework-generation-\(UUID().uuidString)") + try manager.createDirectory(at: workspace, withIntermediateDirectories: false) + let staging = workspace.appendingPathComponent("sources") + var preserveWorkspace = false + var exchangePending = false + defer { if !preserveWorkspace { try? manager.removeItem(at: workspace) } } + if manager.fileExists(atPath: options.sourcesRoot.path) { try manager.copyItem(at: options.sourcesRoot, to: staging) } + else { try manager.createDirectory(at: staging, withIntermediateDirectories: false) } + do { + for config in modules { try writeModule(parsedByModule[config.name]!, config: config, sourcesRoot: staging) } + let sdk = try options.sdkRoot ?? URL(fileURLWithPath: command(["--sdk", "macosx", "--show-sdk-path"], scratch: workspace)) + let sdkVersion = try command(["--sdk", sdk.path, "--show-sdk-version"], scratch: workspace) + let smoke = workspace.appendingPathComponent("Smoke.swift") + try modules.map { "import \($0.name)" }.joined(separator: "\n").write(to: smoke, atomically: true, encoding: .utf8) + var architectures = Set(input.sources.compactMap(\.architecture)) + if architectures.isEmpty { + #if arch(arm64) + architectures.insert("arm64") + #else + architectures.insert("x86_64") + #endif + } + var imports: [[String: Any]] = [] + for architecture in architectures.sorted() { + var arguments = ["swiftc", "-typecheck", "-sdk", sdk.path, "-target", "\(architecture)-apple-macos13.0", + "-module-cache-path", workspace.appendingPathComponent("ModuleCache-\(architecture)").path] + for config in modules { arguments += ["-I", staging.appendingPathComponent("\(config.name)/include").path] } + arguments.append(smoke.path) + _ = try command(arguments, scratch: workspace) + imports += modules.map { ["module": $0.name, "architecture": architecture, "status": "passed"] } + } + report["import_validation"] = imports + report["validation_sdk_version"] = sdkVersion + report["status"] = "validated" + let metadata = staging.appendingPathComponent(".framework-normalization") + try manager.createDirectory(at: metadata, withIntermediateDirectories: true) + try JSONSerialization.data(withJSONObject: report, options: [.prettyPrinted, .sortedKeys]) + .write(to: metadata.appendingPathComponent("\(options.profile).json"), options: .atomic) + guard try snapshot(options.sourcesRoot) == before else { throw GenerationError("Output tree changed during generation; publication was refused.") } + if manager.fileExists(atPath: options.sourcesRoot.path) { + guard renamex_np(staging.path, options.sourcesRoot.path, UInt32(RENAME_SWAP)) == 0 else { + throw GenerationError("Atomic output-group replacement failed (errno \(errno)).") + } + exchangePending = true + // The exchanged old tree must still match the snapshot, including a change just before the swap. + let exchanged = try snapshot(staging) + if exchanged != before { + let differences = Set(exchanged.keys).union(before.keys).filter { exchanged[$0] != before[$0] }.sorted() + guard renamex_np(staging.path, options.sourcesRoot.path, UInt32(RENAME_SWAP)) == 0 else { + preserveWorkspace = true + throw GenerationError("Output changed concurrently and swap recovery failed; retain \(workspace.path) for recovery.") + } + exchangePending = false + throw GenerationError("Output changed concurrently; its bytes were restored and publication was refused. Changed entries: \(differences.prefix(8).joined(separator: ", "))") + } + exchangePending = false + } else { + try manager.moveItem(at: staging, to: options.sourcesRoot) + } + report["status"] = "published" + do { try saveDiagnostic() } + catch { stderr("warning: Output group was published, but the requested diagnostic report could not be updated.") } + print("Published \(modules.count) validated \(options.profile) modules as one output group.") + } catch { + if exchangePending && !preserveWorkspace { + if renamex_np(staging.path, options.sourcesRoot.path, UInt32(RENAME_SWAP)) == 0 { + exchangePending = false + } else { + preserveWorkspace = true + stderr("error: Output exchange recovery failed; retain \(workspace.path) for recovery.") + } + } + report["status"] = "failed" + try saveDiagnostic() + throw error + } +} + +do { try generate(readOptions()) } +catch { stderr("error: \(error.localizedDescription)"); exit(1) } diff --git a/Scripts/notes-private-framework-normalize-full-dump b/Scripts/notes-private-framework-normalize-full-dump index 1f826be..6396240 100755 --- a/Scripts/notes-private-framework-normalize-full-dump +++ b/Scripts/notes-private-framework-normalize-full-dump @@ -1,743 +1,16 @@ #!/usr/bin/xcrun swift - import Foundation -struct ModuleConfig { - let name: String - let imports: [String] - let definesSwiftObject: Bool - let importsModuleNames: [String] -} - -struct ParsedModule { - var classes: [String: String] = [:] - var protocols: [String: String] = [:] - var categories: [(base: String, text: String)] = [] - var forwardClasses: Set = [] - var forwardProtocols: Set = [] - var structNames: Set = [] - var externalSupers: Set = [] - var externalCategoryBases: Set = [] - var orderedClasses: [String] = [] -} - -let foundationClasses: Set = [ - "NSArray", "NSMutableArray", "NSDictionary", "NSMutableDictionary", "NSSet", - "NSMutableSet", "NSOrderedSet", "NSMutableOrderedSet", "NSString", - "NSMutableString", "NSData", "NSMutableData", "NSDate", "NSDateComponents", - "NSDateInterval", "NSURL", "NSURLComponents", "NSUUID", "NSNumber", "NSError", - "NSCache", "NSObject", "NSValue", "NSCalendar", "NSTimeZone", "NSLocale", "NSUserDefaults", - "NSAttributedString", "NSMutableAttributedString", "NSCharacterSet", - "NSISO8601DateFormatter", "NSFileManager", "NSFileVersion", "NSIndexSet", - "NSIndexPath", "NSOperation", "NSOperationQueue", "NSProgress", "NSPredicate", - "NSSortDescriptor", "NSBundle", "NSFormatter", "NSMeasurement", "NSUnitDuration", - "NSUndoManager", "NSPersonNameComponents", "NSRegularExpression", -] - -let importedExternalClasses: Set = foundationClasses.union([ - "ACAccount", "AVAsset", "AVAudioEngine", "AVURLAsset", "CALayer", "CKAsset", - "CKDatabaseOperation", "CKFetchDatabaseChangesOperation", "CKFetchRecordZoneChangesOperation", - "CKFetchRecordsOperation", "CKModifyRecordsOperation", "CKOperation", "CKOperationGroup", - "CKRecord", "CKRecordID", "CKRecordZoneID", "CKServerChangeToken", "CKShare", - "CKShareParticipant", "CLLocation", "CNContact", "CNContactStore", "CSSearchableItem", - "CSSearchableItemAttributeSet", "LPLinkMetadata", "MKMapItem", "NSAlert", "NSAppearance", - "NSApplication", "NSArrayController", "NSBezierPath", "NSButton", "NSCollectionView", - "NSColor", "NSControl", "NSFont", "NSGestureRecognizer", "NSImage", "NSImageView", - "NSLayoutConstraint", "NSLayoutManager", "NSManagedObject", "NSManagedObjectContext", - "NSManagedObjectID", "NSManagedObjectModel", "NSMenu", "NSMenuItem", "NSOpenPanel", - "NSCoreDataCoreSpotlightDelegate", "NSParagraphStyle", "NSPersistentContainer", "NSPersistentStoreCoordinator", "NSPopover", - "NSPrintPanel", "NSScreen", "NSScrollView", "NSTableView", "NSTextAttachment", - "NSTextContainer", "NSTextContentManager", "NSTextContentStorage", "NSTextField", - "NSTextLayoutManager", "NSTextStorage", "NSTextView", "NSTokenField", "NSToolbar", - "NSTouchBar", "NSView", "NSViewController", "NSWindow", "PDFDocument", "PKDrawing", - "QLPreviewPanel", "TUTranscriptionModelAvailability", "WKWebView", "WKUserContentController", -]) - -let importedExternalClassPrefixes: [String] = [ - "AV", "CA", "CK", "CL", "CN", "CS", "LP", "MK", "NS", "PDF", "PK", "QL", "TU", "UN", "VN", "WK", -] - -let forcedExternalClassStubs: Set = [ - "NSTokenAttachmentCell", "PKTextAttachmentDrawingView", "PKTitleQuery", -] - -let importedExternalProtocols: Set = [ - "AVAudioPlayerDelegate", "CLLocationManagerDelegate", "CNKeyDescriptor", - "HTMLConverterProtocol", "MKAnnotation", "NSAlertDelegate", "NSMachPortDelegate", - "NSAccessibility", "NSAccessibilityButton", "NSAccessibilityElement", "NSAccessibilityGroup", - "NSAccessibilityImage", "NSAccessibilityRow", "NSAccessibilityTable", - "NSCollectionViewDataSource", "NSCollectionViewDelegate", "NSControlTextEditingDelegate", - "NSFetchedResultsControllerDelegate", "NSFilePromiseProviderDelegate", - "NSGestureRecognizerDelegate", "NSLayoutManagerDelegate", "NSMenuDelegate", - "NSMenuItemValidation", "NSOpenSavePanelDelegate", "NSPasteboardWriting", - "NSPopoverDelegate", "NSPortDelegate", "NSPrintPanelAccessorizing", - "NSSharingServiceDelegate", "NSSharingServicePickerDelegate", "NSTableViewDataSource", - "NSTableViewDelegate", "NSTextAttachmentCell", "NSTextContentManagerDelegate", - "NSTextContentStorageDelegate", "NSTextDelegate", "NSTextFieldDelegate", - "NSTextFinderClient", "NSTextLayoutManagerDelegate", "NSTextLocation", - "NSTextStorageDelegate", "NSTextViewDelegate", - "NSTextViewportRenderingSurface", "NSTokenFieldDelegate", "NSTouchBarDelegate", - "NSTouchBarProvider", "NSURLDownloadDelegate", "NSURLSessionDownloadDelegate", - "NSUserActivityDelegate", "NSUserInterfaceValidations", - "NSWindowRestoration", "OS_dispatch_queue", "PKDrawingUUID", - "WKNavigationDelegate", "WKScriptMessageHandler", - "WKUIDelegate", "WKURLSchemeHandler", -] - -func stderr(_ message: String) { - FileHandle.standardError.write(Data((message + "\n").utf8)) -} - -func usage() -> Never { - stderr( - """ - Usage: - Scripts/notes-private-framework-normalize-full-dump --dump-root --sources-root - - Expected dump layout: - /NotesSupport/NotesSupport/*.h - /NotesHTML/NotesHTML/*.h - /NotesShared/NotesShared/*.h - /NotesUI/NotesUI/*.h - /NotesEditor/NotesEditor/*.h - /NotesPreviewKit/NotesPreviewKit/*.h - - Output: - //include/.h - //include/module.modulemap - """ - ) - exit(64) -} - -func parseArguments() -> (dumpRoot: URL, sourcesRoot: URL) { - var dumpRoot: String? - var sourcesRoot: String? - var index = 1 - let args = CommandLine.arguments - while index < args.count { - let arg = args[index] - switch arg { - case "--dump-root": - index += 1 - guard index < args.count else { usage() } - dumpRoot = args[index] - case "--sources-root": - index += 1 - guard index < args.count else { usage() } - sourcesRoot = args[index] - case "--help", "-h": - usage() - default: - stderr("Unknown argument: \(arg)") - usage() - } - index += 1 - } - guard let dumpRoot, let sourcesRoot else { usage() } - return ( - URL(fileURLWithPath: dumpRoot, isDirectory: true), - URL(fileURLWithPath: sourcesRoot, isDirectory: true) - ) -} - -func firstCapture(_ text: String, pattern: String) -> String? { - guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil } - let ns = text as NSString - let range = NSRange(location: 0, length: ns.length) - guard let match = regex.firstMatch(in: text, range: range), - match.numberOfRanges > 1, - match.range(at: 1).location != NSNotFound - else { - return nil - } - return ns.substring(with: match.range(at: 1)) -} - -func replaceRegex(_ text: String, pattern: String, with replacement: String) -> String { - guard let regex = try? NSRegularExpression(pattern: pattern) else { return text } - let ns = text as NSString - return regex.stringByReplacingMatches( - in: text, - range: NSRange(location: 0, length: ns.length), - withTemplate: replacement - ) -} - -func isIdentifier(_ value: String) -> Bool { - firstCapture(value, pattern: #"^([A-Za-z_][A-Za-z0-9_]*)$"#) != nil -} - -func matchingBraceEnd(in text: NSString, openBraceLocation: Int) -> Int? { - var depth = 0 - var index = openBraceLocation - while index < text.length { - let scalar = text.character(at: index) - if scalar == Character("{").utf16.first! { - depth += 1 - } else if scalar == Character("}").utf16.first! { - depth -= 1 - if depth == 0 { - return index + 1 - } - } - index += 1 - } - return nil -} - -func replaceCPPStructs(_ input: String) -> String { - var text = input - let prefixes = ["struct basic_string", "struct vector", "struct map", "struct pair"] - for prefix in prefixes { - while let prefixRange = text.range(of: prefix) { - let ns = text as NSString - let start = ns.range(of: prefix).location - let searchRange = NSRange(location: start, length: ns.length - start) - let brace = ns.range(of: "{", range: searchRange) - guard brace.location != NSNotFound, - let end = matchingBraceEnd(in: ns, openBraceLocation: brace.location) - else { - text.replaceSubrange(prefixRange, with: "void *") - continue - } - let before = ns.substring(to: start) - let after = ns.substring(from: end) - text = before + "void *" + after - } - } - return text -} - -func stripInlineStructs(_ input: String, structNames: inout Set) -> String { - var text = replaceCPPStructs(input) - guard let regex = try? NSRegularExpression(pattern: #"\bstruct\s+([A-Za-z_][A-Za-z0-9_]*)\s*\{"#) else { - return text - } - - while true { - let ns = text as NSString - let range = NSRange(location: 0, length: ns.length) - guard let match = regex.firstMatch(in: text, range: range) else { return text } - let name = ns.substring(with: match.range(at: 1)) - let openBrace = match.range.location + match.range.length - 1 - guard let end = matchingBraceEnd(in: ns, openBraceLocation: openBrace) else { - return text - } - structNames.insert(name) - let before = ns.substring(to: match.range.location) - let after = ns.substring(from: end) - text = before + "struct \(name)" + after - } -} - -func headerFiles(in directory: URL) throws -> [URL] { - let contents = try FileManager.default.contentsOfDirectory( - at: directory, - includingPropertiesForKeys: nil, - options: [.skipsHiddenFiles] - ) - return contents.filter { $0.pathExtension == "h" }.sorted { $0.lastPathComponent < $1.lastPathComponent } -} - -func lineIsImporterNoise(_ line: String) -> Bool { - if line.contains("@?<") || line.contains("v48@0:8") { - return true - } - if line.contains("out inout") || line.contains("$__lazy_storage_$_") { - return true - } - if line.contains(" -> ") { - return true - } - if line.contains("Swift.") || line.contains("SwiftUI.") || line.contains("Combine.") { - return true - } - if line.contains("CoreGraphics.") || line.contains("__C.") || line.contains("etwork.") { - return true - } - if line.contains("tesShared.") || line.contains("tesSupport.") || line.contains("tesUI.") - || line.contains("tesEditor.") || line.contains("tesPreviewKit.") - { - return true - } - if line == "@protocol v@?;" { - return true - } - return false -} - -func normalizeLine(_ input: String, structNames: inout Set) -> String? { - var line = input.trimmingCharacters(in: .whitespacesAndNewlines) - guard !line.isEmpty else { return "" } - if line.hasPrefix("//") || line.hasPrefix("#") || line.hasPrefix("@import") || line.hasPrefix("#include") { - return nil - } - if lineIsImporterNoise(line) { - return nil - } - line = stripInlineStructs(line, structNames: &structNames) - line = replaceRegex( - line, - pattern: #"NSObject\s*\*"#, - with: "NSObject *" - ) - line = replaceRegex( - line, - pattern: #"\bstruct\s+CGColor\s*\*"#, - with: "CGColorRef" - ) - line = line.replacingOccurrences( - of: "@property (retain, nonatomic) CGColorRef", - with: "@property (nonatomic) CGColorRef" - ) - line = line.replacingOccurrences( - of: "@property (strong, nonatomic) CGColorRef", - with: "@property (nonatomic) CGColorRef" - ) - line = replaceRegex( - line, - pattern: #"(\([^)]*\))\s*(nil|Nil|YES|NO|NULL)\b"#, - with: "$1value" - ) - if lineIsImporterNoise(line) { - return nil - } - return line -} - -func isImportedExternalClass(_ name: String, importedKnownTypes: Set) -> Bool { - if forcedExternalClassStubs.contains(name) { - return false - } - if importedExternalClasses.contains(name) || importedKnownTypes.contains(name) { - return true - } - return importedExternalClassPrefixes.contains { name.hasPrefix($0) } -} - -func parseModule( - dumpDirectory: URL, - importedKnownTypes: Set -) throws -> ParsedModule { - var parsed = ParsedModule() - var externalSupersSeen: Set = [] - var externalCategoryBasesSeen: Set = [] - - for file in try headerFiles(in: dumpDirectory) { - let content = try String(contentsOf: file, encoding: .utf8) - let lines = content.components(separatedBy: .newlines) - - var currentKind: String? - var currentName: String? - var block: [String] = [] - var seenLines: Set = [] - var seenProperties: Set = [] - var seenMethods: Set = [] - var skippingIvars = false - var ivarDepth = 0 - - func finishCurrent() { - guard let kind = currentKind, let name = currentName else { return } - let text = block.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) + "\n" - if kind == "class" { - parsed.classes[name] = parsed.classes[name] ?? text - } else if kind == "category" { - parsed.categories.append((base: name, text: text)) - } else if kind == "protocol", !importedExternalProtocols.contains(name) { - parsed.protocols[name] = parsed.protocols[name] ?? text - } - currentKind = nil - currentName = nil - block = [] - seenLines = [] - seenProperties = [] - seenMethods = [] - } - - for rawLine in lines { - guard var line = normalizeLine(rawLine, structNames: &parsed.structNames) else { - continue - } - if line.isEmpty { - if currentKind != nil, block.last != "" { - block.append("") - } - continue - } - - if let captured = firstCapture(line, pattern: #"^@class\s+(.+);"#) { - for part in captured.split(separator: ",") { - let name = part.trimmingCharacters(in: .whitespacesAndNewlines) - if isIdentifier(name) { - parsed.forwardClasses.insert(name) - } - } - continue - } - - if let captured = firstCapture(line, pattern: #"^@protocol\s+(.+);"#) { - for part in captured.split(separator: ",") { - let name = part.trimmingCharacters(in: .whitespacesAndNewlines) - if isIdentifier(name), !importedExternalProtocols.contains(name) { - parsed.forwardProtocols.insert(name) - } - } - continue - } - - if skippingIvars { - ivarDepth += line.filter { $0 == "{" }.count - ivarDepth -= line.filter { $0 == "}" }.count - if ivarDepth <= 0 { - skippingIvars = false - } - continue - } - - if line.hasPrefix("@interface") || line.hasPrefix("@protocol") { - finishCurrent() - block = [] - seenLines = [] - seenProperties = [] - seenMethods = [] - - if line.hasPrefix("@protocol") { - guard let name = firstCapture(line, pattern: #"^@protocol\s+([A-Za-z_][A-Za-z0-9_]*)"#) else { - continue - } - currentKind = "protocol" - currentName = name - block.append(line) - continue - } - - if let categoryBase = firstCapture(line, pattern: #"^@interface\s+([A-Za-z_][A-Za-z0-9_]*)\s*\("#) { - currentKind = "category" - currentName = categoryBase - externalCategoryBasesSeen.insert(categoryBase) - if let brace = line.firstIndex(of: "{") { - let before = line[.. 0 - block.append(before) - } else { - block.append(line) - } - continue - } - - guard let className = firstCapture(line, pattern: #"^@interface\s+([A-Za-z_][A-Za-z0-9_]*)"#) else { - continue - } - currentKind = "class" - currentName = className - if let superclass = firstCapture(line, pattern: #":\s*([A-Za-z_][A-Za-z0-9_]*)"#), - superclass != "NSObject" - { - externalSupersSeen.insert(superclass) - } - if let brace = line.firstIndex(of: "{") { - let before = line[.. 0 - block.append(before) - } else { - block.append(line) - } - continue - } - - guard currentKind != nil else { continue } - - if line == "@end" { - block.append(line) - finishCurrent() - continue - } - - if line.hasPrefix("@property"), - let propertyName = firstCapture(line, pattern: #"\b([A-Za-z_][A-Za-z0-9_]*)\s*;$"#) - { - if seenProperties.contains(propertyName) { - continue - } - seenProperties.insert(propertyName) - } - - if line.hasPrefix("-") || line.hasPrefix("+") { - let key = replaceRegex(line, pattern: #"\([^)]*\)"#, with: "()") - if seenMethods.contains(key) { - continue - } - seenMethods.insert(key) - } - - if seenLines.contains(line) { - continue - } - seenLines.insert(line) - block.append(line) - } - - finishCurrent() - } - - parsed.externalSupers = externalSupersSeen.filter { - !parsed.classes.keys.contains($0) - && $0 != "_TtCs12_SwiftObject" - && !isImportedExternalClass($0, importedKnownTypes: importedKnownTypes) - } - parsed.externalCategoryBases = externalCategoryBasesSeen.filter { - !parsed.classes.keys.contains($0) - && $0 != "_TtCs12_SwiftObject" - && !isImportedExternalClass($0, importedKnownTypes: importedKnownTypes) - } - parsed.forwardClasses.formUnion(parsed.classes.keys) - parsed.forwardProtocols.formUnion(parsed.protocols.keys) - - var superclassByClass: [String: String] = [:] - for (name, text) in parsed.classes { - guard let firstLine = text.components(separatedBy: .newlines).first, - let superclass = firstCapture(firstLine, pattern: #":\s*([A-Za-z_][A-Za-z0-9_]*)"#), - parsed.classes.keys.contains(superclass) - else { - continue - } - superclassByClass[name] = superclass - } - - var ordered: [String] = [] - var temporary: Set = [] - var permanent: Set = [] - - func visit(_ name: String) { - if permanent.contains(name) { return } - if temporary.contains(name) { - permanent.insert(name) - ordered.append(name) - return - } - temporary.insert(name) - if let superclass = superclassByClass[name] { - visit(superclass) - } - temporary.remove(name) - permanent.insert(name) - ordered.append(name) - } - - for name in parsed.classes.keys.sorted() { - visit(name) - } - var seenOrdered: Set = [] - parsed.orderedClasses = ordered.filter { seenOrdered.insert($0).inserted } - return parsed -} - -func writeModule( - _ parsed: ParsedModule, - config: ModuleConfig, - sourcesRoot: URL -) throws { - let moduleDirectory = sourcesRoot.appendingPathComponent(config.name, isDirectory: true) - let includeDirectory = moduleDirectory - .appendingPathComponent("include", isDirectory: true) - try FileManager.default.createDirectory(at: includeDirectory, withIntermediateDirectories: true) - - var lines: [String] = [ - "// Normalized full dump import surface for \(config.name).", - "// Source: local dyld shared cache via ipsw class-dump; normalized for Swift/Clang import.", - "#ifndef \(config.name)_h", - "#define \(config.name)_h", - "", - "#import ", - ] - lines.append(contentsOf: config.imports) - lines.append("") - - if config.definesSwiftObject { - lines.append("@interface _TtCs12_SwiftObject : NSObject") - lines.append("@end") - lines.append("") - } - - for name in parsed.externalSupers.union(parsed.externalCategoryBases).sorted() - where name != "NSObject" && name != "_TtCs12_SwiftObject" { - lines.append("@interface \(name) : NSObject") - lines.append("@end") - } - lines.append("") - - for name in parsed.structNames.sorted() { - lines.append("struct \(name);") - } - lines.append("") - - let forwardClasses = parsed.forwardClasses - .filter { $0 != "NSObject" && $0 != "_TtCs12_SwiftObject" } - .sorted() - for chunkStart in stride(from: 0, to: forwardClasses.count, by: 12) { - let chunkEnd = min(chunkStart + 12, forwardClasses.count) - lines.append("@class " + forwardClasses[chunkStart.. URL { - dumpRoot - .appendingPathComponent(module, isDirectory: true) - .appendingPathComponent(module, isDirectory: true) -} - -let publicImports = [ - "@import Accounts;", - "@import AppKit;", - "@import AVFoundation;", - "@import CloudKit;", - "@import CoreData;", - "@import CoreGraphics;", - "@import CoreLocation;", - "@import CoreMedia;", - "@import CoreSpotlight;", - "@import Contacts;", - "@import Dispatch;", - "@import MapKit;", - "@import PDFKit;", - "@import PencilKit;", - "@import QuartzCore;", - "@import Speech;", - "@import UniformTypeIdentifiers;", - "@import UserNotifications;", - "@import WebKit;", -] - -let (dumpRoot, sourcesRoot) = parseArguments() -let modules = [ - ModuleConfig( - name: "NotesSupport", - imports: publicImports, - definesSwiftObject: true, - importsModuleNames: [] - ), - ModuleConfig( - name: "NotesHTML", - imports: publicImports + ["@import NotesSupport;"], - definesSwiftObject: false, - importsModuleNames: ["NotesSupport"] - ), - ModuleConfig( - name: "NotesShared", - imports: publicImports + ["@import NotesSupport;", "@import NotesHTML;"], - definesSwiftObject: false, - importsModuleNames: ["NotesSupport", "NotesHTML"] - ), - ModuleConfig( - name: "NotesUI", - imports: publicImports + [ - "@import NotesSupport;", - "@import NotesHTML;", - "@import NotesShared;", - ], - definesSwiftObject: false, - importsModuleNames: ["NotesSupport", "NotesHTML", "NotesShared"] - ), - ModuleConfig( - name: "NotesEditor", - imports: publicImports + [ - "@import NotesSupport;", - "@import NotesHTML;", - "@import NotesShared;", - "@import NotesUI;", - ], - definesSwiftObject: false, - importsModuleNames: ["NotesSupport", "NotesHTML", "NotesShared", "NotesUI"] - ), - ModuleConfig( - name: "NotesPreviewKit", - imports: publicImports + [ - "@import NotesSupport;", - "@import NotesShared;", - ], - definesSwiftObject: false, - importsModuleNames: ["NotesSupport", "NotesShared"] - ), -] - +let engine = URL(fileURLWithPath: CommandLine.arguments[0]).standardizedFileURL + .deletingLastPathComponent().appendingPathComponent("framework-normalize-full-dump") +let process = Process() +process.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun") +process.arguments = ["swift", engine.path, "--profile", "notes"] + Array(CommandLine.arguments.dropFirst()) do { - var parsedByModule: [String: ParsedModule] = [:] - for config in modules { - let importedKnownTypes = config.importsModuleNames.reduce(into: Set()) { result, moduleName in - if let imported = parsedByModule[moduleName] { - result.formUnion(imported.classes.keys) - result.formUnion(imported.protocols.keys) - } - } - let input = dumpDirectory(for: config.name, dumpRoot: dumpRoot) - guard FileManager.default.fileExists(atPath: input.path) else { - throw NSError( - domain: "NormalizeNotesFullDump", - code: 2, - userInfo: [NSLocalizedDescriptionKey: "Missing dump directory: \(input.path)"] - ) - } - let parsed = try parseModule(dumpDirectory: input, importedKnownTypes: importedKnownTypes) - try writeModule(parsed, config: config, sourcesRoot: sourcesRoot) - parsedByModule[config.name] = parsed - print( - "\(config.name): \(parsed.classes.count) classes, \(parsed.protocols.count) protocols, \(parsed.categories.count) categories, \(parsed.structNames.count) opaque structs" - ) - } + try process.run() + process.waitUntilExit() + exit(process.terminationStatus) } catch { - stderr("error: \(error.localizedDescription)") + FileHandle.standardError.write(Data("error: Could not start the framework normalizer.\n".utf8)) exit(1) } diff --git a/Scripts/package-release b/Scripts/package-release index 503edf7..2c5213f 100755 --- a/Scripts/package-release +++ b/Scripts/package-release @@ -167,6 +167,7 @@ func main() throws { for file in [ "LICENSE", "THIRD_PARTY_NOTICES.md", "CHANGELOG.md", "Package.resolved", "Documentation/Reference/ReleaseGuide.md", "Documentation/Architecture/VersioningAndRelease.md", + "Documentation/Reference/NativeFixtureValidation.md", "Documentation/Reference/NativeFixtureManifest.schema.json", "Documentation/Reference/ThirdPartyLicenses/swift-runtime/LICENSE.txt", ] { guard manager.fileExists(atPath: root.appendingPathComponent(file).path) else { @@ -328,9 +329,11 @@ func main() throws { at: licenses.deletingLastPathComponent(), withIntermediateDirectories: true) try manager.copyItem( at: root.appendingPathComponent("Documentation/Reference/ThirdPartyLicenses"), to: licenses) - try manager.copyItem( - at: root.appendingPathComponent("Documentation/Reference/ReleaseGuide.md"), - to: licenses.deletingLastPathComponent().appendingPathComponent("ReleaseGuide.md")) + for name in ["ReleaseGuide.md", "NativeFixtureValidation.md", "NativeFixtureManifest.schema.json"] { + try manager.copyItem( + at: root.appendingPathComponent("Documentation/Reference/\(name)"), + to: licenses.deletingLastPathComponent().appendingPathComponent(name)) + } let architectureDocs = package.appendingPathComponent( "Documentation/Architecture", isDirectory: true) try manager.createDirectory(at: architectureDocs, withIntermediateDirectories: true) diff --git a/Scripts/reminderkit-normalize-full-dump b/Scripts/reminderkit-normalize-full-dump index 1e4375f..84d1cfa 100755 --- a/Scripts/reminderkit-normalize-full-dump +++ b/Scripts/reminderkit-normalize-full-dump @@ -1,563 +1,16 @@ #!/usr/bin/xcrun swift - import Foundation -struct ModuleConfig { - let name: String - let imports: [String] - let definesSwiftObject: Bool - let importsModuleNames: [String] -} - -struct ParsedModule { - var classes: [String: String] = [:] - var protocols: [String: String] = [:] - var categories: [(base: String, text: String)] = [] - var forwardClasses: Set = [] - var forwardProtocols: Set = [] - var structNames: Set = [] - var externalSupers: Set = [] - var externalCategoryBases: Set = [] - var orderedClasses: [String] = [] -} - -let foundationClasses: Set = [ - "NSArray", "NSMutableArray", "NSDictionary", "NSMutableDictionary", "NSSet", - "NSMutableSet", "NSOrderedSet", "NSMutableOrderedSet", "NSString", - "NSMutableString", "NSData", "NSMutableData", "NSDate", "NSDateComponents", - "NSDateInterval", "NSURL", "NSUUID", "NSNumber", "NSError", "NSObject", - "NSValue", "NSCalendar", "NSTimeZone", "NSLocale", "NSUserDefaults", - "NSAttributedString", "NSMutableAttributedString", "NSCharacterSet", - "NSISO8601DateFormatter", "NSFileManager", "NSIndexSet", "NSIndexPath", - "NSOperationQueue", "NSProgress", "NSPredicate", "NSSortDescriptor", - "NSBundle", "NSFormatter", "NSMeasurement", "NSUnitDuration", -] - -let importedExternalClasses: Set = foundationClasses.union([ - "ACAccount", "CKAllowedSharingOptions", "INIntent", "INIntentResponse", - "INObject", "INObjectResolutionResult", -]) -let importedExternalProtocols: Set = [ - "MLFeatureProvider", -] - -func stderr(_ message: String) { - FileHandle.standardError.write(Data((message + "\n").utf8)) -} - -func usage() -> Never { - stderr( - """ - Usage: - Scripts/reminderkit-normalize-full-dump --dump-root --sources-root - - Expected dump layout: - /ReminderKit/ReminderKit/*.h - /ReminderKitInternal/ReminderKitInternal/*.h - - Output: - /ReminderKit/include/ReminderKit.h - /ReminderKit/include/module.modulemap - /ReminderKitInternal/include/ReminderKitInternal.h - /ReminderKitInternal/include/module.modulemap - """ - ) - exit(64) -} - -func parseArguments() -> (dumpRoot: URL, sourcesRoot: URL) { - var dumpRoot: String? - var sourcesRoot: String? - var index = 1 - let args = CommandLine.arguments - while index < args.count { - let arg = args[index] - switch arg { - case "--dump-root": - index += 1 - guard index < args.count else { usage() } - dumpRoot = args[index] - case "--sources-root": - index += 1 - guard index < args.count else { usage() } - sourcesRoot = args[index] - case "--help", "-h": - usage() - default: - stderr("Unknown argument: \(arg)") - usage() - } - index += 1 - } - guard let dumpRoot, let sourcesRoot else { usage() } - return ( - URL(fileURLWithPath: dumpRoot, isDirectory: true), - URL(fileURLWithPath: sourcesRoot, isDirectory: true) - ) -} - -func firstCapture(_ text: String, pattern: String) -> String? { - guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil } - let ns = text as NSString - let range = NSRange(location: 0, length: ns.length) - guard let match = regex.firstMatch(in: text, range: range), - match.numberOfRanges > 1, - match.range(at: 1).location != NSNotFound - else { - return nil - } - return ns.substring(with: match.range(at: 1)) -} - -func replaceRegex(_ text: String, pattern: String, with replacement: String) -> String { - guard let regex = try? NSRegularExpression(pattern: pattern) else { return text } - let ns = text as NSString - return regex.stringByReplacingMatches( - in: text, - range: NSRange(location: 0, length: ns.length), - withTemplate: replacement - ) -} - -func isIdentifier(_ value: String) -> Bool { - firstCapture(value, pattern: #"^([A-Za-z_][A-Za-z0-9_]*)$"#) != nil -} - -func matchingBraceEnd(in text: NSString, openBraceLocation: Int) -> Int? { - var depth = 0 - var index = openBraceLocation - while index < text.length { - let scalar = text.character(at: index) - if scalar == Character("{").utf16.first! { - depth += 1 - } else if scalar == Character("}").utf16.first! { - depth -= 1 - if depth == 0 { - return index + 1 - } - } - index += 1 - } - return nil -} - -func replaceCPPStructs(_ input: String) -> String { - var text = input - let prefixes = ["struct basic_string", "struct vector", "struct map", "struct pair"] - for prefix in prefixes { - while let prefixRange = text.range(of: prefix) { - let ns = text as NSString - let start = ns.range(of: prefix).location - let searchRange = NSRange(location: start, length: ns.length - start) - let brace = ns.range(of: "{", range: searchRange) - guard brace.location != NSNotFound, - let end = matchingBraceEnd(in: ns, openBraceLocation: brace.location) - else { - let swiftStart = prefixRange.lowerBound - text.replaceSubrange(swiftStart..) -> String { - var text = replaceCPPStructs(input) - guard let regex = try? NSRegularExpression(pattern: #"\bstruct\s+([A-Za-z_][A-Za-z0-9_]*)\s*\{"#) else { - return text - } - - while true { - let ns = text as NSString - let range = NSRange(location: 0, length: ns.length) - guard let match = regex.firstMatch(in: text, range: range) else { return text } - let name = ns.substring(with: match.range(at: 1)) - let openBrace = match.range.location + match.range.length - 1 - guard let end = matchingBraceEnd(in: ns, openBraceLocation: openBrace) else { - return text - } - structNames.insert(name) - let before = ns.substring(to: match.range.location) - let after = ns.substring(from: end) - text = before + "struct \(name)" + after - } -} - -func headerFiles(in directory: URL) throws -> [URL] { - let contents = try FileManager.default.contentsOfDirectory( - at: directory, - includingPropertiesForKeys: nil, - options: [.skipsHiddenFiles] - ) - return contents.filter { $0.pathExtension == "h" }.sorted { $0.lastPathComponent < $1.lastPathComponent } -} - -func parseModule( - dumpDirectory: URL, - importedKnownTypes: Set -) throws -> ParsedModule { - var parsed = ParsedModule() - var externalSupersSeen: Set = [] - var externalCategoryBasesSeen: Set = [] - - for file in try headerFiles(in: dumpDirectory) { - let content = try String(contentsOf: file, encoding: .utf8) - let lines = content.components(separatedBy: .newlines) - - var currentKind: String? - var currentName: String? - var block: [String] = [] - var seenLines: Set = [] - var seenProperties: Set = [] - var seenMethods: Set = [] - var skippingIvars = false - var ivarDepth = 0 - - func finishCurrent() { - guard let kind = currentKind, let name = currentName else { return } - let text = block.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) + "\n" - if kind == "class" { - parsed.classes[name] = parsed.classes[name] ?? text - } else if kind == "category" { - parsed.categories.append((base: name, text: text)) - } else if kind == "protocol", !importedExternalProtocols.contains(name) { - parsed.protocols[name] = parsed.protocols[name] ?? text - } - currentKind = nil - currentName = nil - block = [] - seenLines = [] - seenProperties = [] - seenMethods = [] - } - - for rawLine in lines { - var line = rawLine.trimmingCharacters(in: .whitespacesAndNewlines) - guard !line.isEmpty else { - if currentKind != nil, block.last != "" { - block.append("") - } - continue - } - if line.hasPrefix("//") || line.hasPrefix("#") || line.hasPrefix("@import") { - continue - } - - if let captured = firstCapture(line, pattern: #"^@class\s+(.+);"#) { - for part in captured.split(separator: ",") { - let name = part.trimmingCharacters(in: .whitespacesAndNewlines) - if isIdentifier(name) { - parsed.forwardClasses.insert(name) - } - } - continue - } - - if let captured = firstCapture(line, pattern: #"^@protocol\s+(.+);"#) { - for part in captured.split(separator: ",") { - let name = part.trimmingCharacters(in: .whitespacesAndNewlines) - if isIdentifier(name), !importedExternalProtocols.contains(name) { - parsed.forwardProtocols.insert(name) - } - } - continue - } - - if skippingIvars { - ivarDepth += line.filter { $0 == "{" }.count - ivarDepth -= line.filter { $0 == "}" }.count - if ivarDepth <= 0 { - skippingIvars = false - } - continue - } - - line = stripInlineStructs(line, structNames: &parsed.structNames) - - if line.hasPrefix("@interface") || line.hasPrefix("@protocol") { - finishCurrent() - block = [] - seenLines = [] - seenProperties = [] - seenMethods = [] - - if line.hasPrefix("@protocol") { - guard let name = firstCapture(line, pattern: #"^@protocol\s+([A-Za-z_][A-Za-z0-9_]*)"#) else { - continue - } - currentKind = "protocol" - currentName = name - block.append(line) - continue - } - - if let categoryBase = firstCapture(line, pattern: #"^@interface\s+([A-Za-z_][A-Za-z0-9_]*)\s*\("#) { - currentKind = "category" - currentName = categoryBase - externalCategoryBasesSeen.insert(categoryBase) - if let brace = line.firstIndex(of: "{") { - let before = line[.. 0 - block.append(before) - } else { - block.append(line) - } - continue - } - - guard let className = firstCapture(line, pattern: #"^@interface\s+([A-Za-z_][A-Za-z0-9_]*)"#) else { - continue - } - currentKind = "class" - currentName = className - if let superclass = firstCapture(line, pattern: #":\s*([A-Za-z_][A-Za-z0-9_]*)"#), - superclass != "NSObject" - { - externalSupersSeen.insert(superclass) - } - if let brace = line.firstIndex(of: "{") { - let before = line[.. 0 - block.append(before) - } else { - block.append(line) - } - continue - } - - guard currentKind != nil else { continue } - - if line == "@end" { - block.append(line) - finishCurrent() - continue - } - - if line.hasPrefix("@property"), - let propertyName = firstCapture(line, pattern: #"\b([A-Za-z_][A-Za-z0-9_]*)\s*;$"#) - { - if seenProperties.contains(propertyName) { - continue - } - seenProperties.insert(propertyName) - } - - if line.hasPrefix("-") || line.hasPrefix("+") { - let key = replaceRegex(line, pattern: #"\([^)]*\)"#, with: "()") - if seenMethods.contains(key) { - continue - } - seenMethods.insert(key) - } - - if seenLines.contains(line) { - continue - } - seenLines.insert(line) - block.append(line) - } - - finishCurrent() - } - - let imported = importedExternalClasses.union(importedKnownTypes).union(["_TtCs12_SwiftObject"]) - parsed.externalSupers = externalSupersSeen.filter { !parsed.classes.keys.contains($0) && !imported.contains($0) } - parsed.externalCategoryBases = externalCategoryBasesSeen.filter { - !parsed.classes.keys.contains($0) && !imported.contains($0) - } - parsed.forwardClasses.formUnion(parsed.classes.keys) - parsed.forwardProtocols.formUnion(parsed.protocols.keys) - - var superclassByClass: [String: String] = [:] - for (name, text) in parsed.classes { - guard let firstLine = text.components(separatedBy: .newlines).first, - let superclass = firstCapture(firstLine, pattern: #":\s*([A-Za-z_][A-Za-z0-9_]*)"#), - parsed.classes.keys.contains(superclass) - else { - continue - } - superclassByClass[name] = superclass - } - - var ordered: [String] = [] - var temporary: Set = [] - var permanent: Set = [] - - func visit(_ name: String) { - if permanent.contains(name) { return } - if temporary.contains(name) { - permanent.insert(name) - ordered.append(name) - return - } - temporary.insert(name) - if let superclass = superclassByClass[name] { - visit(superclass) - } - temporary.remove(name) - permanent.insert(name) - ordered.append(name) - } - - for name in parsed.classes.keys.sorted() { - visit(name) - } - var seenOrdered: Set = [] - parsed.orderedClasses = ordered.filter { seenOrdered.insert($0).inserted } - return parsed -} - -func writeModule( - _ parsed: ParsedModule, - config: ModuleConfig, - sourcesRoot: URL -) throws { - let moduleDirectory = sourcesRoot.appendingPathComponent(config.name, isDirectory: true) - let includeDirectory = moduleDirectory - .appendingPathComponent("include", isDirectory: true) - try FileManager.default.createDirectory(at: includeDirectory, withIntermediateDirectories: true) - - var lines: [String] = [ - "// Normalized full dump import surface for \(config.name).", - "// Source: local ipsw class-dump output; normalized for Swift/Clang import.", - "#ifndef \(config.name)_h", - "#define \(config.name)_h", - "", - "#import ", - ] - lines.append(contentsOf: config.imports) - lines.append("") - - if config.definesSwiftObject { - lines.append("@interface _TtCs12_SwiftObject : NSObject") - lines.append("@end") - lines.append("") - } - - for name in parsed.externalSupers.union(parsed.externalCategoryBases).sorted() - where name != "NSObject" && name != "_TtCs12_SwiftObject" { - lines.append("@interface \(name) : NSObject") - lines.append("@end") - } - lines.append("") - - for name in parsed.structNames.sorted() { - lines.append("struct \(name);") - } - lines.append("") - - let forwardClasses = parsed.forwardClasses - .filter { $0 != "NSObject" && $0 != "_TtCs12_SwiftObject" } - .sorted() - for chunkStart in stride(from: 0, to: forwardClasses.count, by: 12) { - let chunkEnd = min(chunkStart + 12, forwardClasses.count) - lines.append("@class " + forwardClasses[chunkStart.. URL { - dumpRoot - .appendingPathComponent(module, isDirectory: true) - .appendingPathComponent(module, isDirectory: true) -} - -let (dumpRoot, sourcesRoot) = parseArguments() -let modules = [ - ModuleConfig( - name: "ReminderKit", - imports: ["@import Accounts;", "@import CloudKit;"], - definesSwiftObject: true, - importsModuleNames: [] - ), - ModuleConfig( - name: "ReminderKitInternal", - imports: ["@import ReminderKit;", "@import Intents;", "@import CoreML;"], - definesSwiftObject: false, - importsModuleNames: ["ReminderKit"] - ), -] - +let engine = URL(fileURLWithPath: CommandLine.arguments[0]).standardizedFileURL + .deletingLastPathComponent().appendingPathComponent("framework-normalize-full-dump") +let process = Process() +process.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun") +process.arguments = ["swift", engine.path, "--profile", "reminders"] + Array(CommandLine.arguments.dropFirst()) do { - var parsedByModule: [String: ParsedModule] = [:] - for config in modules { - let importedKnownTypes = config.importsModuleNames.reduce(into: Set()) { result, moduleName in - if let imported = parsedByModule[moduleName] { - result.formUnion(imported.classes.keys) - result.formUnion(imported.protocols.keys) - } - } - let input = dumpDirectory(for: config.name, dumpRoot: dumpRoot) - guard FileManager.default.fileExists(atPath: input.path) else { - throw NSError( - domain: "NormalizeFullDump", - code: 2, - userInfo: [NSLocalizedDescriptionKey: "Missing dump directory: \(input.path)"] - ) - } - let parsed = try parseModule(dumpDirectory: input, importedKnownTypes: importedKnownTypes) - try writeModule(parsed, config: config, sourcesRoot: sourcesRoot) - parsedByModule[config.name] = parsed - print( - "\(config.name): \(parsed.classes.count) classes, \(parsed.protocols.count) protocols, \(parsed.categories.count) categories, \(parsed.structNames.count) opaque structs" - ) - } + try process.run() + process.waitUntilExit() + exit(process.terminationStatus) } catch { - stderr("error: \(error.localizedDescription)") + FileHandle.standardError.write(Data("error: Could not start the framework normalizer.\n".utf8)) exit(1) } diff --git a/Scripts/verify-release b/Scripts/verify-release index 8fc0a94..288bc95 100755 --- a/Scripts/verify-release +++ b/Scripts/verify-release @@ -140,6 +140,7 @@ def verify(directory, source, tag=None, require_verification=False): for name in [ "LICENSE", "THIRD_PARTY_NOTICES.md", "CHANGELOG.md", "Documentation/Reference/ReleaseGuide.md", "Documentation/Architecture/VersioningAndRelease.md", + "Documentation/Reference/NativeFixtureValidation.md", "Documentation/Reference/NativeFixtureManifest.schema.json", ]: require((package / name).read_bytes() == (source / name).read_bytes(), f"Source input mismatch: {name}") license_directory = Path("Documentation/Reference/ThirdPartyLicenses") diff --git a/Sources/AppleCLI/Info.plist b/Sources/AppleCLI/Info.plist new file mode 100644 index 0000000..2f1fd9c --- /dev/null +++ b/Sources/AppleCLI/Info.plist @@ -0,0 +1,18 @@ + + + + + CFBundleIdentifier + org.computer-mcp.apple-cli + CFBundleExecutable + apple + CFBundleName + Apple CLI + CFBundlePackageType + APPL + NSCalendarsFullAccessUsageDescription + apple reads and manages calendar events you select, and verifies requested changes. + NSCalendarsUsageDescription + apple reads and manages calendar events you select, and verifies requested changes. + + diff --git a/Sources/AppleMCPAdapter/AppleMCPAdapter.swift b/Sources/AppleMCPAdapter/AppleMCPAdapter.swift index 55f1acc..382281b 100644 --- a/Sources/AppleMCPAdapter/AppleMCPAdapter.swift +++ b/Sources/AppleMCPAdapter/AppleMCPAdapter.swift @@ -21,7 +21,8 @@ public struct CLIProcessResult: Codable, Equatable, Sendable { } public protocol CLIProcessRunning: Sendable { - func run(target: String, arguments: [String], timeoutSeconds: Int) async throws -> CLIProcessResult + func run(target: String, arguments: [String], timeoutSeconds: Int) async throws + -> CLIProcessResult } public struct CLIProcessRunner: CLIProcessRunning { @@ -77,11 +78,13 @@ public struct CLIProcessRunner: CLIProcessRunning { var size: UInt32 = 0 _ = _NSGetExecutablePath(nil, &size) guard size > 0 else { - throw CLIError(code: .backendUnavailable, message: "Could not locate the running MCP executable.") + throw CLIError( + code: .backendUnavailable, message: "Could not locate the running MCP executable.") } var buffer = [CChar](repeating: 0, count: Int(size)) guard _NSGetExecutablePath(&buffer, &size) == 0 else { - throw CLIError(code: .backendUnavailable, message: "Could not locate the running MCP executable.") + throw CLIError( + code: .backendUnavailable, message: "Could not locate the running MCP executable.") } let pathBytes = buffer.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) } return URL(fileURLWithPath: String(decoding: pathBytes, as: UTF8.self)) @@ -184,7 +187,8 @@ private func inferredCatalogOptionValueType(names: [String], valueName: String?) guard let valueName else { return "boolean" } - let normalizedNames = names + let normalizedNames = + names .map { $0.trimmingCharacters(in: CharacterSet(charactersIn: "-")).lowercased() } let normalizedValueName = valueName.lowercased() let tokens = Set(normalizedNames + [normalizedValueName]) @@ -193,12 +197,16 @@ private func inferredCatalogOptionValueType(names: [String], valueName: String?) "column", "count", "duration", + "delay-seconds", "from-ordinal", "index", + "if-change-count", "limit", + "max-bytes", "max-commands", "max-depth", "occurrence", + "offset", "ordinal", "relative-amount", "row", @@ -427,11 +435,13 @@ public struct AppleMCPAdapter: Sendable { target: target, arguments: ["doctor", "--json"], timeoutSeconds: timeoutArgument(arguments)) case "apple_cli_status": let target = try targetArgument(arguments) - return try await run(target: target, arguments: ["--json"], timeoutSeconds: timeoutArgument(arguments)) + return try await run( + target: target, arguments: ["--json"], timeoutSeconds: timeoutArgument(arguments)) case "apple_cli_help": let target = try targetArgument(arguments) return try await run( - target: target, arguments: helpArguments(arguments), timeoutSeconds: timeoutArgument(arguments)) + target: target, arguments: helpArguments(arguments), + timeoutSeconds: timeoutArgument(arguments)) case "apple_cli_command_catalog": let target = try targetArgument(arguments) return try await commandCatalog( @@ -641,7 +651,8 @@ public struct AppleMCPAdapter: Sendable { properties["arguments"] = .object([ "type": .string("array"), "items": .object(["type": .string("string")]), - "description": .string("Optional target subcommand path to inspect. `--help` is appended when omitted."), + "description": .string( + "Optional target subcommand path to inspect. `--help` is appended when omitted."), "default": .array([]), ]) root["properties"] = .object(properties) @@ -683,19 +694,23 @@ public struct AppleMCPAdapter: Sendable { var subcommands: [String] = [] var options: [CLICommandCatalogOption] = [] var readingSubcommands = false + var subcommandIndent: Int? var readingOptions = false + var optionIndent: Int? var currentOptionIndex: Int? for line in lines { let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed == "OPTIONS:" { readingOptions = true + optionIndent = nil readingSubcommands = false currentOptionIndex = nil continue } if trimmed == "SUBCOMMANDS:" { readingSubcommands = true + subcommandIndent = nil readingOptions = false currentOptionIndex = nil continue @@ -711,7 +726,9 @@ public struct AppleMCPAdapter: Sendable { currentOptionIndex = nil continue } - if let option = parseOptionLine(trimmed) { + let indent = line.prefix(while: { $0.isWhitespace }).count + if optionIndent == nil || optionIndent == indent, let option = parseOptionLine(trimmed) { + optionIndent = indent options.append(option) currentOptionIndex = options.indices.last continue @@ -737,11 +754,16 @@ public struct AppleMCPAdapter: Sendable { readingSubcommands = false continue } + let indent = line.prefix(while: { $0.isWhitespace }).count + if let subcommandIndent, indent != subcommandIndent { + continue + } guard let name = trimmed.split(separator: " ", maxSplits: 1).first else { continue } let subcommand = String(name) if !subcommand.hasPrefix("-") { + subcommandIndent = indent subcommands.append(subcommand) } } @@ -805,7 +827,8 @@ public struct AppleMCPAdapter: Sendable { valueName: String?, description: String ) { - let tokens = line + let tokens = + line .split(whereSeparator: { $0 == " " || $0 == "\t" }) .map(String.init) var names: [String] = [] diff --git a/Sources/CalendarCLI/Backends.swift b/Sources/CalendarCLI/Backends.swift index 5f20ac2..aa48190 100644 --- a/Sources/CalendarCLI/Backends.swift +++ b/Sources/CalendarCLI/Backends.swift @@ -6,11 +6,19 @@ import Utility public struct EventKitCalendarBackend: CalendarReading, CalendarMutating { public init() {} - public func listCalendars() throws -> [CalendarRecord] { + public func listCalendars(sourceID: String? = nil) throws -> [CalendarRecord] { let store = try eventStoreWithReadAccess() + if let sourceID, store.source(withIdentifier: sourceID) == nil { + throw CLIError( + code: .notFound, message: "Calendar source was not found.", details: ["source_id": sourceID]) + } return store.calendars(for: .event) + .filter { sourceID == nil || $0.source?.sourceIdentifier == sourceID } .map(calendarRecord) - .sorted { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending } + .sorted { lhs, rhs in + let order = lhs.title.localizedCaseInsensitiveCompare(rhs.title) + return order == .orderedSame ? lhs.id < rhs.id : order == .orderedAscending + } } public func listEvents(_ query: CalendarEventQuery) throws -> [CalendarEventSummary] { @@ -125,7 +133,7 @@ public struct EventKitCalendarBackend: CalendarReading, CalendarMutating { let event = EKEvent(eventStore: store) event.calendar = calendar - applyDraft(draft, to: event) + try applyDraft(draft, to: event) try store.save(event, span: .thisEvent, commit: true) return eventDetail(event) } @@ -156,7 +164,7 @@ public struct EventKitCalendarBackend: CalendarReading, CalendarMutating { event.calendar = calendar } - applyPatch(patch, to: event) + try applyPatch(patch, to: event) try store.save(event, span: .thisEvent, commit: true) return eventDetail(event) } diff --git a/Sources/CalendarCLI/CalendarCollectionCommands.swift b/Sources/CalendarCLI/CalendarCollectionCommands.swift new file mode 100644 index 0000000..301f9b2 --- /dev/null +++ b/Sources/CalendarCLI/CalendarCollectionCommands.swift @@ -0,0 +1,92 @@ +import ArgumentParser +import Utility + +extension CalendarTarget { + public struct Sources: ParsableCommand { + public static let configuration = CommandConfiguration( + commandName: "sources", subcommands: [List.self, Read.self]) + public init() {} + + public struct List: Leaf { + public static let configuration = CommandConfiguration(commandName: "list") + public static let positionals = ["sources", "list"] + @OptionGroup public var shared: CLISharedOptions + public var targetOptions: CalendarTargetOptions { .init() } + public init() {} + } + + public struct Read: Leaf { + public static let configuration = CommandConfiguration(commandName: "read") + public static let positionals = ["sources", "read"] + @OptionGroup public var shared: CLISharedOptions + @OptionGroup public var targetOptions: CalendarIdentityOptions + public init() {} + } + } +} + +extension CalendarTarget.Calendars { + public struct Read: CalendarTarget.Leaf { + public static let configuration = CommandConfiguration(commandName: "read") + public static let positionals = ["calendars", "read"] + @OptionGroup public var shared: CLISharedOptions + @OptionGroup public var targetOptions: CalendarIdentityOptions + public init() {} + } + + public struct Create: CalendarTarget.Leaf { + public static let configuration = CommandConfiguration(commandName: "create") + public static let positionals = ["calendars", "create"] + @OptionGroup public var shared: CLISharedOptions + @OptionGroup public var targetOptions: CalendarCreateOptions + public init() {} + } + + public struct Update: CalendarTarget.Leaf { + public static let configuration = CommandConfiguration(commandName: "update") + public static let positionals = ["calendars", "update"] + @OptionGroup public var shared: CLISharedOptions + @OptionGroup public var targetOptions: CalendarUpdateOptions + public init() {} + } + + public struct Delete: CalendarTarget.Leaf { + public static let configuration = CommandConfiguration(commandName: "delete") + public static let positionals = ["calendars", "delete"] + @OptionGroup public var shared: CLISharedOptions + @OptionGroup public var targetOptions: CalendarIdentityOptions + public init() {} + } +} + +public struct CalendarIdentityOptions: CalendarCommandOptions, Sendable { + @Option(help: "Native source or calendar ID.") public var id: String + public init() {} + public var cliTargetOptions: [String: String] { ["id": id] } +} + +public struct CalendarListOptions: CalendarCommandOptions, Sendable { + @Option(help: "Only include calendars belonging to this source ID.") public var source: String? + public init() {} + public var cliTargetOptions: [String: String] { CLITargetOptionBuilder.options([("source", source)]) } +} + +public struct CalendarCreateOptions: CalendarCommandOptions, Sendable { + @Option(help: "Source ID returned by sources list.") public var source: String + @Option public var title: String + @Option(help: "Color in #RRGGBB or #RRGGBBAA form.") public var color: String? + public init() {} + public var cliTargetOptions: [String: String] { + CLITargetOptionBuilder.options([("source", source), ("title", title), ("color", color)]) + } +} + +public struct CalendarUpdateOptions: CalendarCommandOptions, Sendable { + @Option(help: "Native calendar ID.") public var id: String + @Option public var title: String? + @Option(help: "Color in #RRGGBB or #RRGGBBAA form.") public var color: String? + public init() {} + public var cliTargetOptions: [String: String] { + CLITargetOptionBuilder.options([("id", id), ("title", title), ("color", color)]) + } +} diff --git a/Sources/CalendarCLI/CalendarCollections.swift b/Sources/CalendarCLI/CalendarCollections.swift new file mode 100644 index 0000000..bafc38c --- /dev/null +++ b/Sources/CalendarCLI/CalendarCollections.swift @@ -0,0 +1,135 @@ +import EventKit +import Foundation +import Utility + +extension EventKitCalendarBackend { + public func listSources() throws -> [CalendarSourceRecord] { + let store = try eventStoreWithReadAccess() + return store.sources.map(calendarSourceRecord).sorted { lhs, rhs in + let order = lhs.title.localizedCaseInsensitiveCompare(rhs.title) + return order == .orderedSame ? lhs.id < rhs.id : order == .orderedAscending + } + } + + public func readSource(id: String) throws -> CalendarSourceRecord? { + let store = try eventStoreWithReadAccess() + return store.source(withIdentifier: id).map(calendarSourceRecord) + } + + public func readCalendar(id: String) throws -> CalendarRecord? { + let store = try eventStoreWithReadAccess() + guard let calendar = store.calendar(withIdentifier: id), + calendar.allowedEntityTypes.contains(.event) + else { return nil } + return calendarRecord(calendar) + } + + public func createCalendar(_ draft: CalendarCreateDraft) throws -> CalendarRecord { + let title = try validatedCalendarTitle(draft.title) + let canonicalColor = try draft.color.map(validatedCalendarColor) + let color = try canonicalColor.map(calendarColor) + let store = try eventStoreWithCalendarWriteAccess() + guard let source = store.source(withIdentifier: draft.sourceId) else { + throw CLIError( + code: .notFound, message: "Calendar source was not found.", + details: ["source_id": draft.sourceId]) + } + try requireCalendarCreationSource(calendarSourceRecord(source)) + let calendar = EKCalendar(for: .event, eventStore: store) + calendar.source = source + calendar.title = title + if let color { calendar.color = color } + try saveCalendarCollection(calendar, in: store) + let saved = try coldCalendarCollection(id: calendar.calendarIdentifier) + guard saved.sourceId == draft.sourceId, saved.title == title, + canonicalColor == nil || saved.color == canonicalColor + else { throw calendarCollectionVerificationFailure(id: calendar.calendarIdentifier) } + return saved + } + + public func updateCalendar(current: CalendarRecord, patch: CalendarPatch) throws -> CalendarRecord { + let title = try patch.title.map(validatedCalendarTitle) + let color = try patch.color.map(calendarColor) + let store = try eventStoreWithCalendarWriteAccess() + let calendar = try calendarCollection(current: current, in: store) + var expected = current + if let title { expected.title = title } + if let color { expected.color = calendarColorString(color) } + guard expected != current else { return current } + try requireMutableCalendar(current) + if let title { calendar.title = title } + if let color { calendar.color = color } + try saveCalendarCollection(calendar, in: store) + let saved = try coldCalendarCollection(id: calendar.calendarIdentifier) + guard saved == expected else { + throw calendarCollectionVerificationFailure(id: calendar.calendarIdentifier) + } + return saved + } + + public func deleteCalendar(current: CalendarRecord) throws -> Bool { + let store = try eventStoreWithCalendarWriteAccess() + let calendar = try calendarCollection(current: current, in: store) + try requireMutableCalendar(current) + do { + try store.removeCalendar(calendar, commit: true) + } catch { + throw CLIError( + code: .backendUnavailable, + message: "Calendar deletion failed; check its current state before retrying.", + details: CLIError.diagnosticDetails(for: error).merging([ + "calendar_id": current.id, "source_id": current.sourceId ?? "", "outcome": "unknown", + ]) { _, value in value }) + } + let coldStore = try eventStoreWithReadAccess() + guard coldStore.calendar(withIdentifier: current.id) == nil else { + throw calendarCollectionVerificationFailure(id: current.id) + } + return true + } + + private func calendarCollection(current: CalendarRecord, in store: EKEventStore) throws -> EKCalendar { + guard let calendar = store.calendar(withIdentifier: current.id), + calendar.allowedEntityTypes.contains(.event) + else { + throw CLIError( + code: .notFound, message: "Calendar was not found.", details: ["calendar_id": current.id]) + } + let actual = calendarRecord(calendar) + guard actual == current else { + throw CLIError( + code: .ambiguousIdentity, message: "Calendar state changed before the operation; read it again.", + details: ["calendar_id": current.id]) + } + return calendar + } + + private func saveCalendarCollection(_ calendar: EKCalendar, in store: EKEventStore) throws { + do { + try store.saveCalendar(calendar, commit: true) + } catch { + throw CLIError( + code: .backendUnavailable, + message: "Calendar save failed; check its current state before retrying.", + details: CLIError.diagnosticDetails(for: error).merging([ + "calendar_id": calendar.calendarIdentifier, + "source_id": calendar.source?.sourceIdentifier ?? "", "outcome": "unknown", + ]) { _, value in value }) + } + } + + private func coldCalendarCollection(id: String) throws -> CalendarRecord { + let store = try eventStoreWithReadAccess() + guard let calendar = store.calendar(withIdentifier: id), + calendar.allowedEntityTypes.contains(.event) + else { throw calendarCollectionVerificationFailure(id: id) } + return calendarRecord(calendar) + } + + private func calendarCollectionVerificationFailure(id: String) -> CLIError { + CLIError( + code: .backendUnavailable, + message: "Calendar change was accepted but could not be verified from a fresh read.", + details: ["calendar_id": id, "save_outcome": "accepted", "verification": "unavailable"]) + } +} diff --git a/Sources/CalendarCLI/Command.swift b/Sources/CalendarCLI/Command.swift index 4d90fa2..4148cf8 100644 --- a/Sources/CalendarCLI/Command.swift +++ b/Sources/CalendarCLI/Command.swift @@ -13,15 +13,118 @@ public struct CalendarCommand: Sendable { public func run(options: CLIOptions) throws -> CLICommandResult? { switch options.positionals { - case ["calendars", "list"]: + case ["sources", "list"]: try validateReadOnly(options) try validateTargetOptions(options, allowedOptions: []) - let calendars = try backend.listCalendars() + let sources = try backend.listSources() + let selected = Array(sources.prefix(options.limit ?? sources.count)) + return try result( + CalendarSourceListResponse(sources: selected, truncated: selected.count < sources.count), + human: calendarSourcesHumanOutput(selected), options: options) + case ["sources", "read"]: + try validateReadOnly(options) + try validateTargetOptions(options, allowedOptions: ["id"]) + let id = try requiredOption("id", options: options) + guard let source = try backend.readSource(id: id) else { + throw CLIError( + code: .notFound, message: "Calendar source was not found.", details: ["source_id": id]) + } + return try result( + CalendarSourceResponse(source: source), human: calendarSourcesHumanOutput([source]), + options: options) + case ["calendars", "list"]: + try validateReadOnly(options) + try validateTargetOptions(options, allowedOptions: ["source"]) + let sourceID = try options.targetOption("source").map { _ in + try requiredOption("source", options: options) + } + let calendars = try backend.listCalendars(sourceID: sourceID) + let selected = Array(calendars.prefix(options.limit ?? calendars.count)) return try result( - CalendarListResponse(calendars: calendars), - human: calendarsHumanOutput(calendars), + CalendarListResponse(calendars: selected, truncated: selected.count < calendars.count), + human: calendarsHumanOutput(selected), options: options ) + case ["calendars", "read"]: + try validateReadOnly(options) + try validateTargetOptions(options, allowedOptions: ["id"]) + let calendar = try calendarCollectionIdentity(options) + return try result( + CalendarResponse(calendar: calendar), human: calendarsHumanOutput([calendar]), options: options) + case ["calendars", "create"]: + try validateTargetOptions(options, allowedOptions: ["source", "title", "color"]) + let draft = CalendarCreateDraft( + sourceId: try requiredOption("source", options: options), + title: try validatedCalendarTitle(requiredOption("title", options: options)), + color: try options.targetOption("color").map(validatedCalendarColor)) + guard let source = try backend.readSource(id: draft.sourceId) else { + throw CLIError( + code: .notFound, message: "Calendar source was not found.", + details: ["source_id": draft.sourceId]) + } + try requireCalendarCreationSource(source) + return try mutation( + operation: "calendars.create", + scopeDigest: calendarCollectionScope( + operation: "calendars.create", + payload: CalendarCollectionMutationScope(source: source, draft: draft)), + summary: [ + "source_id": source.id, "source_title": source.title, "title": draft.title, + "color": draft.color ?? "default", + ], options: options + ) { + let calendar = try backend.createCalendar(draft) + return CalendarMutationResult( + operation: "calendars.create", changed: true, event: nil, deletedID: nil, calendar: calendar) + } + case ["calendars", "update"]: + try validateTargetOptions(options, allowedOptions: ["id", "title", "color"]) + let patch = CalendarPatch( + title: try options.targetOption("title").map(validatedCalendarTitle), + color: try options.targetOption("color").map(validatedCalendarColor)) + guard patch.title != nil || patch.color != nil else { + throw CLIError( + code: .validationError, message: "At least one calendar field must be supplied for update.") + } + let current = try calendarCollectionIdentity(options) + let unchanged = (patch.title == nil || patch.title == current.title) + && (patch.color == nil || patch.color == current.color) + if !unchanged { try requireMutableCalendar(current) } + return try mutation( + operation: "calendars.update", + scopeDigest: calendarCollectionScope( + operation: "calendars.update", + payload: CalendarCollectionMutationScope(current: current, patch: patch)), + summary: [ + "id": current.id, "source_id": current.sourceId ?? "unavailable", + "title": patch.title ?? current.title, "color": patch.color ?? current.color ?? "unavailable", + ], options: options + ) { + if unchanged { + return CalendarMutationResult( + operation: "calendars.update", changed: false, event: nil, deletedID: nil, calendar: current) + } + let calendar = try backend.updateCalendar(current: current, patch: patch) + return CalendarMutationResult( + operation: "calendars.update", changed: calendar != current, event: nil, deletedID: nil, + calendar: calendar) + } + case ["calendars", "delete"]: + try validateTargetOptions(options, allowedOptions: ["id"]) + let current = try calendarCollectionIdentity(options) + try requireMutableCalendar(current) + return try mutation( + operation: "calendars.delete", + scopeDigest: calendarCollectionScope(operation: "calendars.delete", payload: current), + summary: [ + "id": current.id, "source_id": current.sourceId ?? "unavailable", "title": current.title, + "deletes_calendar_and_contents": "true", + ], options: options + ) { + let changed = try backend.deleteCalendar(current: current) + return CalendarMutationResult( + operation: "calendars.delete", changed: changed, event: nil, deletedID: current.id) + } case ["events", "list"]: try validateReadOnly(options) try validateTargetOptions(options, allowedOptions: ["from", "to", "calendar"]) @@ -125,7 +228,7 @@ public struct CalendarCommand: Sendable { case ["events", "create"]: try validateTargetOptions( options, - allowedOptions: [ + allowedOptions: recurrenceOptionNames.union([ "calendar", "title", "start", @@ -134,11 +237,7 @@ public struct CalendarCommand: Sendable { "notes", "alarm-minutes-before", "alarm-at", - "recurrence-frequency", - "recurrence-interval", - "recurrence-count", - "recurrence-until", - ], + ]), allowedFlags: ["all-day"] ) try validateMutationIntent(options) @@ -156,7 +255,7 @@ public struct CalendarCommand: Sendable { case ["events", "update"]: try validateTargetOptions( options, - allowedOptions: [ + allowedOptions: recurrenceOptionNames.union([ "id", "calendar", "title", @@ -166,11 +265,7 @@ public struct CalendarCommand: Sendable { "notes", "alarm-minutes-before", "alarm-at", - "recurrence-frequency", - "recurrence-interval", - "recurrence-count", - "recurrence-until", - ], + ]), allowedFlags: [ "all-day", "timed", "clear-location", "clear-notes", "clear-alarms", "clear-recurrence", ] @@ -207,15 +302,17 @@ public struct CalendarCommand: Sendable { } } - private func eventCreateDraft(_ options: CLIOptions) throws -> CalendarEventDraft { - let calendar = try backend.calendarForMutation( - selector: try requiredOption("calendar", options: options)) - guard calendar.allowsContentModifications else { + private func calendarCollectionIdentity(_ options: CLIOptions) throws -> CalendarRecord { + let id = try requiredOption("id", options: options) + guard let calendar = try backend.readCalendar(id: id) else { throw CLIError( - code: .validationError, message: "Calendar does not allow modifications.", - details: ["calendar": calendar.title]) + code: .notFound, message: "Calendar was not found.", details: ["calendar_id": id]) } + return calendar + } + private func eventCreateDraft(_ options: CLIOptions) throws -> CalendarEventDraft { + let calendarSelector = try requiredOption("calendar", options: options) let title = try requiredOption("title", options: options).trimmingCharacters( in: .whitespacesAndNewlines) guard !title.isEmpty else { @@ -226,6 +323,15 @@ public struct CalendarCommand: Sendable { let end = try parseEventDate(try requiredOption("end", options: options)) try validateEventRange(start: start, end: end) + let relativeAlarms = try alarmMinutesBeforeOption(options) ?? [] + let absoluteAlarms = try alarmAtOption(options) ?? [] + let recurrence = try recurrenceRuleOption(options, effectiveStart: start) + let calendar = try backend.calendarForMutation(selector: calendarSelector) + guard calendar.allowsContentModifications else { + throw CLIError(code: .validationError, message: "Calendar does not allow modifications.", + details: ["calendar": calendar.title]) + } + return CalendarEventDraft( calendarId: calendar.id, title: title, @@ -234,9 +340,9 @@ public struct CalendarCommand: Sendable { isAllDay: options.hasTargetFlag("all-day"), location: options.targetOption("location"), notes: options.targetOption("notes"), - alarmMinutesBefore: try alarmMinutesBeforeOption(options) ?? [], - absoluteAlarmDates: try alarmAtOption(options) ?? [], - recurrence: try recurrenceRuleOption(options, effectiveStart: start) + alarmMinutesBefore: relativeAlarms, + absoluteAlarmDates: absoluteAlarms, + recurrence: recurrence ) } @@ -338,7 +444,7 @@ public struct CalendarCommand: Sendable { "end": formatDate(event.end), "alarm_minutes_before": alarmList(event.alarmMinutesBefore), "alarm_at": dateList(event.absoluteAlarmDates), - "recurrence": recurrenceSummary(event.recurrence), + "recurrence": recurrenceRulesSummary(event.recurrenceRules ?? event.recurrence.map { [$0] } ?? []), "attendee_count": "\(event.attendees.count)", "attendees_sha256": sha256Hex(attendeeList(event.attendees)), ] @@ -376,6 +482,7 @@ public struct CalendarCommand: Sendable { destinationPath: String, options: CLIOptions ) throws -> CLICommandResult { + let content = try renderICalendar(events) let operation = "events.export" let scope = "calendar-event-export" let eventHash = sha256Hex(calendarExportEventList(events)) @@ -412,7 +519,7 @@ public struct CalendarCommand: Sendable { message: "Calendar export writes a filesystem artifact and requires `--allow-artifact-action`." ) - let data = Data(renderICalendar(events).utf8) + let data = Data(content.utf8) try writeCalendarExport(data, to: destinationPath) return try result( CalendarExportResult( diff --git a/Sources/CalendarCLI/Commands.swift b/Sources/CalendarCLI/Commands.swift index 230b8e9..ae5f1e8 100644 --- a/Sources/CalendarCLI/Commands.swift +++ b/Sources/CalendarCLI/Commands.swift @@ -1,18 +1,19 @@ import ArgumentParser import EventKit +import Foundation import Utility public struct CalendarTarget: ParsableCommand { public static let targetName = "calendar" public static let targetStatus = - "Implemented: EventKit calendar/event read-search, bounded recurring occurrences, attendee metadata, availability/statistics paths, dry-run previewed iCalendar export, and dry-run previewed event create/update/delete with basic relative/absolute alarms and recurrence rules." + "Implemented: Calendar source/calendar reads, calendar create/update/delete, event read-search, bounded recurring occurrences, attendee metadata, availability/statistics, non-recurring iCalendar export, and event create/update/delete with relative/absolute alarms and custom recurrence conditions." public static let isImplemented = true public static let configuration = CommandConfiguration( commandName: "calendar", abstract: "Calendar and event workflows.", version: CLIVersion.current, - subcommands: [Calendars.self, Events.self, Availability.self, Doctor.self] + subcommands: [Sources.self, Calendars.self, Events.self, Availability.self, Doctor.self] ) @OptionGroup public var shared: CLISharedOptions @@ -31,7 +32,7 @@ public struct CalendarTarget: ParsableCommand { public struct Calendars: ParsableCommand { public static let configuration = CommandConfiguration( commandName: "calendars", - subcommands: [List.self] + subcommands: [List.self, Read.self, Create.self, Update.self, Delete.self] ) public init() {} @@ -39,7 +40,7 @@ public struct CalendarTarget: ParsableCommand { public static let configuration = CommandConfiguration(commandName: "list") public static let positionals = ["calendars", "list"] @OptionGroup public var shared: CLISharedOptions - @OptionGroup public var targetOptions: CalendarTargetOptions + @OptionGroup public var targetOptions: CalendarListOptions public init() {} } } @@ -182,9 +183,10 @@ extension CalendarTarget { } public protocol Leaf: ParsableCommand { + associatedtype TargetOptions: CalendarCommandOptions static var positionals: [String] { get } var shared: CLISharedOptions { get } - var targetOptions: CalendarTargetOptions { get } + var targetOptions: TargetOptions { get } } } @@ -199,7 +201,16 @@ extension CalendarTarget.Leaf { } } -public struct CalendarTargetOptions: ParsableArguments, Sendable { +public protocol CalendarCommandOptions: ParsableArguments { + var cliTargetOptions: [String: String] { get } + var cliTargetFlags: Set { get } +} + +extension CalendarCommandOptions { + public var cliTargetFlags: Set { [] } +} + +public struct CalendarTargetOptions: CalendarCommandOptions, Sendable { @Option public var from: String? @Option public var to: String? @Option public var calendar: String? @@ -218,6 +229,12 @@ public struct CalendarTargetOptions: ParsableArguments, Sendable { @Option(name: .customLong("recurrence-interval")) public var recurrenceInterval: String? @Option(name: .customLong("recurrence-count")) public var recurrenceCount: String? @Option(name: .customLong("recurrence-until")) public var recurrenceUntil: String? + @Option(name: .customLong("recurrence-by-day"), help: "Weekdays such as MO,WE or numbered monthly/yearly weekdays such as 2FR,-1MO.") public var recurrenceByDay: String? = nil + @Option(name: .customLong("recurrence-by-month-day"), help: "Monthly days: 1...31 or -31...-1, counted from the month end.") public var recurrenceByMonthDay: String? = nil + @Option(name: .customLong("recurrence-by-month"), help: "Months 1...12 for yearly recurrence.") public var recurrenceByMonth: String? = nil + @Option(name: .customLong("recurrence-by-week-no"), help: "Yearly weeks: 1...53 or -53...-1.") public var recurrenceByWeekNo: String? = nil + @Option(name: .customLong("recurrence-by-year-day"), help: "Yearly days: 1...366 or -366...-1.") public var recurrenceByYearDay: String? = nil + @Option(name: .customLong("recurrence-by-set-pos"), help: "Positions within another selector's results, such as -1 for the last match.") public var recurrenceBySetPos: String? = nil @Flag(name: .customLong("all-day")) public var allDay = false @Flag public var timed = false @Flag(name: .customLong("clear-location")) public var clearLocation = false @@ -247,6 +264,12 @@ public struct CalendarTargetOptions: ParsableArguments, Sendable { ("recurrence-interval", recurrenceInterval), ("recurrence-count", recurrenceCount), ("recurrence-until", recurrenceUntil), + ("recurrence-by-day", recurrenceByDay), + ("recurrence-by-month-day", recurrenceByMonthDay), + ("recurrence-by-month", recurrenceByMonth), + ("recurrence-by-week-no", recurrenceByWeekNo), + ("recurrence-by-year-day", recurrenceByYearDay), + ("recurrence-by-set-pos", recurrenceBySetPos), ]) } @@ -263,7 +286,22 @@ public struct CalendarTargetOptions: ParsableArguments, Sendable { } public func calendarDoctorChecks() -> [CLIDoctorCheck] { - [ + let missingUsageDescriptions = [ + "NSCalendarsFullAccessUsageDescription", "NSCalendarsUsageDescription", + ].filter { key in + (Bundle.main.object(forInfoDictionaryKey: key) as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty != false + } + return [ + CLIDoctorCheck( + name: "calendar_usage_descriptions", + status: missingUsageDescriptions.isEmpty ? .ok : .backendUnavailable, + message: missingUsageDescriptions.isEmpty + ? "The executable includes Calendar access purpose descriptions." + : "The executable is missing Calendar access purpose descriptions.", + details: missingUsageDescriptions.isEmpty + ? [:] : ["missing_keys": missingUsageDescriptions.joined(separator: ",")] + ), eventKitAuthorizationCheck( entityType: .event, name: "eventkit_calendar_authorization", @@ -280,7 +318,7 @@ public func calendarDoctorChecks() -> [CLIDoctorCheck] { name: "eventkit_mutation_backend", status: .ok, message: - "Calendar EventKit event create/update/delete commands are dry-run previewed, including basic relative/absolute alarms and recurrence rules." + "Calendar EventKit event create/update/delete commands are dry-run previewed, including relative/absolute alarms and validated custom recurrence conditions." ), ] } diff --git a/Sources/CalendarCLI/Models.swift b/Sources/CalendarCLI/Models.swift index a9d98c3..3add4f1 100644 --- a/Sources/CalendarCLI/Models.swift +++ b/Sources/CalendarCLI/Models.swift @@ -6,19 +6,86 @@ import Utility public struct CalendarRecord: Codable, Equatable, Sendable { public var id: String public var title: String - public var sourceTitle: String + public var sourceTitle: String? public var allowsContentModifications: Bool + public var sourceId: String? + public var type: String? + public var typeRawValue: Int? + public var isImmutable: Bool? + public var isSubscribed: Bool? + public var color: String? + public var allowedEntityTypesRawValue: UInt? + public var supportedEventAvailabilitiesRawValue: UInt? public init( id: String, title: String, - sourceTitle: String, - allowsContentModifications: Bool + sourceTitle: String?, + allowsContentModifications: Bool, + sourceId: String? = nil, + type: String? = nil, + typeRawValue: Int? = nil, + isImmutable: Bool? = nil, + isSubscribed: Bool? = nil, + color: String? = nil, + allowedEntityTypesRawValue: UInt? = nil, + supportedEventAvailabilitiesRawValue: UInt? = nil ) { self.id = id self.title = title self.sourceTitle = sourceTitle self.allowsContentModifications = allowsContentModifications + self.sourceId = sourceId + self.type = type + self.typeRawValue = typeRawValue + self.isImmutable = isImmutable + self.isSubscribed = isSubscribed + self.color = color + self.allowedEntityTypesRawValue = allowedEntityTypesRawValue + self.supportedEventAvailabilitiesRawValue = supportedEventAvailabilitiesRawValue + } +} + +public struct CalendarSourceRecord: Codable, Equatable, Sendable { + public var id: String + public var title: String + public var type: String + public var typeRawValue: Int + public var isDelegate: Bool + public var calendarIds: [String] + + public init( + id: String, title: String, type: String, typeRawValue: Int, isDelegate: Bool, + calendarIds: [String] + ) { + self.id = id + self.title = title + self.type = type + self.typeRawValue = typeRawValue + self.isDelegate = isDelegate + self.calendarIds = calendarIds + } +} + +public struct CalendarCreateDraft: Codable, Equatable, Sendable { + public var sourceId: String + public var title: String + public var color: String? + + public init(sourceId: String, title: String, color: String? = nil) { + self.sourceId = sourceId + self.title = title + self.color = color + } +} + +public struct CalendarPatch: Codable, Equatable, Sendable { + public var title: String? + public var color: String? + + public init(title: String? = nil, color: String? = nil) { + self.title = title + self.color = color } } @@ -104,6 +171,9 @@ public struct CalendarEventSummary: Codable, Equatable, Sendable { public var absoluteAlarmDates: [Date] public var recurrence: CalendarRecurrenceRule? public var attendees: [CalendarAttendeeRecord] + public var recurrenceRules: [CalendarRecurrenceRule]? + public var isDetached: Bool? + public var timeZoneIdentifier: String? public init( id: String, @@ -117,7 +187,10 @@ public struct CalendarEventSummary: Codable, Equatable, Sendable { alarmMinutesBefore: [Int] = [], absoluteAlarmDates: [Date] = [], recurrence: CalendarRecurrenceRule? = nil, - attendees: [CalendarAttendeeRecord] = [] + attendees: [CalendarAttendeeRecord] = [], + recurrenceRules: [CalendarRecurrenceRule]? = nil, + isDetached: Bool? = nil, + timeZoneIdentifier: String? = nil ) { self.id = id self.calendarId = calendarId @@ -131,6 +204,9 @@ public struct CalendarEventSummary: Codable, Equatable, Sendable { self.absoluteAlarmDates = absoluteAlarmDates self.recurrence = recurrence self.attendees = attendees + self.recurrenceRules = recurrenceRules + self.isDetached = isDetached + self.timeZoneIdentifier = timeZoneIdentifier } } @@ -148,6 +224,8 @@ public struct CalendarEventDetail: Codable, Equatable, Sendable { public var absoluteAlarmDates: [Date] public var recurrence: CalendarRecurrenceRule? public var attendees: [CalendarAttendeeRecord] + public var recurrenceRules: [CalendarRecurrenceRule]? + public var timeZoneIdentifier: String? public init( id: String, @@ -162,7 +240,9 @@ public struct CalendarEventDetail: Codable, Equatable, Sendable { alarmMinutesBefore: [Int] = [], absoluteAlarmDates: [Date] = [], recurrence: CalendarRecurrenceRule? = nil, - attendees: [CalendarAttendeeRecord] = [] + attendees: [CalendarAttendeeRecord] = [], + recurrenceRules: [CalendarRecurrenceRule]? = nil, + timeZoneIdentifier: String? = nil ) { self.id = id self.calendarId = calendarId @@ -177,6 +257,8 @@ public struct CalendarEventDetail: Codable, Equatable, Sendable { self.absoluteAlarmDates = absoluteAlarmDates self.recurrence = recurrence self.attendees = attendees + self.recurrenceRules = recurrenceRules + self.timeZoneIdentifier = timeZoneIdentifier } } @@ -232,27 +314,75 @@ public struct CalendarEventOccurrence: Codable, Equatable, Sendable { } } +public struct CalendarRecurrenceWeekday: Codable, Equatable, Hashable, Sendable { + public var dayOfWeek: String + public var weekNumber: Int + + public init(dayOfWeek: String, weekNumber: Int = 0) { + self.dayOfWeek = dayOfWeek + self.weekNumber = weekNumber + } +} + public struct CalendarRecurrenceRule: Codable, Equatable, Sendable { public var frequency: String public var interval: Int public var occurrenceCount: Int? public var until: Date? + public var calendarIdentifier: String? + public var firstDayOfTheWeek: Int? + public var daysOfTheWeek: [CalendarRecurrenceWeekday]? + public var daysOfTheMonth: [Int]? + public var monthsOfTheYear: [Int]? + public var weeksOfTheYear: [Int]? + public var daysOfTheYear: [Int]? + public var setPositions: [Int]? public init( frequency: String, interval: Int = 1, occurrenceCount: Int? = nil, - until: Date? = nil + until: Date? = nil, + calendarIdentifier: String? = nil, + firstDayOfTheWeek: Int? = nil, + daysOfTheWeek: [CalendarRecurrenceWeekday]? = nil, + daysOfTheMonth: [Int]? = nil, + monthsOfTheYear: [Int]? = nil, + weeksOfTheYear: [Int]? = nil, + daysOfTheYear: [Int]? = nil, + setPositions: [Int]? = nil ) { self.frequency = frequency self.interval = interval self.occurrenceCount = occurrenceCount self.until = until + self.calendarIdentifier = calendarIdentifier + self.firstDayOfTheWeek = firstDayOfTheWeek + self.daysOfTheWeek = daysOfTheWeek + self.daysOfTheMonth = daysOfTheMonth + self.monthsOfTheYear = monthsOfTheYear + self.weeksOfTheYear = weeksOfTheYear + self.daysOfTheYear = daysOfTheYear + self.setPositions = setPositions } } public struct CalendarListResponse: Codable, Equatable, Sendable { public var calendars: [CalendarRecord] + public var truncated: Bool = false +} + +public struct CalendarSourceListResponse: Codable, Equatable, Sendable { + public var sources: [CalendarSourceRecord] + public var truncated: Bool = false +} + +public struct CalendarSourceResponse: Codable, Equatable, Sendable { + public var source: CalendarSourceRecord +} + +public struct CalendarResponse: Codable, Equatable, Sendable { + public var calendar: CalendarRecord } public struct CalendarEventsResponse: Codable, Equatable, Sendable { @@ -366,6 +496,7 @@ public struct CalendarMutationResult: Codable, Equatable, Sendable { public var changed: Bool public var event: CalendarEventDetail? public var deletedID: String? + public var calendar: CalendarRecord? = nil } public struct CalendarExportResult: Codable, Equatable, Sendable { diff --git a/Sources/CalendarCLI/Protocols.swift b/Sources/CalendarCLI/Protocols.swift index 0adc555..4529b03 100644 --- a/Sources/CalendarCLI/Protocols.swift +++ b/Sources/CalendarCLI/Protocols.swift @@ -4,7 +4,10 @@ import Foundation import Utility public protocol CalendarReading: Sendable { - func listCalendars() throws -> [CalendarRecord] + func listSources() throws -> [CalendarSourceRecord] + func readSource(id: String) throws -> CalendarSourceRecord? + func listCalendars(sourceID: String?) throws -> [CalendarRecord] + func readCalendar(id: String) throws -> CalendarRecord? func listEvents(_ query: CalendarEventQuery) throws -> [CalendarEventSummary] func readEvent(id: String) throws -> CalendarEventDetail? func listEventOccurrences(_ query: CalendarEventOccurrenceQuery) throws @@ -12,6 +15,9 @@ public protocol CalendarReading: Sendable { } public protocol CalendarMutating: Sendable { + func createCalendar(_ draft: CalendarCreateDraft) throws -> CalendarRecord + func updateCalendar(current: CalendarRecord, patch: CalendarPatch) throws -> CalendarRecord + func deleteCalendar(current: CalendarRecord) throws -> Bool func calendarForMutation(selector: String) throws -> CalendarRecord func eventForMutation(id: String) throws -> CalendarEventDetail? func createEvent(_ draft: CalendarEventDraft) throws -> CalendarEventDetail diff --git a/Sources/CalendarCLI/Recurrence.swift b/Sources/CalendarCLI/Recurrence.swift new file mode 100644 index 0000000..eaaf7a7 --- /dev/null +++ b/Sources/CalendarCLI/Recurrence.swift @@ -0,0 +1,277 @@ +import EventKit +import Foundation +import Utility + +let recurrenceOptionNames: Set = [ + "recurrence-frequency", "recurrence-interval", "recurrence-count", "recurrence-until", + "recurrence-by-day", "recurrence-by-month-day", "recurrence-by-month", + "recurrence-by-week-no", "recurrence-by-year-day", "recurrence-by-set-pos", +] + +private let recurrenceWeekdays = ["SU", "MO", "TU", "WE", "TH", "FR", "SA"] + +func hasRecurrenceOptions(_ options: CLIOptions) -> Bool { + recurrenceOptionNames.contains { options.targetOption($0) != nil } +} + +func recurrenceRuleOption(_ options: CLIOptions, effectiveStart: Date) throws + -> CalendarRecurrenceRule? +{ + guard hasRecurrenceOptions(options) else { return nil } + let frequency = try normalizedRecurrenceFrequency( + try requiredOption("recurrence-frequency", options: options)) + let rule = CalendarRecurrenceRule( + frequency: frequency, + interval: try positiveIntOption( + "recurrence-interval", options: options, defaultValue: 1, upperBound: 999), + occurrenceCount: try options.targetOption("recurrence-count").map { + try parsePositiveInteger($0, flag: "--recurrence-count", upperBound: 9_999) + }, + until: try options.targetOption("recurrence-until").map(parseEventDate), + daysOfTheWeek: try options.targetOption("recurrence-by-day").map(parseRecurrenceWeekdays), + daysOfTheMonth: try recurrenceIntegers("recurrence-by-month-day", options: options), + monthsOfTheYear: try recurrenceIntegers("recurrence-by-month", options: options), + weeksOfTheYear: try recurrenceIntegers("recurrence-by-week-no", options: options), + daysOfTheYear: try recurrenceIntegers("recurrence-by-year-day", options: options), + setPositions: try recurrenceIntegers("recurrence-by-set-pos", options: options) + ) + try validateRecurrence(rule) + if let until = rule.until, until <= effectiveStart { + throw CLIError( + code: .validationError, message: "`--recurrence-until` must be later than the event start.") + } + return rule +} + +private func parseRecurrenceWeekdays(_ value: String) throws -> [CalendarRecurrenceWeekday] { + guard !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw CLIError( + code: .validationError, message: "`--recurrence-by-day` requires at least one weekday.") + } + let days = try value.split(separator: ",", omittingEmptySubsequences: false).map { part in + let token = part.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + let day = String(token.suffix(2)) + let ordinal = token.dropLast(min(2, token.count)) + guard recurrenceWeekdays.contains(day), + ordinal.isEmpty || (Int(ordinal).map { (-53...53).contains($0) && $0 != 0 } ?? false) + else { + throw CLIError( + code: .validationError, + message: + "`--recurrence-by-day` uses SU, MO, TU, WE, TH, FR, SA, optionally with a signed week number." + ) + } + return CalendarRecurrenceWeekday(dayOfWeek: day, weekNumber: Int(ordinal) ?? 0) + } + return Array(Set(days)).sorted { + let left = recurrenceWeekdays.firstIndex(of: $0.dayOfWeek)! + let right = recurrenceWeekdays.firstIndex(of: $1.dayOfWeek)! + return left == right ? $0.weekNumber < $1.weekNumber : left < right + } +} + +private func recurrenceIntegers(_ name: String, options: CLIOptions) throws -> [Int]? { + try options.targetOption(name).map { value in + guard !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw CLIError(code: .validationError, message: "`--\(name)` requires at least one integer.") + } + let integers = try value.split(separator: ",", omittingEmptySubsequences: false).map { part in + guard let integer = Int(part.trimmingCharacters(in: .whitespacesAndNewlines)) else { + throw CLIError( + code: .validationError, + message: "`--\(name)` requires a comma-separated list of integers.") + } + return integer + } + return Array(Set(integers)).sorted() + } +} + +func validateRecurrence(_ rule: CalendarRecurrenceRule) throws { + _ = try normalizedRecurrenceFrequency(rule.frequency) + guard rule.interval > 0, rule.occurrenceCount.map({ $0 > 0 }) ?? true, + rule.until?.timeIntervalSince1970.isFinite ?? true + else { + throw CLIError( + code: .validationError, + message: "Recurrence interval and count must be positive, and its end must be a valid date.") + } + guard rule.occurrenceCount == nil || rule.until == nil else { + throw CLIError( + code: .validationError, + message: "`--recurrence-count` cannot be combined with `--recurrence-until`.") + } + let days = rule.daysOfTheWeek ?? [] + if !days.isEmpty { + guard rule.frequency != "daily" else { + throw invalidRecurrenceCondition("recurrence-by-day", frequency: rule.frequency) + } + let bound = rule.frequency == "weekly" ? 0 : rule.frequency == "monthly" ? 5 : 53 + guard + days.allSatisfy({ + recurrenceWeekdays.contains($0.dayOfWeek) && (-bound...bound).contains($0.weekNumber) + }) + else { + throw CLIError( + code: .validationError, + message: + "Week numbers must be zero for weekly rules, within -5...5 for monthly rules, and within -53...53 for yearly rules." + ) + } + } + for (name, values, frequency, bound, signed) in [ + ("recurrence-by-month-day", rule.daysOfTheMonth, "monthly", 31, true), + ("recurrence-by-month", rule.monthsOfTheYear, "yearly", 12, false), + ("recurrence-by-week-no", rule.weeksOfTheYear, "yearly", 53, true), + ("recurrence-by-year-day", rule.daysOfTheYear, "yearly", 366, true), + ] { + guard let values, !values.isEmpty else { continue } + guard rule.frequency == frequency else { + throw invalidRecurrenceCondition(name, frequency: rule.frequency) + } + guard values.allSatisfy({ $0 != 0 && (signed ? -bound : 1) <= $0 && $0 <= bound }) else { + throw CLIError(code: .validationError, message: "`--\(name)` contains an out-of-range value.") + } + } + if !(rule.weeksOfTheYear ?? []).isEmpty, days.contains(where: { $0.weekNumber != 0 }) { + throw CLIError( + code: .validationError, + message: "Numbered weekdays cannot be combined with `--recurrence-by-week-no`.") + } + if let positions = rule.setPositions, !positions.isEmpty { + let hasSelector = + !days.isEmpty + || [ + rule.daysOfTheMonth, rule.monthsOfTheYear, + rule.weeksOfTheYear, rule.daysOfTheYear, + ].contains { !($0 ?? []).isEmpty } + guard hasSelector, positions.allSatisfy({ $0 != 0 && (-366...366).contains($0) }) else { + throw CLIError( + code: .validationError, + message: + "`--recurrence-by-set-pos` requires another recurrence selector and values within -366...-1 or 1...366." + ) + } + } + if let firstDay = rule.firstDayOfTheWeek, !(0...7).contains(firstDay) { + throw CLIError(code: .validationError, message: "Recurrence week start is invalid.") + } +} + +private func invalidRecurrenceCondition(_ name: String, frequency: String) -> CLIError { + CLIError( + code: .validationError, message: "`--\(name)` is not supported for \(frequency) recurrence.") +} + +func recurrenceRecord(_ rule: EKRecurrenceRule) -> CalendarRecurrenceRule { + let count = rule.recurrenceEnd?.occurrenceCount ?? 0 + return CalendarRecurrenceRule( + frequency: recurrenceFrequency(rule.frequency), interval: rule.interval, + occurrenceCount: count > 0 ? count : nil, until: rule.recurrenceEnd?.endDate, + calendarIdentifier: rule.calendarIdentifier, firstDayOfTheWeek: rule.firstDayOfTheWeek, + daysOfTheWeek: rule.daysOfTheWeek?.map { + CalendarRecurrenceWeekday( + dayOfWeek: recurrenceWeekdayName($0.dayOfTheWeek.rawValue), weekNumber: $0.weekNumber) + }, + daysOfTheMonth: rule.daysOfTheMonth?.map(\.intValue), + monthsOfTheYear: rule.monthsOfTheYear?.map(\.intValue), + weeksOfTheYear: rule.weeksOfTheYear?.map(\.intValue), + daysOfTheYear: rule.daysOfTheYear?.map(\.intValue), + setPositions: rule.setPositions?.map(\.intValue) + ) +} + +func eventKitRecurrenceRule(_ recurrence: CalendarRecurrenceRule) throws -> EKRecurrenceRule { + // EventKit can raise Objective-C exceptions for invalid weekday ordinals. + try validateRecurrence(recurrence) + let end: EKRecurrenceEnd? + if let count = recurrence.occurrenceCount { + end = EKRecurrenceEnd(occurrenceCount: count) + } else if let until = recurrence.until { + end = EKRecurrenceEnd(end: until) + } else { + end = nil + } + let native = EKRecurrenceRule( + recurrenceWith: try eventKitRecurrenceFrequency(recurrence.frequency), + interval: recurrence.interval, + daysOfTheWeek: recurrence.daysOfTheWeek?.map { + EKRecurrenceDayOfWeek( + EKWeekday(rawValue: recurrenceWeekdays.firstIndex(of: $0.dayOfWeek)! + 1)!, + weekNumber: $0.weekNumber) + }, + daysOfTheMonth: recurrence.daysOfTheMonth?.map { NSNumber(value: $0) }, + monthsOfTheYear: recurrence.monthsOfTheYear?.map { NSNumber(value: $0) }, + weeksOfTheYear: recurrence.weeksOfTheYear?.map { NSNumber(value: $0) }, + daysOfTheYear: recurrence.daysOfTheYear?.map { NSNumber(value: $0) }, + setPositions: recurrence.setPositions?.map { NSNumber(value: $0) }, end: end) + let observed = recurrenceRecord(native) + guard observed.frequency == recurrence.frequency, observed.interval == recurrence.interval, + observed.occurrenceCount == recurrence.occurrenceCount, observed.until == recurrence.until, + Set(observed.daysOfTheWeek ?? []) == Set(recurrence.daysOfTheWeek ?? []), + Set(observed.daysOfTheMonth ?? []) == Set(recurrence.daysOfTheMonth ?? []), + Set(observed.monthsOfTheYear ?? []) == Set(recurrence.monthsOfTheYear ?? []), + Set(observed.weeksOfTheYear ?? []) == Set(recurrence.weeksOfTheYear ?? []), + Set(observed.daysOfTheYear ?? []) == Set(recurrence.daysOfTheYear ?? []), + Set(observed.setPositions ?? []) == Set(recurrence.setPositions ?? []), + recurrence.calendarIdentifier == nil + || observed.calendarIdentifier == recurrence.calendarIdentifier, + recurrence.firstDayOfTheWeek == nil + || observed.firstDayOfTheWeek == recurrence.firstDayOfTheWeek + else { + throw CLIError( + code: .unsupportedOperation, + message: "EventKit did not retain the requested recurrence conditions.", + details: ["frequency": recurrence.frequency]) + } + return native +} + +func recurrenceFrequency(_ frequency: EKRecurrenceFrequency) -> String { + switch frequency { + case .daily: return "daily" + case .weekly: return "weekly" + case .monthly: return "monthly" + case .yearly: return "yearly" + @unknown default: return "unknown" + } +} + +private func eventKitRecurrenceFrequency(_ value: String) throws -> EKRecurrenceFrequency { + switch value { + case "daily": return .daily + case "weekly": return .weekly + case "monthly": return .monthly + case "yearly": return .yearly + default: throw CLIError(code: .validationError, message: "Recurrence frequency is invalid.") + } +} + +private func recurrenceWeekdayName(_ value: Int) -> String { + (1...7).contains(value) ? recurrenceWeekdays[value - 1] : "unknown" +} + +func iCalendarRecurrence(_ recurrence: CalendarRecurrenceRule) -> String { + var parts = ["FREQ=\(recurrence.frequency.uppercased())", "INTERVAL=\(recurrence.interval)"] + if let count = recurrence.occurrenceCount { parts.append("COUNT=\(count)") } + if let until = recurrence.until { parts.append("UNTIL=\(iCalendarDateTime(until))") } + if let days = recurrence.daysOfTheWeek, !days.isEmpty { + parts.append( + "BYDAY=" + + days.map { ($0.weekNumber == 0 ? "" : String($0.weekNumber)) + $0.dayOfWeek }.joined( + separator: ",")) + } + for (name, values) in [ + ("BYMONTHDAY", recurrence.daysOfTheMonth), ("BYMONTH", recurrence.monthsOfTheYear), + ("BYWEEKNO", recurrence.weeksOfTheYear), ("BYYEARDAY", recurrence.daysOfTheYear), + ("BYSETPOS", recurrence.setPositions), + ] { + if let values, !values.isEmpty { + parts.append(name + "=" + values.map(String.init).joined(separator: ",")) + } + } + if let day = recurrence.firstDayOfTheWeek, day != 0 { + parts.append("WKST=" + recurrenceWeekdayName(day)) + } + return parts.joined(separator: ";") +} diff --git a/Sources/CalendarCLI/Support.swift b/Sources/CalendarCLI/Support.swift index dc4aec8..71250ea 100644 --- a/Sources/CalendarCLI/Support.swift +++ b/Sources/CalendarCLI/Support.swift @@ -1,3 +1,4 @@ +import AppKit import CryptoKit import EventKit import Foundation @@ -93,10 +94,11 @@ func eventIdentityScopeDigest(_ event: CalendarEventDetail) -> String { formatDate(event.end), "\(event.isAllDay)", event.location ?? "", + event.timeZoneIdentifier ?? "", event.notes ?? "", alarmList(event.alarmMinutesBefore), dateList(event.absoluteAlarmDates), - recurrenceSummary(event.recurrence), + recurrenceRulesSummary(event.recurrenceRules ?? event.recurrence.map { [$0] } ?? []), attendeeList(event.attendees), ].joined(separator: "|") return "calendar-event:\(sha256Hex(payload))" @@ -153,9 +155,10 @@ func calendarExportEventList(_ events: [CalendarEventSummary]) -> String { formatDate(event.end), "\(event.isAllDay)", event.location ?? "", + event.timeZoneIdentifier ?? "", alarmList(event.alarmMinutesBefore), dateList(event.absoluteAlarmDates), - recurrenceSummary(event.recurrence), + recurrenceRulesSummary(event.recurrenceRules ?? event.recurrence.map { [$0] } ?? []), attendeeList(event.attendees), ].joined(separator: "~") } @@ -354,55 +357,6 @@ func attendeeList(_ attendees: [CalendarAttendeeRecord]) -> String { .joined(separator: ",") } -func hasRecurrenceOptions(_ options: CLIOptions) -> Bool { - options.targetOption("recurrence-frequency") != nil - || options.targetOption("recurrence-interval") != nil - || options.targetOption("recurrence-count") != nil - || options.targetOption("recurrence-until") != nil -} - -func recurrenceRuleOption(_ options: CLIOptions, effectiveStart: Date) throws - -> CalendarRecurrenceRule? -{ - guard hasRecurrenceOptions(options) else { - return nil - } - - let frequency = try normalizedRecurrenceFrequency( - try requiredOption("recurrence-frequency", options: options)) - let interval = try positiveIntOption( - "recurrence-interval", - options: options, - defaultValue: 1, - upperBound: 999 - ) - let occurrenceCount = try options.targetOption("recurrence-count").map { - try parsePositiveInteger($0, flag: "--recurrence-count", upperBound: 9_999) - } - let until = try options.targetOption("recurrence-until").map(parseEventDate) - - if occurrenceCount != nil, until != nil { - throw CLIError( - code: .validationError, - message: "`--recurrence-count` cannot be combined with `--recurrence-until`." - ) - } - - if let until, until <= effectiveStart { - throw CLIError( - code: .validationError, - message: "`--recurrence-until` must be later than the event start." - ) - } - - return CalendarRecurrenceRule( - frequency: frequency, - interval: interval, - occurrenceCount: occurrenceCount, - until: until - ) -} - func normalizedRecurrenceFrequency(_ value: String) throws -> String { let frequency = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() guard ["daily", "weekly", "monthly", "yearly"].contains(frequency) else { @@ -458,13 +412,14 @@ func recurrenceSummary(_ recurrence: CalendarRecurrenceRule?) -> String { func recurrenceSummary(_ recurrence: CalendarRecurrenceRule) -> String { [ - recurrence.frequency, - "interval=\(recurrence.interval)", - recurrence.occurrenceCount.map { "count=\($0)" } ?? "", - recurrence.until.map { "until=\(formatDate($0))" } ?? "", - ] - .filter { !$0.isEmpty } - .joined(separator: ";") + iCalendarRecurrence(recurrence), + recurrence.calendarIdentifier.map { "calendar=\($0)" } ?? "", + recurrence.firstDayOfTheWeek.map { "week_start=\($0)" } ?? "", + ].filter { !$0.isEmpty }.joined(separator: ";") +} + +func recurrenceRulesSummary(_ rules: [CalendarRecurrenceRule]) -> String { + rules.map { recurrenceSummary($0) }.joined(separator: " || ") } func eventAllDayPatch(_ options: CLIOptions) -> Bool? { @@ -547,10 +502,18 @@ func parseDateBoundary(_ value: String, role: DateBoundaryRole) throws -> Date { func parseDateOnly(_ value: String, role: DateBoundaryRole) -> Date? { let parts = value.split(separator: "-") - guard parts.count == 3, + guard value.utf8.count == 10, + parts.count == 3, + parts[0].utf8.count == 4, + parts[1].utf8.count == 2, + parts[2].utf8.count == 2, let year = Int(parts[0]), let month = Int(parts[1]), - let day = Int(parts[2]) + let day = Int(parts[2]), + (1...9999).contains(year), + (1...12).contains(month), + (1...31).contains(day), + value == String(format: "%04d-%02d-%02d", year, month, day) else { return nil } @@ -562,6 +525,10 @@ func parseDateOnly(_ value: String, role: DateBoundaryRole) -> Date? { guard let startOfDay = components.date else { return nil } + let resolved = calendar.dateComponents([.year, .month, .day], from: startOfDay) + guard resolved.year == year, resolved.month == month, resolved.day == day else { + return nil + } switch role { case .lower: @@ -650,10 +617,18 @@ func eventStoreWithReadAccess() throws -> EKEventStore { } } -func eventStoreWithCalendarWriteAccess() throws -> EKEventStore { - switch EKEventStore.authorizationStatus(for: .event) { - case .authorized, .fullAccess, .writeOnly: +func eventStoreWithCalendarWriteAccess( + authorizationStatus: EKAuthorizationStatus = EKEventStore.authorizationStatus(for: .event) +) throws -> EKEventStore { + switch authorizationStatus { + case .authorized, .fullAccess: return EKEventStore() + case .writeOnly: + throw CLIError( + code: .permissionDenied, + message: CLIPermissionWording.fullAccessRequired( + "Calendar", operation: "calendar selection and mutation verification") + ) case .notDetermined: return try requestCalendarFullAccess( deniedMessage: CLIPermissionWording.accessNotGranted( @@ -730,7 +705,7 @@ private final class EventKitAccessRequestBox: @unchecked Sendable { } } -func applyDraft(_ draft: CalendarEventDraft, to event: EKEvent) { +func applyDraft(_ draft: CalendarEventDraft, to event: EKEvent) throws { event.title = draft.title event.startDate = draft.start event.endDate = draft.end @@ -738,10 +713,10 @@ func applyDraft(_ draft: CalendarEventDraft, to event: EKEvent) { event.location = draft.location event.notes = draft.notes replaceAlarms(on: event, with: draft.alarmMinutesBefore, absoluteDates: draft.absoluteAlarmDates) - replaceRecurrence(on: event, with: draft.recurrence) + try replaceRecurrence(on: event, with: draft.recurrence) } -func applyPatch(_ patch: CalendarEventPatch, to event: EKEvent) { +func applyPatch(_ patch: CalendarEventPatch, to event: EKEvent) throws { if let title = patch.title { event.title = title } @@ -774,7 +749,7 @@ func applyPatch(_ patch: CalendarEventPatch, to event: EKEvent) { removeAlarms(from: event) } if let recurrence = patch.recurrence { - replaceRecurrence(on: event, with: recurrence) + try replaceRecurrence(on: event, with: recurrence) } else if patch.clearRecurrence { removeRecurrence(from: event) } @@ -796,12 +771,10 @@ func removeAlarms(from event: EKEvent) { } } -func replaceRecurrence(on event: EKEvent, with recurrence: CalendarRecurrenceRule?) { +func replaceRecurrence(on event: EKEvent, with recurrence: CalendarRecurrenceRule?) throws { + let rule = try recurrence.map(eventKitRecurrenceRule) removeRecurrence(from: event) - guard let recurrence else { - return - } - event.addRecurrenceRule(eventKitRecurrenceRule(recurrence)) + if let rule { event.addRecurrenceRule(rule) } } func removeRecurrence(from event: EKEvent) { @@ -810,47 +783,137 @@ func removeRecurrence(from event: EKEvent) { } } -func eventKitRecurrenceRule(_ recurrence: CalendarRecurrenceRule) -> EKRecurrenceRule { - let end: EKRecurrenceEnd? - if let occurrenceCount = recurrence.occurrenceCount { - end = EKRecurrenceEnd(occurrenceCount: occurrenceCount) - } else if let until = recurrence.until { - end = EKRecurrenceEnd(end: until) - } else { - end = nil - } +func calendarRecord(_ calendar: EKCalendar) -> CalendarRecord { + CalendarRecord( + id: calendar.calendarIdentifier, + title: calendar.title, + sourceTitle: calendar.source?.title, + allowsContentModifications: calendar.allowsContentModifications, + sourceId: calendar.source?.sourceIdentifier, + type: calendarTypeName(calendar.type), + typeRawValue: calendar.type.rawValue, + isImmutable: calendar.isImmutable, + isSubscribed: calendar.isSubscribed, + color: calendar.color.flatMap(calendarColorString), + allowedEntityTypesRawValue: calendar.allowedEntityTypes.rawValue, + supportedEventAvailabilitiesRawValue: calendar.supportedEventAvailabilities.rawValue + ) +} - return EKRecurrenceRule( - recurrenceWith: eventKitRecurrenceFrequency(recurrence.frequency), - interval: recurrence.interval, - end: end +func calendarSourceRecord(_ source: EKSource) -> CalendarSourceRecord { + CalendarSourceRecord( + id: source.sourceIdentifier, title: source.title, type: calendarSourceTypeName(source.sourceType), + typeRawValue: source.sourceType.rawValue, isDelegate: source.isDelegate, + calendarIds: source.calendars(for: .event).map(\.calendarIdentifier).sorted() ) } -func eventKitRecurrenceFrequency(_ value: String) -> EKRecurrenceFrequency { - switch value { - case "weekly": - return .weekly - case "monthly": - return .monthly - case "yearly": - return .yearly - default: - return .daily +func calendarTypeName(_ type: EKCalendarType) -> String { + switch type { + case .local: "local" + case .calDAV: "caldav" + case .exchange: "exchange" + case .subscription: "subscription" + case .birthday: "birthday" + @unknown default: "unknown" } } -func calendarRecord(_ calendar: EKCalendar) -> CalendarRecord { - CalendarRecord( - id: calendar.calendarIdentifier, - title: calendar.title, - sourceTitle: calendar.source.title, - allowsContentModifications: calendar.allowsContentModifications - ) +func calendarSourceTypeName(_ type: EKSourceType) -> String { + switch type { + case .local: "local" + case .exchange: "exchange" + case .calDAV: "caldav" + case .mobileMe: "mobileme" + case .subscribed: "subscribed" + case .birthdays: "birthdays" + @unknown default: "unknown" + } +} + +func validatedCalendarTitle(_ value: String) throws -> String { + let title = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !title.isEmpty else { + throw CLIError(code: .validationError, message: "`--title` must not be empty.") + } + return title +} + +func validatedCalendarColor(_ value: String) throws -> String { + let hex = value.dropFirst() + guard value.hasPrefix("#"), [6, 8].contains(hex.utf8.count), + hex.utf8.allSatisfy({ (48...57).contains($0) || (65...70).contains($0) || (97...102).contains($0) }) + else { + throw CLIError(code: .validationError, message: "`--color` must use #RRGGBB or #RRGGBBAA.") + } + return "#" + hex.uppercased() + (hex.utf8.count == 6 ? "FF" : "") +} + +func calendarColor(_ value: String) throws -> NSColor { + let normalized = try validatedCalendarColor(value) + guard let rgba = UInt32(normalized.dropFirst(), radix: 16) else { + throw CLIError(code: .validationError, message: "Calendar color could not be parsed.") + } + return NSColor( + srgbRed: CGFloat((rgba >> 24) & 255) / 255, + green: CGFloat((rgba >> 16) & 255) / 255, + blue: CGFloat((rgba >> 8) & 255) / 255, + alpha: CGFloat(rgba & 255) / 255) +} + +func calendarColorString(_ color: NSColor) -> String? { + guard let rgb = color.usingColorSpace(.sRGB) else { return nil } + let channels = [rgb.redComponent, rgb.greenComponent, rgb.blueComponent, rgb.alphaComponent] + guard channels.allSatisfy(\.isFinite) else { return nil } + return "#" + channels.map { String(format: "%02X", Int((min(1, max(0, $0)) * 255).rounded())) } + .joined() +} + +func requireMutableCalendar(_ calendar: CalendarRecord) throws { + guard let sourceID = calendar.sourceId, !sourceID.isEmpty else { + throw CLIError( + code: .backendUnavailable, message: "Calendar source identity is unavailable.", + details: ["calendar_id": calendar.id]) + } + guard let immutable = calendar.isImmutable else { + throw CLIError( + code: .backendUnavailable, message: "Calendar attribute permissions are unavailable.", + details: ["calendar_id": calendar.id]) + } + guard !immutable else { + throw CLIError( + code: .unsupportedOperation, message: "This calendar's properties cannot be modified or deleted.", + details: ["calendar_id": calendar.id]) + } +} + +func requireCalendarCreationSource(_ source: CalendarSourceRecord) throws { + guard !source.isDelegate, + source.typeRawValue != EKSourceType.subscribed.rawValue, + source.typeRawValue != EKSourceType.birthdays.rawValue + else { + throw CLIError( + code: .unsupportedOperation, message: "This calendar source does not accept new owned calendars.", + details: ["source_id": source.id]) + } +} + +func calendarCollectionScope(operation: String, payload: T) throws -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + return operation + ":" + sha256Hex(try encoder.encode(payload)) +} + +struct CalendarCollectionMutationScope: Encodable { + var current: CalendarRecord? = nil + var source: CalendarSourceRecord? = nil + var draft: CalendarCreateDraft? = nil + var patch: CalendarPatch? = nil } func eventSummary(_ event: EKEvent) -> CalendarEventSummary { let id = event.eventIdentifier ?? event.calendarItemIdentifier + let rules = event.recurrenceRules?.map(recurrenceRecord) ?? [] return CalendarEventSummary( id: id, calendarId: event.calendar.calendarIdentifier, @@ -862,13 +925,17 @@ func eventSummary(_ event: EKEvent) -> CalendarEventSummary { location: event.location, alarmMinutesBefore: relativeAlarmMinutesBefore(event), absoluteAlarmDates: absoluteAlarmDates(event), - recurrence: recurrenceRule(event), - attendees: eventAttendees(event) + recurrence: rules.first, + attendees: eventAttendees(event), + recurrenceRules: rules, + isDetached: event.isDetached, + timeZoneIdentifier: event.timeZone?.identifier ) } func eventDetail(_ event: EKEvent) -> CalendarEventDetail { - CalendarEventDetail( + let rules = event.recurrenceRules?.map(recurrenceRecord) ?? [] + return CalendarEventDetail( id: event.eventIdentifier ?? event.calendarItemIdentifier, calendarId: event.calendar.calendarIdentifier, calendarTitle: event.calendar.title, @@ -880,8 +947,10 @@ func eventDetail(_ event: EKEvent) -> CalendarEventDetail { notes: event.notes, alarmMinutesBefore: relativeAlarmMinutesBefore(event), absoluteAlarmDates: absoluteAlarmDates(event), - recurrence: recurrenceRule(event), - attendees: eventAttendees(event) + recurrence: rules.first, + attendees: eventAttendees(event), + recurrenceRules: rules, + timeZoneIdentifier: event.timeZone?.identifier ) } @@ -943,36 +1012,6 @@ func absoluteAlarmDates(_ event: EKEvent) -> [Date] { uniqueSortedDates((event.alarms ?? []).compactMap(\.absoluteDate)) } -func recurrenceRule(_ event: EKEvent) -> CalendarRecurrenceRule? { - guard let rule = event.recurrenceRules?.first else { - return nil - } - - let recurrenceEnd = rule.recurrenceEnd - let occurrenceCount = recurrenceEnd?.occurrenceCount ?? 0 - return CalendarRecurrenceRule( - frequency: recurrenceFrequency(rule.frequency), - interval: rule.interval, - occurrenceCount: occurrenceCount > 0 ? occurrenceCount : nil, - until: recurrenceEnd?.endDate - ) -} - -func recurrenceFrequency(_ frequency: EKRecurrenceFrequency) -> String { - switch frequency { - case .daily: - return "daily" - case .weekly: - return "weekly" - case .monthly: - return "monthly" - case .yearly: - return "yearly" - @unknown default: - return "unknown" - } -} - func eventAttendees(_ event: EKEvent) -> [CalendarAttendeeRecord] { (event.attendees ?? []).map { participant in CalendarAttendeeRecord( @@ -1043,7 +1082,12 @@ func participantType(_ type: EKParticipantType) -> String { } } -func renderICalendar(_ events: [CalendarEventSummary]) -> String { +func renderICalendar(_ events: [CalendarEventSummary]) throws -> String { + if let event = events.first(where: { $0.recurrence != nil || !($0.recurrenceRules ?? []).isEmpty || $0.isDetached == true }) { + throw CLIError(code: .unsupportedOperation, + message: "Recurring event export requires complete series and exception data, which is unavailable.", + details: ["event_id": event.id, "calendar_id": event.calendarId]) + } var lines = [ "BEGIN:VCALENDAR", "VERSION:2.0", @@ -1058,8 +1102,18 @@ func renderICalendar(_ events: [CalendarEventSummary]) -> String { lines.append("UID:\(iCalendarText(event.id))") lines.append("DTSTAMP:\(stamp)") if event.isAllDay { - lines.append("DTSTART;VALUE=DATE:\(iCalendarDate(event.start))") - lines.append("DTEND;VALUE=DATE:\(iCalendarDate(event.end))") + let timeZone: TimeZone + if let identifier = event.timeZoneIdentifier { + guard let resolved = TimeZone(identifier: identifier) else { + throw CLIError(code: .unsupportedOperation, message: "The all-day event's time zone is unavailable.", + details: ["event_id": event.id, "time_zone": identifier]) + } + timeZone = resolved + } else { + timeZone = .current + } + lines.append("DTSTART;VALUE=DATE:\(iCalendarDate(event.start, timeZone: timeZone))") + lines.append("DTEND;VALUE=DATE:\(iCalendarDate(event.end, timeZone: timeZone))") } else { lines.append("DTSTART:\(iCalendarDateTime(event.start))") lines.append("DTEND:\(iCalendarDateTime(event.end))") @@ -1070,9 +1124,6 @@ func renderICalendar(_ events: [CalendarEventSummary]) -> String { if let location = event.location, !location.isEmpty { lines.append("LOCATION:\(iCalendarText(location))") } - if let recurrence = event.recurrence { - lines.append("RRULE:\(iCalendarRecurrence(recurrence))") - } for minutes in event.alarmMinutesBefore.sorted() { lines.append("BEGIN:VALARM") lines.append("ACTION:DISPLAY") @@ -1103,11 +1154,11 @@ func iCalendarDateTime(_ date: Date) -> String { return formatter.string(from: date) } -func iCalendarDate(_ date: Date) -> String { +func iCalendarDate(_ date: Date, timeZone: TimeZone) -> String { let formatter = DateFormatter() formatter.calendar = Calendar(identifier: .gregorian) formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.timeZone = timeZone formatter.dateFormat = "yyyyMMdd" return formatter.string(from: date) } @@ -1121,44 +1172,35 @@ func iCalendarText(_ value: String) -> String { .replacingOccurrences(of: ",", with: "\\,") } -func iCalendarRecurrence(_ recurrence: CalendarRecurrenceRule) -> String { - var parts = [ - "FREQ=\(recurrence.frequency.uppercased())", - "INTERVAL=\(recurrence.interval)", - ] - if let occurrenceCount = recurrence.occurrenceCount { - parts.append("COUNT=\(occurrenceCount)") - } - if let until = recurrence.until { - parts.append("UNTIL=\(iCalendarDateTime(until))") - } - return parts.joined(separator: ";") -} - func foldICalendarLine(_ line: String) -> String { - guard line.count > 75 else { + guard line.utf8.count > 75 else { return line } - var chunks: [String] = [] - var remainder = line - while remainder.count > 75 { - let index = remainder.index(remainder.startIndex, offsetBy: 75) - chunks.append(String(remainder[.. 75 { + folded += "\r\n " + byteCount = 1 + } + folded.unicodeScalars.append(scalar) + byteCount += scalarBytes } - chunks.append(remainder) - return chunks.enumerated().map { index, chunk in - index == 0 ? chunk : " \(chunk)" - }.joined(separator: "\r\n") + return folded } func calendarsHumanOutput(_ calendars: [CalendarRecord]) -> String { calendars - .map { "\($0.id)\t\($0.title)\t\($0.sourceTitle)" } + .map { "\($0.id)\t\($0.title)\t\($0.sourceTitle ?? "unavailable")" } .joined(separator: "\n") } +func calendarSourcesHumanOutput(_ sources: [CalendarSourceRecord]) -> String { + sources.map { "\($0.id)\t\($0.title)\t\($0.type)" }.joined(separator: "\n") +} + func eventsHumanOutput(_ events: [CalendarEventSummary]) -> String { events .map { "\($0.id)\t\(formatDate($0.start))\t\(formatDate($0.end))\t\($0.title)" } diff --git a/Sources/ClipboardCLI/Backends.swift b/Sources/ClipboardCLI/Backends.swift index 0d9e749..6999c44 100644 --- a/Sources/ClipboardCLI/Backends.swift +++ b/Sources/ClipboardCLI/Backends.swift @@ -1,39 +1,292 @@ import AppKit -import CryptoKit import Foundation import Utility public struct AppKitClipboardBackend: ClipboardAccessing { - public init() {} + private let pasteboardName: String - public func types() -> [String] { - (NSPasteboard.general.types ?? []) - .map(\.rawValue) - .sorted() + public init() { + pasteboardName = NSPasteboard.Name.general.rawValue } - public func readString(preferredType: String?) -> ClipboardReadItem? { + public init(pasteboard: NSPasteboard) { + pasteboardName = pasteboard.name.rawValue + } + + private var pasteboard: NSPasteboard { + NSPasteboard(name: .init(rawValue: pasteboardName)) + } + + public func types() throws -> ClipboardTypesResponse { + let pasteboard = self.pasteboard + let count = pasteboard.changeCount + let types = (pasteboard.types ?? []).map(\.rawValue).sorted() + try ensureOwnership(pasteboard, count: count) + return ClipboardTypesResponse(types: types, changeCount: count) + } + + public func readString( + preferredType: String?, maxBytes: Int = 1_048_576 + ) throws -> ClipboardReadResponse { + try ClipboardLimits.validate(maxBytes: maxBytes) + let pasteboard = self.pasteboard + try requireReadAccess(pasteboard) + let count = pasteboard.changeCount let type = preferredType.map(NSPasteboard.PasteboardType.init(rawValue:)) ?? .string - guard let value = NSPasteboard.general.string(forType: type) else { - return nil + let declared = (pasteboard.types ?? []).contains(type) + let value = pasteboard.string(forType: type) + try ensureOwnership(pasteboard, count: count) + guard let value else { + guard !declared else { + throw CLIError( + code: .backendUnavailable, message: "The declared clipboard text is unavailable.") + } + return ClipboardReadResponse(item: nil, changeCount: count) } - return ClipboardReadItem(type: type.rawValue, value: value) + guard value.utf8.count <= maxBytes else { throw clipboardSizeError(maxBytes) } + return ClipboardReadResponse( + item: ClipboardReadItem(type: type.rawValue, value: value), changeCount: count) } - public func stateDigest() -> String { - let pasteboard = NSPasteboard.general - let typeDigest = sha256Hex(types().joined(separator: "\n")) - return "change:\(pasteboard.changeCount):types:\(typeDigest)" + public func readItems( + preferredType: String? = nil, limit: Int = 50, maxBytes: Int = 1_048_576 + ) throws -> ClipboardItemsResponse { + try ClipboardLimits.validate(maxBytes: maxBytes, limit: limit) + let pasteboard = self.pasteboard + try requireReadAccess(pasteboard) + let count = pasteboard.changeCount + let native = try nativeItems(pasteboard, count: count) + let selected = native.enumerated().filter { _, item in + preferredType.map { requested in item.types.contains { $0.rawValue == requested } } ?? true + } + var totalBytes = 0 + var items: [ClipboardItem] = [] + for (index, item) in selected.prefix(limit) { + let types = item.types.filter { preferredType == nil || $0.rawValue == preferredType } + guard types.count <= ClipboardLimits.maximumRepresentations, + types.allSatisfy({ $0.rawValue.utf8.count <= 1024 }) + else { + throw CLIError( + code: .unsupportedOperation, + message: "Clipboard representation metadata exceeds the supported bound.") + } + var representations: [ClipboardRepresentation] = [] + for type in types { + let data = item.data(forType: type) + try ensureOwnership(pasteboard, count: count) + if let data { + guard data.count <= maxBytes - totalBytes else { throw clipboardSizeError(maxBytes) } + totalBytes += data.count + } + representations.append(ClipboardRepresentation(type: type.rawValue, data: data)) + } + items.append(ClipboardItem(representations: representations, ordinal: index + 1)) + } + try ensureOwnership(pasteboard, count: count) + return ClipboardItemsResponse( + items: items, changeCount: count, totalItems: native.count, totalBytes: totalBytes, + truncated: selected.count > limit, filtered: preferredType != nil) } - public func writeText(_ text: String) throws -> Bool { - let pasteboard = NSPasteboard.general - pasteboard.clearContents() - return pasteboard.setString(text, forType: .string) + public func writeText( + _ text: String, ifChangeCount: Int? = nil, maxBytes: Int = 1_048_576, + currentHostOnly: Bool = false + ) throws -> ClipboardChange { + try writeItems( + [ + ClipboardItem(representations: [ + ClipboardRepresentation( + type: NSPasteboard.PasteboardType.string.rawValue, data: Data(text.utf8)) + ]) + ], + ifChangeCount: ifChangeCount, maxBytes: maxBytes, currentHostOnly: currentHostOnly) } - public func clear() throws -> Bool { - NSPasteboard.general.clearContents() + public func writeItems( + _ items: [ClipboardItem], ifChangeCount: Int? = nil, maxBytes: Int = 1_048_576, + currentHostOnly: Bool = false + ) throws -> ClipboardChange { + let items = try validatedClipboardItems(items, maxBytes: maxBytes) + let prepared = try prepareClipboardItems(items) + let pasteboard = self.pasteboard + let before = pasteboard.changeCount + try requireChangeCount(ifChangeCount, actual: before) + // Contents options have no public getter. An explicit restriction needs a new ownership claim. + if !currentHostOnly { + let current = try nativeItems(pasteboard, count: before) + if try matches(current, items: items, pasteboard: pasteboard, count: before) { + return ClipboardChange(changed: false, changeCount: before) + } + } + try ensureOwnership(pasteboard, count: before) + let claimed = pasteboard.prepareForNewContents(with: currentHostOnly ? .currentHostOnly : []) + try ensureOwnership(pasteboard, count: claimed, mutation: true) + guard pasteboard.writeObjects(prepared) else { + throw mutationFailure("write") + } + try ensureOwnership(pasteboard, count: claimed, mutation: true) + let cold = self.pasteboard + let readBack = try nativeItems(cold, count: claimed, mutation: true) + guard + try matches( + readBack, items: items, pasteboard: cold, count: claimed, mutation: true, + allowGeneratedRepresentations: true) + else { + throw mutationFailure("readback") + } + return ClipboardChange(changed: true, changeCount: claimed) + } + + public func clear(ifChangeCount: Int? = nil) throws -> ClipboardChange { + let pasteboard = self.pasteboard + let before = pasteboard.changeCount + try requireChangeCount(ifChangeCount, actual: before) + if try nativeItems(pasteboard, count: before).isEmpty { + return ClipboardChange(changed: false, changeCount: before) + } + try ensureOwnership(pasteboard, count: before) + let claimed = pasteboard.clearContents() + let cold = self.pasteboard + guard try nativeItems(cold, count: claimed, mutation: true).isEmpty else { + throw mutationFailure("clear_readback") + } + return ClipboardChange(changed: true, changeCount: claimed) + } + + private func nativeItems( + _ pasteboard: NSPasteboard, count: Int, mutation: Bool = false + ) throws -> [NSPasteboardItem] { + let items = pasteboard.pasteboardItems + let types = pasteboard.types ?? [] + try ensureOwnership(pasteboard, count: count, mutation: mutation) + guard let items else { + guard types.isEmpty else { + if mutation { throw mutationFailure("items_readback") } + throw CLIError( + code: .backendUnavailable, message: "Clipboard items could not be retrieved.") + } + return [] + } + return items + } + + private func matches( + _ native: [NSPasteboardItem], items: [ClipboardItem], pasteboard: NSPasteboard, + count: Int, mutation: Bool = false, allowGeneratedRepresentations: Bool = false + ) throws -> Bool { + try ensureOwnership(pasteboard, count: count, mutation: mutation) + guard native.count == items.count else { return false } + for (actual, expected) in zip(native, items) { + let actualTypes = Set(actual.types.map(\.rawValue)) + let expectedTypes = Set(expected.representations.map(\.type)) + try ensureOwnership(pasteboard, count: count, mutation: mutation) + guard expectedTypes.isSubset(of: actualTypes) else { return false } + guard + actual.types.map(\.rawValue).filter(expectedTypes.contains) + == expected.representations.map(\.type) + else { return false } + let additionalTypes = actualTypes.subtracting(expectedTypes) + if !additionalTypes.isEmpty && !allowGeneratedRepresentations { + guard + try matchesDerivedRTFText( + actual, expected: expected, types: additionalTypes, pasteboard: pasteboard, count: count + ) + else { return false } + } + for representation in expected.representations { + let data = actual.data(forType: .init(rawValue: representation.type)) + try ensureOwnership(pasteboard, count: count, mutation: mutation) + guard data == representation.data else { return false } + } + } + try ensureOwnership(pasteboard, count: count, mutation: mutation) return true } + + private func matchesDerivedRTFText( + _ actual: NSPasteboardItem, expected: ClipboardItem, types: Set, + pasteboard: NSPasteboard, count: Int + ) throws -> Bool { + let utf16Type = "public.utf16-external-plain-text" + let textTypes: Set = [NSPasteboard.PasteboardType.string.rawValue, utf16Type] + guard types.isSubset(of: textTypes), + let rtf = expected.representations.first(where: { + $0.type == NSPasteboard.PasteboardType.rtf.rawValue + })?.data, + let text = NSAttributedString(rtf: rtf, documentAttributes: nil)?.string + else { return false } + // RTF writers gain native plain-text representations. They are redundant only if the text agrees. + for type in types { + let data = actual.data(forType: .init(rawValue: type)) + try ensureOwnership(pasteboard, count: count) + guard let data, + let derived = String(data: data, encoding: type == utf16Type ? .utf16 : .utf8), + derived.utf16.elementsEqual(text.utf16) + else { return false } + } + return true + } + + private func requireChangeCount(_ requested: Int?, actual: Int) throws { + if let requested, requested != actual { + throw CLIError( + code: .unsafeMutationRefused, + message: "Clipboard change count does not match --if-change-count.", + details: ["expected_change_count": "\(requested)", "actual_change_count": "\(actual)"]) + } + } + + private func requireReadAccess(_ pasteboard: NSPasteboard) throws { + if #available(macOS 15.4, *), pasteboard.name == .general, + pasteboard.accessBehavior == .alwaysDeny + { + throw CLIError( + code: .permissionDenied, + message: + "macOS denies programmatic clipboard reads for this app. Check Paste from Other Apps in System Settings." + ) + } + } + + private func ensureOwnership(_ pasteboard: NSPasteboard, count: Int, mutation: Bool = false) + throws + { + guard pasteboard.changeCount == count else { + if mutation { throw mutationFailure("ownership_changed") } + throw CLIError( + code: .unsafeMutationRefused, message: "Clipboard ownership changed during the operation.") + } + } + + private func mutationFailure(_ phase: String) -> CLIError { + // Restoring an old snapshot could overwrite a new owner's clipboard. + CLIError( + code: .backendUnavailable, + message: + "Clipboard replacement could not be verified; reread the current state before retrying.", + details: ["phase": phase, "mutation_may_have_occurred": "true"]) + } +} + +func prepareClipboardItems(_ items: [ClipboardItem]) throws -> [NSPasteboardItem] { + // Only fresh, unbound items may be passed to writeObjects. + try items.map { item in + let native = NSPasteboardItem() + for representation in item.representations { + let type = NSPasteboard.PasteboardType(rawValue: representation.type) + guard let data = representation.data, native.setData(data, forType: type), + native.data(forType: type) == data + else { + throw CLIError( + code: .validationError, + message: "AppKit rejected a clipboard representation before replacement.") + } + } + guard native.types.map(\.rawValue) == item.representations.map(\.type) else { + throw CLIError( + code: .validationError, + message: "AppKit changed the clipboard representation types before replacement.") + } + return native + } } diff --git a/Sources/ClipboardCLI/Command.swift b/Sources/ClipboardCLI/Command.swift index e28620d..722a5e0 100644 --- a/Sources/ClipboardCLI/Command.swift +++ b/Sources/ClipboardCLI/Command.swift @@ -1,5 +1,4 @@ import AppKit -import CryptoKit import Foundation import Utility @@ -16,116 +15,134 @@ public struct ClipboardCommand: Sendable { case ["clipboard", "types"]: try validateReadOnly(options) try validateTargetOptions(options, allowedOptions: []) - return try result( - ClipboardTypesResponse(types: backend.types()), - human: backend.types().joined(separator: "\n"), - options: options - ) + let snapshot = try backend.types() + return try result(snapshot, human: snapshot.types.joined(separator: "\n"), options: options) case ["clipboard", "read"]: try validateReadOnly(options) - try validateTargetOptions(options, allowedOptions: ["type"]) - let item = backend.readString(preferredType: options.targetOption("type")) + try validateTargetOptions(options, allowedOptions: ["type", "max-bytes"]) + let snapshot = try backend.readString( + preferredType: options.targetOption("type"), maxBytes: clipboardMaxBytes(options)) + return try result(snapshot, human: snapshot.item?.value ?? "", options: options) + case ["clipboard", "items", "read"]: + try validateReadOnly(options) + try validateTargetOptions(options, allowedOptions: ["type", "max-bytes"]) + let maxBytes = try clipboardMaxBytes(options) + let limit = options.limit ?? ClipboardLimits.defaultItems + try ClipboardLimits.validate(maxBytes: maxBytes, limit: limit) + let snapshot = try backend.readItems( + preferredType: options.targetOption("type"), limit: limit, maxBytes: maxBytes) return try result( - ClipboardReadResponse(item: item, sensitive: true), - human: item.map(\.value) ?? "", - options: options - ) + snapshot, human: CLIJSON.encodeString(snapshot, pretty: true), options: options) case ["clipboard", "write"]: - try validateTargetOptions(options, allowedOptions: ["text"]) - let text = try requiredOption("text", options: options) - return try writeText(text, options: options) + try validateTargetOptions( + options, allowedOptions: ["text", "if-change-count", "max-bytes"], + allowedFlags: ["current-host-only"]) + let text = try requiredOption("text", options: options, allowEmpty: true) + let maxBytes = try clipboardMaxBytes(options) + guard text.utf8.count <= maxBytes else { throw clipboardSizeError(maxBytes) } + let count = try clipboardChangeCount(options) + let operation = "clipboard.write" + let summary = [ + "type": NSPasteboard.PasteboardType.string.rawValue, + "byte_count": "\(text.utf8.count)", + "sha256": sha256Hex(text), + ] + if options.dryRun { + return try dryRunPreview( + operation: operation, scopeDigest: "clipboard:general:text:\(sha256Hex(text))", + summaryFields: summary, count: count, options: options) + } + try requirePersistentAction(options) + return try mutationResult( + try backend.writeText( + text, ifChangeCount: count, maxBytes: maxBytes, + currentHostOnly: options.hasTargetFlag("current-host-only")), + operation: operation, options: options) + case ["clipboard", "items", "write"]: + try validateTargetOptions( + options, allowedOptions: ["input", "if-change-count", "max-bytes"], + allowedFlags: ["current-host-only"]) + let input = try requiredOption("input", options: options) + let maxBytes = try clipboardMaxBytes(options) + let count = try clipboardChangeCount(options) + let items = try readClipboardInput(input, maxBytes: maxBytes) + let encoder = JSONEncoder() + encoder.outputFormatting = .sortedKeys + let digest = sha256Hex(try encoder.encode(items)) + let operation = "clipboard.items.write" + let representations = items.flatMap(\.representations) + let summary = [ + "item_count": "\(items.count)", + "representation_count": "\(representations.count)", + "byte_count": "\(representations.reduce(0) { $0 + ($1.data?.count ?? 0) })", + "sha256": digest, + ] + if options.dryRun { + _ = try prepareClipboardItems(items) + return try dryRunPreview( + operation: operation, scopeDigest: "clipboard:general:items:\(digest)", + summaryFields: summary, count: count, options: options) + } + try requirePersistentAction(options) + return try mutationResult( + try backend.writeItems( + items, ifChangeCount: count, maxBytes: maxBytes, + currentHostOnly: options.hasTargetFlag("current-host-only")), + operation: operation, options: options) case ["clipboard", "clear"]: - try validateTargetOptions(options, allowedOptions: []) - return try clear(options: options) + try validateTargetOptions(options, allowedOptions: ["if-change-count"]) + let count = try clipboardChangeCount(options) + let operation = "clipboard.clear" + if options.dryRun { + let snapshot = try backend.types() + let digest = + "change:\(snapshot.changeCount):types:\(sha256Hex(snapshot.types.joined(separator: "\n")))" + return try dryRunPreview( + operation: operation, scopeDigest: "clipboard:general:state:\(digest)", + summaryFields: ["state_digest": digest], count: count, options: options) + } + try requirePersistentAction(options) + return try mutationResult( + try backend.clear(ifChangeCount: count), operation: operation, options: options) default: return nil } } - private func writeText(_ text: String, options: CLIOptions) throws -> CLICommandResult { - let operation = "clipboard.write" - let scopeDigest = "clipboard:general:text:\(sha256Hex(text))" - let summary = [ - "type": NSPasteboard.PasteboardType.string.rawValue, - "byte_count": "\(Data(text.utf8).count)", - "sha256": sha256Hex(text), - ] - - if options.dryRun { - try validateDryRunOptions(options) - return try dryRunPreview( - operation: operation, - scopeDigest: scopeDigest, - summaryFields: summary, - options: options - ) - } - + private func requirePersistentAction(_ options: CLIOptions) throws { try CLISafety.requireFlag( - "allow-persistent-action", - in: options, - category: .persistentAction, - message: "Clipboard write persists pasteboard state and requires `--allow-persistent-action`." - ) - - let changed = try backend.writeText(text) - return try result( - ClipboardMutationResult(operation: operation, changed: changed), - human: changed ? "clipboard.write changed=true" : "clipboard.write changed=false", - options: options - ) + "allow-persistent-action", in: options, category: .persistentAction, + message: "Clipboard replacement requires --allow-persistent-action.") } - private func clear(options: CLIOptions) throws -> CLICommandResult { - let operation = "clipboard.clear" - let scopeDigest = "clipboard:general:state:\(backend.stateDigest())" - let summary = ["state_digest": backend.stateDigest()] - - if options.dryRun { - try validateDryRunOptions(options) - return try dryRunPreview( - operation: operation, - scopeDigest: scopeDigest, - summaryFields: summary, - options: options - ) - } - - try CLISafety.requireFlag( - "allow-persistent-action", - in: options, - category: .persistentAction, - message: "Clipboard clear persists pasteboard state and requires `--allow-persistent-action`." - ) - - let changed = try backend.clear() - return try result( - ClipboardMutationResult(operation: operation, changed: changed), - human: changed ? "clipboard.clear changed=true" : "clipboard.clear changed=false", - options: options - ) + private func mutationResult( + _ change: ClipboardChange, operation: String, options: CLIOptions + ) throws -> CLICommandResult { + try result( + ClipboardMutationResult( + operation: operation, changed: change.changed, changeCount: change.changeCount), + human: "\(operation) changed=\(change.changed) changeCount=\(change.changeCount)", + options: options) } private func dryRunPreview( - operation: String, - scopeDigest: String, - summaryFields: [String: String], - options: CLIOptions + operation: String, scopeDigest: String, summaryFields: [String: String], + count: Int?, options: CLIOptions ) throws -> CLICommandResult { + var summary = summaryFields + var scope = scopeDigest + if options.hasTargetFlag("current-host-only") { + summary["current_host_only"] = "true" + scope += ":current-host-only" + } + if let count { + summary["if_change_count"] = "\(count)" + scope += ":if-change-count:\(count)" + } let dryRun = CLISafety.dryRun( - target: target, - operation: operation, - summary: summaryFields, - scope: scopeDigest, - category: .persistentAction, - allowFlags: ["--allow-persistent-action"] - ) - - return try result( - dryRun, - human: "dry-run: \(operation)", - options: options - ) + target: target, operation: operation, summary: summary, scope: scope, + category: .persistentAction, allowFlags: ["--allow-persistent-action"]) + return try result(dryRun, human: "dry-run: \(operation)", options: options) } private func result(_ payload: some Encodable, human: String, options: CLIOptions) throws @@ -135,7 +152,6 @@ public struct ClipboardCommand: Sendable { let envelope = CLISuccessEnvelope(data: payload, meta: ["target": target]) return CLICommandResult(stdout: try CLIJSON.encodeString(envelope, pretty: options.pretty)) } - return CLICommandResult(stdout: human) } } diff --git a/Sources/ClipboardCLI/Commands.swift b/Sources/ClipboardCLI/Commands.swift index e53dad9..8fb4a6c 100644 --- a/Sources/ClipboardCLI/Commands.swift +++ b/Sources/ClipboardCLI/Commands.swift @@ -5,14 +5,14 @@ import Utility public struct ClipboardTarget: ParsableCommand { public static let targetName = "clipboard" public static let targetStatus = - "Implemented: pasteboard type/read and dry-run previewed write/clear paths." + "Implemented: bounded text and typed item reads, verified replacement and clear." public static let isImplemented = true public static let configuration = CommandConfiguration( commandName: "clipboard", - abstract: "Pasteboard read, type inspection, write, and clear workflows.", + abstract: "Read and replace the current system pasteboard.", version: CLIVersion.current, - subcommands: [Types.self, Read.self, Write.self, Clear.self, Doctor.self] + subcommands: [Types.self, Read.self, Write.self, Clear.self, Items.self, Doctor.self] ) @OptionGroup public var shared: CLISharedOptions @@ -20,19 +20,15 @@ public struct ClipboardTarget: ParsableCommand { public mutating func run() throws { try CLICommandOutput.writeStatus( - target: Self.targetName, - status: Self.targetStatus, - implemented: Self.isImplemented, - json: shared.json, - pretty: shared.pretty - ) + target: Self.targetName, status: Self.targetStatus, implemented: Self.isImplemented, + json: shared.json, pretty: shared.pretty) } public struct Types: Leaf { public static let configuration = CommandConfiguration(commandName: "types") public static let positionals = ["clipboard", "types"] @OptionGroup public var shared: CLISharedOptions - @OptionGroup public var targetOptions: ClipboardTargetOptions + public var targetOptions: ClipboardNoOptions { .init() } public init() {} } @@ -40,7 +36,7 @@ public struct ClipboardTarget: ParsableCommand { public static let configuration = CommandConfiguration(commandName: "read") public static let positionals = ["clipboard", "read"] @OptionGroup public var shared: CLISharedOptions - @OptionGroup public var targetOptions: ClipboardTargetOptions + @OptionGroup public var targetOptions: ClipboardReadOptions public init() {} } @@ -48,7 +44,7 @@ public struct ClipboardTarget: ParsableCommand { public static let configuration = CommandConfiguration(commandName: "write") public static let positionals = ["clipboard", "write"] @OptionGroup public var shared: CLISharedOptions - @OptionGroup public var targetOptions: ClipboardTargetOptions + @OptionGroup public var targetOptions: ClipboardWriteOptions public init() {} } @@ -56,10 +52,35 @@ public struct ClipboardTarget: ParsableCommand { public static let configuration = CommandConfiguration(commandName: "clear") public static let positionals = ["clipboard", "clear"] @OptionGroup public var shared: CLISharedOptions - @OptionGroup public var targetOptions: ClipboardTargetOptions + @OptionGroup public var targetOptions: ClipboardClearOptions public init() {} } + public struct Items: ParsableCommand { + public static let configuration = CommandConfiguration( + commandName: "items", abstract: "Preserve item boundaries and typed data.", + subcommands: [Read.self, Write.self]) + public init() {} + + public struct Read: ClipboardTarget.Leaf { + public static let configuration = CommandConfiguration( + commandName: "read", abstract: "Read ordered items and their base64 representations.") + public static let positionals = ["clipboard", "items", "read"] + @OptionGroup public var shared: CLISharedOptions + @OptionGroup public var targetOptions: ClipboardReadOptions + public init() {} + } + + public struct Write: ClipboardTarget.Leaf { + public static let configuration = CommandConfiguration( + commandName: "write", abstract: "Replace all items from a complete JSON payload.") + public static let positionals = ["clipboard", "items", "write"] + @OptionGroup public var shared: CLISharedOptions + @OptionGroup public var targetOptions: ClipboardItemsWriteOptions + public init() {} + } + } + public struct Doctor: ParsableCommand { public static let configuration = CommandConfiguration(commandName: "doctor") @OptionGroup public var shared: CLISharedOptions @@ -67,13 +88,17 @@ public struct ClipboardTarget: ParsableCommand { public mutating func run() throws { try CLICommandOutput.writeDoctor( - target: ClipboardTarget.targetName, - checks: clipboardDoctorChecks(), - json: shared.json, - pretty: shared.pretty - ) + target: ClipboardTarget.targetName, checks: clipboardDoctorChecks(), + json: shared.json, pretty: shared.pretty) } } + + public protocol Leaf: ParsableCommand { + associatedtype TargetOptions: ClipboardCommandOptions + static var positionals: [String] { get } + var shared: CLISharedOptions { get } + var targetOptions: TargetOptions { get } + } } extension ClipboardTarget { @@ -81,15 +106,9 @@ extension ClipboardTarget { do { guard let result = try ClipboardCommand().run(options: options) else { throw CLIError( - code: .backendUnavailable, - message: "Command is documented but this target backend is not implemented yet.", - details: [ - "target": targetName, - "command": options.positionals.joined(separator: " "), - ] - ) + code: .backendUnavailable, message: "Clipboard command is unavailable.", + details: ["target": targetName, "command": options.positionals.joined(separator: " ")]) } - try CLICommandOutput.write(result) } catch let exitCode as ExitCode { throw exitCode @@ -98,61 +117,127 @@ extension ClipboardTarget { error, target: targetName, json: options.json, pretty: options.pretty) } catch { try CLICommandOutput.write( - CLIError.unexpected(error, verbose: options.verbose), - target: targetName, - json: options.json, - pretty: options.pretty - ) + CLIError.unexpected(error, verbose: options.verbose), target: targetName, + json: options.json, pretty: options.pretty) } } - - public protocol Leaf: ParsableCommand { - static var positionals: [String] { get } - var shared: CLISharedOptions { get } - var targetOptions: ClipboardTargetOptions { get } - } } extension ClipboardTarget.Leaf { public mutating func run() throws { - let options = shared.cliOptions( - targetOptions: targetOptions.cliTargetOptions, - targetFlags: targetOptions.cliTargetFlags, - positionals: Self.positionals - ) - try ClipboardTarget.runCommand(options: options) + try ClipboardTarget.runCommand( + options: shared.cliOptions( + targetOptions: targetOptions.cliTargetOptions, targetFlags: targetOptions.cliTargetFlags, + positionals: Self.positionals)) } } -public struct ClipboardTargetOptions: ParsableArguments, Sendable { - @Option public var type: String? - @Option public var text: String? +public protocol ClipboardCommandOptions: ParsableArguments { + var cliTargetOptions: [String: String] { get } + var cliTargetFlags: Set { get } +} + +extension ClipboardCommandOptions { + public var cliTargetFlags: Set { [] } +} + +public struct ClipboardNoOptions: ClipboardCommandOptions { + public init() {} + public var cliTargetOptions: [String: String] { [:] } +} +public struct ClipboardReadOptions: ClipboardCommandOptions, Sendable { + @Option(help: "Exact type. read defaults to plain text; items read defaults to all formats.") + public var type: String? + @Option( + name: .customLong("max-bytes"), help: "Raw content cap, default 1048576, maximum 67108864.") + public var maxBytes: Int? public init() {} + public var cliTargetOptions: [String: String] { + CLITargetOptionBuilder.options([("type", type), ("max-bytes", maxBytes.map(String.init))]) + } +} +public struct ClipboardWriteOptions: ClipboardCommandOptions, Sendable { + @Option(help: "Plain text, including an explicit empty string.") public var text: String + @Flag(name: .customLong("current-host-only"), help: "Keep new content on this device.") + public var currentHostOnly = false + @Option( + name: .customLong("if-change-count"), help: "Refuse replacement if the current counter differs." + ) + public var ifChangeCount: Int? + @Option( + name: .customLong("max-bytes"), help: "Raw content cap, default 1048576, maximum 67108864.") + public var maxBytes: Int? + public init() {} + public var cliTargetFlags: Set { + CLITargetOptionBuilder.flags([("current-host-only", currentHostOnly)]) + } public var cliTargetOptions: [String: String] { CLITargetOptionBuilder.options([ - ("type", type), - ("text", text), + ("text", text), ("if-change-count", ifChangeCount.map(String.init)), + ("max-bytes", maxBytes.map(String.init)), ]) } +} - public var cliTargetFlags: Set { [] } +public struct ClipboardItemsWriteOptions: ClipboardCommandOptions, Sendable { + @Option(help: "Regular JSON file containing items or a complete items read envelope.") public + var input: String + @Flag(name: .customLong("current-host-only"), help: "Keep new content on this device.") + public var currentHostOnly = false + @Option( + name: .customLong("if-change-count"), help: "Refuse replacement if the current counter differs." + ) + public var ifChangeCount: Int? + @Option( + name: .customLong("max-bytes"), help: "Raw content cap, default 1048576, maximum 67108864.") + public var maxBytes: Int? + public init() {} + public var cliTargetFlags: Set { + CLITargetOptionBuilder.flags([("current-host-only", currentHostOnly)]) + } + public var cliTargetOptions: [String: String] { + CLITargetOptionBuilder.options([ + ("input", input), ("if-change-count", ifChangeCount.map(String.init)), + ("max-bytes", maxBytes.map(String.init)), + ]) + } +} + +public struct ClipboardClearOptions: ClipboardCommandOptions, Sendable { + @Option( + name: .customLong("if-change-count"), help: "Refuse clear if the current counter differs.") + public var ifChangeCount: Int? + public init() {} + public var cliTargetOptions: [String: String] { + CLITargetOptionBuilder.options([("if-change-count", ifChangeCount.map(String.init))]) + } } public func clipboardDoctorChecks() -> [CLIDoctorCheck] { - let types = NSPasteboard.general.types ?? [] - return [ + let pasteboard = NSPasteboard.general + let types = pasteboard.types ?? [] + var checks = [ CLIDoctorCheck( - name: "general_pasteboard", - status: .ok, + name: "general_pasteboard", status: .ok, message: "NSPasteboard general pasteboard is reachable.", - details: ["type_count": "\(types.count)"] - ), + details: ["type_count": "\(types.count)"]), CLIDoctorCheck( - name: "pasteboard_backend", - status: .ok, - message: "Clipboard type/read/write/clear backend is implemented." - ), + name: "pasteboard_backend", status: .ok, + message: "Bounded text/item reads and verified replacement/clear are implemented."), ] + if #available(macOS 15.4, *) { + let behavior = pasteboard.accessBehavior + checks.append( + CLIDoctorCheck( + name: "pasteboard_access", + status: behavior == .alwaysDeny + ? .permissionDenied : (behavior == .alwaysAllow ? .ok : .warning), + message: behavior == .alwaysDeny + ? "macOS denies programmatic clipboard reads for this app." + : "Programmatic reads follow the app's Paste from Other Apps setting; macOS may ask for access.", + details: ["access_behavior": "\(behavior.rawValue)"])) + } + return checks } diff --git a/Sources/ClipboardCLI/Models.swift b/Sources/ClipboardCLI/Models.swift index c1dd260..c8b3440 100644 --- a/Sources/ClipboardCLI/Models.swift +++ b/Sources/ClipboardCLI/Models.swift @@ -1,7 +1,4 @@ -import AppKit -import CryptoKit import Foundation -import Utility public struct ClipboardReadItem: Codable, Equatable, Sendable { public var type: String @@ -15,15 +12,86 @@ public struct ClipboardReadItem: Codable, Equatable, Sendable { public struct ClipboardTypesResponse: Codable, Equatable, Sendable { public var types: [String] + public var changeCount: Int + + public init(types: [String], changeCount: Int) { + self.types = types + self.changeCount = changeCount + } } public struct ClipboardReadResponse: Codable, Equatable, Sendable { public var item: ClipboardReadItem? public var sensitive: Bool + public var changeCount: Int + + public init(item: ClipboardReadItem?, changeCount: Int) { + self.item = item + sensitive = true + self.changeCount = changeCount + } } +public struct ClipboardRepresentation: Codable, Equatable, Sendable { + public var type: String + public var data: Data? + + public init(type: String, data: Data?) { + self.type = type + self.data = data + } + + private enum CodingKeys: String, CodingKey { + case type + case data = "dataBase64" + } +} + +public struct ClipboardItem: Codable, Equatable, Sendable { + public var ordinal: Int? + public var representations: [ClipboardRepresentation] + + public init(representations: [ClipboardRepresentation], ordinal: Int? = nil) { + self.ordinal = ordinal + self.representations = representations + } +} + +public struct ClipboardItemsResponse: Codable, Equatable, Sendable { + public var items: [ClipboardItem] + public var changeCount: Int + public var totalItems: Int + public var totalBytes: Int + public var truncated: Bool + public var filtered: Bool + public var sensitive: Bool + + public init( + items: [ClipboardItem], changeCount: Int, totalItems: Int, totalBytes: Int, + truncated: Bool, filtered: Bool + ) { + self.items = items + self.changeCount = changeCount + self.totalItems = totalItems + self.totalBytes = totalBytes + self.truncated = truncated + self.filtered = filtered + sensitive = true + } +} + +public struct ClipboardChange: Codable, Equatable, Sendable { + public var changed: Bool + public var changeCount: Int + + public init(changed: Bool, changeCount: Int) { + self.changed = changed + self.changeCount = changeCount + } +} public struct ClipboardMutationResult: Codable, Equatable, Sendable { public var operation: String public var changed: Bool + public var changeCount: Int } diff --git a/Sources/ClipboardCLI/Protocols.swift b/Sources/ClipboardCLI/Protocols.swift index 74b97e1..174b39b 100644 --- a/Sources/ClipboardCLI/Protocols.swift +++ b/Sources/ClipboardCLI/Protocols.swift @@ -1,12 +1,12 @@ -import AppKit -import CryptoKit -import Foundation -import Utility - public protocol ClipboardAccessing: Sendable { - func types() -> [String] - func readString(preferredType: String?) -> ClipboardReadItem? - func stateDigest() -> String - func writeText(_ text: String) throws -> Bool - func clear() throws -> Bool + func types() throws -> ClipboardTypesResponse + func readString(preferredType: String?, maxBytes: Int) throws -> ClipboardReadResponse + func readItems(preferredType: String?, limit: Int, maxBytes: Int) throws -> ClipboardItemsResponse + func writeText(_ text: String, ifChangeCount: Int?, maxBytes: Int, currentHostOnly: Bool) throws + -> ClipboardChange + func writeItems( + _ items: [ClipboardItem], ifChangeCount: Int?, maxBytes: Int, currentHostOnly: Bool + ) throws + -> ClipboardChange + func clear(ifChangeCount: Int?) throws -> ClipboardChange } diff --git a/Sources/ClipboardCLI/Support.swift b/Sources/ClipboardCLI/Support.swift index 73423fa..710b683 100644 --- a/Sources/ClipboardCLI/Support.swift +++ b/Sources/ClipboardCLI/Support.swift @@ -1,45 +1,211 @@ import AppKit import CryptoKit +import Darwin import Foundation import Utility +enum ClipboardLimits { + static let defaultBytes = 1_048_576 + static let maximumBytes = 67_108_864 + static let defaultItems = 50 + static let maximumItems = 500 + static let maximumRepresentations = 256 + + static func validate(maxBytes: Int, limit: Int = defaultItems) throws { + guard (1...maximumBytes).contains(maxBytes), (1...maximumItems).contains(limit) else { + throw CLIError( + code: .validationError, + message: "Clipboard byte and item limits are outside the supported range.", + details: ["max_bytes": "\(maximumBytes)", "max_items": "\(maximumItems)"]) + } + } +} + func validateReadOnly(_ options: CLIOptions) throws { if options.dryRun { throw CLIError( code: .validationError, - message: - "`--dry-run` is only valid for mutation or external-action commands." - ) + message: "--dry-run is only valid for mutation or external-action commands.") } } -func validateDryRunOptions(_ options: CLIOptions) throws { -} - -func validateTargetOptions(_ options: CLIOptions, allowedOptions: Set) throws { +func validateTargetOptions( + _ options: CLIOptions, allowedOptions: Set, allowedFlags: Set = [] +) throws { let unknownOptions = Set(options.targetOptions.keys).subtracting(allowedOptions) - if !unknownOptions.isEmpty || !options.targetFlags.isEmpty { - let unsupported = (Array(unknownOptions) + Array(options.targetFlags)).sorted() + let unknownFlags = options.targetFlags.subtracting(allowedFlags) + if !unknownOptions.isEmpty || !unknownFlags.isEmpty { + let unsupported = (Array(unknownOptions) + Array(unknownFlags)).sorted() throw CLIError( code: .validationError, message: "Unsupported option for this command.", - details: ["options": unsupported.map { "--\($0)" }.joined(separator: ",")] - ) + details: ["options": unsupported.map { "--\($0)" }.joined(separator: ",")]) } } -func requiredOption(_ name: String, options: CLIOptions) throws -> String { - guard let value = options.targetOption(name), !value.isEmpty else { +func requiredOption(_ name: String, options: CLIOptions, allowEmpty: Bool = false) throws -> String +{ + guard let value = options.targetOption(name), allowEmpty || !value.isEmpty else { + throw CLIError(code: .validationError, message: "--\(name) is required.") + } + return value +} + +func clipboardMaxBytes(_ options: CLIOptions) throws -> Int { + let value: Int + if let raw = options.targetOption("max-bytes") { + guard let parsed = Int(raw) else { + throw CLIError(code: .validationError, message: "--max-bytes requires an integer.") + } + value = parsed + } else { + value = ClipboardLimits.defaultBytes + } + try ClipboardLimits.validate(maxBytes: value) + return value +} + +func clipboardChangeCount(_ options: CLIOptions) throws -> Int? { + guard let raw = options.targetOption("if-change-count") else { return nil } + guard let value = Int(raw), value >= 0 else { + throw CLIError( + code: .validationError, message: "--if-change-count requires a nonnegative integer.") + } + return value +} + +func validatedClipboardItems(_ items: [ClipboardItem], maxBytes: Int) throws -> [ClipboardItem] { + try ClipboardLimits.validate(maxBytes: maxBytes) + guard (1...ClipboardLimits.maximumItems).contains(items.count) else { throw CLIError( code: .validationError, - message: "`--\(name)` is required." - ) + message: "Clipboard replacement requires 1...500 items; use clear for an empty pasteboard.") } + let filePromiseTypes = Set(NSFilePromiseReceiver.readableDraggedTypes) + var remaining = maxBytes + return try items.map { item in + guard (1...ClipboardLimits.maximumRepresentations).contains(item.representations.count) else { + throw CLIError( + code: .validationError, message: "Each clipboard item requires 1...256 representations.") + } + var types = Set() + for representation in item.representations { + guard !filePromiseTypes.contains(representation.type) else { + throw CLIError( + code: .unsupportedOperation, + message: + "File-promise metadata requires a live provider and cannot be restored as raw data.") + } + guard clipboardTypeSyntaxIsValid(representation.type), + types.insert(representation.type).inserted, + let data = representation.data + else { + throw CLIError( + code: .validationError, + message: "Clipboard representations require unique types and available base64 data.") + } + guard data.count <= remaining else { throw clipboardSizeError(maxBytes) } + remaining -= data.count + } + return ClipboardItem(representations: item.representations) + } +} - return value +private func clipboardTypeSyntaxIsValid(_ type: String) -> Bool { + let components = type.split(separator: ".", omittingEmptySubsequences: false) + guard type.utf8.count <= 1024, components.count >= 2, + components.allSatisfy({ !$0.isEmpty }) + else { return false } + return type.unicodeScalars.allSatisfy { scalar in + let value = scalar.value + return value > 127 || (65...90).contains(value) || (97...122).contains(value) + || (48...57).contains(value) || value == 45 || value == 46 + } +} + +func clipboardSizeError(_ maxBytes: Int) -> CLIError { + CLIError( + code: .validationError, message: "Clipboard content exceeds --max-bytes.", + details: ["max_bytes": "\(maxBytes)"]) +} + +private struct ClipboardItemsInput: Decodable { + struct Payload: Decodable { + var items: [ClipboardItem] + var truncated: Bool? + var filtered: Bool? + } + var payload: Payload + private enum CodingKeys: String, CodingKey { case ok, data } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + if container.contains(.data) { + guard try container.decode(Bool.self, forKey: .ok) else { + throw CLIError( + code: .validationError, message: "Clipboard input must be a successful snapshot.") + } + payload = try container.decode(Payload.self, forKey: .data) + } else { + payload = try Payload(from: decoder) + } + } +} + +func readClipboardInput(_ path: String, maxBytes: Int) throws -> [ClipboardItem] { + try ClipboardLimits.validate(maxBytes: maxBytes) + let expandedPath = (path as NSString).expandingTildeInPath + let descriptor = open(expandedPath, O_RDONLY | O_NOFOLLOW | O_NONBLOCK) + guard descriptor >= 0 else { + throw CLIError( + code: .validationError, message: "Clipboard input must be a readable regular JSON file.") + } + let handle = FileHandle(fileDescriptor: descriptor, closeOnDealloc: true) + defer { try? handle.close() } + var metadata = stat() + let encodedLimit = ((maxBytes + 2) / 3) * 4 + 2_097_152 + guard fstat(descriptor, &metadata) == 0, metadata.st_mode & S_IFMT == S_IFREG, + metadata.st_size >= 0, metadata.st_size <= encodedLimit + else { + throw CLIError( + code: .validationError, message: "Clipboard input is not a bounded regular JSON file.") + } + do { + var input = Data() + while let chunk = try handle.read(upToCount: min(65_536, encodedLimit + 1 - input.count)), + !chunk.isEmpty + { + input.append(chunk) + guard input.count <= encodedLimit else { throw clipboardSizeError(maxBytes) } + } + var after = stat() + guard fstat(descriptor, &after) == 0, after.st_size == metadata.st_size, + after.st_mtimespec.tv_sec == metadata.st_mtimespec.tv_sec, + after.st_mtimespec.tv_nsec == metadata.st_mtimespec.tv_nsec + else { + throw CLIError( + code: .unsafeMutationRefused, message: "Clipboard input changed while being read.") + } + let payload = try JSONDecoder().decode(ClipboardItemsInput.self, from: input).payload + guard payload.truncated != true, payload.filtered != true else { + throw CLIError( + code: .validationError, + message: "A truncated or filtered clipboard snapshot cannot be used as a replacement.") + } + return try validatedClipboardItems(payload.items, maxBytes: maxBytes) + } catch let error as CLIError { + throw error + } catch { + throw CLIError( + code: .validationError, + message: "Clipboard input must contain items with type and dataBase64 representations.") + } } func sha256Hex(_ value: String) -> String { - let digest = SHA256.hash(data: Data(value.utf8)) - return digest.map { String(format: "%02x", $0) }.joined() + sha256Hex(Data(value.utf8)) +} + +func sha256Hex(_ value: Data) -> String { + SHA256.hash(data: value).map { String(format: "%02x", $0) }.joined() } diff --git a/Sources/FinderCLI/Backends.swift b/Sources/FinderCLI/Backends.swift index 1f03d28..9868146 100644 --- a/Sources/FinderCLI/Backends.swift +++ b/Sources/FinderCLI/Backends.swift @@ -38,10 +38,8 @@ public struct FileManagerFinderBackend: FinderReading, FinderMutating { { let root = try directoryURL(path) let normalizedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines) - return try directoryItems(root: root, includeHidden: includeHidden, limit: limit) - .filter { $0.name.localizedCaseInsensitiveContains(normalizedQuery) } - .prefix(limit) - .map { $0 } + return try directoryItems( + root: root, includeHidden: includeHidden, limit: limit, query: normalizedQuery) } public func readMetadata(path: String) throws -> FinderItemRecord? { @@ -188,7 +186,7 @@ public struct FileManagerFinderBackend: FinderReading, FinderMutating { return try itemRecord(url) } - private func directoryItems(root: URL, includeHidden: Bool, limit: Int) throws + private func directoryItems(root: URL, includeHidden: Bool, limit: Int, query: String? = nil) throws -> [FinderItemRecord] { let options: FileManager.DirectoryEnumerationOptions = includeHidden ? [] : [.skipsHiddenFiles] @@ -202,6 +200,9 @@ public struct FileManagerFinderBackend: FinderReading, FinderMutating { $0.lastPathComponent.localizedCaseInsensitiveCompare($1.lastPathComponent) == .orderedAscending } + .filter { url in + query.map { url.lastPathComponent.localizedCaseInsensitiveContains($0) } ?? true + } .prefix(limit) return try urls.map(itemRecord) diff --git a/Sources/IntelligenceCLI/Backends.swift b/Sources/IntelligenceCLI/Backends.swift index c515bcb..b47dd8f 100644 --- a/Sources/IntelligenceCLI/Backends.swift +++ b/Sources/IntelligenceCLI/Backends.swift @@ -56,6 +56,7 @@ public struct IntelligenceSubprocessRunner: IntelligenceSystemActionRunning { public struct IntelligenceBackend { private let fileManager: FileManager private let systemRunner: any IntelligenceSystemActionRunning + private let cacheWriter: any IntelligenceCacheFileWriting public init( fileManager: FileManager = .default, @@ -63,6 +64,17 @@ public struct IntelligenceBackend { ) { self.fileManager = fileManager self.systemRunner = systemRunner + self.cacheWriter = IntelligenceAtomicCacheWriter() + } + + init( + fileManager: FileManager = .default, + systemRunner: any IntelligenceSystemActionRunning = IntelligenceSubprocessRunner(), + cacheWriter: any IntelligenceCacheFileWriting + ) { + self.fileManager = fileManager + self.systemRunner = systemRunner + self.cacheWriter = cacheWriter } public func support() -> IntelligenceSupportAssessment { @@ -131,92 +143,97 @@ public struct IntelligenceBackend { skipLock: Bool ) throws -> IntelligenceOperationResult { let rules = intelligenceRules(patchScope: patchScope) - var targets = Array(Set(rules.map { targetPath(for: $0.file, paths: paths) })).sorted() - if eligibilityCountry != nil { - targets.append(paths.countrydPlist) - } - + let preparation = try prepareEnable( + paths: paths, rules: rules, country: eligibilityCountry, createMissing: createMissing) + let writes = preparation.writes + for write in writes { try write.requireOriginal() } + let targets = writes.map(\.path) + let snapshots = Dictionary(uniqueKeysWithValues: writes.compactMap { write in + write.originalData.map { (write.path, $0) } + }) let state = try createBackupState( - paths: paths, - targets: targets.filter { fileManager.fileExists(atPath: $0) }, + paths: paths, targets: Array(snapshots.keys), metadata: [ - "operation": "intelligence.enable", - "patch_scope": patchScope.rawValue, + "operation": "intelligence.enable", "patch_scope": patchScope.rawValue, "eligibility_country": eligibilityCountry ?? "", - ] - ) - - var actions = state.actions - actions.append(contentsOf: lockActions(targets, unlock: true, skipLock: skipLock)) - - let grouped = Dictionary(grouping: rules, by: { targetPath(for: $0.file, paths: paths) }) - for (target, patchRules) in grouped.sorted(by: { $0.key < $1.key }) { - if !fileManager.fileExists(atPath: target) { - if createMissing { + ], snapshots: snapshots) + for write in writes { try write.requireOriginal() } + + var actions = state.actions + preparation.skippedActions + let unlock = lockActions(targets, unlock: true, skipLock: skipLock) + actions.append(contentsOf: unlock) + var warnings: [String] = eligibilityCountry == nil ? [] : [ + "Changing the eligibility country cache may affect iPhone Mirroring expectations; pair iPhone Mirroring before changing the eligibility country when that workflow matters." + ] + var attempted: [IntelligencePreparedCacheWrite] = [] + var activePath: String? + do { + guard !unlock.contains(where: { $0.status == "failed" }) else { + throw CLIError(code: .unsafeMutationRefused, message: "Eligibility cache unlock failed.") + } + for write in writes { + activePath = write.path + try write.requireOriginal() + if write.updatedData != write.originalData { try fileManager.createDirectory( - at: URL(fileURLWithPath: target).deletingLastPathComponent(), - withIntermediateDirectories: true - ) - try writePlist([String: Any](), to: target, binary: true) - actions.append( - IntelligenceActionResult( - kind: "plistCreate", - status: "created", - path: target, - mechanism: IntelligenceMechanism.directPlistPatch.rawValue - )) - } else { - for rule in patchRules { - actions.append( - IntelligenceActionResult( - kind: "plistPatch", - status: "missing", - path: target, - domain: rule.domain.rawValue, - key: rule.keyPath.joined(separator: ":"), - current: "\(rule.value)", - mechanism: rule.mechanism.rawValue - )) - } - continue + at: URL(fileURLWithPath: write.path).deletingLastPathComponent(), + withIntermediateDirectories: true) + attempted.append(write) + try cacheWriter.write(write.updatedData, to: URL(fileURLWithPath: write.path)) } + try write.verifyReadback(Data(contentsOf: URL(fileURLWithPath: write.path))) + actions.append(contentsOf: write.actions) } - - let plist = try mutableDictionary(from: target) - for rule in patchRules { - actions.append( - setNested( - plist, - keyPath: rule.keyPath, - value: rule.value, - createMissing: createMissing, - path: target, - domain: rule.domain.rawValue, - mechanism: rule.mechanism - )) + // Confirm the group again: a daemon can replace an earlier file during a later write. + for write in writes { + activePath = write.path + try write.verifyReadback(Data(contentsOf: URL(fileURLWithPath: write.path))) } - try writePlist(plist, to: target, binary: true) - } - - var warnings: [String] = [] - if let eligibilityCountry { - actions.append( - contentsOf: try rewriteCountryCache( - path: paths.countrydPlist, - country: eligibilityCountry - )) - warnings.append( - "Changing the eligibility country cache may affect iPhone Mirroring expectations; pair iPhone Mirroring before changing the eligibility country when that workflow matters." - ) + } catch { + actions.append(IntelligenceActionResult( + kind: "cacheWrite", status: "failed", path: activePath, + mechanism: IntelligenceMechanism.directPlistPatch.rawValue, + detail: (error as? CLIError)?.message ?? "Cache write or readback failed.")) + for write in attempted.reversed() { + do { + if try write.matchesOriginal() { continue } + // Only restore bytes still attributable to this operation; preserve a concurrent writer. + guard try Data(contentsOf: URL(fileURLWithPath: write.path)) == write.updatedData else { + actions.append(IntelligenceActionResult( + kind: "rollbackRestore", status: "unconfirmed", path: write.path, + mechanism: IntelligenceMechanism.rollbackRestore.rawValue, + detail: "Current bytes differ from this operation's write; automatic restore was refused.")) + warnings.append("A cache changed concurrently; inspect the backup before manual rollback.") + continue + } + if let original = write.originalData { + try cacheWriter.write(original, to: URL(fileURLWithPath: write.path)) + } else { + try fileManager.removeItem(atPath: write.path) + } + guard try write.matchesOriginal() else { + throw CLIError(code: .backendUnavailable, message: "Cache rollback readback failed.") + } + actions.append(IntelligenceActionResult( + kind: "rollbackRestore", status: "restored", path: write.path, + mechanism: IntelligenceMechanism.rollbackRestore.rawValue)) + } catch { + actions.append(IntelligenceActionResult( + kind: "rollbackRestore", status: "failed", path: write.path, + mechanism: IntelligenceMechanism.rollbackRestore.rawValue, + detail: "Automatic restore could not be confirmed; inspect the saved backup.")) + } + } + actions.append(contentsOf: lockActions(targets, unlock: false, skipLock: skipLock)) + let remainingChanges = attempted.contains { (try? $0.matchesOriginal()) != true } + return operationResult( + operation: "intelligence.enable", actions: actions, state: state.state, + warnings: warnings, changed: remainingChanges) } - actions.append(contentsOf: lockActions(targets, unlock: false, skipLock: skipLock)) return operationResult( - operation: "intelligence.enable", - actions: actions, - state: state.state, - warnings: warnings - ) + operation: "intelligence.enable", actions: actions, state: state.state, + warnings: warnings, changed: writes.contains { $0.originalData != $0.updatedData }) } public func resetCache(paths: IntelligencePaths, kickstart: Bool, skipLock: Bool) throws @@ -285,17 +302,31 @@ public struct IntelligenceBackend { let manifest = try JSONDecoder().decode(IntelligenceStateManifest.self, from: data) try validateManifestConfinement(manifest, manifestPath: selected.manifestPath, paths: paths) + let restoreData = try manifest.backups.map { row in + let data = try Data(contentsOf: URL(fileURLWithPath: row.backup)) + let digest = SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + guard digest == row.sha256 else { + throw intelligenceError( + code: .unsafeMutationRefused, failure: .backupDigestMismatch, details: ["path": row.backup]) + } + return (row, data) + } + let targets = manifest.backups.map(\.target) var actions = lockActions(targets, unlock: true, skipLock: skipLock) - for row in manifest.backups { + guard !actions.contains(where: { $0.status == "failed" }) else { + throw CLIError(code: .unsafeMutationRefused, message: "Eligibility cache unlock failed.") + } + for (row, data) in restoreData { try fileManager.createDirectory( at: URL(fileURLWithPath: row.target).deletingLastPathComponent(), withIntermediateDirectories: true ) - if fileManager.fileExists(atPath: row.target) { - try fileManager.removeItem(atPath: row.target) + try cacheWriter.write(data, to: URL(fileURLWithPath: row.target)) + guard try Data(contentsOf: URL(fileURLWithPath: row.target)) == data else { + throw intelligenceError( + code: .backendUnavailable, failure: .cacheVerificationFailed, details: ["path": row.target]) } - try fileManager.copyItem(atPath: row.backup, toPath: row.target) actions.append( IntelligenceActionResult( kind: "rollbackRestore", @@ -472,41 +503,74 @@ public struct IntelligenceBackend { } } - private func rewriteCountryCache(path: String, country: String) throws - -> [IntelligenceActionResult] - { - guard fileManager.fileExists(atPath: path) else { - return [ - IntelligenceActionResult( - kind: "countryRewrite", - status: "missing", - path: path, - current: country, + private func prepareEnable( + paths: IntelligencePaths, rules: [IntelligencePatchRule], country: String?, createMissing: Bool + ) throws -> (writes: [IntelligencePreparedCacheWrite], skippedActions: [IntelligenceActionResult]) { + var countryWrite: IntelligencePreparedCacheWrite? + if let country { + guard fileManager.fileExists(atPath: paths.countrydPlist) else { + throw intelligenceError( + code: .unsafeMutationRefused, failure: .countryCacheMissing, + details: ["path": paths.countrydPlist]) + } + let data = try Data(contentsOf: URL(fileURLWithPath: paths.countrydPlist)) + let rewrite = try IntelligenceCountryCacheRewrite(data: data, country: country) + countryWrite = IntelligencePreparedCacheWrite( + path: paths.countrydPlist, originalData: data, updatedData: rewrite.updatedData, + actions: [IntelligenceActionResult( + kind: "countryRewrite", status: rewrite.changedEstimateCount > 0 ? "changed" : "unchanged", + path: paths.countrydPlist, current: country, mechanism: IntelligenceMechanism.countryCacheRewrite.rawValue, - subsystem: "countryd" - ) - ] + detail: "changed_estimates=\(rewrite.changedEstimateCount); verification=verified", + subsystem: "countryd")], countryRewrite: rewrite) } - let plist = try loadPlist(path) - let rewrite = rewriteCountryCodeStrings(plist, country: country) - try writePlist(rewrite.value, to: path, binary: true) - return [ - IntelligenceActionResult( - kind: "countryRewrite", - status: rewrite.changedCount > 0 ? "changed" : "unchanged", - path: path, - current: country, - mechanism: IntelligenceMechanism.countryCacheRewrite.rawValue, - detail: "changed_values=\(rewrite.changedCount)", - subsystem: "countryd" - ) - ] + var writes: [IntelligencePreparedCacheWrite] = [] + var skipped: [IntelligenceActionResult] = [] + let grouped = Dictionary(grouping: rules, by: { targetPath(for: $0.file, paths: paths) }) + for (path, patchRules) in grouped.sorted(by: { $0.key < $1.key }) { + let exists = fileManager.fileExists(atPath: path) + if !exists && !createMissing { + skipped += patchRules.map { + IntelligenceActionResult( + kind: "plistPatch", status: "missing", path: path, domain: $0.domain.rawValue, + key: $0.keyPath.joined(separator: ":"), current: "\($0.value)", mechanism: $0.mechanism.rawValue) + } + continue + } + let original = exists ? try Data(contentsOf: URL(fileURLWithPath: path)) : nil + let plist: NSMutableDictionary + if let original { + guard let dictionary = try PropertyListSerialization.propertyList( + from: original, options: [], format: nil) as? [String: Any] + else { + throw intelligenceError( + code: .validationError, failure: .plistRootNotDictionary, details: ["path": path]) + } + plist = NSMutableDictionary(dictionary: dictionary) + } else { plist = NSMutableDictionary() } + var actions: [IntelligenceActionResult] = exists ? [] : [IntelligenceActionResult( + kind: "plistCreate", status: "created", path: path, + mechanism: IntelligenceMechanism.directPlistPatch.rawValue)] + for rule in patchRules { + actions.append(setNested( + plist, keyPath: rule.keyPath, value: rule.value, createMissing: createMissing, + path: path, domain: rule.domain.rawValue, mechanism: rule.mechanism)) + } + let data: Data + if let original, !actions.contains(where: { $0.status == "changed" }) { data = original } + else { data = try PropertyListSerialization.data(fromPropertyList: plist, format: .binary, options: 0) } + writes.append(IntelligencePreparedCacheWrite( + path: path, originalData: original, updatedData: data, actions: actions, countryRewrite: nil)) + } + if let countryWrite { writes.append(countryWrite) } + return (writes, skipped) } private func createBackupState( paths: IntelligencePaths, targets: [String], - metadata: [String: String] + metadata: [String: String], + snapshots: [String: Data]? = nil ) throws -> (state: IntelligenceOperationState?, actions: [IntelligenceActionResult]) { let stateID = backupID() let backupDir = URL(fileURLWithPath: paths.stateDir) @@ -527,7 +591,11 @@ public struct IntelligenceBackend { if fileManager.fileExists(atPath: destination.path) { try fileManager.removeItem(at: destination) } - try fileManager.copyItem(atPath: target, toPath: destination.path) + if let snapshot = snapshots?[target] { + try snapshot.write(to: destination, options: .atomic) + } else { + try fileManager.copyItem(atPath: target, toPath: destination.path) + } let digest = try sha256Hex(path: destination.path) entries.append(IntelligenceBackupEntry(target: target, backup: destination.path, sha256: digest)) actions.append( @@ -734,7 +802,8 @@ public struct IntelligenceBackend { actions: [IntelligenceActionResult], state: IntelligenceOperationState?, rollback: IntelligenceRollbackHint? = nil, - warnings: [String] = [] + warnings: [String] = [], + changed changedOverride: Bool? = nil ) -> IntelligenceOperationResult { let status: IntelligenceOperationStatus if actions.contains(where: { $0.status == "failed" }) { @@ -745,7 +814,7 @@ public struct IntelligenceBackend { status = .succeeded } let changedStatuses = Set(["changed", "created", "removed", "restored", "copied"]) - let changed = actions.contains { changedStatuses.contains($0.status) } + let changed = changedOverride ?? actions.contains { $0.kind != "backupCreate" && changedStatuses.contains($0.status) } let rollbackHint = rollback ?? state.map { @@ -837,21 +906,6 @@ private func loadPlist(_ path: String) throws -> Any { return try PropertyListSerialization.propertyList(from: data, options: [], format: nil) } -private func mutableDictionary(from path: String) throws -> NSMutableDictionary { - let plist = try loadPlist(path) - if let dict = plist as? NSMutableDictionary { - return dict - } - if let dict = plist as? [String: Any] { - return NSMutableDictionary(dictionary: dict) - } - throw intelligenceError( - code: .validationError, - failure: .plistRootNotDictionary, - details: ["path": path] - ) -} - private func writePlist(_ plist: Any, to path: String, binary: Bool) throws { let format: PropertyListSerialization.PropertyListFormat = binary ? .binary : .xml let data = try PropertyListSerialization.data(fromPropertyList: plist, format: format, options: 0) @@ -940,36 +994,6 @@ private func setNested( ) } -private func rewriteCountryCodeStrings(_ value: Any, country: String) -> (value: Any, changedCount: Int) { - if let dict = value as? [String: Any] { - var result: [String: Any] = [:] - var count = 0 - for (key, child) in dict { - let rewritten = rewriteCountryCodeStrings(child, country: country) - result[key] = rewritten.value - count += rewritten.changedCount - } - return (result, count) - } - if let array = value as? [Any] { - var count = 0 - let result = array.map { child -> Any in - let rewritten = rewriteCountryCodeStrings(child, country: country) - count += rewritten.changedCount - return rewritten.value - } - return (result, count) - } - if let string = value as? String, isUppercaseCountryCode(string) { - return (country, string == country ? 0 : 1) - } - return (value, 0) -} - -private func isUppercaseCountryCode(_ value: String) -> Bool { - value.count == 2 && value.allSatisfy { $0 >= "A" && $0 <= "Z" } -} - private func stringify(_ value: Any) -> String { if let value = value as? String { return value } if let value = value as? NSNumber { return value.stringValue } diff --git a/Sources/IntelligenceCLI/CacheWrite.swift b/Sources/IntelligenceCLI/CacheWrite.swift new file mode 100644 index 0000000..7764dae --- /dev/null +++ b/Sources/IntelligenceCLI/CacheWrite.swift @@ -0,0 +1,40 @@ +import Foundation +import Utility + +protocol IntelligenceCacheFileWriting { + func write(_ data: Data, to url: URL) throws +} + +struct IntelligenceAtomicCacheWriter: IntelligenceCacheFileWriting { + func write(_ data: Data, to url: URL) throws { try data.write(to: url, options: .atomic) } +} + +struct IntelligencePreparedCacheWrite { + let path: String + let originalData: Data? + let updatedData: Data + let actions: [IntelligenceActionResult] + let countryRewrite: IntelligenceCountryCacheRewrite? + + func matchesOriginal() throws -> Bool { + let url = URL(fileURLWithPath: path) + guard FileManager.default.fileExists(atPath: path) else { return originalData == nil } + return try Data(contentsOf: url) == originalData + } + + func requireOriginal() throws { + guard try matchesOriginal() else { + throw intelligenceError( + code: .unsafeMutationRefused, failure: .cacheChangedSincePreflight, details: ["path": path]) + } + } + + func verifyReadback(_ data: Data) throws { + if let countryRewrite { try countryRewrite.verify(data) } + guard data == updatedData else { + throw intelligenceError( + code: .backendUnavailable, failure: .cacheVerificationFailed, + details: ["path": path, "verification": "unconfirmed"]) + } + } +} diff --git a/Sources/IntelligenceCLI/CountryCache.swift b/Sources/IntelligenceCLI/CountryCache.swift new file mode 100644 index 0000000..8de0a6f --- /dev/null +++ b/Sources/IntelligenceCLI/CountryCache.swift @@ -0,0 +1,304 @@ +import CoreFoundation +import Foundation +import Utility + +struct IntelligenceCountryCacheRewrite { + let originalData: Data + let updatedData: Data + let changedEstimateCount: Int + private let expectedValue: CountryArchiveValue + private let country: String + + init(data: Data, country: String) throws { + guard try validatedCountryCode(country) == country else { + throw intelligenceError(code: .validationError, failure: .invalidCountryCode) + } + let archive = try CountryArchive(data: data) + var envelope = archive.envelope + var objects = archive.objects + var root = archive.root + var combined = archive.combined + var references = archive.estimates + var replacements: [Int: Int] = [:] + var countryIndex: Int? + var changedCount = 0 + for (position, estimateIndex) in archive.estimateIndices.enumerated() { + let estimate = try archive.dictionary(at: estimateIndex) + guard try archive.countryCode(in: estimate) != country else { continue } + changedCount += 1 + if let replacement = replacements[estimateIndex] { + references[position] = try archive.uid.make(replacement) + continue + } + let destinationCountry: Int + if let countryIndex { + destinationCountry = countryIndex + } else { + destinationCountry = objects.count + countryIndex = destinationCountry + objects.append(country) + } + var copy = estimate + copy["CountryCode"] = try archive.uid.make(destinationCountry) + let replacement = objects.count + objects.append(copy) + replacements[estimateIndex] = replacement + references[position] = try archive.uid.make(replacement) + } + if changedCount > 0 { + // Existing nodes may also belong to history, local observations, or another top-level root. + combined["NS.objects"] = references + let combinedIndex = objects.count + objects.append(combined) + root["CombinedEstimate"] = try archive.uid.make(combinedIndex) + let rootIndex = objects.count + objects.append(root) + var top = archive.top + top["root"] = try archive.uid.make(rootIndex) + envelope["$top"] = top + envelope["$objects"] = objects + } + let expected = try archive.uid.value(envelope) + let output = changedCount == 0 ? data : try PropertyListSerialization.data( + fromPropertyList: envelope, format: .binary, options: 0) + let decoded = try CountryArchive(data: output) + guard decoded.value == expected, + try decoded.activeCountries().allSatisfy({ $0 == country }), + Array(decoded.objectValues.prefix(archive.objects.count)) == archive.objectValues + else { throw countryCacheError("copy_preservation_failed") } + self.originalData = data + self.updatedData = output + self.changedEstimateCount = changedCount + self.expectedValue = expected + self.country = country + } + + func verify(_ data: Data) throws { + do { + let archive = try CountryArchive(data: data) + guard archive.value == expectedValue, + try archive.activeCountries().allSatisfy({ $0 == country }) + else { throw countryCacheError("readback_differs") } + } catch { + throw intelligenceError( + code: .backendUnavailable, failure: .countryCacheVerificationFailed, + details: ["verification": "unconfirmed"]) + } + } +} + +private struct CountryArchive { + let uid: CountryArchiveUID + let envelope: [String: Any] + let top: [String: Any] + let objects: [Any] + let objectValues: [CountryArchiveValue] + let value: CountryArchiveValue + let root: [String: Any] + let combined: [String: Any] + let estimates: [Any] + let estimateIndices: [Int] + + init(data: Data) throws { + guard data.count <= 8 * 1_024 * 1_024 else { throw countryCacheError("archive_size_limit") } + let uid = try CountryArchiveUID() + let plist: Any + do { + plist = try PropertyListSerialization.propertyList(from: data, options: [], format: nil) + } catch { throw countryCacheError("invalid_plist") } + guard let envelope = plist as? [String: Any], + envelope["$archiver"] as? String == "NSKeyedArchiver", + countryArchiveInteger(envelope["$version"]) == 100_000, + let objects = envelope["$objects"] as? [Any], + (1...4_096).contains(objects.count), objects[0] as? String == "$null", + let top = envelope["$top"] as? [String: Any], let rootReference = top["root"] + else { throw countryCacheError("unsupported_archive") } + let value = try uid.value(envelope) + let objectValues = try objects.map { try uid.value($0) } + try validateCountryArchiveGraph(value: value, objects: objectValues) + let rootIndex = try uid.requiredIndex(rootReference, objectCount: objects.count) + guard let root = objects[rootIndex] as? [String: Any], + countryArchiveInteger(root["Version"]) == 5 + else { throw countryCacheError("unsupported_cached_data_version") } + try validateCountryArchiveClass(root, name: "RDCachedData", objects: objects, uid: uid) + guard let combinedReference = root["CombinedEstimate"] else { + throw countryCacheError("missing_combined_estimate") + } + let combinedIndex = try uid.requiredIndex(combinedReference, objectCount: objects.count) + guard let combined = objects[combinedIndex] as? [String: Any], + let estimates = combined["NS.objects"] as? [Any], !estimates.isEmpty + else { throw countryCacheError("unavailable_combined_estimate") } + try validateCountryArchiveClass(combined, name: "NSArray", objects: objects, uid: uid) + let indices = try estimates.map { try uid.requiredIndex($0, objectCount: objects.count) } + for index in indices { + guard let estimate = objects[index] as? [String: Any], let reference = estimate["CountryCode"] else { + throw countryCacheError("invalid_estimate") + } + try validateCountryArchiveClass(estimate, name: "RDEstimate", objects: objects, uid: uid) + let countryIndex = try uid.requiredIndex(reference, objectCount: objects.count) + guard let country = objects[countryIndex] as? String, isCountryArchiveCode(country) else { + throw countryCacheError("invalid_country_field") + } + } + self.uid = uid + self.envelope = envelope + self.top = top + self.objects = objects + self.value = value + self.objectValues = objectValues + self.root = root + self.combined = combined + self.estimates = estimates + self.estimateIndices = indices + } + + func dictionary(at index: Int) throws -> [String: Any] { + guard let record = objects[index] as? [String: Any] else { throw countryCacheError("invalid_estimate") } + return record + } + + func countryCode(in estimate: [String: Any]) throws -> String { + guard let reference = estimate["CountryCode"] else { throw countryCacheError("missing_country_field") } + let index = try uid.requiredIndex(reference, objectCount: objects.count) + guard let country = objects[index] as? String else { throw countryCacheError("invalid_country_field") } + return country + } + + func activeCountries() throws -> [String] { + try estimateIndices.map { try countryCode(in: dictionary(at: $0)) } + } +} + +private func validateCountryArchiveClass( + _ record: [String: Any], name: String, objects: [Any], uid: CountryArchiveUID +) throws { + guard let reference = record["$class"] else { throw countryCacheError("missing_class") } + let index = try uid.requiredIndex(reference, objectCount: objects.count) + guard let metadata = objects[index] as? [String: Any], + let className = metadata["$classname"] as? String, let hierarchy = metadata["$classes"] as? [String] + else { throw countryCacheError("invalid_class") } + let expected = name == "NSArray" && className == "NSMutableArray" + ? ["NSMutableArray", "NSArray", "NSObject"] : [name, "NSObject"] + guard hierarchy == expected, className == expected.first else { + throw countryCacheError("unsupported_class") + } +} + +private indirect enum CountryArchiveValue: Equatable { + case uid(Int), dictionary([String: CountryArchiveValue]), array([CountryArchiveValue]) + case string(String), data(Data), date(Date), boolean(Bool), integer(String), real(UInt64) + + var references: [Int] { + switch self { + case .uid(let index): return [index] + case .dictionary(let values): return values.values.flatMap(\.references) + case .array(let values): return values.flatMap(\.references) + default: return [] + } + } +} + +private struct CountryArchiveUID { + private let typeID: CFTypeID + + init() throws { self.typeID = CFGetTypeID(try Self.create(0) as AnyObject) } + func make(_ index: Int) throws -> Any { try Self.create(index) } + + private static func create(_ index: Int) throws -> Any { + guard (0...Int(UInt32.max)).contains(index) else { throw countryCacheError("invalid_uid") } + // The public plist XML bridge preserves native UIDs without instantiating archived classes. + let xml = try PropertyListSerialization.data( + fromPropertyList: ["reference": ["CF$UID": index]], format: .xml, options: 0) + guard let decoded = try PropertyListSerialization.propertyList( + from: xml, options: [], format: nil) as? [String: Any], let reference = decoded["reference"], + !(reference is [String: Any]) + else { throw countryCacheError("uid_codec_unavailable") } + return reference + } + + func index(_ object: Any) throws -> Int? { + guard CFGetTypeID(object as AnyObject) == typeID else { return nil } + let xml = try PropertyListSerialization.data( + fromPropertyList: ["reference": object], format: .xml, options: 0) + let document = try XMLDocument(data: xml, options: [.nodeLoadExternalEntitiesNever]) + let nodes = try document.nodes(forXPath: "/plist/dict/dict[key='CF$UID']/integer") + guard nodes.count == 1, let text = nodes[0].stringValue, let index = Int(text), + (0...Int(UInt32.max)).contains(index) + else { throw countryCacheError("invalid_uid") } + return index + } + + func requiredIndex(_ object: Any, objectCount: Int) throws -> Int { + guard let index = try index(object), (0.. CountryArchiveValue { + var remaining = 100_000 + return try value(object, depth: 0, remaining: &remaining) + } + + private func value(_ object: Any, depth: Int, remaining: inout Int) throws -> CountryArchiveValue { + guard depth <= 128, remaining > 0 else { throw countryCacheError("archive_structure_limit") } + remaining -= 1 + if let index = try index(object) { return .uid(index) } + if let dictionary = object as? [String: Any] { + guard dictionary["CF$UID"] == nil else { throw countryCacheError("non_native_uid") } + var result: [String: CountryArchiveValue] = [:] + for (key, child) in dictionary { + result[key] = try value(child, depth: depth + 1, remaining: &remaining) + } + return .dictionary(result) + } + if let array = object as? [Any] { + return .array(try array.map { try value($0, depth: depth + 1, remaining: &remaining) }) + } + if let string = object as? String { return .string(string) } + if let data = object as? Data { return .data(data) } + if let date = object as? Date { return .date(date) } + if let number = object as? NSNumber { + if CFGetTypeID(number) == CFBooleanGetTypeID() { return .boolean(number.boolValue) } + if ["d", "f"].contains(String(cString: number.objCType)) { + guard number.doubleValue.isFinite else { throw countryCacheError("invalid_number") } + return .real(number.doubleValue.bitPattern) + } + return .integer(number.stringValue) + } + throw countryCacheError("unsupported_value") + } +} + +private func validateCountryArchiveGraph(value: CountryArchiveValue, objects: [CountryArchiveValue]) throws { + let edges = objects.map(\.references) + guard value.references.allSatisfy({ (0.. Int? { + guard let number = object as? NSNumber, CFGetTypeID(number) != CFBooleanGetTypeID(), + !["d", "f"].contains(String(cString: number.objCType)) + else { return nil } + return Int(number.stringValue) +} + +private func isCountryArchiveCode(_ value: String) -> Bool { + value.utf8.count == 2 && value.utf8.allSatisfy { (65...90).contains($0) } +} + +private func countryCacheError(_ reason: String) -> CLIError { + intelligenceError( + code: .unsafeMutationRefused, failure: .countryCacheInvalid, details: ["reason": reason]) +} diff --git a/Sources/IntelligenceCLI/Wording.swift b/Sources/IntelligenceCLI/Wording.swift index cde07a6..67a4fb8 100644 --- a/Sources/IntelligenceCLI/Wording.swift +++ b/Sources/IntelligenceCLI/Wording.swift @@ -18,6 +18,12 @@ public enum IntelligenceFailure: String, Codable, CaseIterable, Sendable { case rollbackTargetEscapesRoot = "rollback_target_escapes_root" case rollbackBackupEscapesStateDir = "rollback_backup_escapes_state_dir" case plistRootNotDictionary = "plist_root_not_dictionary" + case countryCacheInvalid = "country_cache_invalid" + case countryCacheMissing = "country_cache_missing" + case countryCacheVerificationFailed = "country_cache_verification_failed" + case cacheChangedSincePreflight = "cache_changed_since_preflight" + case cacheVerificationFailed = "cache_verification_failed" + case backupDigestMismatch = "backup_digest_mismatch" case lldbPathNotExecutable = "lldb_path_not_executable" case lldbNotFound = "lldb_not_found" case rootRequiredForSystemWrite = "root_required_for_system_write" @@ -68,6 +74,18 @@ public enum IntelligenceWording { return "Intelligence rollback backup escapes state-dir." case .plistRootNotDictionary: return "Plist root is not a dictionary." + case .countryCacheInvalid: + return "The country cache has an unsupported or invalid archive structure." + case .countryCacheMissing: + return "The requested country cache is missing." + case .countryCacheVerificationFailed: + return "The country cache could not be confirmed by readback." + case .cacheChangedSincePreflight: + return "An eligibility cache changed since preflight; the prepared change was refused." + case .cacheVerificationFailed: + return "An eligibility cache could not be confirmed by readback." + case .backupDigestMismatch: + return "An eligibility backup does not match its saved digest; rollback was refused." case .lldbPathNotExecutable: return "Explicit lldb path is not executable." case .lldbNotFound: diff --git a/Sources/KeynoteCLI/Backends.swift b/Sources/KeynoteCLI/Backends.swift index 4a5481d..79a5449 100644 --- a/Sources/KeynoteCLI/Backends.swift +++ b/Sources/KeynoteCLI/Backends.swift @@ -1,6 +1,7 @@ import AppKit import CryptoKit import Foundation +import PDFKit import Utility @@ -19,7 +20,11 @@ public struct NSWorkspaceKeynoteExternalActions: KeynoteExternalActioning { } public struct FileManagerKeynoteBackend: KeynoteReading, KeynoteExporting { - public init() {} + private let native: KeynoteAppleScriptBackend + + public init() { native = KeynoteAppleScriptBackend() } + + init(native: KeynoteAppleScriptBackend) { self.native = native } public func listPresentations(path: String, limit: Int) throws -> [KeynotePresentationRecord] { let url = normalizedURL(path) @@ -47,10 +52,24 @@ public struct FileManagerKeynoteBackend: KeynoteReading, KeynoteExporting { -> [KeynotePresentationRecord] { let normalizedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines) - return try listPresentations(path: path, limit: 500) - .filter { $0.name.localizedCaseInsensitiveContains(normalizedQuery) } + let url = normalizedURL(path) + let candidates: [URL] + if isKeynotePresentation(url) { + candidates = [url] + } else { + candidates = try FileManager.default.contentsOfDirectory( + at: directoryURL(path), + includingPropertiesForKeys: Array(KeynoteResourceKeys.all), + options: [.skipsHiddenFiles] + ).filter(isKeynotePresentation) + } + return try candidates + .filter { $0.lastPathComponent.localizedCaseInsensitiveContains(normalizedQuery) } + .sorted { + $0.lastPathComponent.localizedCaseInsensitiveCompare($1.lastPathComponent) == .orderedAscending + } .prefix(limit) - .map { $0 } + .map(presentationRecord) } public func readPresentation(path: String) throws -> KeynotePresentationRecord? { @@ -60,7 +79,7 @@ public struct FileManagerKeynoteBackend: KeynoteReading, KeynoteExporting { } guard isKeynotePresentation(url) else { throw CLIError( - code: .validationError, message: "`--path` must identify a `.key` presentation package.", + code: .validationError, message: "`--path` must identify a `.key` presentation file or package.", details: ["path": path]) } return try presentationRecord(url) @@ -71,13 +90,38 @@ public struct FileManagerKeynoteBackend: KeynoteReading, KeynoteExporting { return nil } + let snapshot = try native.readSlides( + path: normalizedURL(presentation.path).resolvingSymlinksInPath().path, limit: limit) + let snapshotID = UUID().uuidString + let slides = snapshot.slides.map { slide in + KeynoteSlideRecord( + presentationPath: presentation.path, + index: slide.index, + id: "snapshot:\(snapshotID):\(slide.index)", + skipped: slide.skipped, + titleShowing: slide.titleShowing, + bodyShowing: slide.bodyShowing, + title: slide.title, + body: slide.body, + presenterNotes: slide.presenterNotes + ) + } + return KeynoteSlidesResponse( + presentation: presentation, slides: slides, documentID: snapshot.documentID, + snapshotID: snapshotID, totalSlideCount: snapshot.totalSlideCount, + truncated: slides.count < snapshot.totalSlideCount, readSource: snapshot.readSource + ) + } + + public func listPreviews(path: String, limit: Int) throws -> KeynotePreviewsResponse? { + guard let presentation = try readPresentation(path: path) else { return nil } let root = URL(fileURLWithPath: presentation.path).standardizedFileURL let quickLook = root.appendingPathComponent("QuickLook", isDirectory: true) var isDirectory: ObjCBool = false guard FileManager.default.fileExists(atPath: quickLook.path, isDirectory: &isDirectory), isDirectory.boolValue else { - return KeynoteSlidesResponse(presentation: presentation, slides: []) + return KeynotePreviewsResponse(presentation: presentation, previews: []) } let previews = try FileManager.default.contentsOfDirectory( @@ -85,22 +129,22 @@ public struct FileManagerKeynoteBackend: KeynoteReading, KeynoteExporting { includingPropertiesForKeys: Array(KeynoteResourceKeys.all), options: [.skipsHiddenFiles] ) - .filter(isSlidePreviewArtifact) + .filter(isPreviewArtifact) .sorted { $0.lastPathComponent.localizedStandardCompare($1.lastPathComponent) == .orderedAscending } .prefix(limit) - let slides = previews.enumerated().map { offset, preview in - KeynoteSlideRecord( + let records = previews.enumerated().map { offset, preview in + KeynotePreviewRecord( presentationPath: presentation.path, index: offset + 1, - id: keynoteSlideID(presentation: presentation, preview: preview, index: offset + 1), + id: "preview:\(sha256Hex(preview.path))", previewPath: preview.path ) } - return KeynoteSlidesResponse(presentation: presentation, slides: slides) + return KeynotePreviewsResponse(presentation: presentation, previews: records) } public func exportPresentation(path: String, format: String, to destinationPath: String) throws @@ -110,6 +154,11 @@ public struct FileManagerKeynoteBackend: KeynoteReading, KeynoteExporting { throw CLIError( code: .notFound, message: "Keynote presentation was not found.", details: ["path": path]) } + try validatePresentationArtifactRelationship( + source: normalizedURL(presentation.path), destination: normalizedURL(destinationPath)) + if format == "pdf" { + return try exportNativePDF(presentation, to: destinationPath) + } if format == "package" { guard presentation.isPackage else { throw CLIError( @@ -121,7 +170,7 @@ public struct FileManagerKeynoteBackend: KeynoteReading, KeynoteExporting { let source = normalizedURL(presentation.path) let destination = normalizedURL(destinationPath) try validatePresentationExportDestination(destination.path, format: format) - try validatePackageExportRelationship(source: source, destination: destination) + try validatePresentationArtifactRelationship(source: source, destination: destination) try FileManager.default.copyItem(at: source, to: destination) return KeynoteExportResult( operation: "keynote.export", @@ -133,14 +182,14 @@ public struct FileManagerKeynoteBackend: KeynoteReading, KeynoteExporting { } let sourcePath: String? switch format { - case "pdf": + case "preview-pdf": sourcePath = presentation.quickLookPreviewPath case "thumbnail": sourcePath = presentation.quickLookThumbnailPath default: throw CLIError( code: .unsupportedOperation, - message: "Keynote export currently supports only `pdf`, `thumbnail`, and `package`.") + message: "Keynote export supports `pdf`, `preview-pdf`, `thumbnail`, and `package`.") } guard let sourcePath else { throw CLIError( @@ -159,7 +208,72 @@ public struct FileManagerKeynoteBackend: KeynoteReading, KeynoteExporting { changed: true, sourcePath: presentation.path, destinationPath: destination.path, - format: format + format: format, + source: "quicklook_cache" + ) + } + + private func exportNativePDF( + _ presentation: KeynotePresentationRecord, to destinationPath: String + ) throws -> KeynoteExportResult { + let destination = normalizedURL(destinationPath) + try validatePresentationExportDestination(destination.path, format: "pdf") + let staging = destination.deletingLastPathComponent().appendingPathComponent( + ".apple-cli-keynote-\(UUID().uuidString).pdf") + var published = false + defer { if !published { try? FileManager.default.removeItem(at: staging) } } + let receipt: KeynoteNativePDFReceipt + do { + receipt = try native.exportPDF( + path: normalizedURL(presentation.path).resolvingSymlinksInPath().path, to: staging.path) + } catch let error as CLIError { + throw CLIError( + code: error.code, message: error.message, + details: error.details.merging([ + "artifact_outcome": "unconfirmed", "staging_path": staging.path, + "retry_policy": "inspect_before_retry", + ]) { _, new in new } + ) + } + let data: Data + do { + data = try Data(contentsOf: staging) + } catch { + throw CLIError(code: .backendUnavailable, message: "Keynote PDF export did not produce a readable artifact.") + } + guard data.starts(with: Data("%PDF-".utf8)), + let document = PDFDocument(data: data), !document.isEncrypted, + receipt.slideCount > 0, document.pageCount == receipt.slideCount + else { + throw CLIError( + code: .backendUnavailable, + message: "Keynote PDF export could not verify one page for each slide, including skipped slides." + ) + } + try validatePresentationExportDestination(destination.path, format: "pdf") + // A same-volume hard link publishes the verified file without replacing an existing destination. + do { + try FileManager.default.linkItem(at: staging, to: destination) + } catch { + throw CLIError( + code: FileManager.default.fileExists(atPath: destination.path) ? .validationError : .backendUnavailable, + message: "Could not publish the verified PDF without replacing an existing destination.", + details: CLIError.diagnosticDetails(for: error) + ) + } + published = true + var residualArtifactPaths: [String] = [] + do { + try FileManager.default.removeItem(at: staging) + } catch { + residualArtifactPaths = [staging.path] + } + return KeynoteExportResult( + operation: "keynote.export", changed: true, sourcePath: presentation.path, + destinationPath: destination.path, format: "pdf", source: "keynote_document", + documentID: receipt.documentID, readSource: receipt.readSource, + exportedSlideCount: receipt.slideCount, byteCount: data.count, + sha256: sha256Hex(data), verification: "verified", residualArtifactPaths: residualArtifactPaths ) } @@ -173,7 +287,7 @@ public struct FileManagerKeynoteBackend: KeynoteReading, KeynoteExporting { guard isDirectory.boolValue else { throw CLIError( code: .validationError, - message: "`--path` must identify a directory or `.key` presentation package.", + message: "`--path` must identify a directory or `.key` presentation.", details: ["path": path]) } return url @@ -193,9 +307,10 @@ public struct FileManagerKeynoteBackend: KeynoteReading, KeynoteExporting { guard url.pathExtension.lowercased() == "key" else { return false } - var isDirectory: ObjCBool = false - return FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) - && isDirectory.boolValue + guard let values = try? url.resourceValues(forKeys: [.isDirectoryKey, .isRegularFileKey]) else { + return false + } + return values.isDirectory == true || values.isRegularFile == true } private func presentationRecord(_ url: URL) throws -> KeynotePresentationRecord { @@ -216,7 +331,7 @@ public struct FileManagerKeynoteBackend: KeynoteReading, KeynoteExporting { return FileManager.default.fileExists(atPath: path) ? path : nil } - private func isSlidePreviewArtifact(_ url: URL) -> Bool { + private func isPreviewArtifact(_ url: URL) -> Bool { var isDirectory: ObjCBool = false guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory), !isDirectory.boolValue diff --git a/Sources/KeynoteCLI/Command.swift b/Sources/KeynoteCLI/Command.swift index e72ee9e..f3de2d0 100644 --- a/Sources/KeynoteCLI/Command.swift +++ b/Sources/KeynoteCLI/Command.swift @@ -57,29 +57,38 @@ public struct KeynoteCommand: Sendable { code: .notFound, message: "Keynote presentation was not found.", details: ["path": path]) } return try result(slides, human: slidesHumanOutput(slides), options: options) - case ["slides", "export"]: + case ["previews", "list"]: + try validateReadOnly(options) + try validateTargetOptions(options, allowedOptions: ["path"]) + let path = try requiredOption("path", options: options) + guard let previews = try backend.listPreviews(path: path, limit: try commandLimit(options)) else { + throw CLIError( + code: .notFound, message: "Keynote presentation was not found.", details: ["path": path]) + } + return try result(previews, human: previewsHumanOutput(previews), options: options) + case ["previews", "export"]: try validateTargetOptions(options, allowedOptions: ["path", "format", "to"]) - try validateMutationIntent(options, commandDescription: "Keynote slide export") - let format = try slideExportFormat(options) + try validateMutationIntent(options, commandDescription: "Keynote preview export") + let format = try previewExportFormat(options) let destinationPath = standardizedAbsolutePath(try requiredOption("to", options: options)) if options.dryRun { - try validateSlideExportDestination(destinationPath) + try validatePreviewExportDestination(destinationPath) } else { try CLISafety.requireFlag( "allow-artifact-action", in: options, category: .artifactAction, message: - "Keynote slide export writes filesystem artifacts and requires `--allow-artifact-action`." + "Keynote preview export writes filesystem artifacts and requires `--allow-artifact-action`." ) } let path = try requiredOption("path", options: options) - guard let slides = try backend.listSlides(path: path, limit: try commandLimit(options)) else { + guard let previews = try backend.listPreviews(path: path, limit: try commandLimit(options)) else { throw CLIError( code: .notFound, message: "Keynote presentation was not found.", details: ["path": path]) } - return try exportSlides( - slides, format: format, destinationPath: destinationPath, options: options) + return try exportPreviews( + previews, format: format, destinationPath: destinationPath, options: options) case ["presentations", "open"]: try validateTargetOptions(options, allowedOptions: ["path"]) if !options.dryRun { @@ -188,12 +197,10 @@ public struct KeynoteCommand: Sendable { "format": format, "destination_path": destinationPath, ] - if format == "package" { - try validatePackageExportRelationship( - source: URL(fileURLWithPath: presentation.path).standardizedFileURL, - destination: URL(fileURLWithPath: destinationPath).standardizedFileURL - ) - } + try validatePresentationArtifactRelationship( + source: URL(fileURLWithPath: presentation.path).standardizedFileURL, + destination: URL(fileURLWithPath: destinationPath).standardizedFileURL + ) if options.dryRun { try validateDryRunOptions(options) @@ -225,15 +232,15 @@ public struct KeynoteCommand: Sendable { ) } - private func exportSlides( - _ response: KeynoteSlidesResponse, + private func exportPreviews( + _ response: KeynotePreviewsResponse, format: String, destinationPath: String, options: CLIOptions ) throws -> CLICommandResult { - let operation = "keynote.slides-export" - let artifacts = try slideExportArtifacts(response, destinationPath: destinationPath) - let scopeDigest = slideExportScopeDigest( + let operation = "keynote.previews-export" + let artifacts = try previewExportArtifacts(response, destinationPath: destinationPath) + let scopeDigest = previewExportScopeDigest( response.presentation, artifacts: artifacts, format: format, @@ -244,8 +251,8 @@ public struct KeynoteCommand: Sendable { "name": response.presentation.name, "format": format, "destination_path": destinationPath, - "slides": "\(artifacts.count)", - "sha256": slideExportDigest(artifacts), + "previews": "\(artifacts.count)", + "sha256": previewExportDigest(artifacts), ] if options.dryRun { @@ -269,18 +276,18 @@ public struct KeynoteCommand: Sendable { in: options, category: .artifactAction, message: - "Keynote slide export writes filesystem artifacts and requires `--allow-artifact-action`." + "Keynote preview export writes filesystem artifacts and requires `--allow-artifact-action`." ) - try writeSlideExportArtifacts(artifacts, destinationPath: destinationPath) + try writePreviewExportArtifacts(artifacts, destinationPath: destinationPath) return try result( - KeynoteSlideExportResult( + KeynotePreviewExportResult( operation: operation, changed: true, sourcePath: response.presentation.path, destinationPath: destinationPath, format: format, - exportedSlideCount: artifacts.count, + exportedPreviewCount: artifacts.count, files: artifacts.map(\.file) ), human: "\(operation) executed", diff --git a/Sources/KeynoteCLI/Commands.swift b/Sources/KeynoteCLI/Commands.swift index 75efcb2..88d1ed6 100644 --- a/Sources/KeynoteCLI/Commands.swift +++ b/Sources/KeynoteCLI/Commands.swift @@ -4,14 +4,14 @@ import Utility public struct KeynoteTarget: ParsableCommand { public static let targetName = "keynote" public static let targetStatus = - "Implemented: Keynote presentation list/search/read metadata, QuickLook-backed slides list, slide image export, open, QuickLook PDF/thumbnail export, and package export paths; richer app-native export/write remain gated." + "Implemented: Keynote file/package metadata, native slide text/order/skipped/presenter notes, native PDF export, cached preview list/export, presentation open, and package copy; rich content and presentation writes remain gated." public static let isImplemented = true public static let configuration = CommandConfiguration( commandName: "keynote", abstract: "Keynote presentation and slide workflows.", version: CLIVersion.current, - subcommands: [Presentations.self, Slides.self, Doctor.self] + subcommands: [Presentations.self, Slides.self, Previews.self, Doctor.self] ) @OptionGroup public var shared: CLISharedOptions @@ -78,7 +78,7 @@ public struct KeynoteTarget: ParsableCommand { public struct Slides: ParsableCommand { public static let configuration = CommandConfiguration( commandName: "slides", - subcommands: [List.self, Export.self] + subcommands: [List.self] ) public init() {} @@ -89,10 +89,27 @@ public struct KeynoteTarget: ParsableCommand { @OptionGroup public var targetOptions: KeynoteTargetOptions public init() {} } + } + + public struct Previews: ParsableCommand { + public static let configuration = CommandConfiguration( + commandName: "previews", + abstract: "Inspect or copy cached QuickLook image previews.", + subcommands: [List.self, Export.self] + ) + public init() {} + + public struct List: Leaf { + public static let configuration = CommandConfiguration(commandName: "list") + public static let positionals = ["previews", "list"] + @OptionGroup public var shared: CLISharedOptions + @OptionGroup public var targetOptions: KeynoteTargetOptions + public init() {} + } public struct Export: Leaf { public static let configuration = CommandConfiguration(commandName: "export") - public static let positionals = ["slides", "export"] + public static let positionals = ["previews", "export"] @OptionGroup public var shared: CLISharedOptions @OptionGroup public var targetOptions: KeynoteTargetOptions public init() {} @@ -195,7 +212,7 @@ public func keynoteDoctorChecks() -> [CLIDoctorCheck] { name: "keynote_presentation_backend", status: .ok, message: - "Path-bounded Keynote presentation metadata, slide list, slide image export, QuickLook PDF/thumbnail export, and package export commands are implemented." + "Path-bounded presentation metadata and cached previews are available; native slide reads and PDF export require Keynote Automation permission." ), ] } diff --git a/Sources/KeynoteCLI/Models.swift b/Sources/KeynoteCLI/Models.swift index 17ec16b..954122c 100644 --- a/Sources/KeynoteCLI/Models.swift +++ b/Sources/KeynoteCLI/Models.swift @@ -43,28 +43,89 @@ public struct KeynoteSlideRecord: Codable, Equatable, Sendable { public var presentationPath: String public var index: Int public var id: String - public var previewPath: String? + public var identityKind: String + public var skipped: Bool + public var titleShowing: Bool + public var bodyShowing: Bool + public var title: String? + public var body: String? + public var presenterNotes: String? public init( presentationPath: String, index: Int, id: String, - previewPath: String? = nil + skipped: Bool, + titleShowing: Bool, + bodyShowing: Bool, + title: String? = nil, + body: String? = nil, + presenterNotes: String? = nil ) { self.presentationPath = presentationPath self.index = index self.id = id - self.previewPath = previewPath + self.identityKind = "snapshot_position" + self.skipped = skipped + self.titleShowing = titleShowing + self.bodyShowing = bodyShowing + self.title = title + self.body = body + self.presenterNotes = presenterNotes } } public struct KeynoteSlidesResponse: Codable, Equatable, Sendable { public var presentation: KeynotePresentationRecord public var slides: [KeynoteSlideRecord] + public var documentID: String + public var snapshotID: String + public var totalSlideCount: Int + public var truncated: Bool + public var readSource: String - public init(presentation: KeynotePresentationRecord, slides: [KeynoteSlideRecord]) { + public init( + presentation: KeynotePresentationRecord, + slides: [KeynoteSlideRecord], + documentID: String, + snapshotID: String, + totalSlideCount: Int, + truncated: Bool, + readSource: String + ) { self.presentation = presentation self.slides = slides + self.documentID = documentID + self.snapshotID = snapshotID + self.totalSlideCount = totalSlideCount + self.truncated = truncated + self.readSource = readSource + } +} + +public struct KeynotePreviewRecord: Codable, Equatable, Sendable { + public var presentationPath: String + public var index: Int + public var id: String + public var previewPath: String + + public init(presentationPath: String, index: Int, id: String, previewPath: String) { + self.presentationPath = presentationPath + self.index = index + self.id = id + self.previewPath = previewPath + } +} + +public struct KeynotePreviewsResponse: Codable, Equatable, Sendable { + public var presentation: KeynotePresentationRecord + public var previews: [KeynotePreviewRecord] + public var source: String + + public init(presentation: KeynotePresentationRecord, previews: [KeynotePreviewRecord]) { + self.presentation = presentation + self.previews = previews + self.source = "quicklook_cache" } } @@ -74,24 +135,48 @@ public struct KeynoteExportResult: Codable, Equatable, Sendable { public var sourcePath: String public var destinationPath: String public var format: String + public var source: String + public var documentID: String? + public var readSource: String? + public var exportedSlideCount: Int? + public var byteCount: Int? + public var sha256: String? + public var verification: String? + public var residualArtifactPaths: [String] public init( operation: String, changed: Bool, sourcePath: String, destinationPath: String, - format: String + format: String, + source: String = "filesystem", + documentID: String? = nil, + readSource: String? = nil, + exportedSlideCount: Int? = nil, + byteCount: Int? = nil, + sha256: String? = nil, + verification: String? = nil, + residualArtifactPaths: [String] = [] ) { self.operation = operation self.changed = changed self.sourcePath = sourcePath self.destinationPath = destinationPath self.format = format + self.source = source + self.documentID = documentID + self.readSource = readSource + self.exportedSlideCount = exportedSlideCount + self.byteCount = byteCount + self.sha256 = sha256 + self.verification = verification + self.residualArtifactPaths = residualArtifactPaths } } -public struct KeynoteSlideExportFile: Codable, Equatable, Sendable { - public var slideID: String +public struct KeynotePreviewExportFile: Codable, Equatable, Sendable { + public var previewID: String public var index: Int public var sourcePath: String public var destinationPath: String @@ -99,14 +184,14 @@ public struct KeynoteSlideExportFile: Codable, Equatable, Sendable { public var sha256: String public init( - slideID: String, + previewID: String, index: Int, sourcePath: String, destinationPath: String, byteCount: Int, sha256: String ) { - self.slideID = slideID + self.previewID = previewID self.index = index self.sourcePath = sourcePath self.destinationPath = destinationPath @@ -115,14 +200,15 @@ public struct KeynoteSlideExportFile: Codable, Equatable, Sendable { } } -public struct KeynoteSlideExportResult: Codable, Equatable, Sendable { +public struct KeynotePreviewExportResult: Codable, Equatable, Sendable { public var operation: String public var changed: Bool public var sourcePath: String public var destinationPath: String public var format: String - public var exportedSlideCount: Int - public var files: [KeynoteSlideExportFile] + public var exportedPreviewCount: Int + public var source: String + public var files: [KeynotePreviewExportFile] public init( operation: String, @@ -130,15 +216,16 @@ public struct KeynoteSlideExportResult: Codable, Equatable, Sendable { sourcePath: String, destinationPath: String, format: String, - exportedSlideCount: Int, - files: [KeynoteSlideExportFile] + exportedPreviewCount: Int, + files: [KeynotePreviewExportFile] ) { self.operation = operation self.changed = changed self.sourcePath = sourcePath self.destinationPath = destinationPath self.format = format - self.exportedSlideCount = exportedSlideCount + self.exportedPreviewCount = exportedPreviewCount + self.source = "quicklook_cache" self.files = files } } diff --git a/Sources/KeynoteCLI/NativeContent.swift b/Sources/KeynoteCLI/NativeContent.swift new file mode 100644 index 0000000..630f121 --- /dev/null +++ b/Sources/KeynoteCLI/NativeContent.swift @@ -0,0 +1,247 @@ +import AppKit +import Carbon +import Foundation +import Utility + +struct KeynoteNativeSlide: Equatable, Sendable { + var index: Int + var skipped: Bool + var titleShowing: Bool + var bodyShowing: Bool + var title: String? + var body: String? + var presenterNotes: String? +} + +struct KeynoteNativeSnapshot: Sendable { + var documentID: String + var readSource: String + var totalSlideCount: Int + var slides: [KeynoteNativeSlide] +} + +struct KeynoteNativePDFReceipt: Sendable { + var documentID: String + var readSource: String + var slideCount: Int +} + +struct KeynoteAppleScriptBackend: Sendable { + private let executeScript: @Sendable (String) throws -> NSAppleEventDescriptor + + init() { executeScript = Self.runScript } + + init(executeScript: @escaping @Sendable (String) throws -> NSAppleEventDescriptor) { + self.executeScript = executeScript + } + + func readSlides(path: String, limit: Int) throws -> KeynoteNativeSnapshot { + guard (1...500).contains(limit) else { + throw CLIError(code: .validationError, message: "Keynote slide limit must be in 1...500.") + } + let descriptor = try executeScript(Self.slidesScript(path: path, limit: limit)) + let fields = try descriptor.keynoteList(count: 4) + let documentID = try fields[0].keynoteRequiredText() + let readSource = try fields[1].keynoteReadSource() + let totalSlideCount = try fields[2].keynoteNonnegativeInt() + let rows = try fields[3].keynoteList() + guard rows.count == min(totalSlideCount, limit) else { throw invalidKeynoteReadback() } + let slides = try rows.enumerated().map { offset, row in + let values = try row.keynoteList(count: 7) + let index = try values[0].keynoteNonnegativeInt() + guard index == offset + 1 else { throw invalidKeynoteReadback() } + return KeynoteNativeSlide( + index: index, + skipped: try values[1].keynoteBool(), + titleShowing: try values[2].keynoteBool(), + bodyShowing: try values[3].keynoteBool(), + title: try values[4].keynoteOptionalText(), + body: try values[5].keynoteOptionalText(), + presenterNotes: try values[6].keynoteOptionalText() + ) + } + return KeynoteNativeSnapshot( + documentID: documentID, readSource: readSource, totalSlideCount: totalSlideCount, slides: slides) + } + + func exportPDF(path: String, to destinationPath: String) throws -> KeynoteNativePDFReceipt { + let fields = try executeScript(Self.pdfScript(path: path, to: destinationPath)) + .keynoteList(count: 3) + return KeynoteNativePDFReceipt( + documentID: try fields[0].keynoteRequiredText(), + readSource: try fields[1].keynoteReadSource(), + slideCount: try fields[2].keynoteNonnegativeInt() + ) + } + + static func slidesScript(path: String, limit: Int) -> String { + withDocument(path: path, body: """ + set initialSlideCount to count of slides of targetDocument + set requestedCount to initialSlideCount + if requestedCount is greater than \(limit) then set requestedCount to \(limit) + set slideRows to {} + if requestedCount is greater than 0 then + repeat with slideIndex from 1 to requestedCount + set eachSlide to slide slideIndex of targetDocument + set titleValue to missing value + set bodyValue to missing value + set titleItem to default title item of eachSlide + set bodyItem to default body item of eachSlide + if titleItem is not missing value then set titleValue to object text of titleItem as text + if bodyItem is not missing value then set bodyValue to object text of bodyItem as text + set notesValue to presenter notes of eachSlide + if notesValue is not missing value then set notesValue to notesValue as text + set end of slideRows to {slide number of eachSlide, skipped of eachSlide, title showing of eachSlide, body showing of eachSlide, titleValue, bodyValue, notesValue} + end repeat + end if + if (id of targetDocument as text) is not equal to selectedDocumentID or (count of slides of targetDocument) is not initialSlideCount then error number -2702 + set output to {selectedDocumentID, readSource, initialSlideCount, slideRows} + """) + } + + static func pdfScript(path: String, to destinationPath: String) -> String { + withDocument(path: path, body: """ + set initialSlideCount to count of slides of targetDocument + export targetDocument to (POSIX file \(keynoteAppleScriptLiteral(destinationPath))) as PDF with properties {export style:IndividualSlides, all stages:false, skipped slides:true} + if (id of targetDocument as text) is not equal to selectedDocumentID or (count of slides of targetDocument) is not initialSlideCount then error number -2702 + set output to {selectedDocumentID, readSource, initialSlideCount} + """) + } + + private static func withDocument(path: String, body: String) -> String { + """ + with timeout of 30 seconds + tell application "Keynote" + set targetDocument to missing value + set documentWasOpen to false + set documentOwnedByOperation to false + set existingDocumentIDs to id of every document + try + repeat with eachDocument in documents + try + set documentPath to POSIX path of (file of eachDocument as alias) + if documentPath is equal to \(keynoteAppleScriptLiteral(path)) then + set targetDocument to eachDocument + set documentWasOpen to true + exit repeat + end if + end try + end repeat + if targetDocument is missing value then + set targetDocument to open (POSIX file \(keynoteAppleScriptLiteral(path))) + set documentWasOpen to (id of targetDocument) is in existingDocumentIDs + end if + set selectedDocumentID to id of targetDocument as text + set documentOwnedByOperation to documentWasOpen is false + set readSource to "opened_file" + if documentWasOpen then set readSource to "live_document" + \(body) + if documentOwnedByOperation then close targetDocument saving no + on error errorMessage number errorNumber + if targetDocument is not missing value and documentOwnedByOperation then + try + close targetDocument saving no + end try + end if + error number errorNumber + end try + end tell + return output + end timeout + """ + } + + private static func runScript(_ source: String) throws -> NSAppleEventDescriptor { + var errorInfo: NSDictionary? + guard let script = NSAppleScript(source: source) else { + throw CLIError(code: .internalError, message: "Failed to create Keynote automation script.") + } + let result = script.executeAndReturnError(&errorInfo) + if let errorInfo { throw automationError(errorInfo) } + return result + } + + static func automationError(_ errorInfo: NSDictionary) -> CLIError { + let number = errorInfo[NSAppleScript.errorNumber] as? Int + if number == -2702 { + return CLIError( + code: .backendUnavailable, + message: "Keynote presentation changed during the operation. Read it again before retrying." + ) + } + let code: CLIErrorCode + switch number { + case -1712: code = .timeout + case -1743, -1744: code = .permissionDenied + case -1728, -43: code = .notFound + default: code = .backendUnavailable + } + return CLIError.appleEventFailure(target: "Keynote", code: code, number: number) + } +} + +func keynoteAppleScriptLiteral(_ value: String) -> String { + let escaped = value + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + .replacingOccurrences(of: "\r", with: "\" & return & \"") + .replacingOccurrences(of: "\n", with: "\" & linefeed & \"") + return "(\"\(escaped)\")" +} + +private func invalidKeynoteReadback() -> CLIError { + CLIError(code: .backendUnavailable, message: "Keynote returned incomplete or invalid native content.") +} + +private extension NSAppleEventDescriptor { + func keynoteList(count: Int? = nil) throws -> [NSAppleEventDescriptor] { + guard descriptorType == typeAEList, + count == nil || numberOfItems == count + else { throw invalidKeynoteReadback() } + if numberOfItems == 0 { return [] } + return try (1...numberOfItems).map { index in + guard let value = atIndex(index) else { throw invalidKeynoteReadback() } + return value + } + } + + func keynoteOptionalText() throws -> String? { + if descriptorType == typeNull + || (descriptorType == typeType && typeCodeValue == OSType(cMissingValue)) + { return nil } + guard [typeUnicodeText, typeUTF8Text, typeChar, typeCString].contains(descriptorType), + let value = stringValue + else { throw invalidKeynoteReadback() } + return value + } + + func keynoteRequiredText() throws -> String { + guard let value = try keynoteOptionalText(), !value.isEmpty else { + throw invalidKeynoteReadback() + } + return value + } + + func keynoteReadSource() throws -> String { + let value = try keynoteRequiredText() + guard ["opened_file", "live_document"].contains(value) else { throw invalidKeynoteReadback() } + return value + } + + func keynoteNonnegativeInt() throws -> Int { + guard [typeSInt16, typeSInt32, typeSInt64, typeUInt32, typeUInt64].contains(descriptorType), + let text = coerce(toDescriptorType: typeUnicodeText)?.stringValue, + let value = Int(text), value >= 0 + else { + throw invalidKeynoteReadback() + } + return value + } + + func keynoteBool() throws -> Bool { + guard [typeBoolean, typeTrue, typeFalse].contains(descriptorType) else { + throw invalidKeynoteReadback() + } + return booleanValue + } +} diff --git a/Sources/KeynoteCLI/Protocols.swift b/Sources/KeynoteCLI/Protocols.swift index 3e9859f..e0cebf7 100644 --- a/Sources/KeynoteCLI/Protocols.swift +++ b/Sources/KeynoteCLI/Protocols.swift @@ -9,6 +9,7 @@ public protocol KeynoteReading: Sendable { -> [KeynotePresentationRecord] func readPresentation(path: String) throws -> KeynotePresentationRecord? func listSlides(path: String, limit: Int) throws -> KeynoteSlidesResponse? + func listPreviews(path: String, limit: Int) throws -> KeynotePreviewsResponse? } public protocol KeynoteExporting: Sendable { diff --git a/Sources/KeynoteCLI/Support.swift b/Sources/KeynoteCLI/Support.swift index 9975e92..8f8bf4b 100644 --- a/Sources/KeynoteCLI/Support.swift +++ b/Sources/KeynoteCLI/Support.swift @@ -1,5 +1,6 @@ import AppKit import CryptoKit +import Darwin import Foundation import Utility @@ -69,40 +70,35 @@ func presentationExportScopeDigest( return "keynote-export:\(sha256Hex(payload))" } -struct SlideExportArtifact { - var file: KeynoteSlideExportFile +struct PreviewExportArtifact { + var file: KeynotePreviewExportFile var data: Data } -func slideExportArtifacts( - _ response: KeynoteSlidesResponse, +func previewExportArtifacts( + _ response: KeynotePreviewsResponse, destinationPath: String -) throws -> [SlideExportArtifact] { - guard !response.slides.isEmpty else { +) throws -> [PreviewExportArtifact] { + guard !response.previews.isEmpty else { throw CLIError( code: .unsupportedOperation, - message: "Keynote presentation does not contain QuickLook slide preview artifacts.", + message: "Keynote presentation does not contain QuickLook image preview artifacts.", details: ["path": response.presentation.path] ) } let destination = URL(fileURLWithPath: destinationPath).standardizedFileURL - return try response.slides.map { slide in - guard let previewPath = slide.previewPath else { - throw CLIError( - code: .unsupportedOperation, - message: "Keynote slide does not contain a QuickLook preview artifact.", - details: ["slide_id": slide.id] - ) - } - let source = URL(fileURLWithPath: previewPath).standardizedFileURL + try validatePresentationArtifactRelationship( + source: URL(fileURLWithPath: response.presentation.path), destination: destination) + return try response.previews.map { preview in + let source = URL(fileURLWithPath: preview.previewPath).standardizedFileURL var isDirectory: ObjCBool = false guard FileManager.default.fileExists(atPath: source.path, isDirectory: &isDirectory), !isDirectory.boolValue else { throw CLIError( code: .notFound, - message: "Keynote slide preview artifact was not found.", + message: "Keynote preview artifact was not found.", details: ["path": source.path] ) } @@ -110,12 +106,12 @@ func slideExportArtifacts( let data = try Data(contentsOf: source) let fileExtension = source.pathExtension.lowercased() let destinationFile = destination.appendingPathComponent( - "slide-\(String(format: "%03d", slide.index)).\(fileExtension)" + "preview-\(String(format: "%03d", preview.index)).\(fileExtension)" ) - return SlideExportArtifact( - file: KeynoteSlideExportFile( - slideID: slide.id, - index: slide.index, + return PreviewExportArtifact( + file: KeynotePreviewExportFile( + previewID: preview.id, + index: preview.index, sourcePath: source.path, destinationPath: destinationFile.path, byteCount: data.count, @@ -126,15 +122,15 @@ func slideExportArtifacts( } } -func slideExportScopeDigest( +func previewExportScopeDigest( _ presentation: KeynotePresentationRecord, - artifacts: [SlideExportArtifact], + artifacts: [PreviewExportArtifact], format: String, destinationPath: String ) -> String { let artifactPayload = artifacts.map { artifact in [ - artifact.file.slideID, + artifact.file.previewID, "\(artifact.file.index)", artifact.file.sourcePath, artifact.file.destinationPath, @@ -148,22 +144,13 @@ func slideExportScopeDigest( destinationPath, artifactPayload, ].joined(separator: "|") - return "keynote-slides-export:\(sha256Hex(payload))" + return "keynote-previews-export:\(sha256Hex(payload))" } -func slideExportDigest(_ artifacts: [SlideExportArtifact]) -> String { +func previewExportDigest(_ artifacts: [PreviewExportArtifact]) -> String { sha256Hex(artifacts.map(\.file.sha256).joined(separator: "|")) } -func keynoteSlideID(presentation: KeynotePresentationRecord, preview: URL, index: Int) -> String { - let payload = [ - presentationIdentityScopeDigest(presentation), - String(index), - preview.lastPathComponent, - ].joined(separator: "|") - return "slide-\(index)-\(String(sha256Hex(payload).prefix(8)))" -} - func requiredOption(_ name: String, options: CLIOptions) throws -> String { guard let value = options.targetOption(name), !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty @@ -177,21 +164,21 @@ func exportFormat(_ options: CLIOptions) throws -> String { let value = try requiredOption("format", options: options).trimmingCharacters( in: .whitespacesAndNewlines ).lowercased() - guard ["pdf", "thumbnail", "package"].contains(value) else { + guard ["pdf", "preview-pdf", "thumbnail", "package"].contains(value) else { throw CLIError( code: .unsupportedOperation, - message: "Keynote export currently supports only `pdf`, `thumbnail`, and `package`.") + message: "Keynote export currently supports `pdf`, `preview-pdf`, `thumbnail`, and `package`.") } return value } -func slideExportFormat(_ options: CLIOptions) throws -> String { +func previewExportFormat(_ options: CLIOptions) throws -> String { let value = try requiredOption("format", options: options).trimmingCharacters( in: .whitespacesAndNewlines ).lowercased() guard value == "images" else { throw CLIError( - code: .unsupportedOperation, message: "Keynote slide export currently supports only `images`." + code: .unsupportedOperation, message: "Keynote preview export currently supports `images`." ) } return value @@ -207,7 +194,7 @@ func standardizedAbsolutePath(_ path: String) -> String { ).standardizedFileURL.path } -func validateSlideExportDestination(_ destinationPath: String) throws { +func validatePreviewExportDestination(_ destinationPath: String) throws { let destination = URL(fileURLWithPath: destinationPath).standardizedFileURL guard !FileManager.default.fileExists(atPath: destination.path) else { throw CLIError( @@ -225,11 +212,17 @@ func validateSlideExportDestination(_ destinationPath: String) throws { } } -func writeSlideExportArtifacts(_ artifacts: [SlideExportArtifact], destinationPath: String) throws { - try validateSlideExportDestination(destinationPath) +func writePreviewExportArtifacts(_ artifacts: [PreviewExportArtifact], destinationPath: String) throws { + try validatePreviewExportDestination(destinationPath) let destination = URL(fileURLWithPath: destinationPath).standardizedFileURL + guard mkdir(destination.path, 0o755) == 0 else { + throw CLIError( + code: errno == EEXIST ? .validationError : .backendUnavailable, + message: "Could not create the preview export directory without replacing an existing path.", + details: ["path": destination.path] + ) + } do { - try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: false) for artifact in artifacts { try artifact.data.write( to: URL(fileURLWithPath: artifact.file.destinationPath), options: .withoutOverwriting) @@ -241,7 +234,7 @@ func writeSlideExportArtifacts(_ artifacts: [SlideExportArtifact], destinationPa try? FileManager.default.removeItem(at: destination) throw CLIError( code: .internalError, - message: "Failed to write Keynote slide export.", + message: "Failed to write Keynote preview export.", details: CLIError.diagnosticDetails(for: error).merging(["path": destination.path]) { _, new in new } ) } @@ -251,7 +244,7 @@ func validatePresentationExportDestination(_ destinationPath: String, format: St let destination = URL(fileURLWithPath: destinationPath).standardizedFileURL let expectedExtensions: [String] switch format { - case "pdf": + case "pdf", "preview-pdf": expectedExtensions = ["pdf"] case "thumbnail": expectedExtensions = ["jpg", "jpeg"] @@ -260,7 +253,7 @@ func validatePresentationExportDestination(_ destinationPath: String, format: St default: throw CLIError( code: .unsupportedOperation, - message: "Keynote export currently supports only `pdf`, `thumbnail`, and `package`.") + message: "Keynote export currently supports `pdf`, `preview-pdf`, `thumbnail`, and `package`.") } guard expectedExtensions.contains(destination.pathExtension.lowercased()) else { let extensionList = expectedExtensions.map { ".\($0)" }.joined(separator: "` or `") @@ -285,9 +278,36 @@ func validatePresentationExportDestination(_ destinationPath: String, format: St } } -func validatePackageExportRelationship(source: URL, destination: URL) throws { - let sourcePath = source.standardizedFileURL.path - let destinationPath = destination.standardizedFileURL.path +func validatePresentationArtifactRelationship(source: URL, destination: URL) throws { + let sourcePath = source.standardizedFileURL.resolvingSymlinksInPath().path + // The export leaf is absent; resolve its existing ancestor before adding missing components. + var ancestor = destination.standardizedFileURL + var components: [String] = [] + while !FileManager.default.fileExists(atPath: ancestor.path) && ancestor.path != "/" { + components.append(ancestor.lastPathComponent) + ancestor = ancestor.deletingLastPathComponent() + } + var resolvedDestination = ancestor.resolvingSymlinksInPath() + for component in components.reversed() { + resolvedDestination.appendPathComponent(component) + } + let destinationPath = resolvedDestination.standardizedFileURL.path + let identityKeys: Set = [.fileResourceIdentifierKey, .volumeIdentifierKey] + let sourceIdentity = try? source.resourceValues(forKeys: identityKeys) + var current = ancestor.resolvingSymlinksInPath() + while true { + let candidate = try? current.resourceValues(forKeys: identityKeys) + if let sourceFile = sourceIdentity?.fileResourceIdentifier as? NSObject, + let sourceVolume = sourceIdentity?.volumeIdentifier as? NSObject, + let candidateFile = candidate?.fileResourceIdentifier as? NSObject, + let candidateVolume = candidate?.volumeIdentifier as? NSObject, + sourceFile.isEqual(candidateFile), sourceVolume.isEqual(candidateVolume) + { + throw CLIError(code: .validationError, message: "Export destination must be outside the source presentation.") + } + if current.path == "/" { break } + current = current.deletingLastPathComponent() + } guard sourcePath != destinationPath else { throw CLIError( code: .validationError, message: "Destination path must differ from source path.", @@ -333,7 +353,13 @@ func presentationHumanOutput(_ presentation: KeynotePresentationRecord) -> Strin func slidesHumanOutput(_ response: KeynoteSlidesResponse) -> String { response.slides - .map { "\($0.index)\t\($0.id)\t\($0.previewPath ?? "")" } + .map { "\($0.index)\t\($0.id)\tskipped=\($0.skipped)\t\($0.title ?? "")" } + .joined(separator: "\n") +} + +func previewsHumanOutput(_ response: KeynotePreviewsResponse) -> String { + response.previews + .map { "\($0.index)\t\($0.id)\t\($0.previewPath)" } .joined(separator: "\n") } diff --git a/Sources/MailCLI/Backends.swift b/Sources/MailCLI/Backends.swift index 4636ab1..380f709 100644 --- a/Sources/MailCLI/Backends.swift +++ b/Sources/MailCLI/Backends.swift @@ -9,10 +9,18 @@ public struct MailActionPreviewResponse: Codable, Equatable, Sendable { } public struct MailAppleScriptBackend: MailReading, MailDrafting, MailSending, MailMessageMutating { - public init() {} + private let executeRows: @Sendable (String) throws -> [[String]] + + public init() { + executeRows = runRows + } + + init(executeRows: @escaping @Sendable (String) throws -> [[String]]) { + self.executeRows = executeRows + } public func listAccounts() throws -> [MailAccountRecord] { - try runRows( + try executeRows( """ set output to {} tell application "Mail" @@ -26,7 +34,7 @@ public struct MailAppleScriptBackend: MailReading, MailDrafting, MailSending, Ma } public func listMailboxes(account: String?, limit: Int) throws -> [MailboxRecord] { - try runRows( + try executeRows( """ set output to {} tell application "Mail" @@ -46,7 +54,7 @@ public struct MailAppleScriptBackend: MailReading, MailDrafting, MailSending, Ma } public func readMailbox(account: String?, mailbox: String) throws -> MailboxRecord? { - try runRows( + try executeRows( """ set output to {} tell application "Mail" @@ -85,7 +93,11 @@ public struct MailAppleScriptBackend: MailReading, MailDrafting, MailSending, Ma mailbox: String, maxBytes: Int ) throws -> MailBodyPreviewResponse? { - let rows = try runRows( + guard (1...100_000).contains(maxBytes) else { + throw CLIError(code: .validationError, message: "`--max-bytes` must be between 1 and 100000.") + } + let characterLimit = maxBytes + 1 + let rows = try executeRows( """ set output to {} tell application "Mail" @@ -106,8 +118,8 @@ public struct MailAppleScriptBackend: MailReading, MailDrafting, MailSending, Ma if readValue then set readFlagValue to "true" set bodyValue to content of eachMessage as text set truncatedFlagValue to "false" - if (count characters of bodyValue) is greater than \(maxBytes) then - set bodyValue to text 1 thru \(maxBytes) of bodyValue + if (count characters of bodyValue) is greater than \(characterLimit) then + set bodyValue to text 1 thru \(characterLimit) of bodyValue set truncatedFlagValue to "true" end if set end of output to {stableId, accountName, mailboxName, subjectValue, senderValue, receivedValue, readFlagValue, bodyValue, truncatedFlagValue} @@ -122,11 +134,11 @@ public struct MailAppleScriptBackend: MailReading, MailDrafting, MailSending, Ma return output """) - return rows.first.map(mailBodyPreview) + return rows.first.map { mailBodyPreview($0, maxBytes: maxBytes) } } public func createDraft(_ draft: MailDraftRequest) throws -> MailDraftRecord { - let rows = try runRows( + let rows = try executeRows( """ set output to {} tell application "Mail" @@ -154,23 +166,50 @@ public struct MailAppleScriptBackend: MailReading, MailDrafting, MailSending, Ma } public func sendMail(_ draft: MailDraftRequest) throws -> MailSendRecord { - _ = try runRows( - """ - set output to {} - tell application "Mail" - set newMessage to make new outgoing message with properties {subject:"\(appleScriptString(draft.subject))", content:"\(appleScriptString(draft.body))", visible:false} - \(recipientStatements(draft)) - send newMessage - set end of output to {subject of newMessage as text} - end tell - return output - """) + let rows: [[String]] + do { + rows = try executeRows( + """ + set output to {} + tell application "Mail" + set newMessage to make new outgoing message with properties {subject:"\(appleScriptString(draft.subject))", content:"\(appleScriptString(draft.body))", visible:false} + \(recipientStatements(draft)) + set acceptedSubmission to send newMessage + set submissionFlag to "false" + if acceptedSubmission then set submissionFlag to "true" + set end of output to {submissionFlag} + end tell + return output + """) + } catch let error as CLIError { + var failure = error + failure.details["submission_status"] = "unknown" + failure.details["retry_guidance"] = "inspect_mail_before_retrying" + throw failure + } catch { + throw CLIError( + code: .backendUnavailable, + message: "Mail submission could not be confirmed. Inspect Mail before retrying.", + details: ["submission_status": "unknown", "retry_guidance": "inspect_mail_before_retrying"] + ) + } + guard rows == [["true"]] else { + let status = rows == [["false"]] ? "rejected" : "unknown" + throw CLIError( + code: .backendUnavailable, + message: status == "rejected" + ? "Mail rejected the send request. Inspect outgoing drafts before retrying." + : "Mail submission could not be confirmed. Inspect Mail before retrying.", + details: ["submission_status": status, "retry_guidance": "inspect_mail_before_retrying"] + ) + } return MailSendRecord( to: draft.to, cc: draft.cc, bcc: draft.bcc, subject: draft.subject, + submitted: true, bodyIncluded: false ) } @@ -213,7 +252,7 @@ public struct MailAppleScriptBackend: MailReading, MailDrafting, MailSending, Ma ? "set bodyText to content of eachMessage as text" : "set bodyText to \"\"" - return try runRows( + return try executeRows( """ set output to {} set scannedCount to 0 @@ -272,7 +311,7 @@ public struct MailAppleScriptBackend: MailReading, MailDrafting, MailSending, Ma actionStatement = "delete eachMessage" } - let rows = try runRows( + let rows = try executeRows( """ set output to {} tell application "Mail" diff --git a/Sources/MailCLI/Command.swift b/Sources/MailCLI/Command.swift index 5523928..50e37e6 100644 --- a/Sources/MailCLI/Command.swift +++ b/Sources/MailCLI/Command.swift @@ -227,6 +227,13 @@ public struct MailCommand: Sendable { options: options ) { let record = try backend.sendMail(draft) + guard record.submitted else { + throw CLIError( + code: .backendUnavailable, + message: "Mail rejected the send request. Inspect outgoing drafts before retrying.", + details: ["submission_status": "rejected", "retry_guidance": "inspect_mail_before_retrying"] + ) + } return MailMutationResult(operation: "mail.send", changed: true, draft: nil, sent: record) } case ["mail", "move"]: diff --git a/Sources/MailCLI/Models.swift b/Sources/MailCLI/Models.swift index 9ce7038..07aa3ac 100644 --- a/Sources/MailCLI/Models.swift +++ b/Sources/MailCLI/Models.swift @@ -224,6 +224,7 @@ public struct MailSendRecord: Codable, Equatable, Sendable { public var cc: [String] public var bcc: [String] public var subject: String + public var submitted: Bool public var bodyIncluded: Bool public init( @@ -231,12 +232,14 @@ public struct MailSendRecord: Codable, Equatable, Sendable { cc: [String] = [], bcc: [String] = [], subject: String, + submitted: Bool, bodyIncluded: Bool = false ) { self.to = to self.cc = cc self.bcc = bcc self.subject = subject + self.submitted = submitted self.bodyIncluded = bodyIncluded } } diff --git a/Sources/MailCLI/Support.swift b/Sources/MailCLI/Support.swift index 8a22c29..79962e0 100644 --- a/Sources/MailCLI/Support.swift +++ b/Sources/MailCLI/Support.swift @@ -332,7 +332,7 @@ func messageDetail(_ row: [String]) -> MailMessageDetail { ) } -func mailBodyPreview(_ row: [String]) -> MailBodyPreviewResponse { +func mailBodyPreview(_ row: [String], maxBytes: Int) -> MailBodyPreviewResponse { var message = MailMessageDetail( id: row[safe: 0] ?? "", accountName: row[safe: 1] ?? "", @@ -344,10 +344,19 @@ func mailBodyPreview(_ row: [String]) -> MailBodyPreviewResponse { bodyIncluded: true ) message.recipients = [] + let sourceBody = row[safe: 7] ?? "" + var body = "" + var byteCount = 0 + for character in sourceBody { + let characterBytes = character.utf8.count + guard characterBytes <= maxBytes - byteCount else { break } + body.append(character) + byteCount += characterBytes + } return MailBodyPreviewResponse( message: message, - body: row[safe: 7] ?? "", - truncated: (row[safe: 8] ?? "false") == "true" + body: body, + truncated: (row[safe: 8] ?? "false") == "true" || body != sourceBody ) } diff --git a/Sources/MapsCLI/Backends.swift b/Sources/MapsCLI/Backends.swift index 7c99d4b..698206c 100644 --- a/Sources/MapsCLI/Backends.swift +++ b/Sources/MapsCLI/Backends.swift @@ -1,71 +1,185 @@ import AppKit import CoreLocation -import CryptoKit import Foundation +import MapKit import Utility -public struct CoreLocationMapsBackend: MapsReading { +public struct MapKitMapsBackend: MapsReading, MapsRouting { public init() {} - public func searchPlaces(query: String, limit: Int) throws -> [MapsPlaceRecord] { - let placemarks = try geocode(query) - return try placemarks.prefix(limit).map { try placeRecord($0, fallbackName: query) } + public func searchPlaces(_ request: MapsSearchRequest) throws -> MapsPlacesResponse { + let items = try searchItems(request, deadline: MapsDeadline(seconds: 10)) + return MapsPlacesResponse( + places: try items.prefix(request.limit).map { + try placeRecord($0, fallbackName: request.query) + }, + returnedByService: items.count, truncated: items.count > request.limit) } - public func readPlace(latitude: Double, longitude: Double, name: String?) throws - -> MapsPlaceRecord - { - let location = CLLocation(latitude: latitude, longitude: longitude) - let placemarks = try reverseGeocode(location) - if let placemark = placemarks.first { - return try placeRecord(placemark, fallbackName: name ?? "\(latitude),\(longitude)") + public func readPlace(_ selection: MapsPlaceSelection) throws -> MapsPlaceRecord { + switch selection { + case .identifier(let identifier): + let item = try serviceItem(identifier: identifier, deadline: MapsDeadline(seconds: 10)) + return try placeRecord(item, fallbackName: "Place") + case .coordinate(let latitude, let longitude, let name): + let location = CLLocation(latitude: latitude, longitude: longitude) + let placemarks = try reverseGeocode(location) + if let placemark = placemarks.first { + return try placeRecord(placemark, fallbackName: name ?? "\(latitude),\(longitude)") + } + return try coordinatePlace(latitude: latitude, longitude: longitude, name: name) } - - return try coordinatePlace(latitude: latitude, longitude: longitude, name: name) } - private func geocode(_ query: String) throws -> [CLPlacemark] { - let geocoder = CLGeocoder() - let semaphore = DispatchSemaphore(value: 0) - let box = LockedBox?>(nil) - - geocoder.geocodeAddressString(query) { placemarks, error in - if let error { - box.set(.failure(error)) - } else { - box.set(.success(placemarks ?? [])) - } - semaphore.signal() + func serviceItem(identifier: String, deadline: MapsDeadline) throws -> MKMapItem { + guard #available(macOS 15, *) else { + throw CLIError( + code: .unsupportedOperation, message: "Native place ID lookup requires macOS 15 or newer.") } - - guard semaphore.wait(timeout: .now() + .seconds(10)) == .success else { - geocoder.cancelGeocode() - throw CLIError(code: .timeout, message: "Maps place search timed out.") + guard let nativeID = MKMapItem.Identifier(rawValue: identifier) else { + throw CLIError(code: .validationError, message: "The native place identifier is invalid.") + } + let request = MKMapItemRequest(mapItemIdentifier: nativeID) + let item: MKMapItem = try waitForMapsCallback( + deadline: deadline, phase: "place_read", cancel: { request.cancel() }, + start: { finish in + request.getMapItem { item, error in + finish(nativeMapsResponse(item, error: error, phase: "place_read")) + } + }) + guard mapsSavedTextMatches(item.identifier?.rawValue, identifier) else { + throw CLIError( + code: .backendUnavailable, message: "Maps returned another native place identifier.") } + return item + } - return try mapGeocodeResult(box.value()) + private func searchItems(_ request: MapsSearchRequest, deadline: MapsDeadline) throws + -> [MKMapItem] + { + let native = MKLocalSearch.Request() + native.naturalLanguageQuery = request.query + switch request.kind { + case .all: native.resultTypes = [.address, .pointOfInterest] + case .poi: native.resultTypes = .pointOfInterest + case .address: native.resultTypes = .address + } + if let region = request.region { + native.region = MKCoordinateRegion( + center: CLLocationCoordinate2D(latitude: region.latitude, longitude: region.longitude), + latitudinalMeters: region.spanMeters, longitudinalMeters: region.spanMeters) + } + let search = MKLocalSearch(request: native) + do { + let response: MKLocalSearch.Response = try waitForMapsCallback( + deadline: deadline, phase: "place_search", cancel: { search.cancel() }, + start: { finish in + search.start { response, error in + finish(nativeMapsResponse(response, error: error, phase: "place_search")) + } + }) + return response.mapItems + } catch let error as CLIError where error.code == .notFound { + return [] + } } private func reverseGeocode(_ location: CLLocation) throws -> [CLPlacemark] { let geocoder = CLGeocoder() - let semaphore = DispatchSemaphore(value: 0) - let box = LockedBox?>(nil) + return try waitForMapsCallback( + deadline: MapsDeadline(seconds: 10), phase: "reverse_geocode", + cancel: { geocoder.cancelGeocode() }, + start: { finish in + geocoder.reverseGeocodeLocation(location) { placemarks, error in + do { + if let error { + finish(.success(try mapGeocodeResult(.failure(error)))) + } else if let placemarks { + finish(.success(placemarks)) + } else { + finish( + .failure( + CLIError( + code: .backendUnavailable, message: "Maps geocoding returned no response.") + )) + } + } catch { finish(.failure(error)) } + } + }) + } - geocoder.reverseGeocodeLocation(location) { placemarks, error in - if let error { - box.set(.failure(error)) - } else { - box.set(.success(placemarks ?? [])) - } - semaphore.signal() + private func endpointItem(_ endpoint: MapsRouteEndpoint, label: String, deadline: MapsDeadline) + throws -> MKMapItem + { + if let latitude = endpoint.latitude, let longitude = endpoint.longitude { + return coordinateMapItem(latitude: latitude, longitude: longitude, name: endpoint.name) } + let query = endpoint.query ?? "" + let items = try searchItems( + MapsSearchRequest(query: query, kind: .all, limit: 50), deadline: deadline) + return try uniqueMapItem(items, label: label) + } + + private func nativeRequest(_ request: MapsDirectionsRequest, deadline: MapsDeadline) throws + -> MKDirections.Request + { + let native = MKDirections.Request() + native.source = try endpointItem(request.source, label: "from", deadline: deadline) + native.destination = try endpointItem(request.destination, label: "to", deadline: deadline) + native.transportType = request.mode.nativeType + native.requestsAlternateRoutes = request.alternatives + if let departure = request.departure { native.departureDate = departure } + if let arrival = request.arrival { native.arrivalDate = arrival } + return native + } - guard semaphore.wait(timeout: .now() + .seconds(10)) == .success else { - geocoder.cancelGeocode() - throw CLIError(code: .timeout, message: "Maps place read timed out.") + public func calculateDirections(_ request: MapsDirectionsRequest) throws -> MapsRoutesResponse { + if request.mode == .transit { + throw CLIError(code: .unsupportedOperation, message: "MapKit supports transit ETA only.") } + let deadline = MapsDeadline(seconds: 25) + let directions = MKDirections(request: try nativeRequest(request, deadline: deadline)) + let response: MKDirections.Response = try waitForMapsCallback( + deadline: deadline, phase: "directions_calculate", cancel: { directions.cancel() }, + start: { finish in + directions.calculate { response, error in + finish(nativeMapsResponse(response, error: error, phase: "directions_calculate")) + } + }) + guard !response.routes.isEmpty else { + throw CLIError( + code: .notFound, message: "No route was returned for these endpoints and mode.") + } + for route in response.routes { + try requireTransportType(route.transportType, mode: request.mode) + } + return MapsRoutesResponse( + source: try placeRecord(response.source, fallbackName: request.source.displayValue), + destination: try placeRecord( + response.destination, fallbackName: request.destination.displayValue), + mode: request.mode, routes: response.routes.prefix(request.limit).map(routeRecord), + returnedByService: response.routes.count, truncated: response.routes.count > request.limit) + } - return try mapGeocodeResult(box.value()) + public func calculateETA(_ request: MapsDirectionsRequest) throws -> MapsETAResponse { + let deadline = MapsDeadline(seconds: 25) + let directions = MKDirections(request: try nativeRequest(request, deadline: deadline)) + let response: MKDirections.ETAResponse = try waitForMapsCallback( + deadline: deadline, phase: "directions_eta", cancel: { directions.cancel() }, + start: { finish in + directions.calculateETA { response, error in + finish(nativeMapsResponse(response, error: error, phase: "directions_eta")) + } + }) + try requireTransportType(response.transportType, mode: request.mode) + return MapsETAResponse( + source: try placeRecord(response.source, fallbackName: request.source.displayValue), + destination: try placeRecord( + response.destination, fallbackName: request.destination.displayValue), + mode: request.mode, distanceMeters: response.distance, + expectedTravelTimeSeconds: response.expectedTravelTime, + expectedDeparture: response.expectedDepartureDate, + expectedArrival: response.expectedArrivalDate) } } @@ -76,24 +190,3 @@ public struct NSWorkspaceMapsOpener: MapsOpening { NSWorkspace.shared.open(url) } } - -private final class LockedBox: @unchecked Sendable { - private let lock = NSLock() - private var storage: Value - - init(_ storage: Value) { - self.storage = storage - } - - func set(_ value: Value) { - lock.lock() - storage = value - lock.unlock() - } - - func value() -> Value { - lock.lock() - defer { lock.unlock() } - return storage - } -} diff --git a/Sources/MapsCLI/CollectionCommands.swift b/Sources/MapsCLI/CollectionCommands.swift new file mode 100644 index 0000000..58a6c30 --- /dev/null +++ b/Sources/MapsCLI/CollectionCommands.swift @@ -0,0 +1,147 @@ +import Foundation +import Utility + +extension MapsCommand { + func runCollectionMutation(_ options: CLIOptions) throws -> CLICommandResult? { + guard + [ + ["collections", "create"], ["collections", "update"], ["collections", "delete"], + ["collections", "places", "create"], + ["collections", "places", "add"], ["collections", "places", "remove"], + ].contains(options.positionals) + else { return nil } + guard options.limit == nil else { + throw CLIError(code: .validationError, message: "`--limit` is only valid for list commands.") + } + let operation: String + var summary: [String: String] + let commit: () throws -> MapsCollectionMutationResult + switch options.positionals { + case ["collections", "create"]: + try validateTargetOptions( + options, allowedOptions: ["id", "title", "description", "position"]) + let title = try requiredOption("title", options: options) + try validateCollectionTitle(title) + let id = + try options.targetOption("id").map { try savedIdentifier($0, kind: .collection) } ?? UUID() + try requireCustomCollection(id) + let draft = MapsCollectionDraft( + id: id, title: title, description: options.targetOption("description"), + position: try collectionPosition(options)) + operation = "collections.create" + summary = [ + "id": MapsSavedKind.collection.idPrefix + id.uuidString.lowercased(), "title": title, + ] + if let description = draft.description { summary["description"] = description } + if let position = draft.position { summary["position"] = String(position) } + commit = { try collectionWriter.createCollection(draft) } + case ["collections", "update"]: + try validateTargetOptions( + options, allowedOptions: ["id", "title", "description", "position"], + allowedFlags: ["clear-description"]) + let patch = MapsCollectionPatch( + title: options.targetOption("title"), + description: options.targetOption("description"), + clearDescription: options.hasTargetFlag("clear-description"), + position: try collectionPosition(options)) + try validateCollectionPatch(patch) + let id = try savedIdentifier(options, kind: .collection) + try requireCustomCollection(id) + let current = try collections.readCollection(id: id) + operation = "collections.update" + summary = [ + "id": current.id, "before_sha256": sha256Hex(try CLIJSON.encodeString(current)), + "patch_sha256": sha256Hex(try CLIJSON.encodeString(patch.applying(to: current))), + ] + commit = { try collectionWriter.updateCollection(current: current, patch: patch) } + case ["collections", "delete"]: + try validateTargetOptions(options, allowedOptions: ["id"]) + let id = try savedIdentifier(options, kind: .collection) + try requireCustomCollection(id) + let current = try collections.readCollection(id: id) + operation = "collections.delete" + summary = ["id": current.id, "before_sha256": sha256Hex(try CLIJSON.encodeString(current))] + commit = { try collectionWriter.deleteCollection(current: current) } + case ["collections", "places", "create"]: + try validateTargetOptions( + options, allowedOptions: ["id", "item", "place", "latitude", "longitude", "name", "note"]) + let collectionID = try savedIdentifier(options, kind: .collection) + let itemID = + try options.targetOption("item").map { + try savedIdentifier($0, kind: .collectionItem, option: "item") + } ?? UUID() + let draft = MapsCollectionPlaceDraft( + id: itemID, source: try collectionPlaceSource(options), + customName: options.targetOption("name"), note: options.targetOption("note")) + try validateCollectionPlaceDraft(draft) + let current = try collections.readCollection(id: collectionID) + operation = "collections.places.create" + summary = [ + "id": current.id, + "item": MapsSavedKind.collectionItem.idPrefix + itemID.uuidString.lowercased(), + "before_sha256": sha256Hex(try CLIJSON.encodeString(current)), + ] + switch draft.source { + case .identifier(let id): summary["place"] = "maps-item:" + id + case .coordinate(let latitude, let longitude): + summary["latitude"] = String(latitude) + summary["longitude"] = String(longitude) + } + if let name = draft.customName { summary["name"] = name } + if let note = draft.note { summary["note"] = note } + commit = { try collectionWriter.createCollectionPlace(collection: current, draft: draft) } + case ["collections", "places", "add"], ["collections", "places", "remove"]: + try validateTargetOptions(options, allowedOptions: ["id", "item"]) + let collectionID = try savedIdentifier(options, kind: .collection) + let itemID = try savedIdentifier(options, kind: .collectionItem, option: "item") + let current = try collections.readCollection(id: collectionID) + let item = try collections.readCollectionItem(id: itemID) + let linked = options.positionals.last == "add" + operation = linked ? "collections.places.add" : "collections.places.remove" + summary = [ + "id": current.id, "item": item.id, + "before_sha256": sha256Hex(try CLIJSON.encodeString(current)), + "item_sha256": sha256Hex(try CLIJSON.encodeString(item)), + ] + commit = { + try collectionWriter.setCollectionMembership( + collection: current, item: item, linked: linked) + } + default: return nil + } + if options.dryRun { + return try result( + CLISafety.dryRun( + target: "maps", operation: operation, summary: summary, + scope: "maps-collection:" + sha256Hex(try CLIJSON.encodeString(summary))), + human: "dry-run: \(operation)", options: options) + } + let payload = try commit() + return try result(payload, human: "\(operation) changed=\(payload.changed)", options: options) + } + + private func collectionPosition(_ options: CLIOptions) throws -> Int? { + guard let value = options.targetOption("position") else { return nil } + guard let position = Int(value), position >= 0 else { + throw CLIError(code: .validationError, message: "`--position` must be a nonnegative integer.") + } + return position + } + + private func collectionPlaceSource(_ options: CLIOptions) throws -> MapsCollectionPlaceSource { + if let place = options.targetOption("place") { + guard options.targetOption("latitude") == nil, options.targetOption("longitude") == nil else { + throw CLIError( + code: .validationError, message: "`--place` cannot be combined with coordinate options.") + } + guard place.hasPrefix("maps-item:") else { + throw CLIError( + code: .validationError, message: "`--place` requires a native `maps-item:` identifier.") + } + return .identifier(String(place.dropFirst("maps-item:".count))) + } + return .coordinate( + latitude: try coordinateOption("latitude", options: options), + longitude: try coordinateOption("longitude", options: options)) + } +} diff --git a/Sources/MapsCLI/CollectionMutations.swift b/Sources/MapsCLI/CollectionMutations.swift new file mode 100644 index 0000000..28c4d57 --- /dev/null +++ b/Sources/MapsCLI/CollectionMutations.swift @@ -0,0 +1,164 @@ +import Foundation +import Utility + +public struct MapsCollectionDraft: Equatable, Sendable { + public var id: UUID + public var title: String + public var description: String? + public var position: Int? + public init(id: UUID = UUID(), title: String, description: String? = nil, position: Int? = nil) { + self.id = id + self.title = title + self.description = description + self.position = position + } +} + +public struct MapsCollectionPatch: Equatable, Sendable { + public var title: String? + public var description: String? + public var clearDescription: Bool + public var position: Int? + public init( + title: String? = nil, description: String? = nil, clearDescription: Bool = false, + position: Int? = nil + ) { + self.title = title + self.description = description + self.clearDescription = clearDescription + self.position = position + } + + func applying(to record: MapsCollectionRecord) -> MapsCollectionRecord { + var updated = record + if let title { updated.title = title } + if let description { updated.description = description } + if clearDescription { updated.description = nil } + if let position { updated.position = position } + return updated + } +} + +public struct MapsCollectionMutationResult: Codable, Equatable, Sendable { + public var operation: String + public var changed: Bool + public var collection: MapsCollectionRecord? + public var item: MapsCollectionItemRecord? + public var deletedID: String? +} + +public enum MapsCollectionPlaceSource: Equatable, Sendable { + case identifier(String) + case coordinate(latitude: Double, longitude: Double) +} + +public struct MapsCollectionPlaceDraft: Equatable, Sendable { + public var id: UUID + public var source: MapsCollectionPlaceSource + public var customName: String? + public var note: String? + public init( + id: UUID = UUID(), source: MapsCollectionPlaceSource, customName: String? = nil, + note: String? = nil + ) { + self.id = id + self.source = source + self.customName = customName + self.note = note + } +} + +func validateCollectionPlaceDraft(_ draft: MapsCollectionPlaceDraft) throws { + guard [draft.customName, draft.note].allSatisfy({ $0?.contains("\0") != true }) else { + throw CLIError( + code: .validationError, message: "Saved place text cannot contain NUL characters.") + } + switch draft.source { + case .identifier(let id): + guard !id.isEmpty, !id.contains("\0"), id == id.trimmingCharacters(in: .whitespacesAndNewlines) + else { + throw CLIError(code: .validationError, message: "A native place identifier is required.") + } + case .coordinate(let latitude, let longitude): + guard latitude.isFinite, longitude.isFinite, (-90...90).contains(latitude), + (-180...180).contains(longitude) + else { + throw CLIError(code: .validationError, message: "Saved place coordinates are invalid.") + } + } +} + +func validateCollectionTitle(_ title: String) throws { + guard !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, !title.contains("\0") else { + throw CLIError( + code: .validationError, message: "A collection title must contain text and no NUL characters." + ) + } +} + +func requireCustomCollection(_ id: UUID) throws { + // Maps reserves this identity for its built-in Favorites guide. + guard id != UUID(uuidString: "00000000-0000-0000-0000-000000000001") else { + throw CLIError( + code: .unsafeMutationRefused, + message: + "Maps manages the built-in Favorites guide. Its name, position and deletion cannot be changed." + ) + } +} + +func validateCollectionPatch(_ patch: MapsCollectionPatch) throws { + if let title = patch.title { try validateCollectionTitle(title) } + guard + patch.title != nil || patch.description != nil || patch.clearDescription + || patch.position != nil + else { + throw CLIError( + code: .validationError, message: "At least one collection field must be supplied for update.") + } + guard patch.description == nil || !patch.clearDescription else { + throw CLIError( + code: .validationError, + message: "`--description` and `--clear-description` are mutually exclusive.") + } +} + +func mapsCollectionMatches( + _ lhs: MapsCollectionRecord, _ rhs: MapsCollectionRecord, + ignoringModificationTime: Bool = false +) -> Bool { + lhs.id == rhs.id && mapsSavedTextMatches(lhs.title, rhs.title) + && mapsSavedTextMatches(lhs.description, rhs.description) + && mapsSavedTextMatches(lhs.imageURL, rhs.imageURL) && lhs.position == rhs.position + && lhs.reportedPlaceCount == rhs.reportedPlaceCount && lhs.createdAt == rhs.createdAt + && (ignoringModificationTime || lhs.modifiedAt == rhs.modifiedAt) +} + +func mapsSavedTextMatches(_ lhs: String?, _ rhs: String?) -> Bool { + switch (lhs, rhs) { + case (nil, nil): true + case (.some(let lhs), .some(let rhs)): lhs.utf8.elementsEqual(rhs.utf8) + default: false + } +} + +func mapsCollectionItemMatches(_ lhs: MapsCollectionItemRecord, _ rhs: MapsCollectionItemRecord) + -> Bool +{ + lhs.id == rhs.id && lhs.kind == rhs.kind && lhs.position == rhs.position + && lhs.createdAt == rhs.createdAt && lhs.modifiedAt == rhs.modifiedAt + && lhs.latitude == rhs.latitude && lhs.longitude == rhs.longitude + && mapsSavedTextMatches(lhs.customName, rhs.customName) + && mapsSavedTextMatches(lhs.placeName, rhs.placeName) + && mapsSavedTextMatches(lhs.address, rhs.address) + && mapsSavedTextMatches(lhs.category, rhs.category) + && mapsSavedTextMatches(lhs.note, rhs.note) + && mapsSavedTextMatches(lhs.nativeIdentifier, rhs.nativeIdentifier) + && lhs.transitLineIdentifier == rhs.transitLineIdentifier +} + +func mapsCollectionVerificationFailure(_ id: String, reason: String) -> CLIError { + CLIError( + code: .backendUnavailable, message: "The saved Maps mutation could not be verified.", + details: ["id": id, "reason": reason]) +} diff --git a/Sources/MapsCLI/Command.swift b/Sources/MapsCLI/Command.swift index 8d3b659..cf8474f 100644 --- a/Sources/MapsCLI/Command.swift +++ b/Sources/MapsCLI/Command.swift @@ -1,43 +1,85 @@ -import AppKit -import CoreLocation -import CryptoKit import Foundation import Utility public struct MapsCommand: Sendable { private let reader: any MapsReading private let opener: any MapsOpening + private let router: any MapsRouting + private let favorites: any MapsFavoritesReading + let collections: any MapsCollectionsReading + let collectionWriter: any MapsCollectionsWriting private let target = "maps" public init( - reader: any MapsReading = CoreLocationMapsBackend(), - opener: any MapsOpening = NSWorkspaceMapsOpener() + reader: any MapsReading = MapKitMapsBackend(), + opener: any MapsOpening = NSWorkspaceMapsOpener(), + router: any MapsRouting = MapKitMapsBackend(), + favorites: any MapsFavoritesReading = MapsSyncSavedPlacesBackend(), + collections: any MapsCollectionsReading = MapsSyncSavedPlacesBackend(), + collectionWriter: any MapsCollectionsWriting = MapsSyncSavedPlacesBackend() ) { self.reader = reader self.opener = opener + self.router = router + self.favorites = favorites + self.collections = collections + self.collectionWriter = collectionWriter } public func run(options: CLIOptions) throws -> CLICommandResult? { + if let mutation = try runCollectionMutation(options) { return mutation } switch options.positionals { - case ["places", "search"]: + case ["favorites", "list"]: try validateReadOnly(options) - try validateTargetOptions(options, allowedOptions: ["query"]) - let query = try requiredOption("query", options: options) - guard query.trimmingCharacters(in: .whitespacesAndNewlines).count >= 2 else { - throw CLIError( - code: .validationError, - message: "`--query` must contain at least 2 non-whitespace characters.") - } - let places = try reader.searchPlaces(query: query, limit: try commandLimit(options)) + try validateTargetOptions(options, allowedOptions: ["offset"]) + let response = try favorites.listFavorites(savedListRequest(options)) + let human = response.favorites.map { + "\($0.id) \($0.customName ?? $0.placeName ?? "")" + }.joined(separator: "\n") + return try result(response, human: human, options: options) + case ["favorites", "read"]: + try validateSavedRead(options) + let record = try favorites.readFavorite(id: savedIdentifier(options, kind: .favorite)) + return try result( + MapsFavoriteResponse(favorite: record), + human: "\(record.id) \(record.customName ?? record.placeName ?? "")", options: options) + case ["collections", "list"]: + try validateReadOnly(options) + try validateTargetOptions(options, allowedOptions: ["offset"]) + let response = try collections.listCollections(savedListRequest(options)) + return try result( + response, + human: response.collections.map { "\($0.id) \($0.title ?? "")" }.joined(separator: "\n"), + options: options) + case ["collections", "read"]: + try validateSavedRead(options) + let record = try collections.readCollection(id: savedIdentifier(options, kind: .collection)) return try result( - MapsPlacesResponse(places: places), human: placesHumanOutput(places), options: options) + MapsCollectionResponse(collection: record), human: "\(record.id) \(record.title ?? "")", + options: options) + case ["collections", "places", "list"]: + try validateReadOnly(options) + try validateTargetOptions(options, allowedOptions: ["id", "offset"]) + let response = try collections.listCollectionItems( + id: savedIdentifier(options, kind: .collection), request: savedListRequest(options)) + return try result( + response, + human: response.items.map { + "\($0.id) \($0.customName ?? $0.placeName ?? $0.transitLineIdentifier ?? "")" + }.joined(separator: "\n"), options: options) + case ["places", "search"]: + try validateReadOnly(options) + try validateTargetOptions( + options, + allowedOptions: [ + "query", "kind", "region-latitude", "region-longitude", "region-span-meters", + ]) + let response = try reader.searchPlaces(searchRequest(options)) + return try result(response, human: placesHumanOutput(response.places), options: options) case ["places", "read"]: try validateReadOnly(options) - try validateTargetOptions(options, allowedOptions: ["latitude", "longitude", "name"]) - let latitude = try coordinateOption("latitude", options: options) - let longitude = try coordinateOption("longitude", options: options) - let place = try reader.readPlace( - latitude: latitude, longitude: longitude, name: options.targetOption("name")) + try validateTargetOptions(options, allowedOptions: ["id", "latitude", "longitude", "name"]) + let place = try reader.readPlace(placeSelection(options)) return try result( MapsPlaceResponse(place: place), human: placeHumanOutput(place), options: options) case ["directions", "preview"]: @@ -60,6 +102,22 @@ public struct MapsCommand: Sendable { return try result( MapsDirectionsResponse(directions: preview), human: directionsHumanOutput(preview), options: options) + case ["directions", "calculate"], ["directions", "eta"]: + try validateReadOnly(options) + let eta = options.positionals.last == "eta" + try validateTargetOptions( + options, + allowedOptions: [ + "from", "from-latitude", "from-longitude", "from-name", + "to", "to-latitude", "to-longitude", "to-name", "mode", "departure", "arrival", + ], allowedFlags: eta ? [] : ["alternatives"]) + let request = try directionsRequest(options, eta: eta) + if eta { + let response = try router.calculateETA(request) + return try result(response, human: etaHumanOutput(response), options: options) + } + let response = try router.calculateDirections(request) + return try result(response, human: routesHumanOutput(response), options: options) case ["maps", "open"]: try validateTargetOptions(options, allowedOptions: ["url"]) let url = try validatedMapsURL(requiredOption("url", options: options)) @@ -75,7 +133,6 @@ public struct MapsCommand: Sendable { let summary = ["url": url.absoluteString] if options.dryRun { - try validateDryRunOptions(options) return try result( CLISafety.dryRun( target: target, @@ -105,7 +162,7 @@ public struct MapsCommand: Sendable { ) } - private func result(_ payload: some Encodable, human: String, options: CLIOptions) throws + func result(_ payload: some Encodable, human: String, options: CLIOptions) throws -> CLICommandResult { if options.json { diff --git a/Sources/MapsCLI/Commands.swift b/Sources/MapsCLI/Commands.swift index 068812a..a96602d 100644 --- a/Sources/MapsCLI/Commands.swift +++ b/Sources/MapsCLI/Commands.swift @@ -4,14 +4,17 @@ import Utility public struct MapsTarget: ParsableCommand { public static let targetName = "maps" public static let targetStatus = - "Implemented: places search/read, coordinate-aware directions preview, and dry-run previewed Maps open paths." + "Implemented: native places, saved favorites, collection management, routes, ETA, link previews, and gated Maps open." public static let isImplemented = true public static let configuration = CommandConfiguration( commandName: "maps", - abstract: "Places, directions previews, and Maps open actions.", + abstract: + "Search places, manage saved collections, read favorites, calculate routes and ETA, or open Maps.", version: CLIVersion.current, - subcommands: [Places.self, Directions.self, Open.self, Doctor.self] + subcommands: [ + Places.self, Favorites.self, Collections.self, Directions.self, Open.self, Doctor.self, + ] ) @OptionGroup public var shared: CLISharedOptions @@ -38,7 +41,7 @@ public struct MapsTarget: ParsableCommand { public static let configuration = CommandConfiguration(commandName: "search") public static let positionals = ["places", "search"] @OptionGroup public var shared: CLISharedOptions - @OptionGroup public var targetOptions: MapsTargetOptions + @OptionGroup public var targetOptions: MapsSearchOptions public init() {} } @@ -46,23 +49,149 @@ public struct MapsTarget: ParsableCommand { public static let configuration = CommandConfiguration(commandName: "read") public static let positionals = ["places", "read"] @OptionGroup public var shared: CLISharedOptions - @OptionGroup public var targetOptions: MapsTargetOptions + @OptionGroup public var targetOptions: MapsReadOptions public init() {} } } + public struct Favorites: ParsableCommand { + public static let configuration = CommandConfiguration( + commandName: "favorites", subcommands: [List.self, Read.self]) + public init() {} + + public struct List: Leaf { + public static let configuration = CommandConfiguration( + commandName: "list", abstract: "Read saved favorites from the current Maps store.") + @OptionGroup public var shared: CLISharedOptions + @OptionGroup public var targetOptions: MapsSavedListOptions + public static let positionals = ["favorites", "list"] + public init() {} + } + + public struct Read: Leaf { + public static let configuration = CommandConfiguration(commandName: "read") + public static let positionals = ["favorites", "read"] + @OptionGroup public var shared: CLISharedOptions + @OptionGroup public var targetOptions: MapsSavedReadOptions + public init() {} + } + } + + public struct Collections: ParsableCommand { + public static let configuration = CommandConfiguration( + commandName: "collections", + subcommands: [List.self, Read.self, Create.self, Update.self, Delete.self, Places.self]) + public init() {} + public struct List: Leaf { + public static let configuration = CommandConfiguration(commandName: "list") + public static let positionals = ["collections", "list"] + @OptionGroup public var shared: CLISharedOptions + @OptionGroup public var targetOptions: MapsSavedListOptions + public init() {} + } + public struct Read: Leaf { + public static let configuration = CommandConfiguration(commandName: "read") + public static let positionals = ["collections", "read"] + @OptionGroup public var shared: CLISharedOptions + @OptionGroup public var targetOptions: MapsSavedReadOptions + public init() {} + } + public struct Create: Leaf { + public static let configuration = CommandConfiguration(commandName: "create") + public static let positionals = ["collections", "create"] + @OptionGroup public var shared: CLISharedOptions + @OptionGroup public var targetOptions: MapsCollectionCreateOptions + public init() {} + } + public struct Update: Leaf { + public static let configuration = CommandConfiguration(commandName: "update") + public static let positionals = ["collections", "update"] + @OptionGroup public var shared: CLISharedOptions + @OptionGroup public var targetOptions: MapsCollectionUpdateOptions + public init() {} + } + public struct Delete: Leaf { + public static let configuration = CommandConfiguration(commandName: "delete") + public static let positionals = ["collections", "delete"] + @OptionGroup public var shared: CLISharedOptions + @OptionGroup public var targetOptions: MapsSavedReadOptions + public init() {} + } + public struct Places: ParsableCommand { + public static let configuration = CommandConfiguration( + commandName: "places", subcommands: [List.self, Create.self, Add.self, Remove.self]) + public init() {} + public struct List: Leaf { + public static let configuration = CommandConfiguration(commandName: "list") + public static let positionals = ["collections", "places", "list"] + @OptionGroup public var shared: CLISharedOptions + @OptionGroup public var targetOptions: MapsCollectionItemsOptions + public init() {} + } + public struct Add: Leaf { + public static let configuration = CommandConfiguration( + commandName: "add", abstract: "Link an existing saved collection item to this collection." + ) + public static let positionals = ["collections", "places", "add"] + @OptionGroup public var shared: CLISharedOptions + @OptionGroup public var targetOptions: MapsCollectionMembershipOptions + public init() {} + } + public struct Create: Leaf { + public static let configuration = CommandConfiguration( + commandName: "create", + abstract: "Save a native place or explicit coordinate to a collection." + ) + public static let positionals = ["collections", "places", "create"] + @OptionGroup public var shared: CLISharedOptions + @OptionGroup public var targetOptions: MapsCollectionPlaceCreateOptions + public init() {} + } + public struct Remove: Leaf { + public static let configuration = CommandConfiguration( + commandName: "remove", + abstract: "Remove an item from this collection, preserving other memberships.") + public static let positionals = ["collections", "places", "remove"] + @OptionGroup public var shared: CLISharedOptions + @OptionGroup public var targetOptions: MapsCollectionMembershipOptions + public init() {} + } + } + } + public struct Directions: ParsableCommand { public static let configuration = CommandConfiguration( commandName: "directions", - subcommands: [Preview.self] + subcommands: [Preview.self, Calculate.self, ETA.self] ) public init() {} public struct Preview: Leaf { - public static let configuration = CommandConfiguration(commandName: "preview") + public static let configuration = CommandConfiguration( + commandName: "preview", + abstract: "Build a directions link without contacting Maps services.") public static let positionals = ["directions", "preview"] @OptionGroup public var shared: CLISharedOptions - @OptionGroup public var targetOptions: MapsTargetOptions + @OptionGroup public var targetOptions: MapsPreviewOptions + public init() {} + } + + public struct Calculate: Leaf { + public static let configuration = CommandConfiguration( + commandName: "calculate", + abstract: "Calculate driving, walking or cycling routes with steps and geometry.") + public static let positionals = ["directions", "calculate"] + @OptionGroup public var shared: CLISharedOptions + @OptionGroup public var targetOptions: MapsCalculateOptions + public init() {} + } + + public struct ETA: Leaf { + public static let configuration = CommandConfiguration( + commandName: "eta", abstract: "Request travel time and distance, including transit ETA.") + public static let positionals = ["directions", "eta"] + @OptionGroup public var shared: CLISharedOptions + @OptionGroup public var targetOptions: MapsETAOptions public init() {} } } @@ -71,7 +200,7 @@ public struct MapsTarget: ParsableCommand { public static let configuration = CommandConfiguration(commandName: "open") public static let positionals = ["maps", "open"] @OptionGroup public var shared: CLISharedOptions - @OptionGroup public var targetOptions: MapsTargetOptions + @OptionGroup public var targetOptions: MapsOpenOptions public init() {} } @@ -122,9 +251,10 @@ extension MapsTarget { } public protocol Leaf: ParsableCommand { + associatedtype TargetOptions: MapsCommandOptions static var positionals: [String] { get } var shared: CLISharedOptions { get } - var targetOptions: MapsTargetOptions { get } + var targetOptions: TargetOptions { get } } } @@ -139,11 +269,144 @@ extension MapsTarget.Leaf { } } -public struct MapsTargetOptions: ParsableArguments, Sendable { - @Option public var query: String? +public protocol MapsCommandOptions: ParsableArguments { + var cliTargetOptions: [String: String] { get } + var cliTargetFlags: Set { get } +} + +extension MapsCommandOptions { + public var cliTargetFlags: Set { [] } +} + +public struct MapsSearchOptions: MapsCommandOptions, Sendable { + @Option(help: "Address or point of interest, at least two characters.") public var query: String + @Option(help: "Search result kind: all (default), poi, or address.") public var kind: String? + @Option( + name: .customLong("region-latitude"), + help: "Explicit search region center latitude; requires longitude.") public var regionLatitude: + String? + @Option( + name: .customLong("region-longitude"), + help: "Explicit search region center longitude; requires latitude.") public var regionLongitude: + String? + @Option( + name: .customLong("region-span-meters"), + help: + "Region width and height in meters, default 10000. This biases search; it is not a distance filter." + ) public var regionSpanMeters: String? + public init() {} + public var cliTargetOptions: [String: String] { + CLITargetOptionBuilder.options([ + ("query", query), ("kind", kind), ("region-latitude", regionLatitude), + ("region-longitude", regionLongitude), ("region-span-meters", regionSpanMeters), + ]) + } +} + +public struct MapsSavedListOptions: MapsCommandOptions, Sendable { + @Option(help: "Number of saved items to skip, from 0 to 1000000; default 0.") public var offset: + Int? + public init() {} + public var cliTargetOptions: [String: String] { + CLITargetOptionBuilder.options([("offset", offset.map(String.init))]) + } +} + +public struct MapsCollectionCreateOptions: MapsCommandOptions, Sendable { + @Option(help: "Collection title.") public var title: String + @Option(help: "Optional description; text is stored as supplied.") public var description: String? + @Option(help: "Optional maps-collection: UUID for a recoverable create request.") public var id: + String? + @Option( + help: ArgumentHelp( + "Native nonnegative position; defaults to after the last collection.", valueName: "index")) + public var position: Int? + public init() {} + public var cliTargetOptions: [String: String] { + CLITargetOptionBuilder.options([ + ("title", title), ("description", description), ("id", id), + ("position", position.map(String.init)), + ]) + } +} + +public struct MapsCollectionUpdateOptions: MapsCommandOptions, Sendable { + @Option(help: "Native maps-collection: UUID.") public var id: String + @Option public var title: String? + @Option public var description: String? + @Flag(help: "Clear the stored description.") public var clearDescription = false + @Option(help: ArgumentHelp("Native nonnegative position.", valueName: "index")) public + var position: Int? + public init() {} + public var cliTargetOptions: [String: String] { + CLITargetOptionBuilder.options([ + ("id", id), ("title", title), ("description", description), + ("position", position.map(String.init)), + ]) + } + public var cliTargetFlags: Set { clearDescription ? ["clear-description"] : [] } +} + +public struct MapsCollectionMembershipOptions: MapsCommandOptions, Sendable { + @Option(help: "Native maps-collection: UUID.") public var id: String + @Option(help: "Native maps-collection-item: UUID from a collection member list.") public var item: + String + public init() {} + public var cliTargetOptions: [String: String] { ["id": id, "item": item] } +} + +public struct MapsCollectionPlaceCreateOptions: MapsCommandOptions, Sendable { + @Option(help: "Native maps-collection: UUID.") public var id: String + @Option(help: "Optional maps-collection-item: UUID for a recoverable create request.") public + var item: String? + @Option(help: "Native maps-item: identifier returned by search; requires macOS 15 or newer.") + public var place: String? + @Option(help: "Explicit latitude; requires longitude and excludes --place.") public var latitude: + String? + @Option(help: "Explicit longitude; requires latitude and excludes --place.") public var longitude: + String? + @Option(help: "Optional custom name, stored as supplied.") public var name: String? + @Option(help: "Optional note, stored as supplied.") public var note: String? + public init() {} + public var cliTargetOptions: [String: String] { + CLITargetOptionBuilder.options([ + ("id", id), ("item", item), ("place", place), ("latitude", latitude), + ("longitude", longitude), ("name", name), ("note", note), + ]) + } +} + +public struct MapsSavedReadOptions: MapsCommandOptions, Sendable { + @Option(help: "Native maps-favorite: or maps-collection: UUID from the corresponding list.") + public var id: String + public init() {} + public var cliTargetOptions: [String: String] { ["id": id] } +} + +public struct MapsCollectionItemsOptions: MapsCommandOptions, Sendable { + @Option(help: "Native maps-collection: UUID from collections list.") public var id: String + @OptionGroup public var page: MapsSavedListOptions + public init() {} + public var cliTargetOptions: [String: String] { + page.cliTargetOptions.merging(["id": id]) { _, id in id } + } +} + +public struct MapsReadOptions: MapsCommandOptions, Sendable { + @Option(help: "Native maps-item: identifier from search, requires macOS 15 or newer.") public + var id: String? @Option public var latitude: String? @Option public var longitude: String? @Option public var name: String? + public init() {} + public var cliTargetOptions: [String: String] { + CLITargetOptionBuilder.options([ + ("id", id), ("latitude", latitude), ("longitude", longitude), ("name", name), + ]) + } +} + +public struct MapsEndpointOptions: ParsableArguments, Sendable { @Option public var from: String? @Option(name: .customLong("from-latitude")) public var fromLatitude: String? @Option(name: .customLong("from-longitude")) public var fromLongitude: String? @@ -152,17 +415,9 @@ public struct MapsTargetOptions: ParsableArguments, Sendable { @Option(name: .customLong("to-latitude")) public var toLatitude: String? @Option(name: .customLong("to-longitude")) public var toLongitude: String? @Option(name: .customLong("to-name")) public var toName: String? - @Option public var mode: String? - @Option public var url: String? - public init() {} - public var cliTargetOptions: [String: String] { CLITargetOptionBuilder.options([ - ("query", query), - ("latitude", latitude), - ("longitude", longitude), - ("name", name), ("from", from), ("from-latitude", fromLatitude), ("from-longitude", fromLongitude), @@ -171,12 +426,60 @@ public struct MapsTargetOptions: ParsableArguments, Sendable { ("to-latitude", toLatitude), ("to-longitude", toLongitude), ("to-name", toName), - ("mode", mode), - ("url", url), ]) } +} - public var cliTargetFlags: Set { [] } +public struct MapsPreviewOptions: MapsCommandOptions, Sendable { + @OptionGroup public var endpoints: MapsEndpointOptions + @Option(help: "driving (default), walking, or transit.") public var mode: String? + public init() {} + public var cliTargetOptions: [String: String] { + endpoints.cliTargetOptions.merging(CLITargetOptionBuilder.options([("mode", mode)])) { + _, value in value + } + } +} + +public struct MapsRouteOptions: ParsableArguments, Sendable { + @OptionGroup public var endpoints: MapsEndpointOptions + @Option(help: "driving (default), walking, cycling, or transit (ETA only).") public var mode: + String? + @Option(help: "Departure time as ISO 8601 with time zone; exclusive with arrival.") public + var departure: String? + @Option(help: "Arrival time as ISO 8601 with time zone; exclusive with departure.") public + var arrival: String? + public init() {} + public var cliTargetOptions: [String: String] { + endpoints.cliTargetOptions.merging( + CLITargetOptionBuilder.options([ + ("mode", mode), ("departure", departure), ("arrival", arrival), + ]) + ) { _, value in value } + } +} + +public struct MapsCalculateOptions: MapsCommandOptions, Sendable { + @OptionGroup public var route: MapsRouteOptions + @Flag(help: "Ask Maps for alternative routes; availability depends on the service.") public + var alternatives = false + public init() {} + public var cliTargetOptions: [String: String] { route.cliTargetOptions } + public var cliTargetFlags: Set { + CLITargetOptionBuilder.flags([("alternatives", alternatives)]) + } +} + +public struct MapsETAOptions: MapsCommandOptions, Sendable { + @OptionGroup public var route: MapsRouteOptions + public init() {} + public var cliTargetOptions: [String: String] { route.cliTargetOptions } +} + +public struct MapsOpenOptions: MapsCommandOptions, Sendable { + @Option(help: "A maps: URL.") public var url: String + public init() {} + public var cliTargetOptions: [String: String] { ["url": url] } } public func mapsDoctorChecks() -> [CLIDoctorCheck] { @@ -191,7 +494,7 @@ public func mapsDoctorChecks() -> [CLIDoctorCheck] { name: "maps_backend", status: .ok, message: - "Maps places search/read, coordinate-aware directions preview, and dry-run previewed open commands are implemented." + "MapKit place search, native place ID lookup, routes and ETA are implemented; requests depend on network and regional service availability. Doctor does not make network requests." ), ] } diff --git a/Sources/MapsCLI/Models.swift b/Sources/MapsCLI/Models.swift index dd52c3d..d779117 100644 --- a/Sources/MapsCLI/Models.swift +++ b/Sources/MapsCLI/Models.swift @@ -1,8 +1,4 @@ -import AppKit -import CoreLocation -import CryptoKit import Foundation -import Utility public struct MapsPlaceRecord: Codable, Equatable, Sendable { public var id: String @@ -11,6 +7,11 @@ public struct MapsPlaceRecord: Codable, Equatable, Sendable { public var latitude: Double? public var longitude: Double? public var mapsURL: String + public var nativeIdentifier: String? + public var phoneNumber: String? + public var website: String? + public var category: String? + public var timeZone: String? public init( id: String, @@ -18,7 +19,12 @@ public struct MapsPlaceRecord: Codable, Equatable, Sendable { address: String? = nil, latitude: Double? = nil, longitude: Double? = nil, - mapsURL: String + mapsURL: String, + nativeIdentifier: String? = nil, + phoneNumber: String? = nil, + website: String? = nil, + category: String? = nil, + timeZone: String? = nil ) { self.id = id self.name = name @@ -26,17 +32,101 @@ public struct MapsPlaceRecord: Codable, Equatable, Sendable { self.latitude = latitude self.longitude = longitude self.mapsURL = mapsURL + self.nativeIdentifier = nativeIdentifier + self.phoneNumber = phoneNumber + self.website = website + self.category = category + self.timeZone = timeZone } } public struct MapsPlacesResponse: Codable, Equatable, Sendable { public var places: [MapsPlaceRecord] + public var returnedByService: Int + public var truncated: Bool + + public init(places: [MapsPlaceRecord], returnedByService: Int, truncated: Bool) { + self.places = places + self.returnedByService = returnedByService + self.truncated = truncated + } +} + +public enum MapsSearchKind: String, Codable, Sendable { + case all, poi, address +} + +public struct MapsSearchRegion: Equatable, Sendable { + public var latitude: Double + public var longitude: Double + public var spanMeters: Double +} + +public struct MapsSearchRequest: Equatable, Sendable { + public var query: String + public var kind: MapsSearchKind + public var region: MapsSearchRegion? + public var limit: Int +} + +public enum MapsPlaceSelection: Equatable, Sendable { + case identifier(String) + case coordinate(latitude: Double, longitude: Double, name: String?) } public struct MapsPlaceResponse: Codable, Equatable, Sendable { public var place: MapsPlaceRecord } +public struct MapsFavoriteRecord: Codable, Equatable, Sendable { + public var id: String + public var customName: String? + public var placeName: String? + public var address: String? + public var latitude: Double? + public var longitude: Double? + public var hidden: Bool + public var position: Int + public var createdAt: Date? + public var modifiedAt: Date? + + public init( + id: String, customName: String? = nil, placeName: String? = nil, address: String? = nil, + latitude: Double? = nil, longitude: Double? = nil, hidden: Bool, position: Int, + createdAt: Date? = nil, modifiedAt: Date? = nil + ) { + self.id = id + self.customName = customName + self.placeName = placeName + self.address = address + self.latitude = latitude + self.longitude = longitude + self.hidden = hidden + self.position = position + self.createdAt = createdAt + self.modifiedAt = modifiedAt + } +} + +public struct MapsFavoritesResponse: Codable, Equatable, Sendable { + public var favorites: [MapsFavoriteRecord] + public var limit: Int + public var hasMore: Bool + public var offset: Int + public var nextOffset: Int? + + public init( + favorites: [MapsFavoriteRecord], limit: Int, hasMore: Bool, offset: Int = 0, + nextOffset: Int? = nil + ) { + self.favorites = favorites + self.limit = limit + self.hasMore = hasMore + self.offset = offset + self.nextOffset = nextOffset + } +} + public struct MapsRouteEndpoint: Codable, Equatable, Sendable { public var id: String public var query: String? @@ -91,6 +181,66 @@ public struct MapsDirectionsResponse: Codable, Equatable, Sendable { public var directions: MapsDirectionsPreview } +public enum MapsTransportMode: String, Codable, Sendable { + case driving, walking, transit, cycling +} + +public struct MapsDirectionsRequest: Equatable, Sendable { + public var source: MapsRouteEndpoint + public var destination: MapsRouteEndpoint + public var mode: MapsTransportMode + public var alternatives: Bool + public var departure: Date? + public var arrival: Date? + public var limit: Int +} + +public struct MapsCoordinate: Codable, Equatable, Sendable { + public var latitude: Double + public var longitude: Double +} + +public struct MapsRouteStep: Codable, Equatable, Sendable { + public var instructions: String + public var notice: String? + public var distanceMeters: Double + public var mode: String +} + +public struct MapsRouteRecord: Codable, Equatable, Sendable { + public var name: String + public var distanceMeters: Double + public var expectedTravelTimeSeconds: Double + public var mode: String + public var advisoryNotices: [String] + public var hasTolls: Bool + public var hasHighways: Bool + public var steps: [MapsRouteStep] + public var stepCount: Int + public var stepsTruncated: Bool + public var polyline: [MapsCoordinate] + public var polylinePointCount: Int + public var polylineTruncated: Bool +} + +public struct MapsRoutesResponse: Codable, Equatable, Sendable { + public var source: MapsPlaceRecord + public var destination: MapsPlaceRecord + public var mode: MapsTransportMode + public var routes: [MapsRouteRecord] + public var returnedByService: Int + public var truncated: Bool +} + +public struct MapsETAResponse: Codable, Equatable, Sendable { + public var source: MapsPlaceRecord + public var destination: MapsPlaceRecord + public var mode: MapsTransportMode + public var distanceMeters: Double + public var expectedTravelTimeSeconds: Double + public var expectedDeparture: Date + public var expectedArrival: Date +} public struct MapsOpenResult: Codable, Equatable, Sendable { public var operation: String diff --git a/Sources/MapsCLI/NativeCallback.swift b/Sources/MapsCLI/NativeCallback.swift new file mode 100644 index 0000000..ca7629c --- /dev/null +++ b/Sources/MapsCLI/NativeCallback.swift @@ -0,0 +1,69 @@ +import Foundation +import Utility + +struct MapsDeadline { + let uptime: TimeInterval + init(seconds: TimeInterval) { uptime = ProcessInfo.processInfo.systemUptime + seconds } +} + +// MapKit delivers on the main queue. A synchronous CLI must keep that queue runnable. +// Invoke from the CLI entrypoint or a worker, rather than blocking an active MainActor job. +func waitForMapsCallback( + deadline: MapsDeadline, phase: String, cancel: () -> Void, + start: (@escaping @Sendable (Result) -> Void) -> Void +) throws -> Value { + let box = MapsCallbackBox() + guard deadline.uptime > ProcessInfo.processInfo.systemUptime else { + throw CLIError(code: .timeout, message: "Maps request timed out.", details: ["phase": phase]) + } + start { box.finish($0) } + while ProcessInfo.processInfo.systemUptime < deadline.uptime { + if let result = box.take() { return try result.get() } + let remaining = deadline.uptime - ProcessInfo.processInfo.systemUptime + if remaining <= 0 { break } + let slice = min(0.02, remaining) + if Thread.isMainThread { + _ = RunLoop.current.run(mode: .default, before: Date(timeIntervalSinceNow: slice)) + } else { + box.wait(seconds: slice) + } + } + if let result = box.close() { return try result.get() } + cancel() + throw CLIError(code: .timeout, message: "Maps request timed out.", details: ["phase": phase]) +} + +private final class MapsCallbackBox: @unchecked Sendable { + private let condition = NSCondition() + private var result: Result? + private var closed = false + + func finish(_ value: Result) { + condition.lock() + defer { condition.unlock() } + guard !closed, result == nil else { return } + result = value + condition.signal() + } + + func take() -> Result? { + condition.lock() + defer { condition.unlock() } + guard let result else { return nil } + closed = true + return result + } + + func wait(seconds: TimeInterval) { + condition.lock() + defer { condition.unlock() } + if result == nil, !closed { _ = condition.wait(until: Date(timeIntervalSinceNow: seconds)) } + } + + func close() -> Result? { + condition.lock() + defer { condition.unlock() } + closed = true + return result + } +} diff --git a/Sources/MapsCLI/NativeCollectionChanges.swift b/Sources/MapsCLI/NativeCollectionChanges.swift new file mode 100644 index 0000000..1e59c1e --- /dev/null +++ b/Sources/MapsCLI/NativeCollectionChanges.swift @@ -0,0 +1,103 @@ +import Foundation +import ObjectiveC +import Utility + +extension MapsSavedRuntime { + func validateCollectionSetters(_ receiver: AnyObject, patch: MapsCollectionPatch) throws { + guard let cls = object_getClass(receiver) else { throw failure("object_class_unavailable") } + if patch.title != nil { _ = try method(cls, "setTitle:", result: "v", arguments: ["@"]) } + if patch.description != nil || patch.clearDescription { + _ = try method(cls, "setCollectionDescription:", result: "v", arguments: ["@"]) + } + if patch.position != nil { + _ = try method(cls, "setPositionIndex:", result: "v", arguments: ["q"]) + } + } + + func setObject(_ receiver: AnyObject, selector: String, value: AnyObject?) throws { + guard let cls = object_getClass(receiver) else { throw failure("object_class_unavailable") } + let method = try method(cls, selector, result: "v", arguments: ["@"]) + typealias Call = @convention(c) (AnyObject, Selector, AnyObject?) -> Void + unsafeBitCast(method_getImplementation(method), to: Call.self)( + receiver, NSSelectorFromString(selector), value) + } + + func setPosition(_ receiver: AnyObject, position: Int) throws { + guard let cls = object_getClass(receiver) else { throw failure("object_class_unavailable") } + let method = try method(cls, "setPositionIndex:", result: "v", arguments: ["q"]) + typealias Call = @convention(c) (AnyObject, Selector, Int) -> Void + unsafeBitCast(method_getImplementation(method), to: Call.self)( + receiver, NSSelectorFromString("setPositionIndex:"), position) + } + + func createCollection(_ draft: MapsCollectionDraft, position: Int, store: AnyObject) throws + -> AnyObject + { + let cls: AnyClass = try type("MSCollection") + let name = "initWithStore:collectionDescription:image:imageUrl:positionIndex:title:" + let method = try method(cls, name, result: "@", arguments: ["@", "@", "@", "@", "q", "@"]) + _ = try self.method(cls, "setIdentifier:", result: "v", arguments: ["@"]) + typealias Call = + @convention(c) ( + AnyObject, Selector, AnyObject, AnyObject?, AnyObject?, AnyObject?, Int, AnyObject + ) -> Unmanaged? + guard + let collection = unsafeBitCast(method_getImplementation(method), to: Call.self)( + try allocate(cls).takeUnretainedValue(), NSSelectorFromString(name), store, + draft.description.map { $0 as NSString }, nil, nil, position, draft.title as NSString)? + .takeRetainedValue() + else { throw failure("collection_initialization_failed") } + try setObject(collection, selector: "setIdentifier:", value: draft.id as NSUUID) + guard + try identifier(collection, kind: .collection) == MapsSavedKind.collection.idPrefix + + draft.id.uuidString.lowercased() + else { throw failure("collection_identity_mismatch") } + return collection + } + + func collectionChange( + _ store: AnyObject, objects: [AnyObject], delete: Bool, deadline: MapsDeadline + ) throws { + let name = + delete ? "deleteWithObjects:completionHandler:" : "saveWithObjects:completionHandler:" + let method = try collectionChangeMethod(store, delete: delete) + typealias Block = @convention(block) (NSError?) -> Void + typealias Call = @convention(c) (AnyObject, Selector, NSArray, Block) -> Void + let call = unsafeBitCast(method_getImplementation(method), to: Call.self) + let _: Bool = try waitForMapsCallback( + deadline: deadline, phase: delete ? "saved_delete" : "saved_save", cancel: {}, + start: { finish in + let block: Block = { error in + finish( + nativeMapsResponse(true, error: error, phase: delete ? "saved_delete" : "saved_save")) + } + call(store, NSSelectorFromString(name), objects as NSArray, block) + }) + } + + func validateCollectionChange(_ store: AnyObject, delete: Bool) throws { + _ = try collectionChangeMethod(store, delete: delete) + } + + private func collectionChangeMethod(_ store: AnyObject, delete: Bool) throws -> Method { + let name = + delete ? "deleteWithObjects:completionHandler:" : "saveWithObjects:completionHandler:" + guard let cls = object_getClass(store), + let method = class_getInstanceMethod(cls, NSSelectorFromString(name)), + let encoding = method_getTypeEncoding(method), + String(cString: encoding) == "v32@0:8@\"NSArray\"16@?24" + else { throw failure("signature_mismatch", selector: name) } + return method + } + + func setMembership(_ collection: AnyObject, item: AnyObject, linked: Bool) throws { + guard let cls = object_getClass(collection) else { + throw failure("object_class_unavailable") + } + let name = linked ? "addPlace:" : "removePlace:" + let method = try method(cls, name, result: "v", arguments: ["@"]) + typealias Call = @convention(c) (AnyObject, Selector, AnyObject) -> Void + unsafeBitCast(method_getImplementation(method), to: Call.self)( + collection, NSSelectorFromString(name), item) + } +} diff --git a/Sources/MapsCLI/NativeProjection.swift b/Sources/MapsCLI/NativeProjection.swift new file mode 100644 index 0000000..fc0f86f --- /dev/null +++ b/Sources/MapsCLI/NativeProjection.swift @@ -0,0 +1,137 @@ +import CoreLocation +import Foundation +import MapKit +import Utility + +func coordinateMapItem(latitude: Double, longitude: Double, name: String?) -> MKMapItem { + let item: MKMapItem + if #available(macOS 26, *) { + item = MKMapItem(location: CLLocation(latitude: latitude, longitude: longitude), address: nil) + } else { + item = MKMapItem( + placemark: MKPlacemark( + coordinate: CLLocationCoordinate2D(latitude: latitude, longitude: longitude))) + } + item.name = name + return item +} + +func placeRecord(_ item: MKMapItem, fallbackName: String) throws -> MapsPlaceRecord { + let coordinate: CLLocationCoordinate2D + let address: String? + if #available(macOS 26, *) { + coordinate = item.location.coordinate + address = item.addressRepresentations?.fullAddress(includingRegion: true, singleLine: true) + } else { + coordinate = item.placemark.coordinate + address = formattedAddress(item.placemark) + } + var record = try coordinatePlace( + latitude: coordinate.latitude, longitude: coordinate.longitude, + name: item.name ?? fallbackName, address: address) + if #available(macOS 15, *), let identifier = item.identifier { + record.nativeIdentifier = identifier.rawValue + record.id = "maps-item:\(identifier.rawValue)" + } + record.phoneNumber = item.phoneNumber + record.website = item.url?.absoluteString + record.category = item.pointOfInterestCategory?.rawValue + record.timeZone = item.timeZone?.identifier + return record +} + +func uniqueMapItem(_ items: [MKMapItem], label: String) throws -> MKMapItem { + guard let item = items.first else { + throw CLIError( + code: .notFound, message: "The route endpoint could not be resolved.", + details: ["endpoint": label]) + } + guard items.count == 1 else { + throw CLIError( + code: .ambiguousIdentity, + message: + "The route endpoint matches multiple places. Search and select explicit coordinates.", + details: ["endpoint": label, "candidate_count": String(items.count)]) + } + return item +} + +func nativeMapsResponse(_ response: Value?, error: (any Error)?, phase: String) -> Result< + Value, any Error +> { + if let error { + let native = error as NSError + let missing = + native.domain == MKErrorDomain + && [ + Int(MKError.Code.placemarkNotFound.rawValue), Int(MKError.Code.directionsNotFound.rawValue), + ].contains(native.code) + var details = CLIError.diagnosticDetails(for: error) + details["phase"] = phase + return .failure( + CLIError( + code: missing ? .notFound : .backendUnavailable, + message: missing + ? "Maps returned no matching place or route." : "Maps service request failed.", + details: details)) + } + guard let response else { + return .failure( + CLIError( + code: .backendUnavailable, message: "Maps service returned no response.", + details: ["phase": phase])) + } + return .success(response) +} + +extension MapsTransportMode { + var nativeType: MKDirectionsTransportType { + switch self { + case .driving: .automobile + case .walking: .walking + case .transit: .transit + case .cycling: .cycling + } + } +} + +func transportName(_ type: MKDirectionsTransportType) -> String { + switch type { + case .automobile: "driving" + case .walking: "walking" + case .transit: "transit" + case .cycling: "cycling" + default: "unknown:\(type.rawValue)" + } +} + +func requireTransportType(_ type: MKDirectionsTransportType, mode: MapsTransportMode) throws { + guard type == mode.nativeType else { + throw CLIError( + code: .backendUnavailable, message: "Maps returned a different transport mode.", + details: [ + "requested_mode": mode.rawValue, "returned_mode": transportName(type), + ]) + } +} + +func routeRecord(_ route: MKRoute) -> MapsRouteRecord { + let pointCount = route.polyline.pointCount + let cap = min(pointCount, 20_000) + var coordinates = [CLLocationCoordinate2D](repeating: CLLocationCoordinate2D(), count: cap) + if cap > 0 { + route.polyline.getCoordinates(&coordinates, range: NSRange(location: 0, length: cap)) + } + return MapsRouteRecord( + name: route.name, distanceMeters: route.distance, + expectedTravelTimeSeconds: route.expectedTravelTime, + mode: transportName(route.transportType), advisoryNotices: route.advisoryNotices, + hasTolls: route.hasTolls, hasHighways: route.hasHighways, + steps: route.steps.prefix(2_000).map { + MapsRouteStep( + instructions: $0.instructions, notice: $0.notice, distanceMeters: $0.distance, + mode: transportName($0.transportType)) + }, stepCount: route.steps.count, stepsTruncated: route.steps.count > 2_000, + polyline: coordinates.map { MapsCoordinate(latitude: $0.latitude, longitude: $0.longitude) }, + polylinePointCount: pointCount, polylineTruncated: pointCount > cap) +} diff --git a/Sources/MapsCLI/NativeSavedPlace.swift b/Sources/MapsCLI/NativeSavedPlace.swift new file mode 100644 index 0000000..faa3aad --- /dev/null +++ b/Sources/MapsCLI/NativeSavedPlace.swift @@ -0,0 +1,183 @@ +import Foundation +import MapKit +import ObjectiveC +import Utility + +struct MapsSavedPlaceSnapshot { + var record: MapsCollectionItemRecord + var dictionary: NSDictionary + var unknownFields: Data? + var nativeMUID: UInt64 + var hasMUID: Bool + var storedMUID: NSNumber? + var droppedPinCoordinate: Data? + var floor: Int32 + var origin: Int16 + var type: Int16 + var hasOriginalIdentifier: Bool + var refreshedAt: Date? + + func matches(_ other: Self, allowingCreationTimes: Bool = false) -> Bool { + var expected = record + if allowingCreationTimes { + expected.createdAt = other.record.createdAt + expected.modifiedAt = other.record.modifiedAt + } + return mapsCollectionItemMatches(expected, other.record) + && dictionary.isEqual(other.dictionary) && unknownFields == other.unknownFields + && nativeMUID == other.nativeMUID && hasMUID == other.hasMUID + && storedMUID == other.storedMUID && droppedPinCoordinate == other.droppedPinCoordinate + && floor == other.floor && origin == other.origin && type == other.type + && hasOriginalIdentifier == other.hasOriginalIdentifier && refreshedAt == other.refreshedAt + } + + func matches(_ draft: MapsCollectionPlaceDraft) -> Bool { + guard record.id == MapsSavedKind.collectionItem.idPrefix + draft.id.uuidString.lowercased(), + record.kind == .place, mapsSavedTextMatches(record.customName, draft.customName), + mapsSavedTextMatches(record.note, draft.note), !hasOriginalIdentifier, + droppedPinCoordinate == nil, floor == 0, origin == 0, type == 0 + else { return false } + switch draft.source { + case .identifier(let id): + return mapsSavedTextMatches(record.nativeIdentifier, id) && hasMUID && nativeMUID > 0 + && storedMUID?.uint64Value == nativeMUID + case .coordinate(let latitude, let longitude): + return record.nativeIdentifier == nil && !hasMUID && nativeMUID == 0 && storedMUID == nil + && record.latitude == latitude && record.longitude == longitude + && mapsSavedTextMatches(record.placeName, draft.customName) + } + } +} + +extension MapsSavedRuntime { + func savedPlaceStorage(_ place: AnyObject) throws -> AnyObject? { + guard let storage = try object(place, "mapItemStorage") else { return nil } + guard let native = storage as? NSObject, native.isKind(of: try type("GEOMapItemStorage")) else { + throw failure("saved_place_storage_type_mismatch") + } + return storage + } + + func mapItem(_ storage: AnyObject) throws -> MKMapItem { + guard let geo = try object(storage, "_geoMapItem") else { + throw failure("saved_geo_item_unavailable") + } + let name = "_itemWithGeoMapItem:" + let method = try method(MKMapItem.self, name, classMethod: true, result: "@", arguments: ["@"]) + typealias Call = @convention(c) (AnyObject, Selector, AnyObject) -> Unmanaged? + guard + let item = unsafeBitCast(method_getImplementation(method), to: Call.self)( + MKMapItem.self as AnyObject, NSSelectorFromString(name), geo)?.takeUnretainedValue() + as? MKMapItem + else { throw failure("saved_map_item_unavailable") } + return item + } + + func savedPlaceIdentifier(_ place: AnyObject) throws -> String? { + guard #available(macOS 15, *), let storage = try savedPlaceStorage(place) else { return nil } + return try mapItem(storage).identifier?.rawValue + } + + func unsigned(_ receiver: AnyObject, _ selector: String) throws -> UInt64 { + guard let cls = object_getClass(receiver) else { throw failure("object_class_unavailable") } + let method = try method(cls, selector, result: "Q") + typealias Call = @convention(c) (AnyObject, Selector) -> UInt64 + return unsafeBitCast(method_getImplementation(method), to: Call.self)( + receiver, NSSelectorFromString(selector)) + } + + func short(_ receiver: AnyObject, _ selector: String) throws -> Int16 { + guard let cls = object_getClass(receiver) else { throw failure("object_class_unavailable") } + let method = try method(cls, selector, result: "s") + typealias Call = @convention(c) (AnyObject, Selector) -> Int16 + return unsafeBitCast(method_getImplementation(method), to: Call.self)( + receiver, NSSelectorFromString(selector)) + } + + func savedPlaceSnapshot(_ place: AnyObject) throws -> MapsSavedPlaceSnapshot { + let record = try collectionItemRecord(place) + guard record.kind == .place, let storage = try savedPlaceStorage(place), + let dictionary = try value(storage, "dictionaryRepresentation", as: NSDictionary.self) + else { throw failure("saved_place_snapshot_unavailable") } + let unknown = try object(storage, "unknownFields") + let bytes: Data? + if let unknown { + guard let data = try value(unknown, "data", as: Data.self) else { + throw failure("saved_place_unknown_fields_unavailable") + } + bytes = data + } else { + bytes = nil + } + guard let cls = object_getClass(place) else { throw failure("object_class_unavailable") } + let floorMethod = try method(cls, "droppedPinFloorOrdinal", result: "i") + typealias Floor = @convention(c) (AnyObject, Selector) -> Int32 + return try MapsSavedPlaceSnapshot( + record: record, dictionary: dictionary, unknownFields: bytes, + nativeMUID: unsigned(storage, "_muid"), hasMUID: boolean(storage, "_hasMUID"), + storedMUID: value(place, "muid", as: NSNumber.self), + droppedPinCoordinate: value(place, "droppedPinCoordinate", as: Data.self), + floor: unsafeBitCast(method_getImplementation(floorMethod), to: Floor.self)( + place, NSSelectorFromString("droppedPinFloorOrdinal")), + origin: short(place, "origin"), type: short(place, "type"), + hasOriginalIdentifier: object(place, "originalIdentifier") != nil, + refreshedAt: value(place, "mapItemLastRefreshed", as: Date.self)) + } + + func createPlace(_ draft: MapsCollectionPlaceDraft, item: MKMapItem, store: AnyObject) throws + -> AnyObject + { + let cls: AnyClass = try type("MSCollectionPlaceItem") + let stripName = "strippedMapItemWith:" + let strip = try method(cls, stripName, classMethod: true, result: "@", arguments: ["@"]) + typealias Strip = @convention(c) (AnyObject, Selector, AnyObject) -> Unmanaged? + guard let source = try object(item, "_geoMapItemStorageForPersistence"), + let geo = try object(source, "_geoMapItem"), + let storage = unsafeBitCast(method_getImplementation(strip), to: Strip.self)( + cls as AnyObject, NSSelectorFromString(stripName), geo)?.takeUnretainedValue(), + let native = storage as? NSObject, native.isKind(of: try type("GEOMapItemStorage")) + else { throw failure("collection_place_storage_unavailable") } + let nativeMUID = try unsigned(storage, "_muid") + let hasMUID = try boolean(storage, "_hasMUID") + let projection = try placeRecord(item, fallbackName: "Place") + switch draft.source { + case .identifier(let id): + guard mapsSavedTextMatches(projection.nativeIdentifier, id), hasMUID, nativeMUID > 0 else { + throw failure("collection_service_identity_unavailable") + } + case .coordinate(let latitude, let longitude): + guard projection.nativeIdentifier == nil, !hasMUID, nativeMUID == 0, + projection.latitude == latitude, projection.longitude == longitude + else { throw failure("collection_coordinate_identity_mismatch") } + } + let name = + "initWithStore:customName:droppedPinCoordinate:droppedPinFloorOrdinal:latitude:longitude:mapItemAddress:mapItemCategory:mapItemLastRefreshed:mapItemName:muid:origin:originalIdentifier:placeItemNote:type:" + let initializer = try method( + cls, name, result: "@", + arguments: ["@", "@", "@", "i", "@", "@", "@", "@", "@", "@", "@", "s", "@", "@", "s"]) + for setter in ["setIdentifier:", "setMapItemStorage:"] { + _ = try method(cls, setter, result: "v", arguments: ["@"]) + } + typealias Create = + @convention(c) ( + AnyObject, Selector, AnyObject, AnyObject?, AnyObject?, Int32, AnyObject?, AnyObject?, + AnyObject?, AnyObject?, AnyObject?, AnyObject?, AnyObject?, Int16, AnyObject?, AnyObject?, + Int16 + ) -> Unmanaged? + guard + let place = unsafeBitCast(method_getImplementation(initializer), to: Create.self)( + try allocate(cls).takeUnretainedValue(), NSSelectorFromString(name), store, + draft.customName.map { $0 as NSString }, nil, 0, + projection.latitude.map { NSNumber(value: $0) }, + projection.longitude.map { NSNumber(value: $0) }, + projection.address.map { $0 as NSString }, projection.category.map { $0 as NSString }, + hasMUID ? Date() as NSDate : nil, item.name.map { $0 as NSString }, + hasMUID ? NSNumber(value: nativeMUID) : nil, 0, nil, draft.note.map { $0 as NSString }, 0 + )?.takeRetainedValue() + else { throw failure("collection_place_initialization_failed") } + try setObject(place, selector: "setIdentifier:", value: draft.id as NSUUID) + // MapsSync's factory owns its persisted GEO representation and pruning rules. + try setObject(place, selector: "setMapItemStorage:", value: storage) + return place + } +} diff --git a/Sources/MapsCLI/NativeStore.swift b/Sources/MapsCLI/NativeStore.swift new file mode 100644 index 0000000..f86d5ab --- /dev/null +++ b/Sources/MapsCLI/NativeStore.swift @@ -0,0 +1,318 @@ +import Darwin +import Foundation +import ObjectiveC +import Utility + +enum MapsSavedKind { + case favorite, collection, collectionItem + var requestClass: String { + switch self { + case .favorite: "MSFavoriteItemRequest" + case .collection: "MSCollectionRequest" + case .collectionItem: "MSCollectionItemRequest" + } + } + var objectClass: String { + switch self { + case .favorite: "MSFavoriteItem" + case .collection: "MSCollection" + case .collectionItem: "MSCollectionItem" + } + } + var idPrefix: String { + switch self { + case .favorite: "maps-favorite:" + case .collection: "maps-collection:" + case .collectionItem: "maps-collection-item:" + } + } +} + +struct MapsSavedRuntime { + init() throws { + guard + dlopen("/System/Library/PrivateFrameworks/MapsSync.framework/MapsSync", RTLD_LOCAL | RTLD_NOW) + != nil + else { + throw failure("framework_unavailable") + } + } + + func failure(_ reason: String, selector: String? = nil) -> CLIError { + var details = ["reason": reason] + if let selector { details["selector"] = selector } + return CLIError( + code: .backendUnavailable, + message: "Saved Maps data could not be accessed through the native framework.", + details: details) + } + + func type(_ name: String) throws -> AnyClass { + guard let cls = NSClassFromString(name) else { throw failure("class_unavailable") } + return cls + } + + func method( + _ cls: AnyClass, _ name: String, classMethod: Bool = false, + result: String, arguments: [String] = [] + ) throws -> Method { + let selector = NSSelectorFromString(name) + guard + let method = classMethod + ? class_getClassMethod(cls, selector) : class_getInstanceMethod(cls, selector), + method_getNumberOfArguments(method) == arguments.count + 2 + else { + throw failure("method_unavailable_or_incompatible", selector: name) + } + let copiedReturn = method_copyReturnType(method) + let actualReturn = String(cString: copiedReturn) + free(copiedReturn) + guard actualReturn == result else { throw failure("signature_mismatch", selector: name) } + for (index, expected) in arguments.enumerated() { + guard let copied = method_copyArgumentType(method, UInt32(index + 2)) else { + throw failure("signature_unavailable", selector: name) + } + let actual = String(cString: copied) + free(copied) + guard actual == expected else { throw failure("signature_mismatch", selector: name) } + } + return method + } + + func allocate(_ cls: AnyClass) throws -> Unmanaged { + let method = try method(cls, "alloc", classMethod: true, result: "@") + typealias Call = @convention(c) (AnyObject, Selector) -> Unmanaged? + let call = unsafeBitCast(method_getImplementation(method), to: Call.self) + guard let object = call(cls as AnyObject, NSSelectorFromString("alloc")) else { + throw failure("allocation_failed") + } + return object + } + + func queryOptions( + limit: Int, offset: Int = 0, predicate: NSPredicate? = nil, ascending: Bool = true + ) throws -> AnyObject { + let rangeClass: AnyClass = try type("MapsSync.MapsSyncRange") + let rangeMethod = try method( + rangeClass, "initWithOffset:limit:", result: "@", arguments: ["q", "q"]) + typealias InitRange = @convention(c) (AnyObject, Selector, Int, Int) -> Unmanaged? + let rangeCall = unsafeBitCast(method_getImplementation(rangeMethod), to: InitRange.self) + guard + let range = rangeCall( + try allocate(rangeClass).takeUnretainedValue(), + NSSelectorFromString("initWithOffset:limit:"), offset, limit)?.takeRetainedValue() + else { + throw failure("range_initialization_failed") + } + guard try integer(range, "offset") == offset, try integer(range, "limit") == limit else { + throw failure("query_range_mismatch") + } + let optionsClass: AnyClass = try type("MapsSync.MapsSyncQueryOptions") + let initializer = try method( + optionsClass, "initWithPredicate:sortDescriptors:range:", + result: "@", arguments: ["@", "@", "@"]) + typealias InitOptions = + @convention(c) (AnyObject, Selector, AnyObject?, AnyObject?, AnyObject?) -> Unmanaged< + AnyObject + >? + let call = unsafeBitCast(method_getImplementation(initializer), to: InitOptions.self) + let sorting = + [ + NSSortDescriptor(key: "positionIndex", ascending: ascending), + NSSortDescriptor(key: "identifier", ascending: true), + ] as NSArray + guard + let options = call( + try allocate(optionsClass).takeUnretainedValue(), + NSSelectorFromString("initWithPredicate:sortDescriptors:range:"), predicate, sorting, range)? + .takeRetainedValue() + else { + throw failure("query_initialization_failed") + } + guard let storedRange = try object(options, "range"), + try integer(storedRange, "offset") == offset, try integer(storedRange, "limit") == limit + else { + throw failure("query_range_mismatch") + } + let notify = try method(optionsClass, "setNotifyOnLoad:", result: "v", arguments: ["B"]) + typealias SetBool = @convention(c) (AnyObject, Selector, Bool) -> Void + unsafeBitCast(method_getImplementation(notify), to: SetBool.self)( + options, NSSelectorFromString("setNotifyOnLoad:"), false) + return options + } + + func defaultStore(deadline: MapsDeadline) throws -> AnyObject { + let cls: AnyClass = try type("MapsSync.MapsSyncStore") + let method = try method( + cls, "withDefaultStoreWithBlock:", classMethod: true, result: "v", arguments: ["@?"]) + typealias Block = @convention(block) (AnyObject?, NSError?) -> Void + typealias Call = @convention(c) (AnyObject, Selector, Block) -> Void + let call = unsafeBitCast(method_getImplementation(method), to: Call.self) + return try waitForMapsCallback( + deadline: deadline, phase: "saved_store", cancel: {}, + start: { finish in + let block: Block = { value, error in + finish(nativeMapsResponse(value, error: error, phase: "saved_store")) + } + call(cls as AnyObject, NSSelectorFromString("withDefaultStoreWithBlock:"), block) + }) + } + + func fetch(kind: MapsSavedKind, store: AnyObject, options: AnyObject, deadline: MapsDeadline) + throws -> [AnyObject] + { + let cls: AnyClass = try type(kind.requestClass) + let initializer = try method(cls, "initWithStore:", result: "@", arguments: ["@"]) + typealias InitRequest = @convention(c) (AnyObject, Selector, AnyObject) -> Unmanaged? + let create = unsafeBitCast(method_getImplementation(initializer), to: InitRequest.self) + guard + let request = create( + try allocate(cls).takeUnretainedValue(), NSSelectorFromString("initWithStore:"), store)? + .takeRetainedValue() + else { + throw failure("request_initialization_failed") + } + let selector = NSSelectorFromString("fetchWithOptions:completionHandler:") + // Objective-C's argument parser splits Swift's extended quoted/block encoding. + // Check the complete verified signature before calling the native block API. + guard let method = class_getInstanceMethod(cls, selector), + let encoding = method_getTypeEncoding(method), + String(cString: encoding) + == "v32@0:8@\"_TtC8MapsSync20MapsSyncQueryOptions\"16@?24" + else { + throw failure("signature_mismatch", selector: NSStringFromSelector(selector)) + } + typealias Block = @convention(block) (NSArray?, NSError?) -> Void + typealias Call = @convention(c) (AnyObject, Selector, AnyObject, Block) -> Void + let call = unsafeBitCast(method_getImplementation(method), to: Call.self) + return try waitForMapsCallback( + deadline: deadline, phase: "saved_fetch", cancel: {}, + start: { finish in + let block: Block = { values, error in + finish( + nativeMapsResponse( + values?.map { $0 as AnyObject }, error: error, phase: "saved_fetch")) + } + call(request, selector, options, block) + }) + } + + func object(_ receiver: AnyObject, _ selector: String) throws -> AnyObject? { + guard let cls = object_getClass(receiver) else { throw failure("object_class_unavailable") } + let method = try method(cls, selector, result: "@") + typealias Call = @convention(c) (AnyObject, Selector) -> Unmanaged? + return unsafeBitCast(method_getImplementation(method), to: Call.self)( + receiver, NSSelectorFromString(selector))?.takeUnretainedValue() + } + + func value(_ receiver: AnyObject, _ selector: String, as: Value.Type) throws -> Value? { + guard let object = try object(receiver, selector) else { return nil } + guard let value = object as? Value else { + throw failure("field_type_mismatch", selector: selector) + } + return value + } + + func integer(_ receiver: AnyObject, _ selector: String) throws -> Int { + guard let cls = object_getClass(receiver) else { throw failure("object_class_unavailable") } + let method = try method(cls, selector, result: "q") + typealias Call = @convention(c) (AnyObject, Selector) -> Int + return unsafeBitCast(method_getImplementation(method), to: Call.self)( + receiver, NSSelectorFromString(selector)) + } + + func boolean(_ receiver: AnyObject, _ selector: String) throws -> Bool { + guard let cls = object_getClass(receiver) else { throw failure("object_class_unavailable") } + let method = try method(cls, selector, result: "B") + typealias Call = @convention(c) (AnyObject, Selector) -> Bool + return unsafeBitCast(method_getImplementation(method), to: Call.self)( + receiver, NSSelectorFromString(selector)) + } + + func identifier(_ object: AnyObject, kind: MapsSavedKind) throws -> String { + guard let object = object as? NSObject, object.isKind(of: try type(kind.objectClass)), + let uuid = try value(object, "identifier", as: UUID.self) + else { + throw failure("saved_identity_unavailable") + } + return kind.idPrefix + uuid.uuidString.lowercased() + } + + func coordinates(_ object: AnyObject) throws -> (Double?, Double?) { + let latitude = try value(object, "latitude", as: NSNumber.self)?.doubleValue + let longitude = try value(object, "longitude", as: NSNumber.self)?.doubleValue + guard latitude.map({ $0.isFinite && (-90...90).contains($0) }) ?? true, + longitude.map({ $0.isFinite && (-180...180).contains($0) }) ?? true + else { + throw failure("saved_coordinate_invalid") + } + return (latitude, longitude) + } + + func favoriteRecord(_ object: AnyObject) throws -> MapsFavoriteRecord { + let id = try identifier(object, kind: .favorite) + let (latitude, longitude) = try coordinates(object) + return try MapsFavoriteRecord( + id: id, customName: value(object, "customName", as: String.self), + placeName: value(object, "mapItemName", as: String.self), + address: value(object, "mapItemAddress", as: String.self), + latitude: latitude, longitude: longitude, hidden: boolean(object, "hidden"), + position: integer(object, "positionIndex"), + createdAt: value(object, "createTime", as: Date.self), + modifiedAt: value(object, "modificationTime", as: Date.self)) + } + + func collectionRecord(_ object: AnyObject) throws -> MapsCollectionRecord { + let id = try identifier(object, kind: .collection) + guard let cls = object_getClass(object) else { throw failure("object_class_unavailable") } + let countMethod = try method(cls, "placesCount", result: "i") + typealias GetCount = @convention(c) (AnyObject, Selector) -> Int32 + let count = unsafeBitCast(method_getImplementation(countMethod), to: GetCount.self)( + object, NSSelectorFromString("placesCount")) + guard count >= 0 else { throw failure("collection_count_invalid") } + return try MapsCollectionRecord( + id: id, title: value(object, "title", as: String.self), + description: value(object, "collectionDescription", as: String.self), + imageURL: value(object, "imageUrl", as: String.self), + position: integer(object, "positionIndex"), reportedPlaceCount: Int(count), + createdAt: value(object, "createTime", as: Date.self), + modifiedAt: value(object, "modificationTime", as: Date.self)) + } + + func collectionItemRecord(_ object: AnyObject) throws -> MapsCollectionItemRecord { + let id = try identifier(object, kind: .collectionItem) + guard let object = object as? NSObject else { throw failure("object_class_unavailable") } + let kind: MapsCollectionItemKind + if object.isKind(of: try type("MSCollectionPlaceItem")) { + kind = .place + } else if object.isKind(of: try type("MSCollectionTransitItem")) { + kind = .transit + } else { + throw CLIError( + code: .unsupportedOperation, message: "This saved collection item type is not supported.", + details: ["native_type": NSStringFromClass(Swift.type(of: object))]) + } + var record = try MapsCollectionItemRecord( + id: id, kind: kind, position: integer(object, "positionIndex"), + createdAt: value(object, "createTime", as: Date.self), + modifiedAt: value(object, "modificationTime", as: Date.self)) + switch kind { + case .place: + record.customName = try value(object, "customName", as: String.self) + record.placeName = try value(object, "mapItemName", as: String.self) + record.address = try value(object, "mapItemAddress", as: String.self) + (record.latitude, record.longitude) = try coordinates(object) + record.category = try value(object, "mapItemCategory", as: String.self) + record.note = try value(object, "placeItemNote", as: String.self) + record.nativeIdentifier = try savedPlaceIdentifier(object) + case .transit: + guard let cls = object_getClass(object) else { throw failure("object_class_unavailable") } + let method = try method(cls, "muid", result: "Q") + typealias GetIdentifier = @convention(c) (AnyObject, Selector) -> UInt64 + let lineID = unsafeBitCast(method_getImplementation(method), to: GetIdentifier.self)( + object, NSSelectorFromString("muid")) + record.transitLineIdentifier = String(lineID) + } + return record + } +} diff --git a/Sources/MapsCLI/Protocols.swift b/Sources/MapsCLI/Protocols.swift index d4254ea..c22c96c 100644 --- a/Sources/MapsCLI/Protocols.swift +++ b/Sources/MapsCLI/Protocols.swift @@ -1,12 +1,39 @@ -import AppKit -import CoreLocation -import CryptoKit import Foundation -import Utility public protocol MapsReading: Sendable { - func searchPlaces(query: String, limit: Int) throws -> [MapsPlaceRecord] - func readPlace(latitude: Double, longitude: Double, name: String?) throws -> MapsPlaceRecord + func searchPlaces(_ request: MapsSearchRequest) throws -> MapsPlacesResponse + func readPlace(_ selection: MapsPlaceSelection) throws -> MapsPlaceRecord +} + +public protocol MapsRouting: Sendable { + func calculateDirections(_ request: MapsDirectionsRequest) throws -> MapsRoutesResponse + func calculateETA(_ request: MapsDirectionsRequest) throws -> MapsETAResponse +} + +public protocol MapsFavoritesReading: Sendable { + func listFavorites(_ request: MapsSavedListRequest) throws -> MapsFavoritesResponse + func readFavorite(id: UUID) throws -> MapsFavoriteRecord +} + +public protocol MapsCollectionsReading: Sendable { + func listCollections(_ request: MapsSavedListRequest) throws -> MapsCollectionsResponse + func readCollection(id: UUID) throws -> MapsCollectionRecord + func readCollectionItem(id: UUID) throws -> MapsCollectionItemRecord + func listCollectionItems(id: UUID, request: MapsSavedListRequest) throws + -> MapsCollectionItemsResponse +} + +public protocol MapsCollectionsWriting: Sendable { + func createCollectionPlace(collection: MapsCollectionRecord, draft: MapsCollectionPlaceDraft) + throws + -> MapsCollectionMutationResult + func createCollection(_ draft: MapsCollectionDraft) throws -> MapsCollectionMutationResult + func updateCollection(current: MapsCollectionRecord, patch: MapsCollectionPatch) throws + -> MapsCollectionMutationResult + func deleteCollection(current: MapsCollectionRecord) throws -> MapsCollectionMutationResult + func setCollectionMembership( + collection: MapsCollectionRecord, item: MapsCollectionItemRecord, linked: Bool + ) throws -> MapsCollectionMutationResult } public protocol MapsOpening: Sendable { diff --git a/Sources/MapsCLI/SavedCollectionChanges.swift b/Sources/MapsCLI/SavedCollectionChanges.swift new file mode 100644 index 0000000..0502da8 --- /dev/null +++ b/Sources/MapsCLI/SavedCollectionChanges.swift @@ -0,0 +1,313 @@ +import Foundation +import Utility + +extension MapsSyncSavedPlacesBackend: MapsCollectionsWriting { + public func createCollection(_ draft: MapsCollectionDraft) throws -> MapsCollectionMutationResult + { + try requireCustomCollection(draft.id) + try validateCollectionTitle(draft.title) + if let position = draft.position, position < 0 { + throw CLIError(code: .validationError, message: "A collection position must be nonnegative.") + } + let runtime = try MapsSavedRuntime() + let deadline = MapsDeadline(seconds: 20) + let store = try runtime.defaultStore(deadline: deadline) + let id = MapsSavedKind.collection.idPrefix + draft.id.uuidString.lowercased() + if let existing = try present( + .collection, id: draft.id, runtime: runtime, store: store, deadline: deadline) + { + let record = try runtime.collectionRecord(existing) + guard mapsSavedTextMatches(record.title, draft.title), + mapsSavedTextMatches(record.description, draft.description), + draft.position == nil || draft.position == record.position + else { + throw CLIError( + code: .unsafeMutationRefused, + message: "The collection ID already belongs to different content.", details: ["id": id]) + } + return MapsCollectionMutationResult( + operation: "collections.create", changed: false, collection: record) + } + let position: Int + if let explicit = draft.position { + position = explicit + } else { + let values = try runtime.fetch( + kind: .collection, store: store, + options: runtime.queryOptions(limit: 1, ascending: false), deadline: deadline) + try requireBound(values, limit: 1) + let last = try values.first.map { try runtime.integer($0, "positionIndex") } ?? -1 + guard last < Int.max else { throw runtime.failure("collection_position_overflow") } + position = max(0, last + 1) + } + try runtime.validateCollectionChange(store, delete: false) + let object = try runtime.createCollection(draft, position: position, store: store) + return try verifyingChange(id: id) { + try runtime.collectionChange(store, objects: [object], delete: false, deadline: deadline) + let cold = try runtime.defaultStore(deadline: deadline) + let saved = try selected( + .collection, id: draft.id, runtime: runtime, store: cold, deadline: deadline) + let record = try runtime.collectionRecord(saved) + guard record.id == id, mapsSavedTextMatches(record.title, draft.title), + mapsSavedTextMatches(record.description, draft.description), record.position == position, + record.reportedPlaceCount == 0, record.imageURL == nil, + try runtime.value(saved, "image", as: Data.self) == nil, + try links(draft.id, runtime: runtime, store: cold, deadline: deadline).isEmpty + else { + throw mapsCollectionVerificationFailure(id, reason: "created_fields_mismatch") + } + return MapsCollectionMutationResult( + operation: "collections.create", changed: true, collection: record) + } + } + + public func updateCollection(current: MapsCollectionRecord, patch: MapsCollectionPatch) throws + -> MapsCollectionMutationResult + { + try validateCollectionPatch(patch) + try requireCustomCollection(savedIdentifier(current.id, kind: .collection)) + if let position = patch.position, position < 0 { + throw CLIError(code: .validationError, message: "A collection position must be nonnegative.") + } + let runtime = try MapsSavedRuntime() + let deadline = MapsDeadline(seconds: 20) + let store = try runtime.defaultStore(deadline: deadline) + let id = try savedIdentifier(current.id, kind: .collection) + let object = try selected( + .collection, id: id, runtime: runtime, store: store, deadline: deadline) + try requireCurrent(current, object: object, runtime: runtime) + let desired = patch.applying(to: current) + if mapsCollectionMatches(current, desired) { + return MapsCollectionMutationResult( + operation: "collections.update", changed: false, collection: current) + } + let members = try links(id, runtime: runtime, store: store, deadline: deadline) + let image = try runtime.value(object, "image", as: Data.self) + try runtime.validateCollectionChange(store, delete: false) + try runtime.validateCollectionSetters(object, patch: patch) + if let title = patch.title { + try runtime.setObject(object, selector: "setTitle:", value: title as NSString) + } + if let description = patch.description { + try runtime.setObject( + object, selector: "setCollectionDescription:", value: description as NSString) + } + if patch.clearDescription { + try runtime.setObject(object, selector: "setCollectionDescription:", value: nil) + } + if let position = patch.position { try runtime.setPosition(object, position: position) } + return try verifyingChange(id: current.id) { + try runtime.collectionChange(store, objects: [object], delete: false, deadline: deadline) + let cold = try runtime.defaultStore(deadline: deadline) + let saved = try selected( + .collection, id: id, runtime: runtime, store: cold, deadline: deadline) + let record = try runtime.collectionRecord(saved) + guard mapsCollectionMatches(desired, record, ignoringModificationTime: true), + try links(id, runtime: runtime, store: cold, deadline: deadline) == members, + try runtime.value(saved, "image", as: Data.self) == image + else { + throw mapsCollectionVerificationFailure( + current.id, reason: "updated_fields_or_preservation_mismatch") + } + return MapsCollectionMutationResult( + operation: "collections.update", changed: true, collection: record) + } + } + + public func deleteCollection(current: MapsCollectionRecord) throws -> MapsCollectionMutationResult + { + try requireCustomCollection(savedIdentifier(current.id, kind: .collection)) + let runtime = try MapsSavedRuntime() + let deadline = MapsDeadline(seconds: 20) + let store = try runtime.defaultStore(deadline: deadline) + let id = try savedIdentifier(current.id, kind: .collection) + let object = try selected( + .collection, id: id, runtime: runtime, store: store, deadline: deadline) + try requireCurrent(current, object: object, runtime: runtime) + let objects = try related( + .collectionItem, predicate: "ANY collections.identifier == %@", id: id, + runtime: runtime, store: store, deadline: deadline) + let memberships = try objects.map { item in + ( + try runtime.identifier(item, kind: .collectionItem), + try parents( + savedIdentifier(runtime.identifier(item, kind: .collectionItem), kind: .collectionItem), + runtime: runtime, store: store, deadline: deadline) + ) + } + try runtime.validateCollectionChange(store, delete: true) + return try verifyingChange(id: current.id) { + try runtime.collectionChange(store, objects: [object], delete: true, deadline: deadline) + let cold = try runtime.defaultStore(deadline: deadline) + guard + try present(.collection, id: id, runtime: runtime, store: cold, deadline: deadline) == nil + else { + throw mapsCollectionVerificationFailure( + current.id, reason: "deleted_collection_still_present") + } + for (itemID, before) in memberships { + let expected = before.subtracting([current.id]) + let item = try present( + .collectionItem, id: savedIdentifier(itemID, kind: .collectionItem), runtime: runtime, + store: cold, deadline: deadline) + if item != nil { + guard + try parents( + savedIdentifier(itemID, kind: .collectionItem), runtime: runtime, store: cold, + deadline: deadline) == expected + else { + throw mapsCollectionVerificationFailure(current.id, reason: "other_memberships_changed") + } + } else if !expected.isEmpty { + throw mapsCollectionVerificationFailure(current.id, reason: "shared_item_missing") + } + } + return MapsCollectionMutationResult( + operation: "collections.delete", changed: true, deletedID: current.id) + } + } + + public func setCollectionMembership( + collection: MapsCollectionRecord, item: MapsCollectionItemRecord, linked: Bool + ) throws -> MapsCollectionMutationResult { + let runtime = try MapsSavedRuntime() + let deadline = MapsDeadline(seconds: 20) + let store = try runtime.defaultStore(deadline: deadline) + let collectionID = try savedIdentifier(collection.id, kind: .collection) + let itemID = try savedIdentifier(item.id, kind: .collectionItem) + let object = try selected( + .collection, id: collectionID, runtime: runtime, store: store, deadline: deadline) + let member = try selected( + .collectionItem, id: itemID, runtime: runtime, store: store, deadline: deadline) + try requireCurrent(collection, object: object, runtime: runtime) + guard try mapsCollectionItemMatches(runtime.collectionItemRecord(member), item) else { + throw mapsCollectionVerificationFailure(collection.id, reason: "item_changed_before_write") + } + let before = try links(collectionID, runtime: runtime, store: store, deadline: deadline) + let otherParents = try parents(itemID, runtime: runtime, store: store, deadline: deadline) + guard before.contains(item.id) == otherParents.contains(collection.id) else { + throw mapsCollectionVerificationFailure( + collection.id, reason: "membership_inconsistent_before_write") + } + let operation = linked ? "collections.places.add" : "collections.places.remove" + if before.contains(item.id) == linked { + return MapsCollectionMutationResult( + operation: operation, changed: false, collection: collection, item: item) + } + var expectedLinks = before + var expectedParents = otherParents + if linked { + expectedLinks.insert(item.id) + expectedParents.insert(collection.id) + } else { + expectedLinks.remove(item.id) + expectedParents.remove(collection.id) + } + let image = try runtime.value(object, "image", as: Data.self) + try runtime.validateCollectionChange(store, delete: false) + try runtime.setMembership(object, item: member, linked: linked) + return try verifyingChange(id: collection.id) { + try runtime.collectionChange( + store, objects: [object, member], delete: false, deadline: deadline) + let cold = try runtime.defaultStore(deadline: deadline) + let saved = try selected( + .collection, id: collectionID, runtime: runtime, store: cold, deadline: deadline) + let savedMember = try selected( + .collectionItem, id: itemID, runtime: runtime, store: cold, deadline: deadline) + let record = try runtime.collectionRecord(saved) + let itemRecord = try runtime.collectionItemRecord(savedMember) + var expectedCollection = collection + var expectedItem = item + expectedCollection.reportedPlaceCount = record.reportedPlaceCount + expectedItem.modifiedAt = itemRecord.modifiedAt + guard mapsCollectionMatches(expectedCollection, record, ignoringModificationTime: true) else { + throw mapsCollectionVerificationFailure(collection.id, reason: "collection_fields_changed") + } + guard mapsCollectionItemMatches(itemRecord, expectedItem) else { + throw mapsCollectionVerificationFailure(collection.id, reason: "item_fields_changed") + } + guard + try links(collectionID, runtime: runtime, store: cold, deadline: deadline) == expectedLinks + else { + throw mapsCollectionVerificationFailure( + collection.id, reason: "collection_membership_mismatch") + } + guard + try parents(itemID, runtime: runtime, store: cold, deadline: deadline) == expectedParents + else { + throw mapsCollectionVerificationFailure(collection.id, reason: "other_memberships_changed") + } + guard try runtime.value(saved, "image", as: Data.self) == image else { + throw mapsCollectionVerificationFailure(collection.id, reason: "collection_cover_changed") + } + return MapsCollectionMutationResult( + operation: operation, changed: true, collection: record, item: itemRecord) + } + } + + func present( + _ kind: MapsSavedKind, id: UUID, runtime: MapsSavedRuntime, store: AnyObject, + deadline: MapsDeadline + ) throws -> AnyObject? { + do { + return try selected(kind, id: id, runtime: runtime, store: store, deadline: deadline) + } catch let error as CLIError where error.code == .notFound { return nil } + } + + func links( + _ id: UUID, runtime: MapsSavedRuntime, store: AnyObject, deadline: MapsDeadline + ) throws -> Set { + Set( + try related( + .collectionItem, predicate: "ANY collections.identifier == %@", id: id, + runtime: runtime, store: store, deadline: deadline + ).map { try runtime.identifier($0, kind: .collectionItem) }) + } + + func parents( + _ id: UUID, runtime: MapsSavedRuntime, store: AnyObject, deadline: MapsDeadline + ) throws -> Set { + Set( + try related( + .collection, predicate: "ANY places.identifier == %@", id: id, + runtime: runtime, store: store, deadline: deadline + ).map { try runtime.identifier($0, kind: .collection) }) + } + + func related( + _ kind: MapsSavedKind, predicate: String, id: UUID, runtime: MapsSavedRuntime, + store: AnyObject, deadline: MapsDeadline + ) throws -> [AnyObject] { + let limit = 10_001 + let values = try runtime.fetch( + kind: kind, store: store, + options: runtime.queryOptions( + limit: limit, predicate: NSPredicate(format: predicate, id as NSUUID)), + deadline: deadline) + try requireBound(values, limit: limit) + guard values.count < limit else { throw runtime.failure("relationship_scope_exceeds_limit") } + try requireUnique(values.map { try runtime.identifier($0, kind: kind) }) + return values + } + + func requireCurrent( + _ record: MapsCollectionRecord, object: AnyObject, runtime: MapsSavedRuntime + ) throws { + guard try mapsCollectionMatches(record, runtime.collectionRecord(object)) else { + throw CLIError( + code: .unsafeMutationRefused, + message: "The selected Maps collection changed before the write.", + details: ["id": record.id]) + } + } + + func verifyingChange(id: String, work: () throws -> MapsCollectionMutationResult) throws + -> MapsCollectionMutationResult + { + do { return try work() } catch var error as CLIError { + error.details["id"] = id + error.details["mutation_outcome"] = "unverified" + throw error + } + } +} diff --git a/Sources/MapsCLI/SavedPlaceCreation.swift b/Sources/MapsCLI/SavedPlaceCreation.swift new file mode 100644 index 0000000..129d5d7 --- /dev/null +++ b/Sources/MapsCLI/SavedPlaceCreation.swift @@ -0,0 +1,115 @@ +import Foundation +import MapKit +import Utility + +extension MapsSyncSavedPlacesBackend { + public func createCollectionPlace( + collection: MapsCollectionRecord, draft: MapsCollectionPlaceDraft + ) + throws -> MapsCollectionMutationResult + { + try validateCollectionPlaceDraft(draft) + let runtime = try MapsSavedRuntime() + let deadline = MapsDeadline(seconds: 25) + let store = try runtime.defaultStore(deadline: deadline) + let collectionID = try savedIdentifier(collection.id, kind: .collection) + var parent = try selected( + .collection, id: collectionID, runtime: runtime, store: store, deadline: deadline) + try requireCurrent(collection, object: parent, runtime: runtime) + if let existing = try present( + .collectionItem, id: draft.id, runtime: runtime, store: store, deadline: deadline) + { + return try existingPlace( + existing, collection: collection, draft: draft, runtime: runtime, store: store, + deadline: deadline) + } + let item: MKMapItem + switch draft.source { + case .identifier(let id): + item = try MapKitMapsBackend().serviceItem(identifier: id, deadline: deadline) + case .coordinate(let latitude, let longitude): + item = coordinateMapItem(latitude: latitude, longitude: longitude, name: draft.customName) + } + parent = try selected( + .collection, id: collectionID, runtime: runtime, store: store, deadline: deadline) + try requireCurrent(collection, object: parent, runtime: runtime) + if let existing = try present( + .collectionItem, id: draft.id, runtime: runtime, store: store, deadline: deadline) + { + return try existingPlace( + existing, collection: collection, draft: draft, runtime: runtime, store: store, + deadline: deadline) + } + let before = try links(collectionID, runtime: runtime, store: store, deadline: deadline) + guard collection.reportedPlaceCount == before.count else { + throw mapsCollectionVerificationFailure( + collection.id, reason: "collection_count_inconsistent") + } + let image = try runtime.value(parent, "image", as: Data.self) + try runtime.validateCollectionChange(store, delete: false) + let place = try runtime.createPlace(draft, item: item, store: store) + let expected = try runtime.savedPlaceSnapshot(place) + guard expected.matches(draft) else { + throw mapsCollectionVerificationFailure( + collection.id, reason: "created_place_fields_mismatch") + } + try runtime.setMembership(parent, item: place, linked: true) + return try verifyingChange(id: expected.record.id) { + try runtime.collectionChange( + store, objects: [parent, place], delete: false, deadline: deadline) + let cold = try runtime.defaultStore(deadline: deadline) + let saved = try selected( + .collectionItem, id: draft.id, runtime: runtime, store: cold, deadline: deadline) + let actual = try runtime.savedPlaceSnapshot(saved) + guard expected.matches(actual, allowingCreationTimes: true), actual.matches(draft) else { + throw mapsCollectionVerificationFailure( + expected.record.id, reason: "saved_place_fields_or_storage_mismatch") + } + let savedParent = try selected( + .collection, id: collectionID, runtime: runtime, store: cold, deadline: deadline) + let parentRecord = try runtime.collectionRecord(savedParent) + var expectedParent = collection + expectedParent.reportedPlaceCount += 1 + guard mapsCollectionMatches(expectedParent, parentRecord, ignoringModificationTime: true), + try runtime.value(savedParent, "image", as: Data.self) == image, + try links(collectionID, runtime: runtime, store: cold, deadline: deadline) + == before.union([actual.record.id]), + try parents(draft.id, runtime: runtime, store: cold, deadline: deadline) == [collection.id] + else { + throw mapsCollectionVerificationFailure( + expected.record.id, reason: "saved_place_relationship_or_collection_mismatch") + } + return MapsCollectionMutationResult( + operation: "collections.places.create", changed: true, collection: parentRecord, + item: actual.record) + } + } + + private func existingPlace( + _ existing: AnyObject, collection: MapsCollectionRecord, draft: MapsCollectionPlaceDraft, + runtime: MapsSavedRuntime, store: AnyObject, deadline: MapsDeadline + ) throws -> MapsCollectionMutationResult { + guard try runtime.collectionItemRecord(existing).kind == .place else { + throw CLIError( + code: .unsafeMutationRefused, + message: "The saved place ID already belongs to another kind of collection item.", + details: ["id": MapsSavedKind.collectionItem.idPrefix + draft.id.uuidString.lowercased()]) + } + let snapshot = try runtime.savedPlaceSnapshot(existing) + let id = try savedIdentifier(collection.id, kind: .collection) + guard snapshot.matches(draft), + try links(id, runtime: runtime, store: store, deadline: deadline).contains( + snapshot.record.id), + try parents(draft.id, runtime: runtime, store: store, deadline: deadline).contains( + collection.id) + else { + throw CLIError( + code: .unsafeMutationRefused, + message: "The saved place ID already belongs to different content or collection.", + details: ["id": snapshot.record.id]) + } + return MapsCollectionMutationResult( + operation: "collections.places.create", changed: false, collection: collection, + item: snapshot.record) + } +} diff --git a/Sources/MapsCLI/SavedPlaceModels.swift b/Sources/MapsCLI/SavedPlaceModels.swift new file mode 100644 index 0000000..85a8e0f --- /dev/null +++ b/Sources/MapsCLI/SavedPlaceModels.swift @@ -0,0 +1,104 @@ +import Foundation + +public struct MapsSavedListRequest: Equatable, Sendable { + public var limit: Int + public var offset: Int + public init(limit: Int = 20, offset: Int = 0) { + self.limit = limit + self.offset = offset + } +} + +public struct MapsFavoriteResponse: Codable, Equatable, Sendable { + public var favorite: MapsFavoriteRecord +} + +public struct MapsCollectionRecord: Codable, Equatable, Sendable { + public var id: String + public var title: String? + public var description: String? + public var imageURL: String? + public var position: Int + public var reportedPlaceCount: Int + public var createdAt: Date? + public var modifiedAt: Date? + + public init( + id: String, title: String? = nil, description: String? = nil, imageURL: String? = nil, + position: Int, reportedPlaceCount: Int, createdAt: Date? = nil, modifiedAt: Date? = nil + ) { + self.id = id + self.title = title + self.description = description + self.imageURL = imageURL + self.position = position + self.reportedPlaceCount = reportedPlaceCount + self.createdAt = createdAt + self.modifiedAt = modifiedAt + } +} + +public struct MapsCollectionsResponse: Codable, Equatable, Sendable { + public var collections: [MapsCollectionRecord] + public var limit: Int + public var offset: Int + public var hasMore: Bool + public var nextOffset: Int? +} + +public struct MapsCollectionResponse: Codable, Equatable, Sendable { + public var collection: MapsCollectionRecord +} + +public enum MapsCollectionItemKind: String, Codable, Sendable { + case place, transit +} + +public struct MapsCollectionItemRecord: Codable, Equatable, Sendable { + public var id: String + public var kind: MapsCollectionItemKind + public var nativeIdentifier: String? + public var customName: String? + public var placeName: String? + public var address: String? + public var latitude: Double? + public var longitude: Double? + public var category: String? + public var note: String? + public var transitLineIdentifier: String? + public var position: Int + public var createdAt: Date? + public var modifiedAt: Date? + + public init( + id: String, kind: MapsCollectionItemKind, customName: String? = nil, placeName: String? = nil, + address: String? = nil, latitude: Double? = nil, longitude: Double? = nil, + category: String? = nil, note: String? = nil, transitLineIdentifier: String? = nil, + position: Int, createdAt: Date? = nil, modifiedAt: Date? = nil, + nativeIdentifier: String? = nil + ) { + self.id = id + self.kind = kind + self.nativeIdentifier = nativeIdentifier + self.customName = customName + self.placeName = placeName + self.address = address + self.latitude = latitude + self.longitude = longitude + self.category = category + self.note = note + self.transitLineIdentifier = transitLineIdentifier + self.position = position + self.createdAt = createdAt + self.modifiedAt = modifiedAt + } +} + +public struct MapsCollectionItemsResponse: Codable, Equatable, Sendable { + public var collection: MapsCollectionRecord + public var items: [MapsCollectionItemRecord] + public var limit: Int + public var offset: Int + public var hasMore: Bool + public var nextOffset: Int? +} diff --git a/Sources/MapsCLI/SavedPlaces.swift b/Sources/MapsCLI/SavedPlaces.swift new file mode 100644 index 0000000..10a5fe5 --- /dev/null +++ b/Sources/MapsCLI/SavedPlaces.swift @@ -0,0 +1,140 @@ +import Foundation +import Utility + +public struct MapsSyncSavedPlacesBackend: MapsFavoritesReading, MapsCollectionsReading { + public init() {} + + public func listFavorites(_ request: MapsSavedListRequest) throws -> MapsFavoritesResponse { + let values = try list(.favorite, request: request) + let runtime = try MapsSavedRuntime() + let records = try values.prefix(request.limit).map(runtime.favoriteRecord) + try requireUnique(records.map(\.id)) + let more = values.count > request.limit + return MapsFavoritesResponse( + favorites: records, limit: request.limit, hasMore: more, + offset: request.offset, nextOffset: more ? request.offset + records.count : nil) + } + + public func readFavorite(id: UUID) throws -> MapsFavoriteRecord { + let runtime = try MapsSavedRuntime() + let value = try read(.favorite, id: id) + let record = try runtime.favoriteRecord(value) + try requireSelected(record.id, kind: .favorite, uuid: id) + return record + } + + public func listCollections(_ request: MapsSavedListRequest) throws -> MapsCollectionsResponse { + let values = try list(.collection, request: request) + let runtime = try MapsSavedRuntime() + let records = try values.prefix(request.limit).map(runtime.collectionRecord) + try requireUnique(records.map(\.id)) + let more = values.count > request.limit + return MapsCollectionsResponse( + collections: records, limit: request.limit, + offset: request.offset, hasMore: more, nextOffset: more ? request.offset + records.count : nil + ) + } + + public func readCollection(id: UUID) throws -> MapsCollectionRecord { + let runtime = try MapsSavedRuntime() + let record = try runtime.collectionRecord(read(.collection, id: id)) + try requireSelected(record.id, kind: .collection, uuid: id) + return record + } + + public func readCollectionItem(id: UUID) throws -> MapsCollectionItemRecord { + let runtime = try MapsSavedRuntime() + let record = try runtime.collectionItemRecord(read(.collectionItem, id: id)) + try requireSelected(record.id, kind: .collectionItem, uuid: id) + return record + } + + public func listCollectionItems(id: UUID, request: MapsSavedListRequest) throws + -> MapsCollectionItemsResponse + { + try validateSavedListRequest(request) + let runtime = try MapsSavedRuntime() + let deadline = MapsDeadline(seconds: 10) + let store = try runtime.defaultStore(deadline: deadline) + let parent = try selected( + .collection, id: id, runtime: runtime, store: store, deadline: deadline) + let collection = try runtime.collectionRecord(parent) + try requireSelected(collection.id, kind: .collection, uuid: id) + let options = try runtime.queryOptions( + limit: request.limit + 1, offset: request.offset, + predicate: NSPredicate(format: "ANY collections.identifier == %@", id as NSUUID)) + let values = try runtime.fetch( + kind: .collectionItem, store: store, options: options, deadline: deadline) + try requireBound(values, limit: request.limit + 1) + let records = try values.prefix(request.limit).map(runtime.collectionItemRecord) + try requireUnique(records.map(\.id)) + let more = values.count > request.limit + return MapsCollectionItemsResponse( + collection: collection, items: records, limit: request.limit, + offset: request.offset, hasMore: more, nextOffset: more ? request.offset + records.count : nil + ) + } + + private func list(_ kind: MapsSavedKind, request: MapsSavedListRequest) throws -> [AnyObject] { + try validateSavedListRequest(request) + let runtime = try MapsSavedRuntime() + let deadline = MapsDeadline(seconds: 10) + let store = try runtime.defaultStore(deadline: deadline) + let options = try runtime.queryOptions(limit: request.limit + 1, offset: request.offset) + let values = try runtime.fetch(kind: kind, store: store, options: options, deadline: deadline) + try requireBound(values, limit: request.limit + 1) + return values + } + + private func read(_ kind: MapsSavedKind, id: UUID) throws -> AnyObject { + let runtime = try MapsSavedRuntime() + let deadline = MapsDeadline(seconds: 10) + let store = try runtime.defaultStore(deadline: deadline) + return try selected(kind, id: id, runtime: runtime, store: store, deadline: deadline) + } + + func selected( + _ kind: MapsSavedKind, id: UUID, runtime: MapsSavedRuntime, + store: AnyObject, deadline: MapsDeadline + ) throws -> AnyObject { + let options = try runtime.queryOptions( + limit: 2, + predicate: NSPredicate(format: "identifier == %@", id as NSUUID)) + let values = try runtime.fetch(kind: kind, store: store, options: options, deadline: deadline) + try requireBound(values, limit: 2) + let handle = kind.idPrefix + id.uuidString.lowercased() + guard let value = values.first else { + throw CLIError( + code: .notFound, message: "The saved Maps item was not found.", details: ["id": handle]) + } + guard values.count == 1 else { + throw CLIError( + code: .ambiguousIdentity, + message: "Multiple saved Maps items have the selected identifier.", details: ["id": handle]) + } + return value + } + + func requireBound(_ values: [AnyObject], limit: Int) throws { + guard values.count <= limit else { + throw CLIError( + code: .backendUnavailable, + message: "The native saved Maps query did not honor its range.") + } + } + + func requireUnique(_ ids: [String]) throws { + guard Set(ids).count == ids.count else { + throw CLIError( + code: .ambiguousIdentity, + message: "The saved Maps batch contains duplicate identifiers.") + } + } + + func requireSelected(_ handle: String, kind: MapsSavedKind, uuid: UUID) throws { + guard handle == kind.idPrefix + uuid.uuidString.lowercased() else { + throw CLIError( + code: .backendUnavailable, message: "The native query returned another saved Maps item.") + } + } +} diff --git a/Sources/MapsCLI/Support.swift b/Sources/MapsCLI/Support.swift index f088da5..5871f62 100644 --- a/Sources/MapsCLI/Support.swift +++ b/Sources/MapsCLI/Support.swift @@ -1,4 +1,3 @@ -import AppKit import CoreLocation import CryptoKit import Foundation @@ -8,6 +7,8 @@ func mapGeocodeResult(_ result: Result<[CLPlacemark], Error>?) throws -> [CLPlac switch result { case .success(let placemarks): return placemarks + case .failure(let error as CLError) where error.code == .geocodeFoundNoResult: + return [] case .failure(let error as CLError) where error.code == .network: throw CLIError( code: .backendUnavailable, message: "Maps geocoding network service is unavailable.") @@ -184,12 +185,64 @@ func validateReadOnly(_ options: CLIOptions) throws { } } -func validateDryRunOptions(_ options: CLIOptions) throws {} +func validateSavedListRequest(_ request: MapsSavedListRequest) throws { + guard (1...100).contains(request.limit) else { + throw CLIError(code: .validationError, message: "`--limit` must be between 1 and 100.") + } + guard (0...1_000_000).contains(request.offset) else { + throw CLIError(code: .validationError, message: "`--offset` must be between 0 and 1000000.") + } +} + +func savedListRequest(_ options: CLIOptions) throws -> MapsSavedListRequest { + let offset: Int + if let value = options.targetOption("offset") { + guard let parsed = Int(value) else { + throw CLIError(code: .validationError, message: "`--offset` must be an integer.") + } + offset = parsed + } else { + offset = 0 + } + let request = MapsSavedListRequest(limit: options.limit ?? 20, offset: offset) + try validateSavedListRequest(request) + return request +} + +func savedIdentifier(_ options: CLIOptions, kind: MapsSavedKind, option: String = "id") throws + -> UUID +{ + let handle = try requiredOption(option, options: options) + return try savedIdentifier(handle, kind: kind, option: option) +} + +func savedIdentifier(_ handle: String, kind: MapsSavedKind, option: String = "id") throws -> UUID { + guard handle.hasPrefix(kind.idPrefix), + let uuid = UUID(uuidString: String(handle.dropFirst(kind.idPrefix.count))) + else { + throw CLIError( + code: .validationError, + message: "`--\(option)` must be a native \(kind.idPrefix) UUID identifier.") + } + return uuid +} -func validateTargetOptions(_ options: CLIOptions, allowedOptions: Set) throws { +func validateSavedRead(_ options: CLIOptions) throws { + try validateReadOnly(options) + try validateTargetOptions(options, allowedOptions: ["id"]) + guard options.limit == nil else { + throw CLIError( + code: .validationError, message: "`--limit` is only valid for saved-item list commands.") + } +} + +func validateTargetOptions( + _ options: CLIOptions, allowedOptions: Set, allowedFlags: Set = [] +) throws { let unknownOptions = Set(options.targetOptions.keys).subtracting(allowedOptions) - if !unknownOptions.isEmpty || !options.targetFlags.isEmpty { - let unsupported = (Array(unknownOptions) + Array(options.targetFlags)).sorted() + let unknownFlags = options.targetFlags.subtracting(allowedFlags) + if !unknownOptions.isEmpty || !unknownFlags.isEmpty { + let unsupported = (Array(unknownOptions) + Array(unknownFlags)).sorted() throw CLIError( code: .validationError, message: "Unsupported option for this command.", @@ -223,9 +276,9 @@ func coordinateOption(_ name: String, options: CLIOptions) throws -> Double { func commandLimit(_ options: CLIOptions) throws -> Int { let limit = options.limit ?? 10 - guard limit <= 50 else { + guard (1...50).contains(limit) else { throw CLIError( - code: .validationError, message: "`--limit` cannot exceed 50 for Maps read commands.") + code: .validationError, message: "`--limit` must be between 1 and 50.") } return limit } @@ -282,6 +335,10 @@ func routeEndpoint( } if let query { + if name != nil { + throw CLIError( + code: .validationError, message: "`--\(nameOption)` requires coordinate endpoints.") + } return MapsRouteEndpoint( id: "maps-route-query:\(sha256Hex(query.lowercased()))", query: query @@ -324,6 +381,117 @@ func routeEndpoint( return nil } +func searchRequest(_ options: CLIOptions) throws -> MapsSearchRequest { + let query = try requiredOption("query", options: options) + .trimmingCharacters(in: .whitespacesAndNewlines) + guard query.count >= 2 else { + throw CLIError( + code: .validationError, + message: "`--query` must contain at least 2 non-whitespace characters.") + } + guard let kind = MapsSearchKind(rawValue: options.targetOption("kind") ?? "all") else { + throw CLIError(code: .validationError, message: "`--kind` must be `all`, `poi`, or `address`.") + } + let regionKeys = ["region-latitude", "region-longitude", "region-span-meters"] + var region: MapsSearchRegion? + if regionKeys.contains(where: { options.targetOption($0) != nil }) { + let latitude = try coordinateOption("region-latitude", options: options) + let longitude = try coordinateOption("region-longitude", options: options) + let spanText = options.targetOption("region-span-meters") ?? "10000" + guard let span = Double(spanText), span.isFinite, (1...1_000_000).contains(span) else { + throw CLIError( + code: .validationError, message: "`--region-span-meters` must be between 1 and 1000000.") + } + region = MapsSearchRegion(latitude: latitude, longitude: longitude, spanMeters: span) + } + return MapsSearchRequest( + query: query, kind: kind, region: region, limit: try commandLimit(options)) +} + +func placeSelection(_ options: CLIOptions) throws -> MapsPlaceSelection { + if let id = options.targetOption("id") { + guard ["latitude", "longitude", "name"].allSatisfy({ options.targetOption($0) == nil }) else { + throw CLIError( + code: .validationError, message: "`--id` cannot be combined with coordinate options.") + } + let id = id.trimmingCharacters(in: .whitespacesAndNewlines) + guard id.hasPrefix("maps-item:"), id.count > "maps-item:".count else { + throw CLIError( + code: .validationError, + message: + "`--id` requires a native `maps-item:` identifier returned by search; coordinate snapshot IDs cannot be looked up." + ) + } + return .identifier(String(id.dropFirst("maps-item:".count))) + } + return .coordinate( + latitude: try coordinateOption("latitude", options: options), + longitude: try coordinateOption("longitude", options: options), + name: options.targetOption("name")) +} + +func directionsRequest(_ options: CLIOptions, eta: Bool) throws -> MapsDirectionsRequest { + guard + let source = try routeEndpoint( + queryOption: "from", latitudeOption: "from-latitude", longitudeOption: "from-longitude", + nameOption: "from-name", required: true, options: options), + let destination = try routeEndpoint( + queryOption: "to", latitudeOption: "to-latitude", longitudeOption: "to-longitude", + nameOption: "to-name", required: true, options: options) + else { throw CLIError(code: .validationError, message: "Both route endpoints are required.") } + guard let mode = MapsTransportMode(rawValue: options.targetOption("mode") ?? "driving") else { + throw CLIError( + code: .validationError, + message: "`--mode` must be `driving`, `walking`, `cycling`, or `transit`.") + } + if !eta, mode == .transit { + throw CLIError( + code: .unsupportedOperation, + message: "MapKit supports transit ETA only. Use `directions eta --mode transit`.") + } + let departure = try directionDate("departure", options: options) + let arrival = try directionDate("arrival", options: options) + guard departure == nil || arrival == nil else { + throw CLIError( + code: .validationError, message: "Supply either `--departure` or `--arrival`, not both.") + } + if eta, options.limit != nil { + throw CLIError( + code: .validationError, message: "`--limit` applies to search or calculated route lists.") + } + let limit = options.limit ?? 3 + guard (1...10).contains(limit) else { + throw CLIError(code: .validationError, message: "Route `--limit` must be between 1 and 10.") + } + return MapsDirectionsRequest( + source: source, destination: destination, mode: mode, + alternatives: options.hasTargetFlag("alternatives"), departure: departure, arrival: arrival, + limit: limit) +} + +func directionDate(_ name: String, options: CLIOptions) throws -> Date? { + guard let value = options.targetOption(name) else { return nil } + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatter.date(from: value) { return date } + formatter.formatOptions = [.withInternetDateTime] + guard let date = formatter.date(from: value) else { + throw CLIError( + code: .validationError, message: "`--\(name)` requires an ISO 8601 date with a time zone.") + } + return date +} + +func routesHumanOutput(_ response: MapsRoutesResponse) -> String { + response.routes.map { + "\($0.name)\t\($0.mode)\t\($0.distanceMeters) m\t\($0.expectedTravelTimeSeconds) s" + }.joined(separator: "\n") +} + +func etaHumanOutput(_ response: MapsETAResponse) -> String { + "\(response.mode.rawValue)\t\(response.distanceMeters) m\t\(response.expectedTravelTimeSeconds) s" +} + func coordinateString(_ value: Double) -> String { String(format: "%.6f", value) } diff --git a/Sources/NotesCLI/Commands/NotesCommandAccounts.swift b/Sources/NotesCLI/Commands/NotesCommandAccounts.swift index 7c89e8b..e7a46f8 100644 --- a/Sources/NotesCLI/Commands/NotesCommandAccounts.swift +++ b/Sources/NotesCLI/Commands/NotesCommandAccounts.swift @@ -399,7 +399,7 @@ extension NotesCommand { return NotesMutationVerificationReport( verifier: "notes_read_v1", operation: "notes.accounts.workflow.audit", - verified: checks.allSatisfy { $0.status != "failed" }, + verified: checks.allSatisfy { $0.status == "passed" || $0.status == "not_applicable" }, evidenceLevel: "capability_accounting+privacy_boundary+no_backend_calls", targetIDSHA256: sha256Hex("notes.accounts.workflow.audit"), checks: checks diff --git a/Sources/NotesCLI/Commands/NotesCommandAttachmentAudio.swift b/Sources/NotesCLI/Commands/NotesCommandAttachmentAudio.swift index 38604a0..935d37b 100644 --- a/Sources/NotesCLI/Commands/NotesCommandAttachmentAudio.swift +++ b/Sources/NotesCLI/Commands/NotesCommandAttachmentAudio.swift @@ -409,7 +409,7 @@ extension NotesCommand { return NotesMutationVerificationReport( verifier: "notes_read_v1", operation: "notes.attachments.audio.audit", - verified: checks.allSatisfy { $0.status != "failed" }, + verified: checks.allSatisfy { $0.status == "passed" || $0.status == "not_applicable" }, evidenceLevel: "capability_accounting+privacy_boundary+no_backend_calls", targetIDSHA256: sha256Hex("notes.attachments.audio.audit"), checks: checks diff --git a/Sources/NotesCLI/Commands/NotesCommandAttachmentSearch.swift b/Sources/NotesCLI/Commands/NotesCommandAttachmentSearch.swift index 731f3ce..ba526ae 100644 --- a/Sources/NotesCLI/Commands/NotesCommandAttachmentSearch.swift +++ b/Sources/NotesCLI/Commands/NotesCommandAttachmentSearch.swift @@ -1013,7 +1013,7 @@ extension NotesCommand { return NotesMutationVerificationReport( verifier: "notes_read_v1", operation: operation, - verified: checks.allSatisfy { $0.status != "failed" }, + verified: checks.allSatisfy { $0.status == "passed" || $0.status == "not_applicable" }, evidenceLevel: "private_framework_attachment_metadata_search+privacy_hash+bounded_scan", targetIDSHA256: sha256Hex(targetFields), checks: checks @@ -1144,7 +1144,7 @@ extension NotesCommand { return NotesMutationVerificationReport( verifier: "notes_read_v1", operation: operation, - verified: checks.allSatisfy { $0.status != "failed" }, + verified: checks.allSatisfy { $0.status == "passed" || $0.status == "not_applicable" }, evidenceLevel: "private_framework_attachment_content_composite_readback+privacy_hash+bounded_scan", targetIDSHA256: sha256Hex(targetFields), checks: checks diff --git a/Sources/NotesCLI/Commands/NotesCommandAttachments.swift b/Sources/NotesCLI/Commands/NotesCommandAttachments.swift index 238f183..e9cc09b 100644 --- a/Sources/NotesCLI/Commands/NotesCommandAttachments.swift +++ b/Sources/NotesCLI/Commands/NotesCommandAttachments.swift @@ -1818,7 +1818,7 @@ extension NotesCommand { return NotesMutationVerificationReport( verifier: "notes_read_v1", operation: "notes.attachments.list.collection", - verified: checks.allSatisfy { $0.status != "failed" }, + verified: checks.allSatisfy { $0.status == "passed" || $0.status == "not_applicable" }, evidenceLevel: "private_framework_attachment_metadata_batch_readback", targetIDSHA256: sha256Hex(records.map(\.noteIDSHA256).joined(separator: "\n")), checks: checks @@ -1978,7 +1978,7 @@ extension NotesCommand { return NotesMutationVerificationReport( verifier: "notes_read_v1", operation: "notes.attachments.audit", - verified: checks.allSatisfy { $0.status != "failed" }, + verified: checks.allSatisfy { $0.status == "passed" || $0.status == "not_applicable" }, evidenceLevel: "private_framework_attachment_metadata_batch_readback", targetIDSHA256: sha256Hex(records.map(\.noteIDSHA256).joined(separator: "\n")), checks: checks @@ -3904,7 +3904,7 @@ extension NotesCommand { return NotesMutationVerificationReport( verifier: "notes_read_v1", operation: "notes.attachments.workflow.audit", - verified: checks.allSatisfy { $0.status != "failed" }, + verified: checks.allSatisfy { $0.status == "passed" || $0.status == "not_applicable" }, evidenceLevel: "capability_accounting+privacy_boundary+no_backend_calls", targetIDSHA256: sha256Hex("notes.attachments.workflow.audit"), checks: checks diff --git a/Sources/NotesCLI/Commands/NotesCommandBodyFormatting.swift b/Sources/NotesCLI/Commands/NotesCommandBodyFormatting.swift index 293dc0b..994a90c 100644 --- a/Sources/NotesCLI/Commands/NotesCommandBodyFormatting.swift +++ b/Sources/NotesCLI/Commands/NotesCommandBodyFormatting.swift @@ -16,14 +16,14 @@ extension NotesCommand { "note_id: \(structure.noteID)", "plain_text_bytes: \(structure.plainTextByteCount.map(String.init) ?? "")", "paragraphs: \(structure.paragraphCount.map(String.init) ?? "")", - "checklist_items: \(structure.checklistItemCount)", - "tables: \(structure.tableCount)", - "collapsible_sections: \(structure.collapsibleSectionCount)", - "collapsed_sections: \(structure.collapsedSectionCount)", - "math_attachments: \(structure.mathAttachmentCount)", - "inline_attachments: \(structure.inlineAttachmentCount)", - "inline_format_runs: \(structure.inlineFormatRunCount)", - "highlight_runs: \(structure.highlightRunCount)", + "checklist_items: \(structure.checklistItemCount.map(String.init) ?? "unknown")", + "tables: \(structure.tableCount.map(String.init) ?? "unknown")", + "collapsible_sections: \(structure.collapsibleSectionCount.map(String.init) ?? "unknown")", + "collapsed_sections: \(structure.collapsedSectionCount.map(String.init) ?? "unknown")", + "math_attachments: \(structure.mathAttachmentCount.map(String.init) ?? "unknown")", + "inline_attachments: \(structure.inlineAttachmentCount.map(String.init) ?? "unknown")", + "inline_format_runs: \(structure.inlineFormatRunCount.map(String.init) ?? "unknown")", + "highlight_runs: \(structure.highlightRunCount.map(String.init) ?? "unknown")", ].joined(separator: "\n"), options: options ) @@ -48,10 +48,10 @@ extension NotesCommand { ), human: [ "note_id: \(structure.noteID)", - "tables: \(summary.tableCount)", - "collapsible_sections: \(summary.collapsibleSectionCount)", - "collapsed_sections: \(summary.collapsedSectionCount)", - "math_attachments: \(summary.mathAttachmentCount)", + "tables: \(summary.tableCount.map(String.init) ?? "unknown")", + "collapsible_sections: \(summary.collapsibleSectionCount.map(String.init) ?? "unknown")", + "collapsed_sections: \(summary.collapsedSectionCount.map(String.init) ?? "unknown")", + "math_attachments: \(summary.mathAttachmentCount.map(String.init) ?? "unknown")", "gated_mutations: \(summary.gatedMutationFamilies.joined(separator: ","))", ].joined(separator: "\n"), options: options @@ -2762,13 +2762,17 @@ extension NotesCommand { } private func bodySurfaceSummary(_ structure: NotesBodyStructureRecord) -> NotesBodySurfaceSummary { - let supportedReadFamilies = structure.isPasswordProtected - ? [] - : ["collapsible_section_count", "collapsible_section_state", "math_surface_count", "table_count", "table_selector_list"] - var gatedReadFamilies: [String] = [] - if structure.isPasswordProtected { - gatedReadFamilies.append("password_protected_body_surface_counts") - } + let readFamilies: [(String, Bool)] = [ + ("collapsible_section_count", structure.collapsibleSectionCount != nil), + ("collapsible_section_state", structure.collapsibleSectionCount != nil && structure.collapsedSectionCount != nil), + ("math_surface_count", structure.mathAttachmentCount != nil), + ("table_count", structure.tableCount != nil), + ("table_selector_list", structure.tableCount != nil), + ] + let supportedReadFamilies = structure.isPasswordProtected ? [] : readFamilies.filter { $0.1 }.map { $0.0 } + let gatedReadFamilies = structure.isPasswordProtected + ? ["password_protected_body_surface_counts"] + : readFamilies.filter { !$0.1 }.map { $0.0 } return NotesBodySurfaceSummary( tableCount: structure.tableCount, collapsibleSectionCount: structure.collapsibleSectionCount, @@ -2799,11 +2803,13 @@ extension NotesCommand { } private func bodySurfaceFamilies(_ structure: NotesBodyStructureRecord) -> [NotesBodySurfaceFamilyRecord] { - let countedStatus = structure.isPasswordProtected ? "unavailable_password_protected" : "counted" + func readbackStatus(_ count: Int?) -> String { + structure.isPasswordProtected ? "unavailable_password_protected" : (count == nil ? "unavailable" : "counted") + } return [ NotesBodySurfaceFamilyRecord( family: "table", - readbackStatus: countedStatus, + readbackStatus: readbackStatus(structure.tableCount), mutationStatus: structure.isPasswordProtected ? "unavailable_password_protected" : "create_update_delete_copy_move_convert_to_text_convert_from_text_structure_supported", @@ -2823,7 +2829,7 @@ extension NotesCommand { ), NotesBodySurfaceFamilyRecord( family: "math_result", - readbackStatus: countedStatus, + readbackStatus: readbackStatus(structure.mathAttachmentCount), mutationStatus: structure.isPasswordProtected ? "unavailable_password_protected" : "insert_update_supported", @@ -2838,7 +2844,7 @@ extension NotesCommand { ), NotesBodySurfaceFamilyRecord( family: "collapsible_section", - readbackStatus: countedStatus, + readbackStatus: readbackStatus(structure.collapsibleSectionCount), mutationStatus: structure.isPasswordProtected ? "unavailable_password_protected" : "state_and_create_update_supported", @@ -2881,23 +2887,27 @@ extension NotesCommand { verificationBoolCheck( name: "table_surface_accounted", expected: true, - actual: summary.tableCount == structure.tableCount - && (structure.isPasswordProtected || tableSurface?.count == structure.tableCount) + actual: structure.tableCount.map { + summary.tableCount == $0 && !structure.isPasswordProtected && tableSurface?.count == $0 + } ), verificationBoolCheck( name: "math_surface_accounted", expected: true, - actual: summary.mathAttachmentCount == structure.mathAttachmentCount - && summary.isMathNote == structure.isMathNote - && (structure.isPasswordProtected || mathSurface?.count == structure.mathAttachmentCount) + actual: structure.mathAttachmentCount.map { + summary.mathAttachmentCount == $0 && summary.isMathNote == structure.isMathNote + && !structure.isPasswordProtected && mathSurface?.count == $0 + } ), verificationBoolCheck( name: "collapsible_section_surface_accounted", expected: true, - actual: summary.collapsibleSectionCount == structure.collapsibleSectionCount - && summary.collapsedSectionCount == structure.collapsedSectionCount - && summary.collapsedSectionCount <= summary.collapsibleSectionCount - && (structure.isPasswordProtected || collapsibleSurface?.count == structure.collapsibleSectionCount) + actual: structure.collapsibleSectionCount.flatMap { count in + structure.collapsedSectionCount.map { collapsed in + summary.collapsibleSectionCount == count && summary.collapsedSectionCount == collapsed + && collapsed <= count && !structure.isPasswordProtected && collapsibleSurface?.count == count + } + } ), ] for mutation in expectedSupportedMutations { @@ -2920,11 +2930,11 @@ extension NotesCommand { } let warnings = structure.isPasswordProtected ? ["password_protected_body_surface_counts_unavailable"] - : [] + : (checks.contains { $0.status == "unavailable" } ? ["body_surface_counts_unavailable"] : []) return NotesMutationVerificationReport( verifier: "notes_read_v1", operation: "notes.body.surfaces", - verified: checks.allSatisfy { $0.status != "failed" }, + verified: checks.allSatisfy { $0.status == "passed" || $0.status == "not_applicable" }, evidenceLevel: "private_framework_body_structure_surface_readback", targetIDSHA256: sha256Hex(structure.noteID), checks: checks, @@ -3566,7 +3576,7 @@ extension NotesCommand { return NotesMutationVerificationReport( verifier: "notes_read_v1", operation: "notes.body.format.audit", - verified: checks.allSatisfy { $0.status != "failed" }, + verified: checks.allSatisfy { $0.status == "passed" || $0.status == "not_applicable" }, evidenceLevel: "capability_accounting+privacy_boundary+no_backend_calls", targetIDSHA256: sha256Hex("notes.body.format.audit"), checks: checks @@ -3582,12 +3592,12 @@ extension NotesCommand { verificationBoolCheck( name: "collapsible_section_count", expected: true, - actual: sections.count == structure.collapsibleSectionCount + actual: structure.collapsibleSectionCount.map { sections.count == $0 } ), verificationBoolCheck( name: "collapsed_section_count", expected: true, - actual: collapsedCount == structure.collapsedSectionCount + actual: structure.collapsedSectionCount.map { collapsedCount == $0 } ), verificationBoolCheck( name: "section_ordinals_are_stable", @@ -3603,7 +3613,7 @@ extension NotesCommand { return NotesMutationVerificationReport( verifier: "notes_read_v1", operation: "notes.body.collapsible.list", - verified: checks.allSatisfy { $0.status != "failed" }, + verified: checks.allSatisfy { $0.status == "passed" || $0.status == "not_applicable" }, evidenceLevel: "private_framework_outline_controller_section_readback", targetIDSHA256: sha256Hex(structure.noteID), checks: checks, @@ -3645,13 +3655,19 @@ extension NotesCommand { guard result.verification.verified else { throw CLIError( code: .internalError, - message: "Notes body checklist mutation verification failed.", + message: "Notes body mutation could not be verified. Read the note before retrying.", details: [ "operation": result.operation, "failed_checks": result.verification.checks .filter { $0.status == "failed" } .map(\.name) .joined(separator: ","), + "unavailable_checks": result.verification.checks + .filter { $0.status == "unavailable" } + .map(\.name) + .joined(separator: ","), + "mutation_may_have_occurred": "true", + "retry_guidance": "inspect_note_before_retrying", "target_id_sha256": result.verification.targetIDSHA256, ] ) diff --git a/Sources/NotesCLI/Commands/NotesCommandBodyMath.swift b/Sources/NotesCLI/Commands/NotesCommandBodyMath.swift index 42d1996..5a2ed9b 100644 --- a/Sources/NotesCLI/Commands/NotesCommandBodyMath.swift +++ b/Sources/NotesCLI/Commands/NotesCommandBodyMath.swift @@ -425,7 +425,7 @@ extension NotesCommand { return NotesMutationVerificationReport( verifier: "notes_read_v1", operation: "notes.body.math.audit", - verified: checks.allSatisfy { $0.status != "failed" }, + verified: checks.allSatisfy { $0.status == "passed" || $0.status == "not_applicable" }, evidenceLevel: "capability_accounting+privacy_boundary+no_backend_calls", targetIDSHA256: sha256Hex("notes.body.math.audit"), checks: checks @@ -442,7 +442,7 @@ extension NotesCommand { verificationBoolCheck( name: "math_result_count_within_structure_count", expected: true, - actual: results.count <= structure.mathAttachmentCount + actual: structure.mathAttachmentCount.map { results.count <= $0 } ), verificationBoolCheck( name: "math_result_ordinals_contiguous", @@ -468,7 +468,7 @@ extension NotesCommand { return NotesMutationVerificationReport( verifier: "notes_read_v1", operation: "notes.body.math.list", - verified: checks.allSatisfy { $0.status != "failed" }, + verified: checks.allSatisfy { $0.status == "passed" || $0.status == "not_applicable" }, evidenceLevel: "private_framework_body_math_result_attachment_readback", targetIDSHA256: sha256Hex(structure.noteID), checks: checks @@ -515,7 +515,7 @@ extension NotesCommand { return NotesMutationVerificationReport( verifier: "notes_body_math_expression_scan_v1", operation: operation, - verified: checks.allSatisfy { $0.status != "failed" }, + verified: checks.allSatisfy { $0.status == "passed" || $0.status == "not_applicable" }, evidenceLevel: "private_calculate_string_scanner+hash_only_expression_matrix", targetIDSHA256: sha256Hex( [ diff --git a/Sources/NotesCLI/Commands/NotesCommandBodyTables.swift b/Sources/NotesCLI/Commands/NotesCommandBodyTables.swift index 8943611..5a7c9d4 100644 --- a/Sources/NotesCLI/Commands/NotesCommandBodyTables.swift +++ b/Sources/NotesCLI/Commands/NotesCommandBodyTables.swift @@ -174,12 +174,19 @@ extension NotesCommand { ) let anchor: NotesBodyParagraphAnchorRecord? let requestedSelector: String + guard let anchors = structure.paragraphAnchors else { + throw CLIError( + code: .backendUnavailable, + message: "Notes body paragraph readback is unavailable.", + details: ["id_sha256": sha256Hex(noteID)] + ) + } if let paragraph { requestedSelector = "paragraph" - anchor = structure.paragraphAnchors.first { $0.idSHA256 == paragraph } + anchor = anchors.first { $0.idSHA256 == paragraph } } else if let ordinal { requestedSelector = "ordinal" - anchor = structure.paragraphAnchors.first { $0.ordinal == ordinal } + anchor = anchors.first { $0.ordinal == ordinal } } else { requestedSelector = "unknown" anchor = nil @@ -192,7 +199,7 @@ extension NotesCommand { "id_sha256": sha256Hex(noteID), "paragraph_sha256": paragraph ?? "", "ordinal": ordinal.map(String.init) ?? "", - "paragraph_anchor_count": "\(structure.paragraphAnchors.count)", + "paragraph_anchor_count": "\(anchors.count)", ] ) } @@ -484,7 +491,7 @@ extension NotesCommand { verificationBoolCheck( name: "table_count_matches_structure", expected: true, - actual: tables.count == structure.tableCount + actual: structure.tableCount.map { tables.count == $0 } ), verificationBoolCheck( name: "table_ordinals_contiguous", @@ -510,7 +517,7 @@ extension NotesCommand { return NotesMutationVerificationReport( verifier: "notes_read_v1", operation: "notes.body.table.list", - verified: checks.allSatisfy { $0.status != "failed" }, + verified: checks.allSatisfy { $0.status == "passed" || $0.status == "not_applicable" }, evidenceLevel: "private_framework_body_table_attachment_readback", targetIDSHA256: sha256Hex(structure.noteID), checks: checks diff --git a/Sources/NotesCLI/Commands/NotesCommandCollaboration.swift b/Sources/NotesCLI/Commands/NotesCommandCollaboration.swift index fee2bcd..2963a76 100644 --- a/Sources/NotesCLI/Commands/NotesCommandCollaboration.swift +++ b/Sources/NotesCLI/Commands/NotesCommandCollaboration.swift @@ -499,7 +499,7 @@ extension NotesCommand { return NotesMutationVerificationReport( verifier: "notes_read_v1", operation: "notes.state.collaboration.audit", - verified: checks.allSatisfy { $0.status != "failed" }, + verified: checks.allSatisfy { $0.status == "passed" || $0.status == "not_applicable" }, evidenceLevel: "capability_accounting+privacy_boundary+no_backend_calls", targetIDSHA256: sha256Hex("notes.state.collaboration.audit"), checks: checks @@ -2273,7 +2273,7 @@ extension NotesCommand { return NotesMutationVerificationReport( verifier: "notes_collaboration_participants_artifact_export_v1", operation: operation, - verified: checks.allSatisfy { $0.status != "failed" }, + verified: checks.allSatisfy { $0.status == "passed" || $0.status == "not_applicable" }, evidenceLevel: "private_framework_share_participant_readback+artifact_hash+privacy_hash", targetIDSHA256: sha256Hex( [ diff --git a/Sources/NotesCLI/Commands/NotesCommandFolders.swift b/Sources/NotesCLI/Commands/NotesCommandFolders.swift index fdde5f7..189ae46 100644 --- a/Sources/NotesCLI/Commands/NotesCommandFolders.swift +++ b/Sources/NotesCLI/Commands/NotesCommandFolders.swift @@ -1470,7 +1470,7 @@ extension NotesCommand { return NotesMutationVerificationReport( verifier: "notes_read_v1", operation: "notes.folders.workflow.audit", - verified: checks.allSatisfy { $0.status != "failed" }, + verified: checks.allSatisfy { $0.status == "passed" || $0.status == "not_applicable" }, evidenceLevel: "capability_accounting+privacy_boundary+no_backend_calls", targetIDSHA256: sha256Hex("notes.folders.workflow.audit"), checks: checks diff --git a/Sources/NotesCLI/Commands/NotesCommandImportExport.swift b/Sources/NotesCLI/Commands/NotesCommandImportExport.swift index 17ed5f6..441e50d 100644 --- a/Sources/NotesCLI/Commands/NotesCommandImportExport.swift +++ b/Sources/NotesCLI/Commands/NotesCommandImportExport.swift @@ -1662,7 +1662,7 @@ extension NotesCommand { return NotesMutationVerificationReport( verifier: "notes_read_v1", operation: "notes.export.audit", - verified: checks.allSatisfy { $0.status != "failed" }, + verified: checks.allSatisfy { $0.status == "passed" || $0.status == "not_applicable" }, evidenceLevel: "private_framework_note_state_readback+apple_notes_export_family_accounting", targetIDSHA256: sha256Hex(note.id), checks: checks @@ -1734,7 +1734,7 @@ extension NotesCommand { return NotesMutationVerificationReport( verifier: "notes_read_v1", operation: "notes.import.audit", - verified: checks.allSatisfy { $0.status != "failed" }, + verified: checks.allSatisfy { $0.status == "passed" || $0.status == "not_applicable" }, evidenceLevel: "filesystem_import_preflight+apple_notes_import_family_accounting", targetIDSHA256: sha256Hex(records.map(\.pathSHA256).joined(separator: "\n")), checks: checks @@ -3002,7 +3002,7 @@ extension NotesCommand { return NotesMutationVerificationReport( verifier: "notes_write_v1", operation: "notes.open-in-pages", - verified: checks.allSatisfy { $0.status != "failed" }, + verified: checks.allSatisfy { $0.status == "passed" || $0.status == "not_applicable" }, evidenceLevel: "private_framework_rtfd_filewrapper+rtfd_tree_hash+external_pages_dispatch+note_readback", targetIDSHA256: sha256Hex(source.noteID), checks: checks diff --git a/Sources/NotesCLI/Commands/NotesCommandLifecycle.swift b/Sources/NotesCLI/Commands/NotesCommandLifecycle.swift index 7bb1a53..414dc8f 100644 --- a/Sources/NotesCLI/Commands/NotesCommandLifecycle.swift +++ b/Sources/NotesCLI/Commands/NotesCommandLifecycle.swift @@ -81,7 +81,9 @@ extension NotesCommand { ) return try verifiedMutationResult( NotesMutationResult( - operation: "notes.update", changed: true, note: note, deletedID: nil, + operation: "notes.update", + changed: patch.body != nil || !note.title.utf8.elementsEqual(identity.note.title.utf8), + note: note, deletedID: nil, verification: verification)) } case ["notes", "append"]: @@ -667,12 +669,13 @@ extension NotesCommand { private func createDraft(_ options: CLIOptions, isSystemPaper: Bool = false) throws -> NotesCreateDraft { let folder = try folderIdentity(selector: try requiredOption("folder", options: options)) let title = try normalizedOption("title", options: options) + try notesValidateTitleEdit(title) return NotesCreateDraft( folderId: folder.id, folderName: folder.name, accountName: folder.accountName, title: title, - body: try normalizedOptionalOption("body", options: options) ?? "", + body: options.targetOption("body") ?? "", isSystemPaper: isSystemPaper ) } @@ -684,17 +687,22 @@ extension NotesCommand { private func updatePatch(_ options: CLIOptions) throws -> NotesUpdatePatch { let patch = NotesUpdatePatch( title: try normalizedOptionalOption("title", options: options), - body: try normalizedOptionalOption("body", options: options) + body: options.targetOption("body") ) guard patch.hasChanges else { throw CLIError( code: .validationError, message: "At least one note field must be supplied for update.") } + if let title = patch.title { try notesValidateTitleEdit(title) } return patch } private func appendPatch(_ options: CLIOptions) throws -> NotesUpdatePatch { - NotesUpdatePatch(appendBody: try normalizedOption("body", options: options)) + let body = try requiredOption("body", options: options) + guard !body.isEmpty else { + throw CLIError(code: .validationError, message: "`--body` must not be empty for append.") + } + return NotesUpdatePatch(appendBody: body) } private func moveDraft(note: NotesNoteDetail, options: CLIOptions) throws -> NotesMoveDraft { diff --git a/Sources/NotesCLI/Commands/NotesCommandLinks.swift b/Sources/NotesCLI/Commands/NotesCommandLinks.swift index ceeea3f..32eb11b 100644 --- a/Sources/NotesCLI/Commands/NotesCommandLinks.swift +++ b/Sources/NotesCLI/Commands/NotesCommandLinks.swift @@ -1811,7 +1811,7 @@ extension NotesCommand { return NotesMutationVerificationReport( verifier: "notes_read_v1", operation: "notes.links.resolve", - verified: checks.allSatisfy { $0.status != "failed" }, + verified: checks.allSatisfy { $0.status == "passed" || $0.status == "not_applicable" }, evidenceLevel: "private_framework_link_resolution+link_metadata_readback+destination_readback", targetIDSHA256: sha256Hex("\(resolution.noteID)|\(resolution.link.id)"), checks: checks, @@ -2197,7 +2197,7 @@ extension NotesCommand { return NotesMutationVerificationReport( verifier: "notes_read_v1", operation: "notes.links.audit", - verified: checks.allSatisfy { $0.status != "failed" }, + verified: checks.allSatisfy { $0.status == "passed" || $0.status == "not_applicable" }, evidenceLevel: "capability_accounting+privacy_boundary+no_backend_calls", targetIDSHA256: sha256Hex("notes.links.audit"), checks: checks diff --git a/Sources/NotesCLI/Commands/NotesCommandReadSearch.swift b/Sources/NotesCLI/Commands/NotesCommandReadSearch.swift index 7d81acc..af4e7fa 100644 --- a/Sources/NotesCLI/Commands/NotesCommandReadSearch.swift +++ b/Sources/NotesCLI/Commands/NotesCommandReadSearch.swift @@ -540,7 +540,7 @@ extension NotesCommand { return NotesMutationVerificationReport( verifier: "notes_read_v1", operation: "notes.search.audit", - verified: checks.allSatisfy { $0.status != "failed" }, + verified: checks.allSatisfy { $0.status == "passed" || $0.status == "not_applicable" }, evidenceLevel: "capability_accounting+privacy_boundary+no_backend_calls", targetIDSHA256: sha256Hex("notes.search.audit"), checks: checks @@ -803,7 +803,7 @@ extension NotesCommand { return NotesMutationVerificationReport( verifier: "notes_read_v1", operation: "notes.search.natural-language", - verified: checks.allSatisfy { $0.status != "failed" }, + verified: checks.allSatisfy { $0.status == "passed" || $0.status == "not_applicable" }, evidenceLevel: "typed_private_ICSearchQueryOperation+natural_language_query_readback+note_summary_mapping", targetIDSHA256: evidence.querySHA256, checks: checks diff --git a/Sources/NotesCLI/Commands/NotesCommandSecurity.swift b/Sources/NotesCLI/Commands/NotesCommandSecurity.swift index 09a8380..1b422d6 100644 --- a/Sources/NotesCLI/Commands/NotesCommandSecurity.swift +++ b/Sources/NotesCLI/Commands/NotesCommandSecurity.swift @@ -452,7 +452,7 @@ extension NotesCommand { return NotesMutationVerificationReport( verifier: "notes_read_v1", operation: "notes.state.security.audit", - verified: checks.allSatisfy { $0.status != "failed" }, + verified: checks.allSatisfy { $0.status == "passed" || $0.status == "not_applicable" }, evidenceLevel: "capability_accounting+privacy_boundary+no_backend_calls", targetIDSHA256: sha256Hex("notes.state.security.audit"), checks: checks diff --git a/Sources/NotesCLI/Commands/NotesCommandSettings.swift b/Sources/NotesCLI/Commands/NotesCommandSettings.swift index 5444635..c5a3d71 100644 --- a/Sources/NotesCLI/Commands/NotesCommandSettings.swift +++ b/Sources/NotesCLI/Commands/NotesCommandSettings.swift @@ -636,7 +636,7 @@ extension NotesCommand { return NotesMutationVerificationReport( verifier: "notes_read_v1", operation: "notes.settings.audit", - verified: checks.allSatisfy { $0.status != "failed" }, + verified: checks.allSatisfy { $0.status == "passed" || $0.status == "not_applicable" }, evidenceLevel: "capability_accounting+privacy_boundary+no_backend_calls", targetIDSHA256: sha256Hex("notes.settings.audit"), checks: checks diff --git a/Sources/NotesCLI/Commands/NotesCommandSmartFolderReasoning.swift b/Sources/NotesCLI/Commands/NotesCommandSmartFolderReasoning.swift index f7699d1..339076a 100644 --- a/Sources/NotesCLI/Commands/NotesCommandSmartFolderReasoning.swift +++ b/Sources/NotesCLI/Commands/NotesCommandSmartFolderReasoning.swift @@ -76,7 +76,7 @@ extension NotesCommand { return NotesMutationVerificationReport( verifier: "notes_read_v1", operation: "notes.smart-folders.criteria", - verified: checks.allSatisfy { $0.status != "failed" }, + verified: checks.allSatisfy { $0.status == "passed" || $0.status == "not_applicable" }, evidenceLevel: "private_framework_smart_folder_criteria+matching_note_readback", targetIDSHA256: sha256Hex(smartFolder.id), checks: checks, @@ -436,7 +436,7 @@ extension NotesCommand { case "math": return smartFolderBoolFilterReason( filter, - actual: state.isMathNote || structure.mathAttachmentCount > 0, + actual: state.isMathNote ? true : structure.mathAttachmentCount.map { $0 > 0 }, source: "note_state+body_structure", rawGates: rawGate ) @@ -461,14 +461,14 @@ extension NotesCommand { private func smartFolderBoolFilterReason( _ filter: NotesSmartFolderCriteriaFilterExplanation, - actual: Bool, + actual: Bool?, source: String, rawGates: [String] ) -> NotesSmartFolderFilterReasonRecord { - let evaluatedActual: Bool + let evaluatedActual: Bool? switch filter.inclusionType { case 0: - evaluatedActual = !actual + evaluatedActual = actual.map { !$0 } case 1, nil: evaluatedActual = actual case let unsupported?: @@ -484,6 +484,9 @@ extension NotesCommand { gatedReasoningFamilies: Array(Set(gates)).sorted() ) } + guard let evaluatedActual else { + return smartFolderUnavailableBodyReason(filter, source: source, count: nil, rawGates: rawGates) + } return NotesSmartFolderFilterReasonRecord( ordinal: filter.ordinal, kind: filter.kind, @@ -635,7 +638,7 @@ extension NotesCommand { rawGates: [String] ) -> NotesSmartFolderFilterReasonRecord { let mentionCount = bodyAttachmentKindCount("mention", in: structure) - let hasMentions = mentionCount > 0 + let hasMentions = mentionCount.map { $0 > 0 } let selectedMentionMatched = smartFolderSelectedHashesMatch( expected: filter.participantUserIDSHA256s, actual: structure.mentionUserIDSHA256s @@ -645,10 +648,10 @@ extension NotesCommand { || (filter.includedCount ?? 0) > 0 || (filter.excludedCount ?? 0) > 0 || !filter.participantUserIDSHA256s.isEmpty - let evaluatedActual: Bool + let evaluatedActual: Bool? switch filter.inclusionType { case 0: - evaluatedActual = selectedMentionMatched.map { !$0 } ?? !hasMentions + evaluatedActual = selectedMentionMatched.map { !$0 } ?? hasMentions.map { !$0 } case 1, nil: evaluatedActual = selectedMentionMatched ?? hasMentions case let unsupported?: @@ -671,7 +674,7 @@ extension NotesCommand { if let selectedMentionMatched { var gates = smartFolderSemanticRawGates(rawGates) - if !evaluatedActual { + if evaluatedActual == false { gates.append("filter_mentions_value_comparison") } return NotesSmartFolderFilterReasonRecord( @@ -691,6 +694,10 @@ extension NotesCommand { ) } + guard let evaluatedActual else { + return smartFolderUnavailableBodyReason(filter, + source: "body_structure.attachmentKindCounts.mention", count: mentionCount, rawGates: rawGates) + } var gates = smartFolderSemanticRawGates(rawGates) if hasSpecificSelection { gates.append("mention_participant_identifier_comparison") @@ -714,19 +721,17 @@ extension NotesCommand { ) } - private func bodyAttachmentKindCount(_ kind: String, in structure: NotesBodyStructureRecord) -> Int { - structure.attachmentKindCounts.first { $0.kind == kind }?.count ?? 0 + private func bodyAttachmentKindCount(_ kind: String, in structure: NotesBodyStructureRecord) -> Int? { + structure.attachmentKindCounts.map { $0.first { $0.kind == kind }?.count ?? 0 } } - private func smartFolderSelectedHashesMatch(expected: [String], actual: [String]) -> Bool? { + private func smartFolderSelectedHashesMatch(expected: [String], actual: [String]?) -> Bool? { let expectedSet = Set(expected.filter { !$0.isEmpty }) guard !expectedSet.isEmpty else { return nil } + guard let actual else { return nil } let actualSet = Set(actual.filter { !$0.isEmpty }) - guard !actualSet.isEmpty else { - return nil - } return expectedSet.isSubset(of: actualSet) } @@ -1202,22 +1207,22 @@ extension NotesCommand { rawGates: [String] ) -> NotesSmartFolderFilterReasonRecord { let selectionType = filter.selectionType ?? 0 - let count: Int - let actualBool: Bool + let count: Int? + let actualBool: Bool? switch selectionType { case 0: count = structure.checklistItemCount - actualBool = count > 0 + actualBool = count.map { $0 > 0 } case 1: count = structure.checklistOpenCount - actualBool = count > 0 + actualBool = count.map { $0 > 0 } case 2: count = structure.checklistDoneCount - actualBool = count > 0 + actualBool = count.map { $0 > 0 } case 3: count = structure.checklistItemCount - actualBool = count == 0 + actualBool = count.map { $0 == 0 } default: var gates = rawGates gates.append("filter_checklists_selection_type_\(selectionType)_boolean_evaluation") @@ -1233,6 +1238,9 @@ extension NotesCommand { ) } + guard let actualBool else { + return smartFolderUnavailableBodyReason(filter, source: "body_structure", count: count, rawGates: rawGates) + } return NotesSmartFolderFilterReasonRecord( ordinal: filter.ordinal, kind: filter.kind, @@ -1246,6 +1254,24 @@ extension NotesCommand { ) } + private func smartFolderUnavailableBodyReason( + _ filter: NotesSmartFolderCriteriaFilterExplanation, + source: String, + count: Int?, + rawGates: [String] + ) -> NotesSmartFolderFilterReasonRecord { + NotesSmartFolderFilterReasonRecord( + ordinal: filter.ordinal, + kind: filter.kind, + reasoningStatus: "body_readback_unavailable", + matchStatus: "matched_by_private_smart_folder_readback", + evidenceSource: source, + actualCount: count, + rawValueComparisonStatus: filter.rawValuePresent ? "gated_hash_only" : "not_applicable", + gatedReasoningFamilies: Array(Set(rawGates + ["filter_\(filter.kind)_body_readback"])).sorted() + ) + } + func verifySmartFolderCriteriaExplanation( smartFolder: NotesSmartFolderRecord, notes: [NotesNoteSummary], @@ -1278,7 +1304,7 @@ extension NotesCommand { return NotesMutationVerificationReport( verifier: "notes_read_v1", operation: "notes.smart-folders.explain", - verified: checks.allSatisfy { $0.status != "failed" }, + verified: checks.allSatisfy { $0.status == "passed" || $0.status == "not_applicable" }, evidenceLevel: "private_framework_smart_folder_criteria_explanation+matching_note_readback", targetIDSHA256: sha256Hex(smartFolder.id), checks: checks, @@ -1431,7 +1457,7 @@ extension NotesCommand { return NotesMutationVerificationReport( verifier: "notes_read_v1", operation: "notes.smart-folders.reasoning", - verified: checks.allSatisfy { $0.status != "failed" }, + verified: checks.allSatisfy { $0.status == "passed" || $0.status == "not_applicable" }, evidenceLevel: "private_framework_smart_folder_membership_reasoning+criteria_summary+boolean_trace", targetIDSHA256: sha256Hex(smartFolder.id), checks: checks, diff --git a/Sources/NotesCLI/Commands/NotesCommandSmartFolders.swift b/Sources/NotesCLI/Commands/NotesCommandSmartFolders.swift index 832f49f..308524f 100644 --- a/Sources/NotesCLI/Commands/NotesCommandSmartFolders.swift +++ b/Sources/NotesCLI/Commands/NotesCommandSmartFolders.swift @@ -1170,7 +1170,7 @@ extension NotesCommand { ] return NotesMutationVerificationReport( operation: "notes.smart-folders.filters.audit", - verified: checks.allSatisfy { $0.status != "failed" }, + verified: checks.allSatisfy { $0.status == "passed" || $0.status == "not_applicable" }, evidenceLevel: "filter_catalog_accounting+privacy_boundary+no_backend_calls", targetIDSHA256: sha256Hex("notes.smart-folders.filters.audit"), checks: checks @@ -1671,7 +1671,7 @@ extension NotesCommand { return NotesMutationVerificationReport( verifier: "notes_read_v1", operation: "notes.smart-folders.workflow.audit", - verified: checks.allSatisfy { $0.status != "failed" }, + verified: checks.allSatisfy { $0.status == "passed" || $0.status == "not_applicable" }, evidenceLevel: "capability_accounting+privacy_boundary+no_backend_calls", targetIDSHA256: sha256Hex("notes.smart-folders.workflow.audit"), checks: checks @@ -1722,7 +1722,7 @@ extension NotesCommand { return NotesMutationVerificationReport( verifier: "notes_read_v1", operation: "notes.smart-folders.audit", - verified: checks.allSatisfy { $0.status != "failed" }, + verified: checks.allSatisfy { $0.status == "passed" || $0.status == "not_applicable" }, evidenceLevel: "private_framework_smart_folder_criteria_audit", targetIDSHA256: sha256Hex(records.map(\.smartFolder.id).joined(separator: "\n")), checks: checks, diff --git a/Sources/NotesCLI/Commands/NotesCommandState.swift b/Sources/NotesCLI/Commands/NotesCommandState.swift index 39e3da7..4ab73ae 100644 --- a/Sources/NotesCLI/Commands/NotesCommandState.swift +++ b/Sources/NotesCLI/Commands/NotesCommandState.swift @@ -447,7 +447,7 @@ extension NotesCommand { return NotesMutationVerificationReport( verifier: "notes_read_v1", operation: "notes.state.lockability", - verified: checks.allSatisfy { $0.status != "failed" }, + verified: checks.allSatisfy { $0.status == "passed" || $0.status == "not_applicable" }, evidenceLevel: "private_note_state+account_lockability+tag_membership+attachment_family_readback", targetIDSHA256: sha256Hex(noteID), checks: checks @@ -571,7 +571,7 @@ extension NotesCommand { return NotesMutationVerificationReport( verifier: "notes_read_v1", operation: "notes.state.audit", - verified: checks.allSatisfy { $0.status != "failed" }, + verified: checks.allSatisfy { $0.status == "passed" || $0.status == "not_applicable" }, evidenceLevel: "private_framework_note_state_batch_readback", targetIDSHA256: sha256Hex(records.map(\.noteID).joined(separator: "\n")), checks: checks @@ -748,7 +748,7 @@ extension NotesCommand { return NotesMutationVerificationReport( verifier: "notes_read_v1", operation: operation, - verified: checks.allSatisfy { $0.status != "failed" }, + verified: checks.allSatisfy { $0.status == "passed" || $0.status == "not_applicable" }, evidenceLevel: "private_framework_activity_events_metadata_readback", targetIDSHA256: activity.noteIDSHA256, checks: checks @@ -806,7 +806,7 @@ extension NotesCommand { return NotesMutationVerificationReport( verifier: "notes_read_v1", operation: operation, - verified: checks.allSatisfy { $0.status != "failed" }, + verified: checks.allSatisfy { $0.status == "passed" || $0.status == "not_applicable" }, evidenceLevel: "private_framework_activity_events_metadata_readback+artifact_hash+activity_readback", targetIDSHA256: activity.noteIDSHA256, checks: checks diff --git a/Sources/NotesCLI/Commands/NotesCommandSupport.swift b/Sources/NotesCLI/Commands/NotesCommandSupport.swift index be92a60..72f8153 100644 --- a/Sources/NotesCLI/Commands/NotesCommandSupport.swift +++ b/Sources/NotesCLI/Commands/NotesCommandSupport.swift @@ -115,11 +115,11 @@ extension NotesCommand { func verificationBoolCheck( name: String, expected: Bool, - actual: Bool + actual: Bool? ) -> NotesVerificationCheckRecord { NotesVerificationCheckRecord( name: name, - status: expected == actual ? "passed" : "failed", + status: actual.map { expected == $0 ? "passed" : "failed" } ?? "unavailable", expectedBool: expected, actualBool: actual ) diff --git a/Sources/NotesCLI/Commands/NotesCommandTags.swift b/Sources/NotesCLI/Commands/NotesCommandTags.swift index 15d8b00..309f333 100644 --- a/Sources/NotesCLI/Commands/NotesCommandTags.swift +++ b/Sources/NotesCLI/Commands/NotesCommandTags.swift @@ -1023,7 +1023,7 @@ extension NotesCommand { return NotesMutationVerificationReport( verifier: "notes_read_v1", operation: "notes.tags.audit", - verified: checks.allSatisfy { $0.status != "failed" }, + verified: checks.allSatisfy { $0.status == "passed" || $0.status == "not_applicable" }, evidenceLevel: "capability_accounting+privacy_boundary+no_backend_calls", targetIDSHA256: sha256Hex("notes.tags.audit"), checks: checks diff --git a/Sources/NotesCLI/Commands/NotesCommandWorkflowAudits.swift b/Sources/NotesCLI/Commands/NotesCommandWorkflowAudits.swift index 20452ca..da5d133 100644 --- a/Sources/NotesCLI/Commands/NotesCommandWorkflowAudits.swift +++ b/Sources/NotesCLI/Commands/NotesCommandWorkflowAudits.swift @@ -603,7 +603,7 @@ extension NotesCommand { return NotesMutationVerificationReport( verifier: "notes_read_v1", operation: "notes.guide.audit", - verified: checks.allSatisfy { $0.status != "failed" }, + verified: checks.allSatisfy { $0.status == "passed" || $0.status == "not_applicable" }, evidenceLevel: "official_toc_page_accounting+privacy_boundary+no_backend_calls", targetIDSHA256: sha256Hex("notes.guide.audit"), checks: checks @@ -1292,7 +1292,7 @@ extension NotesCommand { return NotesMutationVerificationReport( verifier: "notes_read_v1", operation: "notes.workflow.audit", - verified: checks.allSatisfy { $0.status != "failed" }, + verified: checks.allSatisfy { $0.status == "passed" || $0.status == "not_applicable" }, evidenceLevel: "capability_accounting+privacy_boundary+no_backend_calls", targetIDSHA256: sha256Hex("notes.workflow.audit"), checks: checks @@ -2219,7 +2219,7 @@ extension NotesCommand { return NotesMutationVerificationReport( verifier: "notes_read_v1", operation: "notes.workflow.shortcuts.audit", - verified: checks.allSatisfy { $0.status != "failed" }, + verified: checks.allSatisfy { $0.status == "passed" || $0.status == "not_applicable" }, evidenceLevel: "capability_accounting+privacy_boundary+no_backend_calls", targetIDSHA256: sha256Hex("notes.workflow.shortcuts.audit"), checks: checks diff --git a/Sources/NotesCLI/Diagnostics/NotesManagedAccessorProbe.swift b/Sources/NotesCLI/Diagnostics/NotesManagedAccessorProbe.swift index b13e85b..45ed26b 100644 --- a/Sources/NotesCLI/Diagnostics/NotesManagedAccessorProbe.swift +++ b/Sources/NotesCLI/Diagnostics/NotesManagedAccessorProbe.swift @@ -1,6 +1,20 @@ import CoreData import Foundation import ObjectiveC.runtime +import Utility + +func notesRequireDateAccessors(_ object: NSManagedObject, operation: String) throws { + for property in ["creationDate", "modificationDate"] { + guard object.entity.attributesByName[property]?.attributeType == .dateAttributeType else { + throw CLIError(code: .backendUnavailable, + message: "Notes object model does not expose the expected date attribute.", + details: ["operation": operation, "native_selector": property, + "reason": "model_attribute_mismatch"]) + } + try NotesRuntimeMethod(owner: "ICNote", selector: property, returnType: "@") + .require(operation: operation, receiver: object) + } +} struct NotesManagedAccessorProbe: Sendable { var propertiesByClass: [String: Set] diff --git a/Sources/NotesCLI/Diagnostics/NotesRuntimeMethod.swift b/Sources/NotesCLI/Diagnostics/NotesRuntimeMethod.swift new file mode 100644 index 0000000..dbaf3b1 --- /dev/null +++ b/Sources/NotesCLI/Diagnostics/NotesRuntimeMethod.swift @@ -0,0 +1,74 @@ +import Darwin +import Foundation +import ObjectiveC.runtime +import Utility + +struct NotesRuntimeMethod: Sendable { + enum Scope: String, Equatable, Sendable { + case instance + case classMethod = "class" + } + + var owner: String + var selector: String + var scope: Scope = .instance + var returnType: String + var argumentTypes: [String] = [] + + func require(operation: String, receiver: NSObject? = nil) throws { + let runtimeSelector = NSSelectorFromString(selector) + // Core Data may install model accessors when asked about a real object. + if scope == .instance, let receiver { + _ = receiver.responds(to: runtimeSelector) + } + let runtimeClass: AnyClass? + if let receiver { + runtimeClass = object_getClass(receiver) + } else { + runtimeClass = NSClassFromString(owner) + } + let method = runtimeClass.flatMap { + scope == .classMethod + ? class_getClassMethod($0, runtimeSelector) + : class_getInstanceMethod($0, runtimeSelector) + } + var details = [ + "mechanism": "notes_framework", + "operation": operation, + "native_class": runtimeClass.map(NSStringFromClass) ?? owner, + "native_selector": selector, + "method_scope": scope.rawValue, + "expected_return": returnType, + "expected_arguments": (["@", ":"] + argumentTypes).joined(separator: ","), + ] + guard let method else { + details["reason"] = runtimeClass == nil ? "class_unavailable" : "method_unavailable" + throw CLIError(code: .backendUnavailable, + message: "Notes framework method is unavailable.", details: details) + } + let copiedReturn = method_copyReturnType(method) + let actualReturn = String(cString: copiedReturn) + free(copiedReturn) + let actualArguments = (0.." } + defer { free(pointer) } + return String(cString: pointer) + } + details["actual_return"] = actualReturn + details["actual_arguments"] = actualArguments.joined(separator: ",") + guard Self.abiType(actualReturn) == Self.abiType(returnType), + actualArguments.map(Self.abiType) == (["@", ":"] + argumentTypes).map(Self.abiType) + else { + details["reason"] = "signature_mismatch" + throw CLIError(code: .backendUnavailable, + message: "Notes framework method has an incompatible signature.", details: details) + } + } + + private static func abiType(_ encoding: String) -> String { + let type = encoding.drop { "rnNoORV".contains($0) } + if type.first == "^" { return "^" + abiType(String(type.dropFirst())) } + if type.hasPrefix("@\""), type.hasSuffix("\"") { return "@" } + return String(type) + } +} diff --git a/Sources/NotesCLI/Models/Models.swift b/Sources/NotesCLI/Models/Models.swift index 911d41d..3a18afe 100644 --- a/Sources/NotesCLI/Models/Models.swift +++ b/Sources/NotesCLI/Models/Models.swift @@ -7640,6 +7640,8 @@ public struct NotesBodyColorHashCount: Codable, Equatable, Sendable { public struct NotesBodyInlineFormatRunRecord: Codable, Equatable, Sendable { public var ordinal: Int + public var utf16Location: Int? + public var utf16Length: Int? public var paragraphIDSHA256: String? public var format: String public var fontSHA256: String? @@ -7652,8 +7654,12 @@ public struct NotesBodyInlineFormatRunRecord: Codable, Equatable, Sendable { format: String, fontSHA256: String? = nil, textByteCount: Int, - textSHA256: String + textSHA256: String, + utf16Location: Int? = nil, + utf16Length: Int? = nil ) { + self.utf16Location = utf16Location + self.utf16Length = utf16Length self.ordinal = ordinal self.paragraphIDSHA256 = paragraphIDSHA256 self.format = format @@ -7665,6 +7671,8 @@ public struct NotesBodyInlineFormatRunRecord: Codable, Equatable, Sendable { public struct NotesBodyInlineColorRunRecord: Codable, Equatable, Sendable { public var ordinal: Int + public var utf16Location: Int? + public var utf16Length: Int? public var paragraphIDSHA256: String? public var role: String public var colorSHA256: String @@ -7677,8 +7685,12 @@ public struct NotesBodyInlineColorRunRecord: Codable, Equatable, Sendable { role: String, colorSHA256: String, textByteCount: Int, - textSHA256: String + textSHA256: String, + utf16Location: Int? = nil, + utf16Length: Int? = nil ) { + self.utf16Location = utf16Location + self.utf16Length = utf16Length self.ordinal = ordinal self.paragraphIDSHA256 = paragraphIDSHA256 self.role = role @@ -8074,6 +8086,8 @@ public struct NotesLockedContentExportResult: Codable, Equatable, Sendable { public struct NotesBodyParagraphAnchorRecord: Codable, Equatable, Sendable { public var ordinal: Int + public var utf16Location: Int? + public var utf16Length: Int? public var idSHA256: String public var titleByteCount: Int? public var titleSHA256: String? @@ -8102,8 +8116,12 @@ public struct NotesBodyParagraphAnchorRecord: Codable, Equatable, Sendable { isBlockQuote: Bool = false, indentationLevel: Int? = nil, canIndent: Bool? = nil, - checklistDone: Bool? = nil + checklistDone: Bool? = nil, + utf16Location: Int? = nil, + utf16Length: Int? = nil ) { + self.utf16Location = utf16Location + self.utf16Length = utf16Length self.ordinal = ordinal self.idSHA256 = idSHA256 self.titleByteCount = titleByteCount @@ -8653,6 +8671,9 @@ public struct NotesBodyInlineMutationEvidence: Equatable, Sendable { public var textByteCount: Int public var textSHA256: String public var occurrence: Int + public var utf16Location: Int? + public var utf16Length: Int? + public var richTextSHA256: String? public var role: String public var colorSHA256: String? public var fontSHA256: String? @@ -8664,8 +8685,14 @@ public struct NotesBodyInlineMutationEvidence: Equatable, Sendable { occurrence: Int, role: String, colorSHA256: String? = nil, - fontSHA256: String? = nil + fontSHA256: String? = nil, + utf16Location: Int? = nil, + utf16Length: Int? = nil, + richTextSHA256: String? = nil ) { + self.utf16Location = utf16Location + self.utf16Length = utf16Length + self.richTextSHA256 = richTextSHA256 self.paragraphIDSHA256 = paragraphIDSHA256 self.textByteCount = textByteCount self.textSHA256 = textSHA256 @@ -8828,40 +8855,42 @@ public struct NotesBodyStructureRecord: Codable, Equatable, Sendable { public var plainTextByteCount: Int? public var plainTextSHA256: String? public var richTextLength: Int? + public var richTextSHA256: String? public var paragraphCount: Int? - public var paragraphStyleRunCount: Int - public var headingCount: Int - public var listItemCount: Int - public var checklistItemCount: Int - public var checklistDoneCount: Int - public var checklistOpenCount: Int - public var blockQuoteCount: Int - public var tableCount: Int - public var collapsibleSectionCount: Int - public var collapsedSectionCount: Int - public var inlineAttachmentCount: Int - public var linkCount: Int - public var attachmentCount: Int - public var mathAttachmentCount: Int - public var inlineFormatRunCount: Int - public var boldRunCount: Int - public var italicRunCount: Int - public var underlineRunCount: Int - public var strikethroughRunCount: Int - public var fontRunCount: Int - public var foregroundColorRunCount: Int - public var highlightRunCount: Int - public var hasChecklist: Bool - public var hasChecklistInProgress: Bool - public var isMathNote: Bool - public var styleCounts: [NotesBodyStyleCount] - public var attachmentKindCounts: [NotesBodyAttachmentKindCount] - public var inlineFormatCounts: [NotesBodyInlineFormatCount] - public var colorHashCounts: [NotesBodyColorHashCount] - public var inlineFormatRuns: [NotesBodyInlineFormatRunRecord] - public var colorRuns: [NotesBodyInlineColorRunRecord] - public var mentionUserIDSHA256s: [String] - public var paragraphAnchors: [NotesBodyParagraphAnchorRecord] + public var paragraphStyleRunCount: Int? + public var headingCount: Int? + public var listItemCount: Int? + public var checklistItemCount: Int? + public var checklistDoneCount: Int? + public var checklistOpenCount: Int? + public var blockQuoteCount: Int? + public var tableCount: Int? + public var collapsibleSectionCount: Int? + public var collapsedSectionCount: Int? + public var inlineAttachmentCount: Int? + public var linkCount: Int? + public var attachmentCount: Int? + public var mathAttachmentCount: Int? + // Missing attributed-text evidence is unknown; known empty formatting has zero counts and empty arrays. + public var inlineFormatRunCount: Int? + public var boldRunCount: Int? + public var italicRunCount: Int? + public var underlineRunCount: Int? + public var strikethroughRunCount: Int? + public var fontRunCount: Int? + public var foregroundColorRunCount: Int? + public var highlightRunCount: Int? + public var hasChecklist: Bool? + public var hasChecklistInProgress: Bool? + public var isMathNote: Bool? + public var styleCounts: [NotesBodyStyleCount]? + public var attachmentKindCounts: [NotesBodyAttachmentKindCount]? + public var inlineFormatCounts: [NotesBodyInlineFormatCount]? + public var colorHashCounts: [NotesBodyColorHashCount]? + public var inlineFormatRuns: [NotesBodyInlineFormatRunRecord]? + public var colorRuns: [NotesBodyInlineColorRunRecord]? + public var mentionUserIDSHA256s: [String]? + public var paragraphAnchors: [NotesBodyParagraphAnchorRecord]? public init( noteID: String, @@ -8869,46 +8898,48 @@ public struct NotesBodyStructureRecord: Codable, Equatable, Sendable { plainTextByteCount: Int? = nil, plainTextSHA256: String? = nil, richTextLength: Int? = nil, + richTextSHA256: String? = nil, paragraphCount: Int? = nil, - paragraphStyleRunCount: Int = 0, - headingCount: Int = 0, - listItemCount: Int = 0, - checklistItemCount: Int = 0, - checklistDoneCount: Int = 0, - checklistOpenCount: Int = 0, - blockQuoteCount: Int = 0, - tableCount: Int = 0, - collapsibleSectionCount: Int = 0, - collapsedSectionCount: Int = 0, - inlineAttachmentCount: Int = 0, - linkCount: Int = 0, - attachmentCount: Int = 0, - mathAttachmentCount: Int = 0, - inlineFormatRunCount: Int = 0, - boldRunCount: Int = 0, - italicRunCount: Int = 0, - underlineRunCount: Int = 0, - strikethroughRunCount: Int = 0, - fontRunCount: Int = 0, - foregroundColorRunCount: Int = 0, - highlightRunCount: Int = 0, - hasChecklist: Bool = false, - hasChecklistInProgress: Bool = false, - isMathNote: Bool = false, - styleCounts: [NotesBodyStyleCount] = [], - attachmentKindCounts: [NotesBodyAttachmentKindCount] = [], - inlineFormatCounts: [NotesBodyInlineFormatCount] = [], - colorHashCounts: [NotesBodyColorHashCount] = [], - inlineFormatRuns: [NotesBodyInlineFormatRunRecord] = [], - colorRuns: [NotesBodyInlineColorRunRecord] = [], - mentionUserIDSHA256s: [String] = [], - paragraphAnchors: [NotesBodyParagraphAnchorRecord] = [] + paragraphStyleRunCount: Int? = nil, + headingCount: Int? = nil, + listItemCount: Int? = nil, + checklistItemCount: Int? = nil, + checklistDoneCount: Int? = nil, + checklistOpenCount: Int? = nil, + blockQuoteCount: Int? = nil, + tableCount: Int? = nil, + collapsibleSectionCount: Int? = nil, + collapsedSectionCount: Int? = nil, + inlineAttachmentCount: Int? = nil, + linkCount: Int? = nil, + attachmentCount: Int? = nil, + mathAttachmentCount: Int? = nil, + inlineFormatRunCount: Int? = nil, + boldRunCount: Int? = nil, + italicRunCount: Int? = nil, + underlineRunCount: Int? = nil, + strikethroughRunCount: Int? = nil, + fontRunCount: Int? = nil, + foregroundColorRunCount: Int? = nil, + highlightRunCount: Int? = nil, + hasChecklist: Bool? = nil, + hasChecklistInProgress: Bool? = nil, + isMathNote: Bool? = nil, + styleCounts: [NotesBodyStyleCount]? = nil, + attachmentKindCounts: [NotesBodyAttachmentKindCount]? = nil, + inlineFormatCounts: [NotesBodyInlineFormatCount]? = nil, + colorHashCounts: [NotesBodyColorHashCount]? = nil, + inlineFormatRuns: [NotesBodyInlineFormatRunRecord]? = nil, + colorRuns: [NotesBodyInlineColorRunRecord]? = nil, + mentionUserIDSHA256s: [String]? = nil, + paragraphAnchors: [NotesBodyParagraphAnchorRecord]? = nil ) { self.noteID = noteID self.isPasswordProtected = isPasswordProtected self.plainTextByteCount = plainTextByteCount self.plainTextSHA256 = plainTextSHA256 self.richTextLength = richTextLength + self.richTextSHA256 = richTextSHA256 self.paragraphCount = paragraphCount self.paragraphStyleRunCount = paragraphStyleRunCount self.headingCount = headingCount @@ -10810,24 +10841,24 @@ public struct NotesBodyStructureResponse: Codable, Equatable, Sendable { } public struct NotesBodySurfaceSummary: Codable, Equatable, Sendable { - public var tableCount: Int - public var collapsibleSectionCount: Int - public var collapsedSectionCount: Int - public var mathAttachmentCount: Int - public var inlineAttachmentCount: Int - public var isMathNote: Bool + public var tableCount: Int? + public var collapsibleSectionCount: Int? + public var collapsedSectionCount: Int? + public var mathAttachmentCount: Int? + public var inlineAttachmentCount: Int? + public var isMathNote: Bool? public var supportedReadFamilies: [String] public var supportedMutationFamilies: [String] public var gatedReadFamilies: [String] public var gatedMutationFamilies: [String] public init( - tableCount: Int, - collapsibleSectionCount: Int, - collapsedSectionCount: Int, - mathAttachmentCount: Int, - inlineAttachmentCount: Int, - isMathNote: Bool, + tableCount: Int?, + collapsibleSectionCount: Int?, + collapsedSectionCount: Int?, + mathAttachmentCount: Int?, + inlineAttachmentCount: Int?, + isMathNote: Bool?, supportedReadFamilies: [String], supportedMutationFamilies: [String] = [], gatedReadFamilies: [String], @@ -16047,6 +16078,7 @@ public struct NotesStoreObjectRecord: Codable, Equatable, Sendable { public var entity: String? public var primaryKey: Int64? public var matched: Bool + public var lookupSucceeded: Bool? public var markedForDeletion: Bool? public var passwordProtected: Bool? public var pinned: Bool? @@ -16083,7 +16115,8 @@ public struct NotesStoreObjectRecord: Codable, Equatable, Sendable { noteCount: Int? = nil, folderCount: Int? = nil, childFolderCount: Int? = nil, - searchIndexStateCounts: [NotesStoreSearchIndexStateRecord] = [] + searchIndexStateCounts: [NotesStoreSearchIndexStateRecord] = [], + lookupSucceeded: Bool? = nil ) { self.entity = entity self.primaryKey = primaryKey @@ -16104,6 +16137,7 @@ public struct NotesStoreObjectRecord: Codable, Equatable, Sendable { self.folderCount = folderCount self.childFolderCount = childFolderCount self.searchIndexStateCounts = searchIndexStateCounts + self.lookupSucceeded = lookupSucceeded } } diff --git a/Sources/NotesCLI/Operations/NotesBodyEdit.swift b/Sources/NotesCLI/Operations/NotesBodyEdit.swift new file mode 100644 index 0000000..b8bd4cb --- /dev/null +++ b/Sources/NotesCLI/Operations/NotesBodyEdit.swift @@ -0,0 +1,32 @@ +import Foundation +import Utility + +func notesBodyReplacementRange( + in text: NSString, nativeTitleRange: NSRange, expectedTitle: String +) throws -> NSRange { + let title = try notesTitleReplacementRange(in: text, nativeRange: nativeTitleRange, + expectedTitle: expectedTitle) + if title.length == text.length { return NSRange(location: title.length, length: 0) } + let suffix = text.substring(from: title.length) + guard let terminator = ["\r\n", "\n", "\r", "\u{2028}", "\u{2029}"].first(where: suffix.hasPrefix) else { + throw CLIError(code: .backendUnavailable, message: "Notes native title terminator is unavailable.") + } + let start = title.length + (terminator as NSString).length + return NSRange(location: start, length: text.length - start) +} + +func notesReplaceBody( + in text: NSMutableAttributedString, nativeTitleRange: NSRange, expectedTitle: String, body: String +) throws { + var range = try notesBodyReplacementRange(in: text.string as NSString, + nativeTitleRange: nativeTitleRange, expectedTitle: expectedTitle) + if range.location == (expectedTitle as NSString).length, !body.isEmpty { + let attributes = text.length > 0 ? text.attributes(at: text.length - 1, effectiveRange: nil) : [:] + text.append(NSAttributedString(string: "\n", attributes: attributes)) + range.location += 1 + } + text.replaceCharacters(in: range, with: body) + if !body.isEmpty { + text.setAttributes([:], range: NSRange(location: range.location, length: (body as NSString).length)) + } +} diff --git a/Sources/NotesCLI/Operations/NotesImplementation.swift b/Sources/NotesCLI/Operations/NotesImplementation.swift index 764f41a..cd8a9a9 100644 --- a/Sources/NotesCLI/Operations/NotesImplementation.swift +++ b/Sources/NotesCLI/Operations/NotesImplementation.swift @@ -353,6 +353,12 @@ struct NotesImplementation: NotesReading, NotesFolderPurgeReading, try reader.readBodyStructure(noteID: id) } + func readInlineSelection(noteID: String, paragraphIDSHA256: String?, ordinal: Int?, + text: String, occurrence: Int?) throws -> NotesBodyInlineSelectionReadback { + try reader.readInlineSelection(noteID: noteID, paragraphIDSHA256: paragraphIDSHA256, + ordinal: ordinal, text: text, occurrence: occurrence) + } + func listTables(noteID id: String) throws -> [NotesBodyTableRecord] { try reader.listTables(noteID: id) } diff --git a/Sources/NotesCLI/Operations/NotesInlineCoverage.swift b/Sources/NotesCLI/Operations/NotesInlineCoverage.swift new file mode 100644 index 0000000..b0f7057 --- /dev/null +++ b/Sources/NotesCLI/Operations/NotesInlineCoverage.swift @@ -0,0 +1,144 @@ +import Foundation +import Utility + +public struct NotesBodyInlineSelectionReadback: Equatable, Sendable { + public var paragraphIDSHA256: String + public var utf16Location: Int + public var utf16Length: Int + public var textByteCount: Int + public var textSHA256: String + public var richTextSHA256: String + public var occurrence: Int + public var paragraphUTF16Location: Int? + public var paragraphUTF16Length: Int? + + public init(paragraphIDSHA256: String, utf16Location: Int, utf16Length: Int, + textByteCount: Int, textSHA256: String, richTextSHA256: String, occurrence: Int, + paragraphUTF16Location: Int? = nil, paragraphUTF16Length: Int? = nil) { + self.paragraphIDSHA256 = paragraphIDSHA256 + self.utf16Location = utf16Location + self.utf16Length = utf16Length + self.textByteCount = textByteCount + self.textSHA256 = textSHA256 + self.richTextSHA256 = richTextSHA256 + self.occurrence = occurrence + self.paragraphUTF16Location = paragraphUTF16Location + self.paragraphUTF16Length = paragraphUTF16Length + } +} + +func notesInlineTextSelection( + in string: NSString, text: String, paragraphRange: NSRange, occurrence: Int?, operation: String +) throws -> (range: NSRange, occurrence: Int) { + guard !text.isEmpty else { + throw CLIError(code: .validationError, message: "Inline body formatting requires non-empty `--text`.") + } + guard let searchRange = notesInlineRange(location: paragraphRange.location, + length: paragraphRange.length, bodyLength: string.length) else { + throw CLIError(code: .backendUnavailable, message: "Notes inline paragraph range is unavailable.", + details: ["operation": operation]) + } + var matches: [NSRange] = [] + var cursor = searchRange.location + while cursor < NSMaxRange(searchRange) { + let match = string.range(of: text, options: .literal, + range: NSRange(location: cursor, length: NSMaxRange(searchRange) - cursor)) + if match.location == NSNotFound { break } + matches.append(match) + cursor = NSMaxRange(match) + } + let selected = occurrence ?? 1 + guard selected > 0, selected <= matches.count else { + throw CLIError(code: .notFound, message: "Inline text occurrence did not match the selected paragraph.", + details: ["operation": operation, "match_count": "\(matches.count)", "requested_occurrence": "\(selected)"]) + } + guard occurrence != nil || matches.count == 1 else { + throw CLIError(code: .ambiguousIdentity, + message: "Inline text selector matched multiple ranges; use `--occurrence`.", + details: ["operation": operation, "match_count": "\(matches.count)"]) + } + return (matches[selected - 1], selected) +} + +enum NotesInlineCoverage: Equatable { + case absent + case partial + case full + + func matches(enabled: Bool) -> Bool { + enabled ? self == .full : self == .absent + } +} + +func notesInlineRange(location: Int?, length: Int?, bodyLength: Int?) -> NSRange? { + guard let location, let length, let bodyLength, + location >= 0, length > 0, bodyLength >= 0, + location <= bodyLength, length <= bodyLength - location + else { return nil } + return NSRange(location: location, length: length) +} + +func notesInlineSelectionRange( + in structure: NotesBodyStructureRecord, + evidence: NotesBodyInlineMutationEvidence +) -> NSRange? { + guard let bodyHash = structure.richTextSHA256, bodyHash == evidence.richTextSHA256 else { return nil } + return notesInlineRange(location: evidence.utf16Location, length: evidence.utf16Length, + bodyLength: structure.richTextLength) +} + +func notesInlineFormatCoverage( + in structure: NotesBodyStructureRecord, + evidence: NotesBodyInlineMutationEvidence, + format: String +) -> NotesInlineCoverage? { + guard let selection = notesInlineSelectionRange(in: structure, evidence: evidence), + let runs = structure.inlineFormatRuns + else { return nil } + var ranges: [NSRange] = [] + for run in runs where run.format == format { + guard let range = notesInlineRange(location: run.utf16Location, length: run.utf16Length, + bodyLength: structure.richTextLength) else { return nil } + if let paragraph = run.paragraphIDSHA256, let selectedParagraph = evidence.paragraphIDSHA256, + paragraph != selectedParagraph, NSIntersectionRange(range, selection).length > 0 + { return nil } + if evidence.fontSHA256 == nil || run.fontSHA256 == evidence.fontSHA256 { + ranges.append(range) + } + } + return notesInlineCoverage(selection: selection, ranges: ranges) +} + +func notesInlineColorCoverage( + in structure: NotesBodyStructureRecord, + evidence: NotesBodyInlineMutationEvidence +) -> NotesInlineCoverage? { + guard let selection = notesInlineSelectionRange(in: structure, evidence: evidence), + let runs = structure.colorRuns + else { return nil } + var ranges: [NSRange] = [] + for run in runs where run.role == evidence.role { + guard let range = notesInlineRange(location: run.utf16Location, length: run.utf16Length, + bodyLength: structure.richTextLength) else { return nil } + if let paragraph = run.paragraphIDSHA256, let selectedParagraph = evidence.paragraphIDSHA256, + paragraph != selectedParagraph, NSIntersectionRange(range, selection).length > 0 + { return nil } + if evidence.colorSHA256 == nil || run.colorSHA256 == evidence.colorSHA256 { + ranges.append(range) + } + } + return notesInlineCoverage(selection: selection, ranges: ranges) +} + +private func notesInlineCoverage(selection: NSRange, ranges: [NSRange]) -> NotesInlineCoverage { + let intersections = ranges.map { NSIntersectionRange($0, selection) } + .filter { $0.length > 0 }.sorted { $0.location < $1.location } + guard !intersections.isEmpty else { return .absent } + var coveredEnd = selection.location + for range in intersections { + if range.location > coveredEnd { return .partial } + coveredEnd = max(coveredEnd, NSMaxRange(range)) + if coveredEnd == NSMaxRange(selection) { return .full } + } + return .partial +} diff --git a/Sources/NotesCLI/Operations/NotesInlineParagraphs.swift b/Sources/NotesCLI/Operations/NotesInlineParagraphs.swift new file mode 100644 index 0000000..b517240 --- /dev/null +++ b/Sources/NotesCLI/Operations/NotesInlineParagraphs.swift @@ -0,0 +1,65 @@ +import Foundation +import Utility + +struct NotesInlineParagraph { + var range: NSRange + var idSHA256: String +} + +func notesInlineParagraphs( + noteID: String, in string: NSString, nativeAnchors: [NotesInlineParagraph] +) -> [NotesInlineParagraph] { + var paragraphs: [NotesInlineParagraph] = [] + var location = 0 + let snapshot = sha256Hex(string as String) + let noteIdentity = sha256Hex(noteID) + while location < string.length { + let range = string.paragraphRange(for: NSRange(location: location, length: 0)) + let nativeIDs = Set(nativeAnchors.filter { $0.range == range }.map(\.idSHA256)) + let identity: String + if nativeIDs.count == 1, let nativeID = nativeIDs.first { + identity = nativeID + } else { + identity = sha256Hex( + "notes.inline.paragraph.v1:\(noteIdentity):\(snapshot):\(range.location):\(range.length)") + } + paragraphs.append(NotesInlineParagraph(range: range, idSHA256: identity)) + location = NSMaxRange(range) + } + return paragraphs +} + +func notesInlineParagraph( + noteID: String, in string: NSString, nativeAnchors: [NotesInlineParagraph], + paragraphIDSHA256: String?, ordinal: Int?, operation: String +) throws -> NotesInlineParagraph { + guard (paragraphIDSHA256 != nil) != (ordinal != nil) else { + throw CLIError(code: .validationError, + message: "Inline formatting requires one paragraph hash or text paragraph ordinal.", + details: ["operation": operation]) + } + let paragraphs = notesInlineParagraphs(noteID: noteID, in: string, nativeAnchors: nativeAnchors) + let matches: [NotesInlineParagraph] + if let paragraphIDSHA256 { + let native = nativeAnchors.filter { $0.idSHA256 == paragraphIDSHA256 } + matches = native.isEmpty ? paragraphs.filter { $0.idSHA256 == paragraphIDSHA256 } : native + } else if let ordinal, ordinal > 0, ordinal <= paragraphs.count { + matches = [paragraphs[ordinal - 1]] + } else { + matches = [] + } + guard !matches.isEmpty else { + throw CLIError(code: .notFound, message: "Notes inline paragraph selector did not match a paragraph.", + details: ["operation": operation, "paragraph_count": "\(paragraphs.count)"]) + } + guard matches.count == 1, let match = matches.first else { + throw CLIError(code: .ambiguousIdentity, message: "Notes inline paragraph identity is ambiguous.", + details: ["operation": operation]) + } + guard notesInlineRange(location: match.range.location, length: match.range.length, + bodyLength: string.length) != nil else { + throw CLIError(code: .backendUnavailable, message: "Notes inline paragraph range is unavailable.", + details: ["operation": operation]) + } + return match +} diff --git a/Sources/NotesCLI/Operations/NotesManagedObjectLookup.swift b/Sources/NotesCLI/Operations/NotesManagedObjectLookup.swift new file mode 100644 index 0000000..ed18bb4 --- /dev/null +++ b/Sources/NotesCLI/Operations/NotesManagedObjectLookup.swift @@ -0,0 +1,16 @@ +import CoreData +import Foundation + +enum NotesManagedObjectLookup { + static func resolve(id: String, context: NSManagedObjectContext) throws -> NSManagedObject? { + guard id.hasPrefix("x-coredata://"), let url = URL(string: id), + let objectID = context.persistentStoreCoordinator?.managedObjectID(forURIRepresentation: url) + else { return nil } + do { + return try context.existingObject(with: objectID) + } catch let error as NSError + where error.domain == NSCocoaErrorDomain && error.code == NSManagedObjectReferentialIntegrityError { + return nil + } + } +} diff --git a/Sources/NotesCLI/Operations/NotesMutationVerifier.swift b/Sources/NotesCLI/Operations/NotesMutationVerifier.swift index 2f448f3..8c07a9e 100644 --- a/Sources/NotesCLI/Operations/NotesMutationVerifier.swift +++ b/Sources/NotesCLI/Operations/NotesMutationVerifier.swift @@ -461,7 +461,7 @@ struct NotesMutationVerifier { boolCheck(name: "changed", expected: true, actual: changed), boolCheck(name: "visible_exists_after", expected: false, actual: visibleFolder != nil), boolCheck(name: "purgable_exists_after", expected: false, actual: purgableFolder != nil), - storeMatchCheck(objectID: draft.folderID, storeObject: debug.storeObject), + storeAbsenceCheck(objectID: draft.folderID, storeObject: debug.storeObject), ] return checkedReport( @@ -552,19 +552,19 @@ struct NotesMutationVerifier { checks.append( intCheck( name: "checklist_item_count", - expected: beforeStructure.checklistItemCount + 1, + expected: beforeStructure.checklistItemCount.map { $0 + 1 }, actual: postWriteStructure.checklistItemCount )) checks.append( intCheck( name: "checklist_done_count", - expected: beforeStructure.checklistDoneCount + (draft.checked ? 1 : 0), + expected: beforeStructure.checklistDoneCount.map { $0 + (draft.checked ? 1 : 0) }, actual: postWriteStructure.checklistDoneCount )) checks.append( intCheck( name: "checklist_open_count", - expected: beforeStructure.checklistOpenCount + (draft.checked ? 0 : 1), + expected: beforeStructure.checklistOpenCount.map { $0 + (draft.checked ? 0 : 1) }, actual: postWriteStructure.checklistOpenCount )) checks.append(boolCheck(name: "has_checklist", expected: true, actual: postWriteStructure.hasChecklist)) @@ -602,6 +602,7 @@ struct NotesMutationVerifier { let beforeTableKindCount = bodyAttachmentKindCount("table", in: beforeStructure) let postTableKindCount = bodyAttachmentKindCount("table", in: postWriteStructure) var checks = commonNoteChecks(note: postWriteNote, debug: debug) + checks.append(paragraphReadbackCheck(before: beforeStructure, after: postWriteStructure)) checks.append(boolCheck(name: "changed_reported", expected: true, actual: result.changed)) checks.append(boolCheck(name: "identity_preserved", expected: true, actual: postWriteNote.id == before.id)) checks.append(boolCheck(name: "draft_target_preserved", expected: true, actual: postWriteNote.id == draft.noteID)) @@ -612,19 +613,19 @@ struct NotesMutationVerifier { checks.append( intCheck( name: "table_count", - expected: beforeStructure.tableCount + 1, + expected: beforeStructure.tableCount.map { $0 + 1 }, actual: postWriteStructure.tableCount )) checks.append( intCheck( name: "table_attachment_kind_count", - expected: beforeTableKindCount + 1, + expected: beforeTableKindCount.map { $0 + 1 }, actual: postTableKindCount )) checks.append( intCheck( name: "inline_attachment_count", - expected: beforeStructure.inlineAttachmentCount + 1, + expected: beforeStructure.inlineAttachmentCount.map { $0 + 1 }, actual: postWriteStructure.inlineAttachmentCount )) checks.append( @@ -717,6 +718,7 @@ struct NotesMutationVerifier { ) var checks = commonNoteChecks(note: postWriteNote, debug: debug) + checks.append(paragraphReadbackCheck(before: beforeStructure, after: postWriteStructure)) checks.append(boolCheck(name: "changed_reported", expected: true, actual: result.changed)) checks.append(boolCheck(name: "identity_preserved", expected: true, actual: postWriteNote.id == before.id)) checks.append(boolCheck(name: "draft_target_preserved", expected: true, actual: postWriteNote.id == draft.noteID)) @@ -727,7 +729,7 @@ struct NotesMutationVerifier { checks.append( intCheck( name: "table_count", - expected: beforeStructure.tableCount + 1, + expected: beforeStructure.tableCount.map { $0 + 1 }, actual: postWriteStructure.tableCount )) checks.append( @@ -745,13 +747,13 @@ struct NotesMutationVerifier { checks.append( intCheck( name: "table_attachment_kind_count", - expected: beforeTableKindCount + 1, + expected: beforeTableKindCount.map { $0 + 1 }, actual: postTableKindCount )) checks.append( intCheck( name: "inline_attachment_count", - expected: beforeStructure.inlineAttachmentCount + 1, + expected: beforeStructure.inlineAttachmentCount.map { $0 + 1 }, actual: postWriteStructure.inlineAttachmentCount )) checks.append( @@ -866,6 +868,7 @@ struct NotesMutationVerifier { let expectedChanged = beforeCell.textSHA256 != draft.textSHA256 || beforeCell.textByteCount != draft.textByteCount var checks = commonNoteChecks(note: postWriteNote, debug: debug) + checks.append(paragraphReadbackCheck(before: beforeStructure, after: postWriteStructure)) checks.append(boolCheck(name: "changed_reported", expected: expectedChanged, actual: result.changed)) checks.append(boolCheck(name: "identity_preserved", expected: true, actual: postWriteNote.id == before.id)) checks.append(boolCheck(name: "draft_target_preserved", expected: true, actual: postWriteNote.id == draft.noteID)) @@ -1070,6 +1073,7 @@ struct NotesMutationVerifier { table: selectedPost ) var checks = commonNoteChecks(note: postWriteNote, debug: debug) + checks.append(paragraphReadbackCheck(before: beforeStructure, after: postWriteStructure)) checks.append(boolCheck(name: "changed_reported", expected: true, actual: result.changed)) checks.append(boolCheck(name: "identity_preserved", expected: true, actual: postWriteNote.id == before.id)) checks.append(boolCheck(name: "draft_target_preserved", expected: true, actual: postWriteNote.id == draft.noteID)) @@ -1453,6 +1457,7 @@ struct NotesMutationVerifier { let beforeSet = Set(beforeOrder) let postSet = Set(postOrder) var checks = commonNoteChecks(note: postWriteNote, debug: debug) + checks.append(paragraphReadbackCheck(before: beforeStructure, after: postWriteStructure)) checks.append(boolCheck(name: "changed_reported", expected: true, actual: result.changed)) checks.append(boolCheck(name: "identity_preserved", expected: true, actual: postWriteNote.id == before.id)) checks.append(boolCheck(name: "draft_target_preserved", expected: true, actual: postWriteNote.id == draft.noteID)) @@ -1620,6 +1625,7 @@ struct NotesMutationVerifier { let expectedChanged = selectedBefore?.resultSHA256 != draft.resultSHA256 || selectedBefore?.resultByteCount != draft.resultByteCount var checks = commonNoteChecks(note: postWriteNote, debug: debug) + checks.append(paragraphReadbackCheck(before: beforeStructure, after: postWriteStructure)) checks.append(boolCheck(name: "changed_reported", expected: expectedChanged, actual: result.changed)) checks.append(boolCheck(name: "identity_preserved", expected: true, actual: postWriteNote.id == before.id)) checks.append(boolCheck(name: "draft_target_preserved", expected: true, actual: postWriteNote.id == draft.noteID)) @@ -1872,6 +1878,7 @@ struct NotesMutationVerifier { let beforeIDs = Set(beforeResults.map(\.idSHA256)) let postTarget = postWriteResults.first { $0.idSHA256 == result.target.idSHA256 } var checks = commonNoteChecks(note: postWriteNote, debug: debug) + checks.append(paragraphReadbackCheck(before: beforeStructure, after: postWriteStructure)) checks.append(boolCheck(name: "changed_reported", expected: true, actual: result.changed)) checks.append(boolCheck(name: "identity_preserved", expected: true, actual: postWriteNote.id == before.id)) checks.append(boolCheck(name: "draft_target_preserved", expected: true, actual: postWriteNote.id == draft.noteID)) @@ -1906,13 +1913,13 @@ struct NotesMutationVerifier { checks.append( intCheck( name: "math_attachment_count_incremented", - expected: beforeStructure.mathAttachmentCount + 1, + expected: beforeStructure.mathAttachmentCount.map { $0 + 1 }, actual: postWriteStructure.mathAttachmentCount )) checks.append( intCheck( name: "inline_attachment_count_incremented", - expected: beforeStructure.inlineAttachmentCount + 1, + expected: beforeStructure.inlineAttachmentCount.map { $0 + 1 }, actual: postWriteStructure.inlineAttachmentCount )) checks.append( @@ -2040,7 +2047,7 @@ struct NotesMutationVerifier { checks.append( intCheck( name: "math_attachment_count_incremented", - expected: beforeStructure.mathAttachmentCount + 2, + expected: beforeStructure.mathAttachmentCount.map { $0 + 2 }, actual: postWriteStructure.mathAttachmentCount )) checks.append( @@ -2253,6 +2260,7 @@ struct NotesMutationVerifier { ? beforeTables[draft.ordinal - 1] : nil var checks = commonNoteChecks(note: postWriteNote, debug: debug) + checks.append(paragraphReadbackCheck(before: beforeStructure, after: postWriteStructure)) checks.append(boolCheck(name: "changed_reported", expected: true, actual: result.changed)) checks.append(boolCheck(name: "identity_preserved", expected: true, actual: postWriteNote.id == before.id)) checks.append(boolCheck(name: "draft_target_preserved", expected: true, actual: postWriteNote.id == draft.noteID)) @@ -2281,7 +2289,7 @@ struct NotesMutationVerifier { checks.append( intCheck( name: "table_count", - expected: max(0, beforeStructure.tableCount - 1), + expected: beforeStructure.tableCount.map { max(0, $0 - 1) }, actual: postWriteStructure.tableCount )) checks.append( @@ -2299,13 +2307,13 @@ struct NotesMutationVerifier { checks.append( intCheck( name: "table_attachment_kind_count", - expected: max(0, beforeTableKindCount - 1), + expected: beforeTableKindCount.map { max(0, $0 - 1) }, actual: postTableKindCount )) checks.append( intCheck( name: "inline_attachment_count", - expected: max(0, beforeStructure.inlineAttachmentCount - 1), + expected: beforeStructure.inlineAttachmentCount.map { max(0, $0 - 1) }, actual: postWriteStructure.inlineAttachmentCount )) checks.append( @@ -2387,6 +2395,7 @@ struct NotesMutationVerifier { ? true : (postWriteNote.body?.contains(result.convertedText) ?? false) var checks = commonNoteChecks(note: postWriteNote, debug: debug) + checks.append(paragraphReadbackCheck(before: beforeStructure, after: postWriteStructure)) checks.append(boolCheck(name: "changed_reported", expected: true, actual: result.changed)) checks.append(boolCheck(name: "identity_preserved", expected: true, actual: postWriteNote.id == before.id)) checks.append(boolCheck(name: "draft_target_preserved", expected: true, actual: postWriteNote.id == draft.noteID)) @@ -2410,7 +2419,7 @@ struct NotesMutationVerifier { checks.append( intCheck( name: "table_count", - expected: max(0, beforeStructure.tableCount - 1), + expected: beforeStructure.tableCount.map { max(0, $0 - 1) }, actual: postWriteStructure.tableCount )) checks.append( @@ -2428,13 +2437,13 @@ struct NotesMutationVerifier { checks.append( intCheck( name: "table_attachment_kind_count", - expected: max(0, beforeTableKindCount - 1), + expected: beforeTableKindCount.map { max(0, $0 - 1) }, actual: postTableKindCount )) checks.append( intCheck( name: "inline_attachment_count", - expected: max(0, beforeStructure.inlineAttachmentCount - 1), + expected: beforeStructure.inlineAttachmentCount.map { max(0, $0 - 1) }, actual: postWriteStructure.inlineAttachmentCount )) checks.append(intCheck(name: "converted_text_row_count", expected: draft.rowCount, actual: result.rowCount)) @@ -2523,8 +2532,8 @@ struct NotesMutationVerifier { let beforeTableKindCount = bodyAttachmentKindCount("table", in: beforeStructure) let postTableKindCount = bodyAttachmentKindCount("table", in: postWriteStructure) let beforeTableIDs = Set(beforeTables.map(\.idSHA256)) - let selectedBefore = beforeStructure.paragraphAnchors.first { $0.idSHA256 == draft.paragraphIDSHA256 } - let postAnchorIDs = Set(postWriteStructure.paragraphAnchors.map(\.idSHA256)) + let selectedBefore = (beforeStructure.paragraphAnchors ?? []).first { $0.idSHA256 == draft.paragraphIDSHA256 } + let postAnchorIDs = Set((postWriteStructure.paragraphAnchors ?? []).map(\.idSHA256)) let sourceText = try normalizedBodyTableText(result.sourceText) let targetCellMatches = try tableCellHashesMatchSourceText( reader: bodyStructureReader, @@ -2536,6 +2545,7 @@ struct NotesMutationVerifier { ) var checks = commonNoteChecks(note: postWriteNote, debug: debug) + checks.append(paragraphReadbackCheck(before: beforeStructure, after: postWriteStructure)) checks.append(boolCheck(name: "changed_reported", expected: true, actual: result.changed)) checks.append(boolCheck(name: "identity_preserved", expected: true, actual: postWriteNote.id == before.id)) checks.append(boolCheck(name: "draft_target_preserved", expected: true, actual: postWriteNote.id == draft.noteID)) @@ -2562,14 +2572,14 @@ struct NotesMutationVerifier { boolCheck( name: "other_paragraph_anchors_preserved", expected: true, - actual: beforeStructure.paragraphAnchors + actual: (beforeStructure.paragraphAnchors ?? []) .filter { $0.idSHA256 != draft.paragraphIDSHA256 } .allSatisfy { postAnchorIDs.contains($0.idSHA256) } )) checks.append( intCheck( name: "table_count", - expected: beforeStructure.tableCount + 1, + expected: beforeStructure.tableCount.map { $0 + 1 }, actual: postWriteStructure.tableCount )) checks.append( @@ -2587,13 +2597,13 @@ struct NotesMutationVerifier { checks.append( intCheck( name: "table_attachment_kind_count", - expected: beforeTableKindCount + 1, + expected: beforeTableKindCount.map { $0 + 1 }, actual: postTableKindCount )) checks.append( intCheck( name: "inline_attachment_count", - expected: beforeStructure.inlineAttachmentCount + 1, + expected: beforeStructure.inlineAttachmentCount.map { $0 + 1 }, actual: postWriteStructure.inlineAttachmentCount )) checks.append( @@ -2710,11 +2720,11 @@ struct NotesMutationVerifier { targetTableOrdinal: result.targetTable.ordinal ) let debug = sqliteReader.debugNote(postTargetNote) - let expectedSourceTableCount = sameNote ? beforeSourceStructure.tableCount + 1 : beforeSourceStructure.tableCount + let expectedSourceTableCount = sameNote ? beforeSourceStructure.tableCount.map { $0 + 1 } : beforeSourceStructure.tableCount let expectedSourceTableListCount = sameNote ? beforeSourceTables.count + 1 : beforeSourceTables.count - let expectedSourceTableKindCount = sameNote ? beforeSourceTableKindCount + 1 : beforeSourceTableKindCount + let expectedSourceTableKindCount = sameNote ? beforeSourceTableKindCount.map { $0 + 1 } : beforeSourceTableKindCount let expectedSourceInlineCount = sameNote - ? beforeSourceStructure.inlineAttachmentCount + 1 + ? beforeSourceStructure.inlineAttachmentCount.map { $0 + 1 } : beforeSourceStructure.inlineAttachmentCount var checks = commonNoteChecks(note: postTargetNote, debug: debug) @@ -2754,7 +2764,7 @@ struct NotesMutationVerifier { checks.append( intCheck( name: "target_table_count", - expected: beforeTargetStructure.tableCount + 1, + expected: beforeTargetStructure.tableCount.map { $0 + 1 }, actual: postTargetStructure.tableCount )) checks.append( @@ -2790,13 +2800,13 @@ struct NotesMutationVerifier { checks.append( intCheck( name: "target_table_attachment_kind_count", - expected: beforeTargetTableKindCount + 1, + expected: beforeTargetTableKindCount.map { $0 + 1 }, actual: postTargetTableKindCount )) checks.append( intCheck( name: "target_inline_attachment_count", - expected: beforeTargetStructure.inlineAttachmentCount + 1, + expected: beforeTargetStructure.inlineAttachmentCount.map { $0 + 1 }, actual: postTargetStructure.inlineAttachmentCount )) checks.append( @@ -2894,9 +2904,10 @@ struct NotesMutationVerifier { let postWriteStructure = try bodyStructureReader.readBodyStructure(noteID: result.note.id) let debug = sqliteReader.debugNote(postWriteNote) let delta = result.changed ? 1 : 0 - let expectedDone = beforeStructure.checklistDoneCount + (draft.checked ? delta : -delta) - let expectedOpen = beforeStructure.checklistOpenCount + (draft.checked ? -delta : delta) + let expectedDone = beforeStructure.checklistDoneCount.map { $0 + (draft.checked ? delta : -delta) } + let expectedOpen = beforeStructure.checklistOpenCount.map { $0 + (draft.checked ? -delta : delta) } var checks = commonNoteChecks(note: postWriteNote, debug: debug) + checks.append(paragraphReadbackCheck(before: beforeStructure, after: postWriteStructure)) checks.append(boolCheck(name: "identity_preserved", expected: true, actual: postWriteNote.id == before.id)) checks.append(boolCheck(name: "draft_target_preserved", expected: true, actual: postWriteNote.id == draft.noteID)) checks.append(stringCheck(name: "title_preserved", expected: before.title, actual: postWriteNote.title)) @@ -2908,14 +2919,14 @@ struct NotesMutationVerifier { boolCheck( name: "ordinal_in_range", expected: true, - actual: ordinal > 0 && ordinal <= beforeStructure.checklistItemCount + actual: beforeStructure.checklistItemCount.map { ordinal > 0 && ordinal <= $0 } )) } if let paragraphIDSHA256 = draft.paragraphIDSHA256 { - let beforeAnchor = beforeStructure.paragraphAnchors.first { + let beforeAnchor = (beforeStructure.paragraphAnchors ?? []).first { $0.idSHA256 == paragraphIDSHA256 && $0.isChecklist } - let afterAnchor = postWriteStructure.paragraphAnchors.first { + let afterAnchor = (postWriteStructure.paragraphAnchors ?? []).first { $0.idSHA256 == paragraphIDSHA256 && $0.isChecklist } checks.append(boolCheck(name: "paragraph_anchor_checklist_before", expected: true, actual: beforeAnchor != nil)) @@ -3082,8 +3093,9 @@ struct NotesMutationVerifier { let debug = sqliteReader.debugNote(postWriteNote) let expectedDone = draft.checked ? beforeStructure.checklistItemCount : 0 let expectedOpen = draft.checked ? 0 : beforeStructure.checklistItemCount - let beforeChecklistAnchors = beforeStructure.paragraphAnchors.filter { $0.isChecklist } + let beforeChecklistAnchors = (beforeStructure.paragraphAnchors ?? []).filter { $0.isChecklist } var checks = commonNoteChecks(note: postWriteNote, debug: debug) + checks.append(paragraphReadbackCheck(before: beforeStructure, after: postWriteStructure)) checks.append(boolCheck(name: "identity_preserved", expected: true, actual: postWriteNote.id == before.id)) checks.append(boolCheck(name: "draft_target_preserved", expected: true, actual: postWriteNote.id == draft.noteID)) checks.append(stringCheck(name: "title_preserved", expected: before.title, actual: postWriteNote.title)) @@ -3094,7 +3106,7 @@ struct NotesMutationVerifier { boolCheck( name: "has_checklist_before", expected: true, - actual: beforeStructure.checklistItemCount > 0 + actual: beforeStructure.checklistItemCount.map { $0 > 0 } )) checks.append( intCheck( @@ -3119,7 +3131,7 @@ struct NotesMutationVerifier { boolCheck( name: "paragraph_anchor_preserved_\(anchor.ordinal)", expected: true, - actual: postWriteStructure.paragraphAnchors.contains { + actual: (postWriteStructure.paragraphAnchors ?? []).contains { $0.idSHA256 == anchor.idSHA256 && $0.isChecklist } )) @@ -3168,6 +3180,7 @@ struct NotesMutationVerifier { let actualOrder = postChecklistAnchors.map(\.idSHA256) let expectedChanged = expectedOrder != beforeChecklistAnchors.map(\.idSHA256) var checks = commonNoteChecks(note: postWriteNote, debug: debug) + checks.append(paragraphReadbackCheck(before: beforeStructure, after: postWriteStructure)) checks.append(boolCheck(name: "changed_reported", expected: expectedChanged, actual: result.changed)) checks.append(boolCheck(name: "identity_preserved", expected: true, actual: postWriteNote.id == before.id)) checks.append(boolCheck(name: "draft_target_preserved", expected: true, actual: postWriteNote.id == draft.noteID)) @@ -3254,9 +3267,10 @@ struct NotesMutationVerifier { let debug = sqliteReader.debugNote(postWriteNote) let beforeAnchor = bodyChecklistConvertAnchor(draft: draft, structure: beforeStructure) let afterAnchor = beforeAnchor.flatMap { beforeAnchor in - postWriteStructure.paragraphAnchors.first { $0.idSHA256 == beforeAnchor.idSHA256 } + (postWriteStructure.paragraphAnchors ?? []).first { $0.idSHA256 == beforeAnchor.idSHA256 } } var checks = commonNoteChecks(note: postWriteNote, debug: debug) + checks.append(paragraphReadbackCheck(before: beforeStructure, after: postWriteStructure)) checks.append(boolCheck(name: "changed_reported", expected: true, actual: result.changed)) checks.append(boolCheck(name: "identity_preserved", expected: true, actual: postWriteNote.id == before.id)) checks.append(boolCheck(name: "draft_target_preserved", expected: true, actual: postWriteNote.id == draft.noteID)) @@ -3269,7 +3283,7 @@ struct NotesMutationVerifier { boolCheck( name: "paragraph_ordinal_in_range", expected: true, - actual: ordinal > 0 && ordinal <= beforeStructure.paragraphAnchors.count + actual: beforeStructure.paragraphAnchors.map { ordinal > 0 && ordinal <= $0.count } )) } checks.append(boolCheck(name: "paragraph_anchor_before", expected: true, actual: beforeAnchor != nil)) @@ -3298,19 +3312,19 @@ struct NotesMutationVerifier { checks.append( intCheck( name: "checklist_item_count", - expected: beforeStructure.checklistItemCount + 1, + expected: beforeStructure.checklistItemCount.map { $0 + 1 }, actual: postWriteStructure.checklistItemCount )) checks.append( intCheck( name: "checklist_done_count", - expected: beforeStructure.checklistDoneCount + (draft.checked ? 1 : 0), + expected: beforeStructure.checklistDoneCount.map { $0 + (draft.checked ? 1 : 0) }, actual: postWriteStructure.checklistDoneCount )) checks.append( intCheck( name: "checklist_open_count", - expected: beforeStructure.checklistOpenCount + (draft.checked ? 0 : 1), + expected: beforeStructure.checklistOpenCount.map { $0 + (draft.checked ? 0 : 1) }, actual: postWriteStructure.checklistOpenCount )) if let beforeBytes = beforeStructure.plainTextByteCount, let afterBytes = postWriteStructure.plainTextByteCount { @@ -3351,12 +3365,12 @@ struct NotesMutationVerifier { let postWriteNote = try requiredPostWriteNote(id: result.note.id, operation: operation) let postWriteStructure = try bodyStructureReader.readBodyStructure(noteID: result.note.id) let debug = sqliteReader.debugNote(postWriteNote) - let beforeAnchors = beforeStructure.paragraphAnchors.sorted { $0.ordinal < $1.ordinal } - let postAnchors = postWriteStructure.paragraphAnchors.sorted { $0.ordinal < $1.ordinal } + let beforeAnchors = (beforeStructure.paragraphAnchors ?? []).sorted { $0.ordinal < $1.ordinal } + let postAnchors = (postWriteStructure.paragraphAnchors ?? []).sorted { $0.ordinal < $1.ordinal } let selectedAnchors = bodyChecklistConvertRangeAnchors(draft: draft, structure: beforeStructure) let selectedIDs = Set(selectedAnchors.map(\.idSHA256)) let afterSelectedAnchors = selectedAnchors.compactMap { beforeAnchor in - postWriteStructure.paragraphAnchors.first { $0.idSHA256 == beforeAnchor.idSHA256 } + (postWriteStructure.paragraphAnchors ?? []).first { $0.idSHA256 == beforeAnchor.idSHA256 } } let selectedCount = selectedAnchors.count let allSelectedWereNonChecklist = !selectedAnchors.isEmpty && selectedAnchors.allSatisfy { !$0.isChecklist } @@ -3369,6 +3383,7 @@ struct NotesMutationVerifier { let expectedDoneDelta = draft.checked ? selectedCount : 0 let expectedOpenDelta = draft.checked ? 0 : selectedCount var checks = commonNoteChecks(note: postWriteNote, debug: debug) + checks.append(paragraphReadbackCheck(before: beforeStructure, after: postWriteStructure)) checks.append(boolCheck(name: "changed_reported", expected: true, actual: result.changed)) checks.append(boolCheck(name: "identity_preserved", expected: true, actual: postWriteNote.id == before.id)) checks.append(boolCheck(name: "draft_target_preserved", expected: true, actual: postWriteNote.id == draft.noteID)) @@ -3433,26 +3448,26 @@ struct NotesMutationVerifier { checks.append( intCheck( name: "checklist_item_count", - expected: beforeStructure.checklistItemCount + selectedCount, + expected: beforeStructure.checklistItemCount.map { $0 + selectedCount }, actual: postWriteStructure.checklistItemCount )) checks.append( intCheck( name: "checklist_done_count", - expected: beforeStructure.checklistDoneCount + expectedDoneDelta, + expected: beforeStructure.checklistDoneCount.map { $0 + expectedDoneDelta }, actual: postWriteStructure.checklistDoneCount )) checks.append( intCheck( name: "checklist_open_count", - expected: beforeStructure.checklistOpenCount + expectedOpenDelta, + expected: beforeStructure.checklistOpenCount.map { $0 + expectedOpenDelta }, actual: postWriteStructure.checklistOpenCount )) checks.append( intCheck( name: "paragraph_anchor_count_preserved", - expected: beforeStructure.paragraphAnchors.count, - actual: postWriteStructure.paragraphAnchors.count + expected: beforeStructure.paragraphAnchors?.count, + actual: postWriteStructure.paragraphAnchors?.count )) let nonSelectedBefore = beforeAnchors.filter { !selectedIDs.contains($0.idSHA256) }.map(\.idSHA256) let nonSelectedAfter = postAnchors.filter { !selectedIDs.contains($0.idSHA256) }.map(\.idSHA256) @@ -3514,6 +3529,7 @@ struct NotesMutationVerifier { let actualOrder = postChecklistAnchors.map(\.idSHA256) let expectedChanged = sourceIndex.map { $0 != targetIndex } ?? true var checks = commonNoteChecks(note: postWriteNote, debug: debug) + checks.append(paragraphReadbackCheck(before: beforeStructure, after: postWriteStructure)) checks.append(boolCheck(name: "changed_reported", expected: expectedChanged, actual: result.changed)) checks.append(boolCheck(name: "identity_preserved", expected: true, actual: postWriteNote.id == before.id)) checks.append(boolCheck(name: "draft_target_preserved", expected: true, actual: postWriteNote.id == draft.noteID)) @@ -3630,6 +3646,7 @@ struct NotesMutationVerifier { let addedAnchors = postListAnchors.filter { !beforeIDs.contains($0.idSHA256) } let addedAnchor = addedAnchors.first var checks = commonNoteChecks(note: postWriteNote, debug: debug) + checks.append(paragraphReadbackCheck(before: beforeStructure, after: postWriteStructure)) checks.append(boolCheck(name: "changed_reported", expected: true, actual: result.changed)) checks.append(boolCheck(name: "identity_preserved", expected: true, actual: postWriteNote.id == before.id)) checks.append(boolCheck(name: "draft_target_preserved", expected: true, actual: postWriteNote.id == draft.noteID)) @@ -3653,7 +3670,7 @@ struct NotesMutationVerifier { checks.append( intCheck( name: "list_item_count", - expected: beforeStructure.listItemCount + 1, + expected: beforeStructure.listItemCount.map { $0 + 1 }, actual: postWriteStructure.listItemCount )) checks.append( @@ -3715,9 +3732,10 @@ struct NotesMutationVerifier { let debug = sqliteReader.debugNote(postWriteNote) let beforeAnchor = bodyListConvertAnchor(draft: draft, structure: beforeStructure) let afterAnchor = beforeAnchor.flatMap { beforeAnchor in - postWriteStructure.paragraphAnchors.first { $0.idSHA256 == beforeAnchor.idSHA256 } + (postWriteStructure.paragraphAnchors ?? []).first { $0.idSHA256 == beforeAnchor.idSHA256 } } var checks = commonNoteChecks(note: postWriteNote, debug: debug) + checks.append(paragraphReadbackCheck(before: beforeStructure, after: postWriteStructure)) checks.append(boolCheck(name: "changed_reported", expected: true, actual: result.changed)) checks.append(boolCheck(name: "identity_preserved", expected: true, actual: postWriteNote.id == before.id)) checks.append(boolCheck(name: "draft_target_preserved", expected: true, actual: postWriteNote.id == draft.noteID)) @@ -3753,7 +3771,7 @@ struct NotesMutationVerifier { checks.append( intCheck( name: "list_item_count", - expected: beforeStructure.listItemCount + 1, + expected: beforeStructure.listItemCount.map { $0 + 1 }, actual: postWriteStructure.listItemCount )) checks.append( @@ -3799,11 +3817,11 @@ struct NotesMutationVerifier { let postWriteNote = try requiredPostWriteNote(id: result.note.id, operation: operation) let postWriteStructure = try bodyStructureReader.readBodyStructure(noteID: result.note.id) let debug = sqliteReader.debugNote(postWriteNote) - let beforeAnchors = beforeStructure.paragraphAnchors.sorted { $0.ordinal < $1.ordinal } - let postAnchors = postWriteStructure.paragraphAnchors.sorted { $0.ordinal < $1.ordinal } + let beforeAnchors = (beforeStructure.paragraphAnchors ?? []).sorted { $0.ordinal < $1.ordinal } + let postAnchors = (postWriteStructure.paragraphAnchors ?? []).sorted { $0.ordinal < $1.ordinal } let selectedAnchors = bodyListConvertRangeAnchors(draft: draft, structure: beforeStructure) let afterSelectedAnchors = selectedAnchors.compactMap { beforeAnchor in - postWriteStructure.paragraphAnchors.first { $0.idSHA256 == beforeAnchor.idSHA256 } + (postWriteStructure.paragraphAnchors ?? []).first { $0.idSHA256 == beforeAnchor.idSHA256 } } let selectedCount = selectedAnchors.count let allSelectedWereNonList = !selectedAnchors.isEmpty && selectedAnchors.allSatisfy { !$0.isList && !$0.isChecklist } @@ -3814,6 +3832,7 @@ struct NotesMutationVerifier { && beforeAnchor.titleSHA256 == afterAnchor.titleSHA256 } var checks = commonNoteChecks(note: postWriteNote, debug: debug) + checks.append(paragraphReadbackCheck(before: beforeStructure, after: postWriteStructure)) checks.append(boolCheck(name: "changed_reported", expected: true, actual: result.changed)) checks.append(boolCheck(name: "identity_preserved", expected: true, actual: postWriteNote.id == before.id)) checks.append(boolCheck(name: "draft_target_preserved", expected: true, actual: postWriteNote.id == draft.noteID)) @@ -3834,7 +3853,7 @@ struct NotesMutationVerifier { checks.append( intCheck( name: "list_item_count", - expected: beforeStructure.listItemCount + selectedCount, + expected: beforeStructure.listItemCount.map { $0 + selectedCount }, actual: postWriteStructure.listItemCount )) checks.append( @@ -3888,6 +3907,7 @@ struct NotesMutationVerifier { } let expectedChanged = beforeTarget.map { $0.listStyle != draft.style.rawValue } ?? true var checks = commonNoteChecks(note: postWriteNote, debug: debug) + checks.append(paragraphReadbackCheck(before: beforeStructure, after: postWriteStructure)) checks.append(boolCheck(name: "changed_reported", expected: expectedChanged, actual: result.changed)) checks.append(boolCheck(name: "identity_preserved", expected: true, actual: postWriteNote.id == before.id)) checks.append(boolCheck(name: "draft_target_preserved", expected: true, actual: postWriteNote.id == draft.noteID)) @@ -3976,15 +3996,15 @@ struct NotesMutationVerifier { let postWriteStructure = try bodyStructureReader.readBodyStructure(noteID: result.note.id) let postWriteSections = try bodyStructureReader.listCollapsibleSections(noteID: result.note.id) let debug = sqliteReader.debugNote(postWriteNote) - let beforeAnchors = beforeStructure.paragraphAnchors.sorted { $0.ordinal < $1.ordinal } - let postAnchors = postWriteStructure.paragraphAnchors.sorted { $0.ordinal < $1.ordinal } + let beforeAnchors = (beforeStructure.paragraphAnchors ?? []).sorted { $0.ordinal < $1.ordinal } + let postAnchors = (postWriteStructure.paragraphAnchors ?? []).sorted { $0.ordinal < $1.ordinal } let beforeTarget = bodyParagraphFormatAnchor( paragraphIDSHA256: draft.paragraphIDSHA256, ordinal: draft.ordinal, structure: beforeStructure ) let afterTarget = beforeTarget.flatMap { target in - postWriteStructure.paragraphAnchors.first { $0.idSHA256 == target.idSHA256 } + (postWriteStructure.paragraphAnchors ?? []).first { $0.idSHA256 == target.idSHA256 } } let beforeTargetSection = beforeTarget.flatMap { target in beforeSections.first { $0.paragraphIDSHA256 == target.idSHA256 } @@ -4001,6 +4021,7 @@ struct NotesMutationVerifier { + ((beforeTargetSection == nil && expectedCollapsible) ? 1 : 0) - ((beforeTargetSection != nil && !expectedCollapsible) ? 1 : 0) var checks = commonNoteChecks(note: postWriteNote, debug: debug) + checks.append(paragraphReadbackCheck(before: beforeStructure, after: postWriteStructure)) checks.append(boolCheck(name: "changed_reported", expected: expectedChanged, actual: result.changed)) checks.append(boolCheck(name: "identity_preserved", expected: true, actual: postWriteNote.id == before.id)) checks.append(boolCheck(name: "draft_target_preserved", expected: true, actual: postWriteNote.id == draft.noteID)) @@ -4062,8 +4083,8 @@ struct NotesMutationVerifier { checks.append( intCheck( name: "paragraph_count_preserved", - expected: beforeStructure.paragraphAnchors.count, - actual: postWriteStructure.paragraphAnchors.count + expected: beforeStructure.paragraphAnchors?.count, + actual: postWriteStructure.paragraphAnchors?.count )) checks.append( intCheck( @@ -4126,21 +4147,22 @@ struct NotesMutationVerifier { let postWriteNote = try requiredPostWriteNote(id: result.note.id, operation: operation) let postWriteStructure = try bodyStructureReader.readBodyStructure(noteID: result.note.id) let debug = sqliteReader.debugNote(postWriteNote) - let beforeAnchors = beforeStructure.paragraphAnchors.sorted { $0.ordinal < $1.ordinal } - let postAnchors = postWriteStructure.paragraphAnchors.sorted { $0.ordinal < $1.ordinal } + let beforeAnchors = (beforeStructure.paragraphAnchors ?? []).sorted { $0.ordinal < $1.ordinal } + let postAnchors = (postWriteStructure.paragraphAnchors ?? []).sorted { $0.ordinal < $1.ordinal } let beforeTarget = bodyParagraphFormatAnchor( paragraphIDSHA256: draft.paragraphIDSHA256, ordinal: draft.ordinal, structure: beforeStructure ) let afterTarget = beforeTarget.flatMap { target in - postWriteStructure.paragraphAnchors.first { $0.idSHA256 == target.idSHA256 } + (postWriteStructure.paragraphAnchors ?? []).first { $0.idSHA256 == target.idSHA256 } } let targetWasSupported = beforeTarget.map { !$0.isList && !$0.isChecklist && !$0.isBlockQuote } ?? false let expectedChanged = beforeTarget.map { $0.alignment != draft.alignment.rawValue } ?? true var checks = commonNoteChecks(note: postWriteNote, debug: debug) + checks.append(paragraphReadbackCheck(before: beforeStructure, after: postWriteStructure)) checks.append(boolCheck(name: "changed_reported", expected: expectedChanged, actual: result.changed)) checks.append(boolCheck(name: "identity_preserved", expected: true, actual: postWriteNote.id == before.id)) checks.append(boolCheck(name: "draft_target_preserved", expected: true, actual: postWriteNote.id == draft.noteID)) @@ -4175,8 +4197,8 @@ struct NotesMutationVerifier { checks.append( intCheck( name: "paragraph_count_preserved", - expected: beforeStructure.paragraphAnchors.count, - actual: postWriteStructure.paragraphAnchors.count + expected: beforeStructure.paragraphAnchors?.count, + actual: postWriteStructure.paragraphAnchors?.count )) checks.append( intCheck( @@ -4227,22 +4249,23 @@ struct NotesMutationVerifier { let postWriteNote = try requiredPostWriteNote(id: result.note.id, operation: operation) let postWriteStructure = try bodyStructureReader.readBodyStructure(noteID: result.note.id) let debug = sqliteReader.debugNote(postWriteNote) - let beforeAnchors = beforeStructure.paragraphAnchors.sorted { $0.ordinal < $1.ordinal } - let postAnchors = postWriteStructure.paragraphAnchors.sorted { $0.ordinal < $1.ordinal } + let beforeAnchors = (beforeStructure.paragraphAnchors ?? []).sorted { $0.ordinal < $1.ordinal } + let postAnchors = (postWriteStructure.paragraphAnchors ?? []).sorted { $0.ordinal < $1.ordinal } let beforeTarget = bodyParagraphFormatAnchor( paragraphIDSHA256: draft.paragraphIDSHA256, ordinal: draft.ordinal, structure: beforeStructure ) let afterTarget = beforeTarget.flatMap { target in - postWriteStructure.paragraphAnchors.first { $0.idSHA256 == target.idSHA256 } + (postWriteStructure.paragraphAnchors ?? []).first { $0.idSHA256 == target.idSHA256 } } let targetWasSupported = beforeTarget.map { !$0.isList && !$0.isChecklist } ?? false let expectedChanged = beforeTarget.map { $0.isBlockQuote != draft.enabled } ?? true - let expectedBlockQuoteCount = beforeTarget.map { target in - beforeStructure.blockQuoteCount + ((target.isBlockQuote == draft.enabled) ? 0 : (draft.enabled ? 1 : -1)) + let expectedBlockQuoteCount = beforeTarget.flatMap { target in + beforeStructure.blockQuoteCount.map { $0 + ((target.isBlockQuote == draft.enabled) ? 0 : (draft.enabled ? 1 : -1)) } } ?? beforeStructure.blockQuoteCount var checks = commonNoteChecks(note: postWriteNote, debug: debug) + checks.append(paragraphReadbackCheck(before: beforeStructure, after: postWriteStructure)) checks.append(boolCheck(name: "changed_reported", expected: expectedChanged, actual: result.changed)) checks.append(boolCheck(name: "identity_preserved", expected: true, actual: postWriteNote.id == before.id)) checks.append(boolCheck(name: "draft_target_preserved", expected: true, actual: postWriteNote.id == draft.noteID)) @@ -4262,7 +4285,7 @@ struct NotesMutationVerifier { checks.append( intCheck( name: "block_quote_count", - expected: max(0, expectedBlockQuoteCount), + expected: expectedBlockQuoteCount.map { max(0, $0) }, actual: postWriteStructure.blockQuoteCount )) if let beforeTarget, let afterTarget { @@ -4282,8 +4305,8 @@ struct NotesMutationVerifier { )) checks.append(intCheck( name: "paragraph_count_preserved", - expected: beforeStructure.paragraphAnchors.count, - actual: postWriteStructure.paragraphAnchors.count + expected: beforeStructure.paragraphAnchors?.count, + actual: postWriteStructure.paragraphAnchors?.count )) checks.append(intCheck( name: "list_item_count_preserved", @@ -4332,19 +4355,24 @@ struct NotesMutationVerifier { let postWriteNote = try requiredPostWriteNote(id: result.note.id, operation: operation) let postWriteStructure = try bodyStructureReader.readBodyStructure(noteID: result.note.id) let debug = sqliteReader.debugNote(postWriteNote) - let beforeRun = inlineFormatRunExists( + let beforeRun = notesInlineFormatCoverage( in: beforeStructure, evidence: result.evidence, format: draft.format.rawValue ) - let afterRun = inlineFormatRunExists( + let afterRun = notesInlineFormatCoverage( in: postWriteStructure, evidence: result.evidence, format: draft.format.rawValue ) - let beforeAnchors = beforeStructure.paragraphAnchors.sorted { $0.ordinal < $1.ordinal } - let postAnchors = postWriteStructure.paragraphAnchors.sorted { $0.ordinal < $1.ordinal } + let beforeAnchors = (beforeStructure.paragraphAnchors ?? []).sorted { $0.ordinal < $1.ordinal } + let postAnchors = (postWriteStructure.paragraphAnchors ?? []).sorted { $0.ordinal < $1.ordinal } var checks = commonNoteChecks(note: postWriteNote, debug: debug) + checks.append(paragraphReadbackCheck(before: beforeStructure, after: postWriteStructure)) + checks.append(contentsOf: inlineSelectionChecks( + before: beforeStructure, after: postWriteStructure, evidence: result.evidence, + text: draft.text, paragraph: draft.paragraphIDSHA256, ordinal: draft.ordinal, + occurrence: draft.occurrence)) checks.append(boolCheck(name: "identity_preserved", expected: true, actual: postWriteNote.id == before.id)) checks.append(boolCheck(name: "draft_target_preserved", expected: true, actual: postWriteNote.id == draft.noteID)) checks.append(stringCheck(name: "title_preserved", expected: before.title, actual: postWriteNote.title)) @@ -4353,9 +4381,9 @@ struct NotesMutationVerifier { checks.append(boolCheck(name: "body_structure_note", expected: true, actual: postWriteStructure.noteID == draft.noteID)) checks.append(stringCheck(name: "selection_text_sha256", expected: sha256Hex(draft.text), actual: result.evidence.textSHA256)) checks.append(intCheck(name: "selection_text_byte_count", expected: draft.text.utf8.count, actual: result.evidence.textByteCount)) - checks.append(boolCheck(name: "target_format_before", expected: draft.enabled ? false : true, actual: beforeRun)) - checks.append(boolCheck(name: "target_format_after", expected: draft.enabled, actual: afterRun)) - checks.append(boolCheck(name: "changed_reported", expected: beforeRun != draft.enabled, actual: result.changed)) + checks.append(boolCheck(name: "target_format_before", expected: !result.changed, actual: beforeRun.map { $0.matches(enabled: draft.enabled) })) + checks.append(boolCheck(name: "target_format_after", expected: true, actual: afterRun.map { $0.matches(enabled: draft.enabled) })) + checks.append(boolCheck(name: "changed_reported", expected: beforeRun.map { !$0.matches(enabled: draft.enabled) }, actual: result.changed)) checks.append( stringCheck( name: "paragraph_anchor_order_preserved", @@ -4364,8 +4392,8 @@ struct NotesMutationVerifier { )) checks.append(intCheck( name: "paragraph_count_preserved", - expected: beforeStructure.paragraphAnchors.count, - actual: postWriteStructure.paragraphAnchors.count + expected: beforeStructure.paragraphAnchors?.count, + actual: postWriteStructure.paragraphAnchors?.count )) if let beforeBytes = beforeStructure.plainTextByteCount, let afterBytes = postWriteStructure.plainTextByteCount { checks.append(intCheck(name: "body_byte_count_preserved", expected: beforeBytes, actual: afterBytes)) @@ -4404,18 +4432,23 @@ struct NotesMutationVerifier { let postWriteNote = try requiredPostWriteNote(id: result.note.id, operation: operation) let postWriteStructure = try bodyStructureReader.readBodyStructure(noteID: result.note.id) let debug = sqliteReader.debugNote(postWriteNote) - let beforeRun = inlineColorRunExists( + let beforeRun = notesInlineColorCoverage( in: beforeStructure, evidence: result.evidence ) - let afterRun = inlineColorRunExists( + let afterRun = notesInlineColorCoverage( in: postWriteStructure, evidence: result.evidence ) let expectedAfter = result.evidence.colorSHA256 != nil - let beforeAnchors = beforeStructure.paragraphAnchors.sorted { $0.ordinal < $1.ordinal } - let postAnchors = postWriteStructure.paragraphAnchors.sorted { $0.ordinal < $1.ordinal } + let beforeAnchors = (beforeStructure.paragraphAnchors ?? []).sorted { $0.ordinal < $1.ordinal } + let postAnchors = (postWriteStructure.paragraphAnchors ?? []).sorted { $0.ordinal < $1.ordinal } var checks = commonNoteChecks(note: postWriteNote, debug: debug) + checks.append(paragraphReadbackCheck(before: beforeStructure, after: postWriteStructure)) + checks.append(contentsOf: inlineSelectionChecks( + before: beforeStructure, after: postWriteStructure, evidence: result.evidence, + text: draft.text, paragraph: draft.paragraphIDSHA256, ordinal: draft.ordinal, + occurrence: draft.occurrence)) checks.append(boolCheck(name: "identity_preserved", expected: true, actual: postWriteNote.id == before.id)) checks.append(boolCheck(name: "draft_target_preserved", expected: true, actual: postWriteNote.id == draft.noteID)) checks.append(stringCheck(name: "title_preserved", expected: before.title, actual: postWriteNote.title)) @@ -4424,8 +4457,8 @@ struct NotesMutationVerifier { checks.append(boolCheck(name: "body_structure_note", expected: true, actual: postWriteStructure.noteID == draft.noteID)) checks.append(stringCheck(name: "selection_text_sha256", expected: sha256Hex(draft.text), actual: result.evidence.textSHA256)) checks.append(intCheck(name: "selection_text_byte_count", expected: draft.text.utf8.count, actual: result.evidence.textByteCount)) - checks.append(boolCheck(name: "target_color_after", expected: expectedAfter, actual: afterRun)) - checks.append(boolCheck(name: "changed_reported", expected: beforeRun != expectedAfter, actual: result.changed)) + checks.append(boolCheck(name: "target_color_after", expected: true, actual: afterRun.map { $0.matches(enabled: expectedAfter) })) + checks.append(boolCheck(name: "changed_reported", expected: beforeRun.map { !$0.matches(enabled: expectedAfter) }, actual: result.changed)) checks.append( stringCheck( name: "paragraph_anchor_order_preserved", @@ -4434,8 +4467,8 @@ struct NotesMutationVerifier { )) checks.append(intCheck( name: "paragraph_count_preserved", - expected: beforeStructure.paragraphAnchors.count, - actual: postWriteStructure.paragraphAnchors.count + expected: beforeStructure.paragraphAnchors?.count, + actual: postWriteStructure.paragraphAnchors?.count )) if let beforeBytes = beforeStructure.plainTextByteCount, let afterBytes = postWriteStructure.plainTextByteCount { checks.append(intCheck(name: "body_byte_count_preserved", expected: beforeBytes, actual: afterBytes)) @@ -4474,19 +4507,24 @@ struct NotesMutationVerifier { let postWriteNote = try requiredPostWriteNote(id: result.note.id, operation: operation) let postWriteStructure = try bodyStructureReader.readBodyStructure(noteID: result.note.id) let debug = sqliteReader.debugNote(postWriteNote) - let beforeRun = inlineFormatRunExists( + let beforeRun = notesInlineFormatCoverage( in: beforeStructure, evidence: result.evidence, format: "font" - ) - let afterRun = inlineFormatRunExists( + ).map { $0 == .full } + let afterRun = notesInlineFormatCoverage( in: postWriteStructure, evidence: result.evidence, format: "font" - ) - let beforeAnchors = beforeStructure.paragraphAnchors.sorted { $0.ordinal < $1.ordinal } - let postAnchors = postWriteStructure.paragraphAnchors.sorted { $0.ordinal < $1.ordinal } + ).map { $0 == .full } + let beforeAnchors = (beforeStructure.paragraphAnchors ?? []).sorted { $0.ordinal < $1.ordinal } + let postAnchors = (postWriteStructure.paragraphAnchors ?? []).sorted { $0.ordinal < $1.ordinal } var checks = commonNoteChecks(note: postWriteNote, debug: debug) + checks.append(paragraphReadbackCheck(before: beforeStructure, after: postWriteStructure)) + checks.append(contentsOf: inlineSelectionChecks( + before: beforeStructure, after: postWriteStructure, evidence: result.evidence, + text: draft.text, paragraph: draft.paragraphIDSHA256, ordinal: draft.ordinal, + occurrence: draft.occurrence)) checks.append(boolCheck(name: "identity_preserved", expected: true, actual: postWriteNote.id == before.id)) checks.append(boolCheck(name: "draft_target_preserved", expected: true, actual: postWriteNote.id == draft.noteID)) checks.append(stringCheck(name: "title_preserved", expected: before.title, actual: postWriteNote.title)) @@ -4495,14 +4533,14 @@ struct NotesMutationVerifier { checks.append(boolCheck(name: "body_structure_note", expected: true, actual: postWriteStructure.noteID == draft.noteID)) checks.append(stringCheck(name: "selection_text_sha256", expected: sha256Hex(draft.text), actual: result.evidence.textSHA256)) checks.append(intCheck(name: "selection_text_byte_count", expected: draft.text.utf8.count, actual: result.evidence.textByteCount)) - checks.append(boolCheck(name: "target_font_before", expected: false, actual: beforeRun)) + checks.append(boolCheck(name: "target_font_before", expected: !result.changed, actual: beforeRun)) checks.append(boolCheck(name: "target_font_after", expected: true, actual: afterRun)) - checks.append(boolCheck(name: "changed_reported", expected: !beforeRun, actual: result.changed)) + checks.append(boolCheck(name: "changed_reported", expected: beforeRun.map { !$0 }, actual: result.changed)) checks.append( boolCheck( name: "target_font_hash_readback", expected: true, - actual: result.evidence.fontSHA256 != nil && afterRun + actual: afterRun.map { result.evidence.fontSHA256 != nil && $0 } )) checks.append( stringCheck( @@ -4512,8 +4550,8 @@ struct NotesMutationVerifier { )) checks.append(intCheck( name: "paragraph_count_preserved", - expected: beforeStructure.paragraphAnchors.count, - actual: postWriteStructure.paragraphAnchors.count + expected: beforeStructure.paragraphAnchors?.count, + actual: postWriteStructure.paragraphAnchors?.count )) if let beforeBytes = beforeStructure.plainTextByteCount, let afterBytes = postWriteStructure.plainTextByteCount { checks.append(intCheck(name: "body_byte_count_preserved", expected: beforeBytes, actual: afterBytes)) @@ -4571,6 +4609,7 @@ struct NotesMutationVerifier { let actualOrder = postListAnchors.map(\.idSHA256) let expectedChanged = sourceIndex.map { $0 != targetIndex } ?? true var checks = commonNoteChecks(note: postWriteNote, debug: debug) + checks.append(paragraphReadbackCheck(before: beforeStructure, after: postWriteStructure)) checks.append(boolCheck(name: "changed_reported", expected: expectedChanged, actual: result.changed)) checks.append(boolCheck(name: "identity_preserved", expected: true, actual: postWriteNote.id == before.id)) checks.append(boolCheck(name: "draft_target_preserved", expected: true, actual: postWriteNote.id == draft.noteID)) @@ -4705,6 +4744,7 @@ struct NotesMutationVerifier { expectedChanged: expectedChanged ) var checks = commonNoteChecks(note: postWriteNote, debug: debug) + checks.append(paragraphReadbackCheck(before: beforeStructure, after: postWriteStructure)) if let expectedChanged { checks.append(boolCheck(name: "changed_reported", expected: expectedChanged, actual: result.changed)) } @@ -4824,6 +4864,7 @@ struct NotesMutationVerifier { .map(\.idSHA256) let targetWasDone = beforeTarget?.checklistDone var checks = commonNoteChecks(note: postWriteNote, debug: debug) + checks.append(paragraphReadbackCheck(before: beforeStructure, after: postWriteStructure)) checks.append(boolCheck(name: "changed_reported", expected: true, actual: result.changed)) checks.append(boolCheck(name: "identity_preserved", expected: true, actual: postWriteNote.id == before.id)) checks.append(boolCheck(name: "draft_target_preserved", expected: true, actual: postWriteNote.id == draft.noteID)) @@ -4869,26 +4910,26 @@ struct NotesMutationVerifier { checks.append( intCheck( name: "checklist_item_count", - expected: max(0, beforeStructure.checklistItemCount - 1), + expected: beforeStructure.checklistItemCount.map { max(0, $0 - 1) }, actual: postWriteStructure.checklistItemCount )) checks.append( intCheck( name: "list_item_count", - expected: max(0, beforeStructure.listItemCount - 1), + expected: beforeStructure.listItemCount.map { max(0, $0 - 1) }, actual: postWriteStructure.listItemCount )) if let targetWasDone { checks.append( intCheck( name: "checklist_done_count", - expected: beforeStructure.checklistDoneCount - (targetWasDone ? 1 : 0), + expected: beforeStructure.checklistDoneCount.map { $0 - (targetWasDone ? 1 : 0) }, actual: postWriteStructure.checklistDoneCount )) checks.append( intCheck( name: "checklist_open_count", - expected: beforeStructure.checklistOpenCount - (targetWasDone ? 0 : 1), + expected: beforeStructure.checklistOpenCount.map { $0 - (targetWasDone ? 0 : 1) }, actual: postWriteStructure.checklistOpenCount )) } @@ -4901,7 +4942,7 @@ struct NotesMutationVerifier { checks.append( boolCheck( name: "has_checklist", - expected: beforeStructure.checklistItemCount > 1, + expected: beforeStructure.checklistItemCount.map { $0 > 1 }, actual: postWriteStructure.hasChecklist )) checks.append(boolCheck(name: "password_protected", expected: false, actual: postWriteStructure.isPasswordProtected)) @@ -4954,6 +4995,7 @@ struct NotesMutationVerifier { expectedChanged: expectedChanged ) var checks = commonNoteChecks(note: postWriteNote, debug: debug) + checks.append(paragraphReadbackCheck(before: beforeStructure, after: postWriteStructure)) if let expectedChanged { checks.append(boolCheck(name: "changed_reported", expected: expectedChanged, actual: result.changed)) } @@ -5078,6 +5120,7 @@ struct NotesMutationVerifier { } .map(\.idSHA256) var checks = commonNoteChecks(note: postWriteNote, debug: debug) + checks.append(paragraphReadbackCheck(before: beforeStructure, after: postWriteStructure)) checks.append(boolCheck(name: "changed_reported", expected: true, actual: result.changed)) checks.append(boolCheck(name: "identity_preserved", expected: true, actual: postWriteNote.id == before.id)) checks.append(boolCheck(name: "draft_target_preserved", expected: true, actual: postWriteNote.id == draft.noteID)) @@ -5118,7 +5161,7 @@ struct NotesMutationVerifier { checks.append( intCheck( name: "list_item_count", - expected: max(0, beforeStructure.listItemCount - 1), + expected: beforeStructure.listItemCount.map { max(0, $0 - 1) }, actual: postWriteStructure.listItemCount )) checks.append( @@ -5327,6 +5370,7 @@ struct NotesMutationVerifier { createdParagraphs.first { $0.idSHA256 == id } } ?? createdParagraphs.first var checks = commonNoteChecks(note: postWriteNote, debug: debug) + checks.append(paragraphReadbackCheck(before: beforeStructure, after: postWriteStructure)) checks.append(boolCheck(name: "changed_reported", expected: true, actual: result.changed)) checks.append(boolCheck(name: "identity_preserved", expected: true, actual: postWriteNote.id == before.id)) checks.append(boolCheck(name: "draft_target_preserved", expected: true, actual: postWriteNote.id == draft.noteID)) @@ -5424,7 +5468,7 @@ struct NotesMutationVerifier { intCheck( name: "paragraph_anchor_count_delta", expected: 1, - actual: postWriteStructure.paragraphAnchors.count - beforeStructure.paragraphAnchors.count + actual: beforeStructure.paragraphAnchors.flatMap { before in postWriteStructure.paragraphAnchors.map { $0.count - before.count } } )) if let beforeBytes = beforeStructure.plainTextByteCount, let afterBytes = postWriteStructure.plainTextByteCount { checks.append(boolCheck(name: "body_byte_count_increased", expected: true, actual: afterBytes > beforeBytes)) @@ -5656,12 +5700,24 @@ struct NotesMutationVerifier { before: NotesNoteDetail, changed: Bool ) throws -> NotesMutationVerificationReport { - try retryingReport { + guard let restorableReader else { + throw CLIError(code: .backendUnavailable, + message: "Notes delete verification requires restorable private framework readback.", + details: ["operation": operation]) + } + return try retryingReport { let postWriteNote = try reader.readNote(id: before.id) + let restorable = try restorableReader.readRestorableNote(id: before.id) let debug = sqliteReader.debugNote(before) let checks = [ boolCheck(name: "changed", expected: true, actual: changed), boolCheck(name: "exists_after", expected: false, actual: postWriteNote != nil), + boolCheck(name: "restorable_exists_after", expected: true, actual: restorable != nil), + boolCheck(name: "identity_preserved", expected: true, actual: restorable?.id == before.id), + boolCheck(name: "title_preserved", expected: true, actual: restorable?.title == before.title), + boolCheck(name: "account_preserved", expected: true, actual: restorable?.accountName == before.accountName), + boolCheck(name: "body_preserved", expected: true, actual: restorable?.body == before.body), + boolCheck(name: "tags_preserved", expected: true, actual: restorable?.tags == before.tags), storeMatchCheck(noteID: before.id, storeObject: debug.storeObject), ] @@ -5697,7 +5753,7 @@ struct NotesMutationVerifier { boolCheck(name: "changed", expected: true, actual: changed), boolCheck(name: "visible_exists_after", expected: false, actual: visibleAfter != nil), boolCheck(name: "restorable_exists_after", expected: false, actual: restorableAfter != nil), - storeMatchCheck(noteID: before.id, storeObject: debug.storeObject), + storeAbsenceCheck(objectID: before.id, storeObject: debug.storeObject), ] return checkedReport( @@ -7835,7 +7891,7 @@ struct NotesMutationVerifier { checks: [NotesVerificationCheckRecord], warnings: [String] ) -> NotesMutationVerificationReport { - let verified = checks.allSatisfy { $0.status != "failed" } + let verified = checks.allSatisfy { $0.status == "passed" || $0.status == "not_applicable" } let evidenceLevel: String if readback != nil, storeObject?.matched == true { evidenceLevel = "readback_and_store" @@ -7885,6 +7941,14 @@ struct NotesMutationVerifier { throw CLIError(code: .internalError, message: "Notes mutation verification did not run.") } + private func paragraphReadbackCheck( + before: NotesBodyStructureRecord, after: NotesBodyStructureRecord + ) -> NotesVerificationCheckRecord { + boolCheck( + name: "paragraph_anchor_readback_available", expected: true, + actual: before.paragraphAnchors.flatMap { _ in after.paragraphAnchors.map { _ in true } }) + } + private func commonNoteChecks( note: NotesNoteDetail, debug: NotesObjectDebugResponse @@ -7916,31 +7980,39 @@ struct NotesMutationVerifier { return before.body ?? "" } - private func inlineFormatRunExists( - in structure: NotesBodyStructureRecord, - evidence: NotesBodyInlineMutationEvidence, - format: String - ) -> Bool { - structure.inlineFormatRuns.contains { run in - run.format == format - && run.textSHA256 == evidence.textSHA256 - && run.textByteCount == evidence.textByteCount - && (evidence.fontSHA256 == nil || run.fontSHA256 == evidence.fontSHA256) - && (evidence.paragraphIDSHA256 == nil || run.paragraphIDSHA256 == evidence.paragraphIDSHA256) - } - } - - private func inlineColorRunExists( - in structure: NotesBodyStructureRecord, - evidence: NotesBodyInlineMutationEvidence - ) -> Bool { - structure.colorRuns.contains { run in - run.role == evidence.role - && run.textSHA256 == evidence.textSHA256 - && run.textByteCount == evidence.textByteCount - && (evidence.colorSHA256 == nil || run.colorSHA256 == evidence.colorSHA256) - && (evidence.paragraphIDSHA256 == nil || run.paragraphIDSHA256 == evidence.paragraphIDSHA256) + private func inlineSelectionChecks( + before: NotesBodyStructureRecord, after: NotesBodyStructureRecord, + evidence: NotesBodyInlineMutationEvidence, text: String, + paragraph: String?, ordinal: Int?, occurrence: Int? + ) -> [NotesVerificationCheckRecord] { + let readback = try? bodyStructureReader?.readInlineSelection(noteID: before.noteID, + paragraphIDSHA256: paragraph, ordinal: ordinal, text: text, occurrence: occurrence) + let range = notesInlineSelectionRange(in: before, evidence: evidence) + let paragraphBound = readback.flatMap { selection -> Bool? in + guard let paragraphRange = notesInlineRange(location: selection.paragraphUTF16Location, + length: selection.paragraphUTF16Length, bodyLength: before.richTextLength), let range else { return nil } + return evidence.paragraphIDSHA256 == selection.paragraphIDSHA256 + && NSIntersectionRange(paragraphRange, range) == range } + return [ + boolCheck(name: "selection_position_readback", expected: true, + actual: readback.map { $0.utf16Location == evidence.utf16Location + && $0.utf16Length == evidence.utf16Length && $0.occurrence == evidence.occurrence + && $0.paragraphIDSHA256 == evidence.paragraphIDSHA256 }), + boolCheck(name: "selection_text_readback", expected: true, + actual: readback.map { $0.textSHA256 == evidence.textSHA256 && $0.textByteCount == evidence.textByteCount }), + boolCheck(name: "selection_snapshot_readback", expected: true, + actual: readback.map { $0.richTextSHA256 == evidence.richTextSHA256 }), + boolCheck(name: "selection_rich_snapshot_before", expected: true, + actual: before.richTextSHA256.flatMap { hash in evidence.richTextSHA256.map { $0 == hash } }), + boolCheck(name: "selection_rich_snapshot_preserved", expected: true, + actual: before.richTextSHA256.flatMap { hash in after.richTextSHA256.map { $0 == hash } }), + boolCheck(name: "selection_utf16_range", expected: true, + actual: range.map { $0.length == (text as NSString).length }), + boolCheck(name: "selection_paragraph_bound", expected: true, actual: paragraphBound), + boolCheck(name: "selection_occurrence", expected: true, + actual: evidence.occurrence > 0 && (occurrence == nil || occurrence == evidence.occurrence)), + ] } private func bodyChecklistConvertAnchor( @@ -7948,10 +8020,10 @@ struct NotesMutationVerifier { structure: NotesBodyStructureRecord ) -> NotesBodyParagraphAnchorRecord? { if let paragraphIDSHA256 = draft.paragraphIDSHA256 { - return structure.paragraphAnchors.first { $0.idSHA256 == paragraphIDSHA256 } + return (structure.paragraphAnchors ?? []).first { $0.idSHA256 == paragraphIDSHA256 } } if let ordinal = draft.ordinal { - return structure.paragraphAnchors.first { $0.ordinal == ordinal } + return (structure.paragraphAnchors ?? []).first { $0.ordinal == ordinal } } return nil } @@ -7963,7 +8035,7 @@ struct NotesMutationVerifier { guard draft.fromOrdinal > 0, draft.fromOrdinal <= draft.toOrdinal else { return [] } - return structure.paragraphAnchors + return (structure.paragraphAnchors ?? []) .filter { $0.ordinal >= draft.fromOrdinal && $0.ordinal <= draft.toOrdinal } .sorted { $0.ordinal < $1.ordinal } } @@ -7973,10 +8045,10 @@ struct NotesMutationVerifier { structure: NotesBodyStructureRecord ) -> NotesBodyParagraphAnchorRecord? { if let paragraphIDSHA256 = draft.paragraphIDSHA256 { - return structure.paragraphAnchors.first { $0.idSHA256 == paragraphIDSHA256 } + return (structure.paragraphAnchors ?? []).first { $0.idSHA256 == paragraphIDSHA256 } } if let ordinal = draft.ordinal { - return structure.paragraphAnchors.first { $0.ordinal == ordinal } + return (structure.paragraphAnchors ?? []).first { $0.ordinal == ordinal } } return nil } @@ -7988,7 +8060,7 @@ struct NotesMutationVerifier { guard draft.fromOrdinal > 0, draft.fromOrdinal <= draft.toOrdinal else { return [] } - return structure.paragraphAnchors + return (structure.paragraphAnchors ?? []) .filter { $0.ordinal >= draft.fromOrdinal && $0.ordinal <= draft.toOrdinal } .sorted { $0.ordinal < $1.ordinal } } @@ -7999,10 +8071,10 @@ struct NotesMutationVerifier { structure: NotesBodyStructureRecord ) -> NotesBodyParagraphAnchorRecord? { if let paragraphIDSHA256 { - return structure.paragraphAnchors.first { $0.idSHA256 == paragraphIDSHA256 } + return (structure.paragraphAnchors ?? []).first { $0.idSHA256 == paragraphIDSHA256 } } if let ordinal { - return structure.paragraphAnchors.first { $0.ordinal == ordinal } + return (structure.paragraphAnchors ?? []).first { $0.ordinal == ordinal } } return nil } @@ -8021,13 +8093,13 @@ struct NotesMutationVerifier { } private func checklistAnchors(in structure: NotesBodyStructureRecord) -> [NotesBodyParagraphAnchorRecord] { - structure.paragraphAnchors + (structure.paragraphAnchors ?? []) .filter { $0.isChecklist } .sorted { $0.ordinal < $1.ordinal } } private func ordinaryListAnchors(in structure: NotesBodyStructureRecord) -> [NotesBodyParagraphAnchorRecord] { - structure.paragraphAnchors + (structure.paragraphAnchors ?? []) .filter { $0.isList && !$0.isChecklist } .sorted { $0.ordinal < $1.ordinal } } @@ -8086,8 +8158,8 @@ struct NotesMutationVerifier { before: NotesBodyStructureRecord, after: NotesBodyStructureRecord ) -> [NotesBodyParagraphAnchorRecord] { - let beforeIDs = Set(before.paragraphAnchors.map(\.idSHA256)) - return after.paragraphAnchors + let beforeIDs = Set((before.paragraphAnchors ?? []).map(\.idSHA256)) + return (after.paragraphAnchors ?? []) .filter { !beforeIDs.contains($0.idSHA256) && $0.style == "body" @@ -8097,8 +8169,8 @@ struct NotesMutationVerifier { .sorted { $0.ordinal < $1.ordinal } } - private func bodyAttachmentKindCount(_ kind: String, in structure: NotesBodyStructureRecord) -> Int { - structure.attachmentKindCounts.first { $0.kind == kind }?.count ?? 0 + private func bodyAttachmentKindCount(_ kind: String, in structure: NotesBodyStructureRecord) -> Int? { + structure.attachmentKindCounts.map { $0.first { $0.kind == kind }?.count ?? 0 } } private func expectedTableDimension( @@ -8541,7 +8613,7 @@ struct NotesMutationVerifier { private func bodyCheck(name: String, expected: String, actual: String) -> NotesVerificationCheckRecord { NotesVerificationCheckRecord( name: name, - status: bodyEquivalent(expected: expected, actual: actual) ? "passed" : "failed", + status: expected == actual ? "passed" : "failed", expectedSHA256: sha256Hex(expected), actualSHA256: sha256Hex(actual), expectedLength: expected.count, @@ -8549,17 +8621,10 @@ struct NotesMutationVerifier { ) } - private func bodyEquivalent(expected: String, actual: String) -> Bool { - if expected.isEmpty { - return actual.isEmpty - } - return actual == expected || actual == "\n\(expected)" - } - - private func boolCheck(name: String, expected: Bool, actual: Bool) -> NotesVerificationCheckRecord { + private func boolCheck(name: String, expected: Bool?, actual: Bool?) -> NotesVerificationCheckRecord { NotesVerificationCheckRecord( name: name, - status: expected == actual ? "passed" : "failed", + status: expected == nil || actual == nil ? "unavailable" : (expected == actual ? "passed" : "failed"), expectedBool: expected, actualBool: actual ) @@ -8574,10 +8639,10 @@ struct NotesMutationVerifier { ) } - private func intCheck(name: String, expected: Int, actual: Int?) -> NotesVerificationCheckRecord { + private func intCheck(name: String, expected: Int?, actual: Int?) -> NotesVerificationCheckRecord { NotesVerificationCheckRecord( name: name, - status: actual == expected ? "passed" : "failed", + status: expected == nil || actual == nil ? "unavailable" : (actual == expected ? "passed" : "failed"), expectedLength: expected, actualLength: actual ) @@ -8801,6 +8866,18 @@ struct NotesMutationVerifier { storeMatchCheck(objectID: noteID, storeObject: storeObject) } + private func storeAbsenceCheck(objectID: String, storeObject: NotesStoreObjectRecord) + -> NotesVerificationCheckRecord + { + guard objectID.hasPrefix("x-coredata://") else { + return NotesVerificationCheckRecord(name: "store_object_absent", status: "not_applicable") + } + guard storeObject.lookupSucceeded == true else { + return NotesVerificationCheckRecord(name: "store_object_absent", status: "unavailable") + } + return boolCheck(name: "store_object_absent", expected: false, actual: storeObject.matched) + } + private func storeMatchCheck(objectID: String, storeObject: NotesStoreObjectRecord) -> NotesVerificationCheckRecord { diff --git a/Sources/NotesCLI/Operations/NotesNativeContext.swift b/Sources/NotesCLI/Operations/NotesNativeContext.swift new file mode 100644 index 0000000..089edca --- /dev/null +++ b/Sources/NotesCLI/Operations/NotesNativeContext.swift @@ -0,0 +1,71 @@ +import CoreData +import Foundation +import NotesShared +import NotesSupport +import Utility + +enum NotesContextBootstrapStep: Equatable, Sendable { + case shared, start, create +} + +func notesBootstrapContext( + require: (NotesContextBootstrapStep) throws -> Void, + shared: () -> Context?, start: () -> Void, create: () -> Context? +) throws -> Context? { + try require(.shared) + if let context = shared() { return context } + try require(.start) + start() + try require(.shared) + if let context = shared() { return context } + try require(.create) + return create() +} + +enum NotesNativeContext { + static let noteSave = NotesRuntimeMethod(owner: "ICNote", selector: "save", returnType: "v") + static let managedContext = NotesRuntimeMethod( + owner: "ICNoteContext", selector: "managedObjectContext", returnType: "@") + static let contextSave = NotesRuntimeMethod( + owner: "ICNoteContext", selector: "save:", returnType: "B", argumentTypes: ["^@"]) + static let managedSave = NotesRuntimeMethod( + owner: "NSManagedObjectContext", selector: "ic_save", returnType: "B") + + static func open(requiresSave: Bool = false) throws -> ICNoteContext { + let operation = "notes.context.open" + let context: ICNoteContext? = try notesBootstrapContext( + require: { step in + let method: NotesRuntimeMethod = switch step { + case .shared: + NotesRuntimeMethod(owner: "ICNoteContext", selector: "sharedContext", + scope: .classMethod, returnType: "@") + case .start: + NotesRuntimeMethod(owner: "ICNoteContext", selector: "startSharedContextWithOptions:", + scope: .classMethod, returnType: "v", argumentTypes: ["Q"]) + case .create: + NotesRuntimeMethod(owner: "ICNoteContext", selector: "initWithOptions:", + returnType: "@", argumentTypes: ["Q"]) + } + try method.require(operation: operation) + }, + shared: { ICNoteContext.sharedContext() as? ICNoteContext }, + start: { ICNoteContext.startSharedContext(withOptions: 0) }, + create: { ICNoteContext(options: 0) } + ) + guard let context else { + throw CLIError(code: .backendUnavailable, + message: "Notes private framework context could not be started.") + } + try managedContext.require(operation: operation, receiver: context) + if requiresSave { try preflightSave(context, operation: operation) } + return context + } + + static func preflightSave(_ context: ICNoteContext, operation: String) throws { + try contextSave.require(operation: operation, receiver: context) + try managedContext.require(operation: operation, receiver: context) + if let managedObjectContext = context.managedObjectContext { + try managedSave.require(operation: operation, receiver: managedObjectContext) + } + } +} diff --git a/Sources/NotesCLI/Operations/NotesNativeInlineStyle.swift b/Sources/NotesCLI/Operations/NotesNativeInlineStyle.swift new file mode 100644 index 0000000..137a220 --- /dev/null +++ b/Sources/NotesCLI/Operations/NotesNativeInlineStyle.swift @@ -0,0 +1,85 @@ +import AppKit +import Foundation +import NotesUI +import Utility + +struct NotesNativeInlineStyle { + private let controller: ICTTTextController + private let operation: String + + init(operation: String) throws { + self.operation = operation + try NotesRuntimeMethod(owner: "ICTTTextController", selector: "init", returnType: "@").require(operation: operation) + controller = ICTTTextController() + try NotesRuntimeMethod(owner: "ICTTTextController", selector: "styleForModelAttributes:", + returnType: "@", argumentTypes: ["@"]).require(operation: operation, receiver: controller) + try NotesRuntimeMethod(owner: "ICTTTextController", selector: "modelForStyleAttributes:filterAttributes:", + returnType: "@", argumentTypes: ["@", "B"]).require(operation: operation, receiver: controller) + } + + func presentation(_ attributes: [NSAttributedString.Key: Any]) throws -> [NSAttributedString.Key: Any] { + try dictionary(controller.style(forModelAttributes: raw(attributes))) + } + + func model(_ attributes: [NSAttributedString.Key: Any]) throws -> [NSAttributedString.Key: Any] { + try dictionary(controller.model(forStyleAttributes: raw(attributes), filterAttributes: true)) + } + + private func raw(_ attributes: [NSAttributedString.Key: Any]) -> NSDictionary { + Dictionary(uniqueKeysWithValues: attributes.map { ($0.key.rawValue, $0.value) }) as NSDictionary + } + + private func dictionary(_ value: Any?) throws -> [NSAttributedString.Key: Any] { + guard let attributes = value as? [String: Any] else { + throw CLIError(code: .backendUnavailable, message: "Notes inline style conversion is unavailable.", + details: ["operation": operation]) + } + return Dictionary(uniqueKeysWithValues: attributes.map { (NSAttributedString.Key($0.key), $0.value) }) + } +} + +func notesHasModelAttributes(_ text: NSAttributedString) -> Bool { + var found = false + let keys: Set = ["TTStyle", "TTTimestamp", "TTHints", "ICTTFont", "TTUnderline", + "TTStrikethrough", "TTColor", "TTEmphasis"] + text.enumerateAttributes(in: NSRange(location: 0, length: text.length), options: []) { attributes, _, stop in + if attributes.keys.contains(where: { keys.contains($0.rawValue) }) { + found = true + stop.pointee = true + } + } + return found +} + +func notesMergeInlineModelAttributes( + original: [NSAttributedString.Key: Any], converted: [NSAttributedString.Key: Any], ownedKeys: [String] +) -> [NSAttributedString.Key: Any] { + var merged = original + for name in ownedKeys { + let key = NSAttributedString.Key(name) + merged[key] = converted[key] + } + return merged +} + +func notesMutateNativeInlineAttributes( + in text: NSMutableAttributedString, range: NSRange, ownedKeys: [String], operation: String, + mutate: ([NSAttributedString.Key: Any]) -> [NSAttributedString.Key: Any] +) throws { + let converter = try NotesNativeInlineStyle(operation: operation) + var replacements: [(range: NSRange, attributes: [NSAttributedString.Key: Any])] = [] + var failure: Error? + text.enumerateAttributes(in: range, options: []) { attributes, run, stop in + do { + let presentation = try converter.presentation(attributes) + let converted = try converter.model(mutate(presentation)) + replacements.append((run, notesMergeInlineModelAttributes( + original: attributes, converted: converted, ownedKeys: ownedKeys))) + } catch { + failure = error + stop.pointee = true + } + } + if let failure { throw failure } + for replacement in replacements { text.setAttributes(replacement.attributes, range: replacement.range) } +} diff --git a/Sources/NotesCLI/Operations/NotesParagraphStructure.swift b/Sources/NotesCLI/Operations/NotesParagraphStructure.swift new file mode 100644 index 0000000..13511ac --- /dev/null +++ b/Sources/NotesCLI/Operations/NotesParagraphStructure.swift @@ -0,0 +1,73 @@ +import Foundation + +struct NotesParagraphStyleEvidence: Hashable { + var kind: String + var isHeader: Bool? + var isList: Bool? + var isChecklist: Bool? + var checked: Bool? + var isBlockQuote: Bool? +} + +struct NotesParagraphStructure { + var styleRunCount = 0 + var headingCount: Int? = 0 + var listItemCount: Int? = 0 + var checklistItemCount: Int? = 0 + var checklistDoneCount: Int? = 0 + var checklistOpenCount: Int? = 0 + var blockQuoteCount: Int? = 0 + var styleCounts: [String: Int]? = [:] +} + +func notesParagraphStructure( + in text: NSAttributedString, resolve: (Any) -> NotesParagraphStyleEvidence? +) -> NotesParagraphStructure { + var result = NotesParagraphStructure() + var paragraphs: [Int: Set] = [:] + let string = text.string as NSString + text.enumerateAttributes(in: NSRange(location: 0, length: text.length), options: []) { attributes, range, _ in + let styles = Set(attributes.values.compactMap(resolve)) + result.styleRunCount += styles.isEmpty ? 0 : 1 + guard !styles.isEmpty else { return } + var location = range.location + while location < NSMaxRange(range) { + let paragraph = string.paragraphRange(for: NSRange(location: location, length: 0)) + paragraphs[paragraph.location, default: []].formUnion(styles) + location = min(NSMaxRange(paragraph), NSMaxRange(range)) + } + } + var styleCounts: [String: Int] = [:] + var stylesAvailable = true + for styles in paragraphs.values { + let kinds = Set(styles.map(\.kind)) + if kinds.count == 1, let kind = kinds.first { styleCounts[kind, default: 0] += 1 } + else { stylesAvailable = false } + add(singleFlag(styles.map(\.isHeader)), to: &result.headingCount) + add(singleFlag(styles.map(\.isList)), to: &result.listItemCount) + let checklist = singleFlag(styles.map(\.isChecklist)) + add(checklist, to: &result.checklistItemCount) + add(singleFlag(styles.map(\.isBlockQuote)), to: &result.blockQuoteCount) + if checklist == true { + let checked = singleFlag(styles.map(\.checked)) + add(checked, to: &result.checklistDoneCount) + add(checked.map { !$0 }, to: &result.checklistOpenCount) + } else if checklist == nil { + result.checklistDoneCount = nil + result.checklistOpenCount = nil + } + } + result.styleCounts = stylesAvailable ? styleCounts : nil + return result +} + +private func singleFlag(_ flags: [Bool?]) -> Bool? { + let values = Set(flags) + guard values.count == 1 else { return nil } + return values.first ?? nil +} + +private func add(_ flag: Bool?, to count: inout Int?) { + guard let flag else { count = nil; return } + if flag { count = count.map { $0 + 1 } } +} diff --git a/Sources/NotesCLI/Operations/NotesPlainTextBody.swift b/Sources/NotesCLI/Operations/NotesPlainTextBody.swift new file mode 100644 index 0000000..675ae8b --- /dev/null +++ b/Sources/NotesCLI/Operations/NotesPlainTextBody.swift @@ -0,0 +1,12 @@ +func notesPlainTextBody(title: String, fullText: String?, nativeBody: String) -> String { + guard !title.isEmpty, let fullText else { return nativeBody } + if fullText.utf8.elementsEqual(nativeBody.utf8) { + if fullText.utf8.elementsEqual(title.utf8) { return "" } + let prefix = title + "\n" + if fullText.utf8.starts(with: prefix.utf8) { return String(fullText.dropFirst(prefix.count)) } + } + guard fullText.utf8.elementsEqual((title + nativeBody).utf8) else { return nativeBody } + // The native title-less projection can retain the title's LF terminator. + guard nativeBody.hasPrefix("\n") else { return nativeBody } + return String(nativeBody.dropFirst()) +} diff --git a/Sources/NotesCLI/Operations/NotesReader.swift b/Sources/NotesCLI/Operations/NotesReader.swift index 199e224..8e81840 100644 --- a/Sources/NotesCLI/Operations/NotesReader.swift +++ b/Sources/NotesCLI/Operations/NotesReader.swift @@ -1512,7 +1512,16 @@ struct NotesReader: NotesReading, NotesFolderPurgeReading, NotesTagReading, Note } func readNote(id: String) throws -> NotesNoteDetail? { - try frameworkNote(id: id).map(noteDetail) + let context = try managedObjectContext() + return try context.performAndWait { + guard let note = try frameworkNote(id: id, context: context) else { return nil } + try notesRequireDateAccessors(note, operation: "notes.read") + if !note.isPasswordProtected { + try NotesRuntimeMethod(owner: "ICNote", selector: "noteAsPlainTextWithoutTitle", returnType: "@") + .require(operation: "notes.read", receiver: note) + } + return noteDetail(note) + } } func readRestorableNote(id: String) throws -> NotesNoteDetail? { @@ -1540,7 +1549,8 @@ struct NotesReader: NotesReading, NotesFolderPurgeReading, NotesTagReading, Note try visibleNotes() .filter { note in matchesFolder(note, selector: selector) - && string(note.title).localizedCaseInsensitiveCompare(title) == .orderedSame + && (string(note.title).localizedCaseInsensitiveCompare(title) == .orderedSame + || noteTextTitle(note).localizedCaseInsensitiveCompare(title) == .orderedSame) } .map(noteDetail) .sorted(by: compareNoteDetails) @@ -2009,6 +2019,33 @@ struct NotesReader: NotesReading, NotesFolderPurgeReading, NotesTagReading, Note return bodyStructureRecord(note) } + func readInlineSelection(noteID: String, paragraphIDSHA256: String?, ordinal: Int?, + text: String, occurrence: Int?) throws -> NotesBodyInlineSelectionReadback { + guard let note = try frameworkNote(id: noteID) else { + throw CLIError(code: .notFound, message: "Note was not found.") + } + guard !note.isPasswordProtected else { + throw CLIError(code: .permissionDenied, message: "Password-protected Notes inline selection is not exposed.") + } + guard let attributed = note.attributedString() as? NSAttributedString else { + throw CLIError(code: .backendUnavailable, message: "Notes inline selection readback is unavailable.") + } + let anchors = paragraphAnchorResolutions(note, attributedString: attributed).map(\.anchor) + let paragraph = try notesInlineParagraph(noteID: noteID, in: attributed.string as NSString, + nativeAnchors: anchors.map { NotesInlineParagraph( + range: NSRange(location: $0.utf16Location ?? NSNotFound, length: $0.utf16Length ?? 0), + idSHA256: $0.idSHA256) }, paragraphIDSHA256: paragraphIDSHA256, + ordinal: ordinal, operation: "notes.body.inline.readback") + let selection = try notesInlineTextSelection(in: attributed.string as NSString, + text: text, paragraphRange: paragraph.range, occurrence: occurrence, operation: "notes.body.inline.readback") + let selectedText = attributed.attributedSubstring(from: selection.range).string + return NotesBodyInlineSelectionReadback(paragraphIDSHA256: paragraph.idSHA256, + utf16Location: selection.range.location, utf16Length: selection.range.length, + textByteCount: selectedText.utf8.count, textSHA256: sha256Hex(selectedText), + richTextSHA256: sha256Hex(attributed.string), occurrence: selection.occurrence, + paragraphUTF16Location: paragraph.range.location, paragraphUTF16Length: paragraph.range.length) + } + func listTables(noteID id: String) throws -> [NotesBodyTableRecord] { guard let note = try frameworkNote(id: id) else { throw CLIError(code: .notFound, message: "Note was not found.", details: ["id": id]) @@ -2021,7 +2058,7 @@ struct NotesReader: NotesReading, NotesFolderPurgeReading, NotesTagReading, Note ) } - return bodyTableRecords(note) + return try bodyTableRecords(note) } func listMathResults(noteID id: String) throws -> [NotesBodyMathResultRecord] { @@ -2036,7 +2073,7 @@ struct NotesReader: NotesReading, NotesFolderPurgeReading, NotesTagReading, Note ) } - return bodyMathResultRecords(note) + return try bodyMathResultRecords(note) } func readMathResultsPreference(noteID id: String) throws -> NotesBodyMathResultsPreferenceRecord { @@ -2149,7 +2186,13 @@ struct NotesReader: NotesReading, NotesFolderPurgeReading, NotesTagReading, Note details: ["id_sha256": sha256Hex(id)] ) } - return collapsibleSections(note: note, paragraphs: paragraphAnchorResolutions(note)) + guard let attributedString = note.attributedString() as? NSAttributedString, + let sections = collapsibleSections( + note: note, paragraphs: paragraphAnchorResolutions(note, attributedString: attributedString)) + else { + throw CLIError(code: .backendUnavailable, message: "Notes collapsible section readback is unavailable.") + } + return sections } func resolveParagraphAnchor(noteID id: String, paragraphIDSHA256: String) throws -> NotesParagraphAnchorResolution { @@ -2696,7 +2739,7 @@ struct NotesReader: NotesReading, NotesFolderPurgeReading, NotesTagReading, Note return ( isPasswordProtected: state.isPasswordProtected, isPasswordProtectedAndLocked: state.isPasswordProtectedAndLocked, - title: state.isPasswordProtected ? nil : nonEmpty(string(note.title)) + title: state.isPasswordProtected ? nil : nonEmpty(noteTextTitle(note)) ) } @@ -2776,10 +2819,12 @@ struct NotesReader: NotesReading, NotesFolderPurgeReading, NotesTagReading, Note ) } - private func frameworkNote(id: String, includeDeleted: Bool = false) throws -> ICNote? { - let managedObjectContext = try managedObjectContext() + private func frameworkNote( + id: String, includeDeleted: Bool = false, context: NSManagedObjectContext? = nil + ) throws -> ICNote? { + let managedObjectContext = try context ?? managedObjectContext() if includeDeleted, - let note = try managedObject(id: id, context: managedObjectContext) as? ICNote + let note = try NotesManagedObjectLookup.resolve(id: id, context: managedObjectContext) as? ICNote { return note } @@ -2810,7 +2855,7 @@ struct NotesReader: NotesReading, NotesFolderPurgeReading, NotesTagReading, Note private func frameworkFolder(id: String) throws -> ICFolder? { let managedObjectContext = try managedObjectContext() - if let folder = try managedObject(id: id, context: managedObjectContext) as? ICFolder { + if let folder = try NotesManagedObjectLookup.resolve(id: id, context: managedObjectContext) as? ICFolder { return folder } @@ -2832,16 +2877,6 @@ struct NotesReader: NotesReading, NotesFolderPurgeReading, NotesTagReading, Note return matches.first } - private func managedObject(id: String, context: NSManagedObjectContext) throws -> NSManagedObject? { - guard id.hasPrefix("x-coredata://"), - let url = URL(string: id), - let objectID = context.persistentStoreCoordinator?.managedObjectID(forURIRepresentation: url) - else { - return nil - } - return try context.existingObject(with: objectID) - } - private func visibleNotes() throws -> [ICNote] { let managedObjectContext = try managedObjectContext() return objects(ICNote.visibleNotes(inContext: managedObjectContext)) @@ -2859,6 +2894,7 @@ struct NotesReader: NotesReading, NotesFolderPurgeReading, NotesTagReading, Note } private func save(context: ICNoteContext, operation: String) throws { + try NotesNativeContext.preflightSave(context, operation: operation) var error: AnyObject? guard context.save(&error) else { throw CLIError( @@ -2871,19 +2907,23 @@ struct NotesReader: NotesReading, NotesFolderPurgeReading, NotesTagReading, Note ] ) } - if let managedObjectContext = context.managedObjectContext, !managedObjectContext.ic_save() { - throw CLIError( - code: .internalError, - message: "Notes private framework managed object context save failed.", - details: [ - "operation": operation, - "reason": "NSManagedObjectContext.ic_save returned false.", - ] - ) + if let managedObjectContext = context.managedObjectContext { + try NotesNativeContext.managedSave.require(operation: operation, receiver: managedObjectContext) + guard managedObjectContext.ic_save() else { + throw CLIError( + code: .internalError, + message: "Notes private framework managed object context save failed.", + details: [ + "operation": operation, + "reason": "NSManagedObjectContext.ic_save returned false.", + ] + ) + } } } private func save(managedObjectContext: NSManagedObjectContext, operation: String) throws { + try NotesNativeContext.managedSave.require(operation: operation, receiver: managedObjectContext) guard managedObjectContext.ic_save() else { throw CLIError( code: .internalError, @@ -2897,23 +2937,7 @@ struct NotesReader: NotesReading, NotesFolderPurgeReading, NotesTagReading, Note } private func noteContext() throws -> ICNoteContext { - if let context = ICNoteContext.sharedContext() as? ICNoteContext { - return context - } - - ICNoteContext.startSharedContext(withOptions: 0) - if let context = ICNoteContext.sharedContext() as? ICNoteContext { - return context - } - - if let context = ICNoteContext(options: 0) { - return context - } - - throw CLIError( - code: .backendUnavailable, - message: "Notes private framework context could not be started." - ) + try NotesNativeContext.open() } private func localAccountVisibleNoteCountIncludingTrash(_ account: ICAccount) -> Int? { @@ -3383,7 +3407,7 @@ struct NotesReader: NotesReading, NotesFolderPurgeReading, NotesTagReading, Note private func noteSummary(_ note: ICNote) -> NotesNoteSummary { NotesNoteSummary( id: noteIdentifier(note), - title: nonEmpty(note.title) ?? "Untitled", + title: nonEmpty(noteTextTitle(note)) ?? "Untitled", folderName: noteFolderDisplayName(note), accountName: nonEmpty(note.accountName) ?? "Notes", createdAt: note.creationDate, @@ -3444,7 +3468,7 @@ struct NotesReader: NotesReading, NotesFolderPurgeReading, NotesTagReading, Note private func noteDetail(_ note: ICNote) -> NotesNoteDetail { NotesNoteDetail( id: noteIdentifier(note), - title: nonEmpty(note.title) ?? "Untitled", + title: nonEmpty(noteTextTitle(note)) ?? "Untitled", folderName: noteFolderDisplayName(note), accountName: nonEmpty(note.accountName) ?? "Notes", body: note.isPasswordProtected ? nil : plainTextBody(note), @@ -3455,7 +3479,7 @@ struct NotesReader: NotesReading, NotesFolderPurgeReading, NotesTagReading, Note } private func noteSearchTextMatches(_ note: ICNote, normalizedQuery: String) -> Bool { - if string(note.title).localizedLowercase.contains(normalizedQuery) { + if noteTextTitle(note).localizedLowercase.contains(normalizedQuery) { return true } guard note.isPasswordProtected == false, @@ -3559,11 +3583,11 @@ struct NotesReader: NotesReading, NotesFolderPurgeReading, NotesTagReading, Note } private func markdownPackageMainFilename(_ note: ICNote) -> String { - "\(sanitizedMarkdownPackageFilename(nonEmpty(string(note.title)) ?? "Note", fallback: "Note")).md" + "\(sanitizedMarkdownPackageFilename(nonEmpty(noteTextTitle(note)) ?? "Note", fallback: "Note")).md" } private func htmlPackageMainFilename(_ note: ICNote) -> String { - "\(sanitizedMarkdownPackageFilename(nonEmpty(string(note.title)) ?? "Note", fallback: "Note")).html" + "\(sanitizedMarkdownPackageFilename(nonEmpty(noteTextTitle(note)) ?? "Note", fallback: "Note")).html" } private func markdownResourceFiles(from attachments: [AnyObject]) throws -> [NotesNoteMarkdownExportFile] { @@ -4449,69 +4473,90 @@ struct NotesReader: NotesReading, NotesFolderPurgeReading, NotesTagReading, Note let passwordProtected = note.isPasswordProtected let plainText = passwordProtected ? nil : plainTextBody(note) let attributedString = passwordProtected ? nil : note.attributedString() as? NSAttributedString - let attributeSummary = attributedString.map(bodyAttributeSummary) ?? BodyAttributeSummary() - let inlineAttachments = passwordProtected ? [] : anyObjects(note.allNoteTextInlineAttachments()) - let attachmentKindCounts = mergedAttachmentKindCounts( - attributeSummary.attachmentKindCounts, - inlineAttachments: inlineAttachments - ) - let tableCount = max(attributeSummary.tableCount, attachmentKindCounts["table"] ?? 0) - let mathAttachmentCount = max( - attributeSummary.mathAttachmentCount, - inlineAttachments.filter(isMathAttachmentObject).count - ) - - let paragraphAnchors = passwordProtected ? [] : paragraphAnchorResolutions(note) - let collapsibleSections = passwordProtected - ? (collapsible: 0, collapsed: 0) - : collapsibleSectionCounts(note: note, paragraphs: paragraphAnchors) + var record = bodyAttributeStructureRecord( + noteID: noteIdentifier(note), isPasswordProtected: passwordProtected, + plainText: plainText, attributedString: attributedString) + let inlineAttachments = passwordProtected + ? nil : availableObjects(note.allNoteTextInlineAttachments()) + record.inlineAttachmentCount = record.inlineAttachmentCount.flatMap { count in + inlineAttachments.map { max(count, $0.count) } + } + record.mathAttachmentCount = record.mathAttachmentCount.flatMap { count in + inlineAttachments.map { max(count, $0.filter(isMathAttachmentObject).count) } + } + record.attachmentKindCounts = record.attachmentKindCounts.flatMap { counts in + inlineAttachments.map { attachments in + attachmentKindCountRecords(mergedAttachmentKindCounts( + Dictionary(uniqueKeysWithValues: counts.map { ($0.kind, $0.count) }), + inlineAttachments: attachments)) + } + } + record.linkCount = inlineAttachments.map { $0.filter(isLinkObject).count } + record.mentionUserIDSHA256s = inlineAttachments.map(mentionUserIDSHA256s) + let orderedAttachments = availableObjects(note.attachmentsInOrder()) + let visibleAttachments = availableObjects(note.visibleAttachments()) + record.attachmentCount = (orderedAttachments?.isEmpty == false + ? orderedAttachments : (visibleAttachments ?? orderedAttachments))?.count + record.hasChecklist = optionalBool(note, key: "hasChecklist") + record.hasChecklistInProgress = optionalBool(note, key: "hasChecklistInProgress") + record.isMathNote = optionalBool(note, key: "isMathNote") + let paragraphAnchors = attributedString.map { paragraphAnchorResolutions(note, attributedString: $0) } + record.paragraphAnchors = paragraphAnchors.map { $0.map(\.anchor) } + let sections = paragraphAnchors.flatMap { collapsibleSections(note: note, paragraphs: $0) } + record.collapsibleSectionCount = sections?.count + record.collapsedSectionCount = sections.map { $0.filter { $0.collapsed }.count } + return record + } - return NotesBodyStructureRecord( - noteID: noteIdentifier(note), - isPasswordProtected: passwordProtected, + func bodyAttributeStructureRecord( + noteID: String, isPasswordProtected: Bool, + plainText: String?, attributedString: NSAttributedString? + ) -> NotesBodyStructureRecord { + let plainText = isPasswordProtected ? nil : plainText + let attributedString = isPasswordProtected ? nil : attributedString + let summary = attributedString.map(bodyAttributeSummary) + var record = NotesBodyStructureRecord( + noteID: noteID, isPasswordProtected: isPasswordProtected, plainTextByteCount: plainText?.utf8.count, - plainTextSHA256: plainText.map(sha256Hex), - richTextLength: attributedString?.length, + plainTextSHA256: plainText.map(sha256Hex), richTextLength: attributedString?.length, + richTextSHA256: attributedString.map { sha256Hex($0.string) }, paragraphCount: plainText.map(paragraphCount), - paragraphStyleRunCount: attributeSummary.paragraphStyleRunCount, - headingCount: attributeSummary.headingCount, - listItemCount: attributeSummary.listItemCount, - checklistItemCount: attributeSummary.checklistItemCount, - checklistDoneCount: attributeSummary.checklistDoneCount, - checklistOpenCount: attributeSummary.checklistOpenCount, - blockQuoteCount: attributeSummary.blockQuoteCount, - tableCount: tableCount, - collapsibleSectionCount: collapsibleSections.collapsible, - collapsedSectionCount: collapsibleSections.collapsed, - inlineAttachmentCount: max(attributeSummary.inlineAttachmentCount, inlineAttachments.count), - linkCount: linkObjects(note).count, - attachmentCount: attachmentObjects(note).count, - mathAttachmentCount: mathAttachmentCount, - inlineFormatRunCount: attributeSummary.inlineFormatRunCount, - boldRunCount: attributeSummary.boldRunCount, - italicRunCount: attributeSummary.italicRunCount, - underlineRunCount: attributeSummary.underlineRunCount, - strikethroughRunCount: attributeSummary.strikethroughRunCount, - fontRunCount: attributeSummary.fontRunCount, - foregroundColorRunCount: attributeSummary.foregroundColorRunCount, - highlightRunCount: attributeSummary.highlightRunCount, - hasChecklist: optionalBool(note, key: "hasChecklist") ?? false, - hasChecklistInProgress: optionalBool(note, key: "hasChecklistInProgress") ?? false, - isMathNote: optionalBool(note, key: "isMathNote") ?? false, - styleCounts: styleCountRecords(attributeSummary.styleCounts), - attachmentKindCounts: attachmentKindCountRecords(attachmentKindCounts), - inlineFormatCounts: inlineFormatCountRecords(attributeSummary.inlineFormatCounts), - colorHashCounts: colorHashCountRecords(attributeSummary.colorHashCounts), - inlineFormatRuns: attributeSummary.inlineFormatRuns, - colorRuns: attributeSummary.colorRuns, - mentionUserIDSHA256s: mentionUserIDSHA256s(inlineAttachments), - paragraphAnchors: paragraphAnchors.map(\.anchor) - ) + paragraphStyleRunCount: summary?.paragraphs.styleRunCount, + headingCount: summary?.paragraphs.headingCount, listItemCount: summary?.paragraphs.listItemCount, + checklistItemCount: summary?.paragraphs.checklistItemCount, + checklistDoneCount: summary?.paragraphs.checklistDoneCount, + checklistOpenCount: summary?.paragraphs.checklistOpenCount, + blockQuoteCount: summary?.paragraphs.blockQuoteCount, tableCount: summary?.tableCount, + inlineAttachmentCount: summary?.inlineAttachmentCount, + mathAttachmentCount: summary?.mathAttachmentCount, + styleCounts: summary.flatMap { $0.paragraphs.styleCounts.map(styleCountRecords) }, + attachmentKindCounts: summary.map { attachmentKindCountRecords($0.attachmentKindCounts) }) + applyInlineFormatReadback(summary, to: &record) + return record } - private func bodyTableRecords(_ note: ICNote) -> [NotesBodyTableRecord] { + func applyInlineFormatReadback( + _ summary: BodyAttributeSummary?, to record: inout NotesBodyStructureRecord + ) { + let summary = summary.flatMap { $0.inlineReadbackAvailable ? $0 : nil } + record.inlineFormatRunCount = summary?.inlineFormatRunCount + record.boldRunCount = summary?.boldRunCount + record.italicRunCount = summary?.italicRunCount + record.underlineRunCount = summary?.underlineRunCount + record.strikethroughRunCount = summary?.strikethroughRunCount + record.fontRunCount = summary?.fontRunCount + record.foregroundColorRunCount = summary?.foregroundColorRunCount + record.highlightRunCount = summary?.highlightRunCount + record.inlineFormatRuns = summary?.inlineFormatRuns + record.colorRuns = summary?.colorRuns + record.inlineFormatCounts = summary.map { inlineFormatCountRecords($0.inlineFormatCounts) } + record.colorHashCounts = summary.map { colorHashCountRecords($0.colorHashCounts) } + } + + private func bodyTableRecords(_ note: ICNote) throws -> [NotesBodyTableRecord] { guard let attributedString = note.attributedString() as? NSAttributedString else { - return [] + throw CLIError(code: .backendUnavailable, message: "Notes body table readback is unavailable.", + details: ["id_sha256": sha256Hex(noteIdentifier(note))]) } var records: [NotesBodyTableRecord] = [] @@ -4536,10 +4581,14 @@ struct NotesReader: NotesReading, NotesFolderPurgeReading, NotesTagReading, Note return records } - private func bodyMathResultRecords(_ note: ICNote) -> [NotesBodyMathResultRecord] { + private func bodyMathResultRecords(_ note: ICNote) throws -> [NotesBodyMathResultRecord] { + guard let attachments = availableObjects(note.allNoteTextInlineAttachments()) else { + throw CLIError(code: .backendUnavailable, message: "Notes math result readback is unavailable.", + details: ["id_sha256": sha256Hex(noteIdentifier(note))]) + } var records: [NotesBodyMathResultRecord] = [] var seen = Set() - for attachment in anyObjects(note.allNoteTextInlineAttachments()) { + for attachment in attachments { guard isMathResultAttachmentObject(attachment) else { continue } @@ -4592,9 +4641,9 @@ struct NotesReader: NotesReading, NotesFolderPurgeReading, NotesTagReading, Note } guard let attributedString = note.attributedString() as? NSAttributedString else { throw CLIError( - code: .notFound, - message: "Notes body table selector did not match any table.", - details: ["operation": operation, "ordinal": "\(ordinal)", "table_count": "0"] + code: .backendUnavailable, + message: "Notes body table readback is unavailable.", + details: ["operation": operation, "ordinal": "\(ordinal)"] ) } @@ -5217,25 +5266,42 @@ struct NotesReader: NotesReading, NotesFolderPurgeReading, NotesTagReading, Note } } - private func bodyAttributeSummary(_ attributedString: NSAttributedString) -> BodyAttributeSummary { + func bodyAttributeSummary(_ attributedString: NSAttributedString) -> BodyAttributeSummary { var summary = BodyAttributeSummary() + summary.paragraphs = notesParagraphStructure(in: attributedString) { value in + let object = value as AnyObject + guard let kind = bodyParagraphStyleKind(object) else { return nil } + return NotesParagraphStyleEvidence(kind: kind, + isHeader: optionalBool(object, key: "isHeader"), isList: optionalBool(object, key: "isList"), + isChecklist: optionalBool(object, key: "isChecklist"), + checked: optionalObject(object, key: "todo").flatMap { optionalBool($0, key: "done") }, + isBlockQuote: optionalBool(object, key: "isBlockQuote")) + } let fullRange = NSRange(location: 0, length: attributedString.length) guard fullRange.length > 0 else { return summary } + let usesModel = notesHasModelAttributes(attributedString) + let converter = usesModel ? try? NotesNativeInlineStyle(operation: "notes.body.structure") : nil + summary.inlineReadbackAvailable = !usesModel || converter != nil attributedString.enumerateAttributes(in: fullRange, options: []) { attributes, range, _ in let runText = attributedString.attributedSubstring(from: range).string let runTextByteCount = runText.utf8.count let runTextSHA256 = sha256Hex(runText) let paragraphIDSHA256 = paragraphIDSHA256(attributes) - let inlineFormats = inlineFormatKinds(attributes) + var inlineAttributes = attributes + if let converter { + do { inlineAttributes = try converter.presentation(attributes) } + catch { summary.inlineReadbackAvailable = false } + } + let inlineFormats = inlineFormatKinds(inlineAttributes) if inlineFormats.isEmpty == false { summary.inlineFormatRunCount += 1 for format in inlineFormats { summary.inlineFormatCounts[format, default: 0] += 1 let fontSHA256 = format == "font" - ? (attributes[.font] as? NSFont).map(notesFontSHA256) + ? (inlineAttributes[.font] as? NSFont).map(notesFontSHA256) : nil summary.inlineFormatRuns.append( NotesBodyInlineFormatRunRecord( @@ -5244,7 +5310,8 @@ struct NotesReader: NotesReading, NotesFolderPurgeReading, NotesTagReading, Note format: format, fontSHA256: fontSHA256, textByteCount: runTextByteCount, - textSHA256: runTextSHA256 + textSHA256: runTextSHA256, + utf16Location: range.location, utf16Length: range.length )) } } @@ -5269,7 +5336,7 @@ struct NotesReader: NotesReading, NotesFolderPurgeReading, NotesTagReading, Note if inlineFormats.contains("highlight") { summary.highlightRunCount += 1 } - for (role, hash) in inlineColorHashes(attributes) { + for (role, hash) in inlineColorHashes(inlineAttributes) { summary.colorHashCounts[BodyColorHashKey(role: role, colorSHA256: hash), default: 0] += 1 summary.colorRuns.append( NotesBodyInlineColorRunRecord( @@ -5278,7 +5345,8 @@ struct NotesReader: NotesReading, NotesFolderPurgeReading, NotesTagReading, Note role: role, colorSHA256: hash, textByteCount: runTextByteCount, - textSHA256: runTextSHA256 + textSHA256: runTextSHA256, + utf16Location: range.location, utf16Length: range.length )) } @@ -5286,28 +5354,6 @@ struct NotesReader: NotesReading, NotesFolderPurgeReading, NotesTagReading, Note guard let object = value as AnyObject? else { continue } - if let style = bodyParagraphStyleKind(object) { - summary.paragraphStyleRunCount += 1 - summary.styleCounts[style, default: 0] += 1 - if optionalBool(object, key: "isHeader") == true { - summary.headingCount += 1 - } - if optionalBool(object, key: "isList") == true { - summary.listItemCount += 1 - } - if optionalBool(object, key: "isChecklist") == true { - summary.checklistItemCount += 1 - if optionalObject(object, key: "todo").flatMap({ optionalBool($0, key: "done") }) == true { - summary.checklistDoneCount += 1 - } else { - summary.checklistOpenCount += 1 - } - } - if optionalBool(object, key: "isBlockQuote") == true { - summary.blockQuoteCount += 1 - } - } - if let kind = bodyAttachmentKind(object) { summary.attachmentKindCounts[kind, default: 0] += 1 summary.inlineAttachmentCount += 1 @@ -5432,33 +5478,31 @@ struct NotesReader: NotesReading, NotesFolderPurgeReading, NotesTagReading, Note } private func paragraphAnchorResolutions(_ note: ICNote) -> [NotesParagraphAnchorResolution] { - guard let attributedString = note.attributedString() as? NSAttributedString else { - return [] - } + guard let attributedString = note.attributedString() as? NSAttributedString else { return [] } + return paragraphAnchorResolutions(note, attributedString: attributedString) + } - var anchors: [NotesParagraphAnchorResolution] = [] - var seenIDs = Set() - let fullRange = NSRange(location: 0, length: attributedString.length) - guard fullRange.length > 0 else { - return anchors - } + private func paragraphAnchorResolutions( + _ note: ICNote, attributedString: NSAttributedString + ) -> [NotesParagraphAnchorResolution] { - attributedString.enumerateAttributes(in: fullRange, options: []) { attributes, _, _ in - for value in attributes.values { - guard let object = value as AnyObject?, - let style = bodyParagraphStyleKind(object), - let paragraphID = paragraphStyleUUIDString(object), - seenIDs.insert(paragraphID).inserted - else { - continue - } + let paragraphs = notesStyledParagraphs(in: attributedString) { value + -> (id: String, style: (object: AnyObject, kind: String))? in + let object = value as AnyObject + guard let kind = bodyParagraphStyleKind(object), let id = paragraphStyleUUIDString(object) else { return nil } + return (id, (object, kind)) + } + return paragraphs.enumerated().map { index, paragraph in + let object = paragraph.style.object + let paragraphID = paragraph.id let title = paragraphTitle(note, paragraphID: paragraphID) + let paragraphRange = paragraph.range let anchor = NotesBodyParagraphAnchorRecord( - ordinal: anchors.count + 1, + ordinal: index + 1, idSHA256: sha256Hex(paragraphID), titleByteCount: title?.utf8.count, titleSHA256: title.map(sha256Hex), - style: style, + style: paragraph.style.kind, listStyle: bodyParagraphListStyle(object), alignment: bodyParagraphAlignment(object), isHeader: optionalBool(object, key: "isHeader") == true, @@ -5467,39 +5511,26 @@ struct NotesReader: NotesReading, NotesFolderPurgeReading, NotesTagReading, Note isBlockQuote: optionalBool(object, key: "isBlockQuote") == true, indentationLevel: optionalInt(object, key: "indent"), canIndent: optionalBool(object, key: "canIndent"), - checklistDone: optionalObject(object, key: "todo").flatMap { optionalBool($0, key: "done") } + checklistDone: optionalObject(object, key: "todo").flatMap { optionalBool($0, key: "done") }, + utf16Location: paragraphRange.location, utf16Length: paragraphRange.length ) - anchors.append(NotesParagraphAnchorResolution(anchor: anchor, paragraphID: paragraphID, title: title)) - } + return NotesParagraphAnchorResolution(anchor: anchor, paragraphID: paragraphID, title: title) } - - return anchors - } - - private func collapsibleSectionCounts( - note: ICNote, - paragraphs: [NotesParagraphAnchorResolution] - ) -> (collapsible: Int, collapsed: Int) { - let sections = collapsibleSections(note: note, paragraphs: paragraphs) - return (sections.count, sections.filter { $0.collapsed }.count) } private func collapsibleSections( note: ICNote, paragraphs: [NotesParagraphAnchorResolution] - ) -> [NotesBodyCollapsibleSectionRecord] { - guard paragraphs.isEmpty == false, - let textStorage = note.textStorage() as? ICTTTextStorage - else { - return [] - } + ) -> [NotesBodyCollapsibleSectionRecord]? { + if paragraphs.isEmpty { return [] } + guard let textStorage = note.textStorage() as? ICTTTextStorage else { return nil } let collapsedUUIDs = note.outlineState?.collapsedUUIDs ?? Set() guard let outlineController = ICOutlineController( textStorage: textStorage, collapsedUUIDs: collapsedUUIDs, asynchronous: false ) else { - return [] + return nil } var sections: [NotesBodyCollapsibleSectionRecord] = [] for paragraph in paragraphs { @@ -5815,8 +5846,24 @@ struct NotesReader: NotesReading, NotesFolderPurgeReading, NotesTagReading, Note } } + private func noteTextTitle(_ note: ICNote) -> String { + let metadata = note.title ?? "" + guard !note.isPasswordProtected, metadata.hasSuffix("…"), + (try? NotesRuntimeMethod(owner: "ICNote", selector: "rangeForTitle:", + returnType: "{_NSRange=QQ}", argumentTypes: ["^B"]).require(operation: "notes.title-projection", receiver: note)) != nil, + let text = note.attributedString() as? NSAttributedString else { return metadata } + var truncated = false + let range = note.range(forTitle: &truncated) + return notesNativeTitleText(in: text.string as NSString, metadataTitle: metadata, + nativeRange: range, truncated: truncated) ?? metadata + } + private func plainTextBody(_ note: ICNote) -> String { - string(note.noteAsPlainTextWithoutTitle) + let nativeBody = string(note.noteAsPlainTextWithoutTitle) + let method = NotesRuntimeMethod(owner: "ICNote", selector: "noteAsPlainText", returnType: "@") + guard (try? method.require(operation: "notes.body-projection", receiver: note)) != nil else { return nativeBody } + return notesPlainTextBody(title: noteTextTitle(note), fullText: note.noteAsPlainText() as? String, + nativeBody: nativeBody) } private func compareNotes(_ lhs: NotesNoteSummary, _ rhs: NotesNoteSummary) -> Bool { @@ -5869,6 +5916,16 @@ struct NotesReader: NotesReading, NotesFolderPurgeReading, NotesTagReading, Note return [] } + private func availableObjects(_ value: Any?) -> [AnyObject]? { + switch value { + case let values as [AnyObject]: return values + case let values as NSArray: return values.map { $0 as AnyObject } + case let values as NSSet: return values.allObjects.map { $0 as AnyObject } + case let values as NSOrderedSet: return values.array.map { $0 as AnyObject } + default: return nil + } + } + private func anyObjects(_ value: Any?) -> [AnyObject] { if let values = value as? [AnyObject] { return values @@ -6090,14 +6147,9 @@ struct NotesReader: NotesReading, NotesFolderPurgeReading, NotesTagReading, Note } } -private struct BodyAttributeSummary { - var paragraphStyleRunCount = 0 - var headingCount = 0 - var listItemCount = 0 - var checklistItemCount = 0 - var checklistDoneCount = 0 - var checklistOpenCount = 0 - var blockQuoteCount = 0 +struct BodyAttributeSummary { + var inlineReadbackAvailable = true + var paragraphs = NotesParagraphStructure() var tableCount = 0 var inlineAttachmentCount = 0 var mathAttachmentCount = 0 @@ -6109,7 +6161,6 @@ private struct BodyAttributeSummary { var fontRunCount = 0 var foregroundColorRunCount = 0 var highlightRunCount = 0 - var styleCounts: [String: Int] = [:] var attachmentKindCounts: [String: Int] = [:] var inlineFormatCounts: [String: Int] = [:] var colorHashCounts: [BodyColorHashKey: Int] = [:] @@ -6117,7 +6168,7 @@ private struct BodyAttributeSummary { var colorRuns: [NotesBodyInlineColorRunRecord] = [] } -private struct BodyColorHashKey: Hashable { +struct BodyColorHashKey: Hashable { var role: String var colorSHA256: String } diff --git a/Sources/NotesCLI/Operations/NotesStyledParagraphs.swift b/Sources/NotesCLI/Operations/NotesStyledParagraphs.swift new file mode 100644 index 0000000..ec0e36f --- /dev/null +++ b/Sources/NotesCLI/Operations/NotesStyledParagraphs.swift @@ -0,0 +1,30 @@ +import Foundation + +struct NotesStyledParagraph