diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bfdcb7e..e7b3fb2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,3 +65,8 @@ jobs: /usr/libexec/PlistBuddy -c "Print :NSExtension:NSExtensionAttributes:NSExtensionServiceRoleType" "$extension_info" | grep -Fx NSExtensionServiceRoleTypeEditor /usr/libexec/PlistBuddy -c "Print :NSExtension:NSExtensionAttributes:NSExtensionServiceAllowsFinderPreviewItem" "$extension_info" | grep -Fx true /usr/libexec/PlistBuddy -c "Print :NSExtension:NSExtensionAttributes:NSExtensionActivationRule" "$extension_info" | grep -Fx 'SUBQUERY (extensionItems, $extensionItem, SUBQUERY ($extensionItem.attachments, $attachment, ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.item").@count == $extensionItem.attachments.@count).@count == 1' + - name: Preflight sandboxed Mac App Store archive + env: + SORTING_HAT_APP_STORE_DERIVED_DATA: ${{ runner.temp }}/SortingHatAppStoreDerivedData + SORTING_HAT_APP_STORE_ARCHIVE: ${{ runner.temp }}/SortingHat-AppStore-Preflight.xcarchive + run: ./script/preflight_app_store.sh diff --git a/Configuration/AppStoreExportOptions.plist b/Configuration/AppStoreExportOptions.plist new file mode 100644 index 0000000..eaab28a --- /dev/null +++ b/Configuration/AppStoreExportOptions.plist @@ -0,0 +1,18 @@ + + + + + destination + export + distributionBundleIdentifier + com.tcballard.sortinghat + manageAppVersionAndBuildNumber + + method + app-store-connect + signingStyle + automatic + teamID + R8HXTBY3NM + + diff --git a/Configuration/PrivacyInfo.xcprivacy b/Configuration/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..a450330 --- /dev/null +++ b/Configuration/PrivacyInfo.xcprivacy @@ -0,0 +1,32 @@ + + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + NSPrivacyCollectedDataTypes + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryFileTimestamp + NSPrivacyAccessedAPITypeReasons + + C617.1 + 3B52.1 + + + + + diff --git a/Configuration/SendToSortingHatAction-Info.plist b/Configuration/SendToSortingHatAction-Info.plist index a750907..dd7e453 100644 --- a/Configuration/SendToSortingHatAction-Info.plist +++ b/Configuration/SendToSortingHatAction-Info.plist @@ -19,7 +19,7 @@ CFBundleShortVersionString 0.1.0 CFBundleVersion - 1 + 2 NSExtension NSExtensionAttributes diff --git a/Configuration/SortingHatApp-AppStore.entitlements b/Configuration/SortingHatApp-AppStore.entitlements new file mode 100644 index 0000000..d45323a --- /dev/null +++ b/Configuration/SortingHatApp-AppStore.entitlements @@ -0,0 +1,18 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.application-groups + + R8HXTBY3NM.com.tcballard.sortinghat + + com.apple.security.files.bookmarks.app-scope + + com.apple.security.files.user-selected.read-write + + com.apple.security.network.client + + + diff --git a/Configuration/SortingHatApp-Info.plist b/Configuration/SortingHatApp-Info.plist index eeaa16d..bb5d851 100644 --- a/Configuration/SortingHatApp-Info.plist +++ b/Configuration/SortingHatApp-Info.plist @@ -19,7 +19,9 @@ CFBundleShortVersionString 0.1.0 CFBundleVersion - 1 + 2 + ITSAppUsesNonExemptEncryption + LSApplicationCategoryType public.app-category.productivity LSMinimumSystemVersion @@ -32,5 +34,7 @@ NSApplication SortingHatAppGroupIdentifier R8HXTBY3NM.com.tcballard.sortinghat + SortingHatLocalOnlyDistribution + $(SORTING_HAT_LOCAL_ONLY) diff --git a/Package.swift b/Package.swift index e665563..1a77705 100644 --- a/Package.swift +++ b/Package.swift @@ -6,6 +6,7 @@ let package = Package( platforms: [.macOS(.v14)], products: [ .library(name: "SortingHatCore", targets: ["SortingHatCore"]), + .library(name: "SortingHatFMResearch", targets: ["SortingHatFMResearch"]), .library(name: "SortingHatFinderAdapter", targets: ["SortingHatFinderAdapter"]), .executable(name: "sorting-hat", targets: ["SortingHat"]), .executable(name: "SortingHatApp", targets: ["SortingHatApp"]), @@ -13,12 +14,13 @@ let package = Package( targets: [ .target(name: "SortingHatQueueLock"), .target(name: "SortingHatCore", dependencies: ["SortingHatQueueLock"]), + .target(name: "SortingHatFMResearch", dependencies: ["SortingHatCore"]), .target(name: "SortingHatFinderAdapter", dependencies: ["SortingHatCore"]), - .executableTarget(name: "SortingHat", dependencies: ["SortingHatCore"]), + .executableTarget(name: "SortingHat", dependencies: ["SortingHatCore", "SortingHatFMResearch"]), .executableTarget(name: "SortingHatApp", dependencies: ["SortingHatCore"]), .testTarget( name: "SortingHatTests", - dependencies: ["SortingHatCore", "SortingHatFinderAdapter"], + dependencies: ["SortingHatCore", "SortingHatFMResearch", "SortingHatFinderAdapter"], resources: [.process("Fixtures")] ), ] diff --git a/README.md b/README.md index f4a6f3a..924139f 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ [See exactly how the product extends Apple’s WWDC26 file-sorting demo, including the passing shipping-path benchmark and its limitations.](docs/wwdc26-comparison.md) > [!WARNING] -> The current `v0.1.0` build is an experimental pre-release. Its app bundle is ad-hoc signed, not signed with an Apple Developer ID, and it is not notarized. Gatekeeper may block the first launch; managed Macs may prohibit it entirely. Homebrew installation confirms the archive and cask are valid, but does not bypass these macOS security checks. +> The downloadable GitHub `v0.1.0` artifact is an experimental pre-release. Its app bundle is ad-hoc signed, not signed with an Apple Developer ID, and it is not notarized. Gatekeeper may block the first launch; managed Macs may prohibit it entirely. Homebrew installation confirms the archive and cask are valid, but does not bypass these macOS security checks. This warning does not describe the separately signed Mac App Store build. ## Requirements @@ -30,7 +30,7 @@ mkdir -p ~/SortingHat/Inbox ## Menu-bar app -The packaged app is currently intended for testing, not polished public distribution. If Gatekeeper blocks it, macOS may offer **System Settings → Privacy & Security → Open Anyway**. Only override Gatekeeper if you understand and accept the risk; the Finder Quick Action may produce additional trust prompts. +The local and GitHub-packaged app is currently intended for testing. If Gatekeeper blocks the GitHub build, macOS may offer **System Settings → Privacy & Security → Open Anyway**. Only override Gatekeeper if you understand and accept the risk; the Finder Quick Action may produce additional trust prompts. The separately signed Mac App Store build follows Apple's Store trust and sandbox path. Build and open the native companion with: @@ -89,7 +89,7 @@ rules: - Put everything else in Files/YYYY-MM and add one useful topic tag. ``` -Choose **Automatic**, **Apple**, **Ollama**, or **OpenAI** under **Model Settings**. Apple can use the local `system` model, Private Cloud Compute (`pcc`), or an automatic policy. Automatic always tries on-device first and retries PCC only after an availability or generation failure. Content extraction, unsafe paths, and invalid filing decisions never trigger cloud escalation. PCC is disabled until `allow_apple_pcc: true` is explicitly saved; enabling it means file context may be sent to Apple's Private Cloud Compute service. Overall provider selection then falls back to configured Ollama and OpenAI providers when Apple is unavailable. +Choose **Automatic**, **Apple**, **Ollama**, or **OpenAI** under **Model Settings** in source and Developer ID builds. Apple's shipping app integration uses the on-device Foundation Model; automatic provider selection can fall back to configured providers when Apple is unavailable. The Mac App Store build is deliberately local-only: it offers Apple on-device processing and Ollama restricted to loopback addresses on the same Mac, with no OpenAI or remote Ollama route. Content extraction, unsafe paths, and invalid filing decisions never trigger cloud escalation. Private Cloud Compute remains research-only and is not exposed by the shipping app. For the on-device system model, `apple_use_case: content-tagging` opts into the Foundation Models framework's content-tagging specialization. `apple_guardrails: permissive-content-transformations` relaxes the system model's content-transformation guardrails for filing material that the default policy refuses; use the default unless your rules require that behavior. These system-only options are omitted from PCC requests. Apple requests use in-process guided generation and greedy sampling. The app stores the OpenAI API key in macOS Keychain; the CLI reads `OPENAI_API_KEY`. @@ -140,6 +140,9 @@ Bounded tool-calling candidates are also evaluated there under a documented thre ```sh swift test ./script/generate_xcode_project.sh +./script/preflight_app_store.sh +# After one-time notarytool Keychain setup: +./script/release_local.sh 0.2.0 ``` Inference is behind `FileAnalyzing`, so filesystem behavior and safety can be tested without a model. Provider selection prefers Apple's in-process Foundation Models framework when available and otherwise uses the configured local Ollama endpoint. @@ -149,3 +152,5 @@ The Foundation Models decision path can be shared by a future iPhone or iPad cli ## Release status Release archives are currently ad-hoc signed and published as GitHub pre-releases. Before treating Sorting Hat as production-ready, releases must be signed with a **Developer ID Application** certificate using the hardened runtime, submitted to Apple for notarization, stapled, and validated with Gatekeeper. See [Apple's Gatekeeper guidance](https://support.apple.com/en-gb/102445) and [Apple's notarization documentation](https://developer.apple.com/documentation/security/notarizing-macos-software-before-distribution). + +Developer ID/Homebrew and Mac App Store are separate tracked channels. App Store Connect build **0.1.0 (1)** has been uploaded and is selectable; product-page metadata, privacy declarations, review submission, and installed-build verification remain. The GitHub pre-release is still an ad-hoc-signed experimental artifact and is not equivalent to the Store build. See the [distribution guide](docs/distribution.md), [Issue #24](https://github.com/tcballard/SortingHat/issues/24), and [Issue #29](https://github.com/tcballard/SortingHat/issues/29). diff --git a/SortingHat.xcodeproj/project.pbxproj b/SortingHat.xcodeproj/project.pbxproj index 41a295a..02aad87 100644 --- a/SortingHat.xcodeproj/project.pbxproj +++ b/SortingHat.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 07FFEE04A6DF0BCCA1222E6E /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D4A766D04A9129702226E922 /* Assets.xcassets */; }; 1075DF6EF3C2B7E879D45496 /* ReviewQueueView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D732DC01AE240AE483F423D6 /* ReviewQueueView.swift */; }; 134DFD0111902BE8DE6477C8 /* ActionRequestHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88ADD613E69FA0224084FE99 /* ActionRequestHandler.swift */; }; 37ECE3C90C9DC861415275D9 /* RulePlanGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B2C203CFB8321153FC8503E /* RulePlanGenerator.swift */; }; @@ -22,6 +23,7 @@ C2ABECC8A7B77D8EA2907583 /* ModelSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79D9589A55D599EC62EC61FB /* ModelSettingsView.swift */; }; CD5BCBDFE31FA826097C6016 /* DashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 698A482A0EAAA4043EBF9D97 /* DashboardView.swift */; }; E19A1580814AC46E0FD700F1 /* SortingHatApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47521A9F025FA497A89BCEA8 /* SortingHatApp.swift */; }; + EF7F78159BA7E53BB24B0C23 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 60D0A89FFA32378E1092BFB0 /* PrivacyInfo.xcprivacy */; }; F39D70455D9C7C813E5125ED /* SetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C24EBAF0EB6D03C27BC24A53 /* SetupView.swift */; }; FE3299CFCE57CC534115D018 /* SortingHatCore in Frameworks */ = {isa = PBXBuildFile; productRef = CFB4CBD66911A4EEF5239E80 /* SortingHatCore */; }; /* End PBXBuildFile section */ @@ -58,6 +60,7 @@ 3B2C203CFB8321153FC8503E /* RulePlanGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RulePlanGenerator.swift; sourceTree = ""; }; 44D8496DA7F3A4922495C13B /* SortingHat */ = {isa = PBXFileReference; lastKnownFileType = folder; name = SortingHat; path = .; sourceTree = SOURCE_ROOT; }; 47521A9F025FA497A89BCEA8 /* SortingHatApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SortingHatApp.swift; sourceTree = ""; }; + 60D0A89FFA32378E1092BFB0 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; 698A482A0EAAA4043EBF9D97 /* DashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardView.swift; sourceTree = ""; }; 79D9589A55D599EC62EC61FB /* ModelSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelSettingsView.swift; sourceTree = ""; }; 88ADD613E69FA0224084FE99 /* ActionRequestHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionRequestHandler.swift; sourceTree = ""; }; @@ -65,6 +68,7 @@ BC8CEA00F91AA384944B6DC1 /* APIKeyStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIKeyStore.swift; sourceTree = ""; }; C24EBAF0EB6D03C27BC24A53 /* SetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupView.swift; sourceTree = ""; }; CF783C14FBCE3B3B969A8140 /* RulesEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RulesEditorView.swift; sourceTree = ""; }; + D4A766D04A9129702226E922 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; D732DC01AE240AE483F423D6 /* ReviewQueueView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewQueueView.swift; sourceTree = ""; }; E34BBCCB587929B4260D649C /* RulePlan.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RulePlan.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -110,6 +114,7 @@ 44FC3D9D444D2B4D7A0D319C = { isa = PBXGroup; children = ( + B7512D5FF4076258F6282A1F /* Configuration */, 127F7468AA983B9A420BB13B /* Packages */, 2AE1F696BF6E123F10112D33 /* SendToSortingHatAction */, CB2D288FF9296E0A8CAF1FB0 /* SortingHatApp */, @@ -152,9 +157,18 @@ path = App; sourceTree = ""; }; + B7512D5FF4076258F6282A1F /* Configuration */ = { + isa = PBXGroup; + children = ( + 60D0A89FFA32378E1092BFB0 /* PrivacyInfo.xcprivacy */, + ); + path = Configuration; + sourceTree = ""; + }; CB2D288FF9296E0A8CAF1FB0 /* SortingHatApp */ = { isa = PBXGroup; children = ( + D4A766D04A9129702226E922 /* Assets.xcassets */, 8A4E8675988EC29D12ABC814 /* App */, F4A0A0BF472449C9C0CC2E45 /* Models */, 64F4FDFB90B9426C54FE4A7D /* Services */, @@ -194,6 +208,7 @@ buildConfigurationList = 1C7C9BEE6AC9FEBE028BD5B1 /* Build configuration list for PBXNativeTarget "SortingHatApp" */; buildPhases = ( 043F6436CCD20C426BD8BE0D /* Sources */, + B4292045A65CB12B2DAADA4B /* Resources */, 151305D292D632EF9E22215F /* Frameworks */, 2FF81515C530954AA8AF8C6B /* Embed Foundation Extensions */, ); @@ -272,6 +287,18 @@ }; /* End PBXProject section */ +/* Begin PBXResourcesBuildPhase section */ + B4292045A65CB12B2DAADA4B /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 07FFEE04A6DF0BCCA1222E6E /* Assets.xcassets in Resources */, + EF7F78159BA7E53BB24B0C23 /* PrivacyInfo.xcprivacy in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + /* Begin PBXSourcesBuildPhase section */ 043F6436CCD20C426BD8BE0D /* Sources */ = { isa = PBXSourcesBuildPhase; @@ -311,6 +338,65 @@ /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ + 19EFF1725607A2884B659F81 /* AppStore */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ARCHS = arm64; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = R8HXTBY3NM; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 6.0; + }; + name = AppStore; + }; 2505882B3D577106A5B4592E /* Release */ = { isa = XCBuildConfiguration; buildSettings = { @@ -451,6 +537,7 @@ PRODUCT_BUNDLE_IDENTIFIER = com.tcballard.sortinghat; PRODUCT_NAME = "Sorting Hat"; SDKROOT = macosx; + SORTING_HAT_LOCAL_ONLY = NO; }; name = Release; }; @@ -469,6 +556,7 @@ PRODUCT_BUNDLE_IDENTIFIER = com.tcballard.sortinghat; PRODUCT_NAME = "Sorting Hat"; SDKROOT = macosx; + SORTING_HAT_LOCAL_ONLY = NO; }; name = Debug; }; @@ -493,6 +581,47 @@ }; name = Release; }; + BDAFA3597C62F07E73B52B21 /* AppStore */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = "Configuration/SortingHatApp-AppStore.entitlements"; + COMBINE_HIDPI_IMAGES = YES; + ENABLE_HARDENED_RUNTIME = YES; + INFOPLIST_FILE = "Configuration/SortingHatApp-Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.tcballard.sortinghat; + PRODUCT_NAME = "Sorting Hat"; + SDKROOT = macosx; + SORTING_HAT_LOCAL_ONLY = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) SORTING_HAT_APP_STORE"; + }; + name = AppStore; + }; + E18DFAC70F9C64ACA981DBC6 /* AppStore */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGN_ENTITLEMENTS = Configuration/SendToSortingHatAction.entitlements; + COMBINE_HIDPI_IMAGES = YES; + ENABLE_HARDENED_RUNTIME = YES; + INFOPLIST_FILE = "Configuration/SendToSortingHatAction-Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@executable_path/../../../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "com.tcballard.sortinghat.finder-action"; + PRODUCT_MODULE_NAME = SendToSortingHatAction; + PRODUCT_NAME = "Send to Sorting Hat"; + SDKROOT = macosx; + SKIP_INSTALL = YES; + }; + name = AppStore; + }; F743A534800F38A613B8ADA4 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -520,29 +649,32 @@ 0643962556F7792172197E17 /* Build configuration list for PBXNativeTarget "SendToSortingHatAction" */ = { isa = XCConfigurationList; buildConfigurations = ( + E18DFAC70F9C64ACA981DBC6 /* AppStore */, F743A534800F38A613B8ADA4 /* Debug */, 84759DED9F7479989BFA11BD /* Release */, ); defaultConfigurationIsVisible = 0; - defaultConfigurationName = Debug; + defaultConfigurationName = AppStore; }; 1C7C9BEE6AC9FEBE028BD5B1 /* Build configuration list for PBXNativeTarget "SortingHatApp" */ = { isa = XCConfigurationList; buildConfigurations = ( + BDAFA3597C62F07E73B52B21 /* AppStore */, 735B2C373D56B38E46C4DED9 /* Debug */, 72DA2D57A4508BBD7B32C929 /* Release */, ); defaultConfigurationIsVisible = 0; - defaultConfigurationName = Debug; + defaultConfigurationName = AppStore; }; 4602B212D46D62438061819D /* Build configuration list for PBXProject "SortingHat" */ = { isa = XCConfigurationList; buildConfigurations = ( + 19EFF1725607A2884B659F81 /* AppStore */, 6DC971CA1048E9F550FC314B /* Debug */, 2505882B3D577106A5B4592E /* Release */, ); defaultConfigurationIsVisible = 0; - defaultConfigurationName = Debug; + defaultConfigurationName = AppStore; }; /* End XCConfigurationList section */ diff --git a/SortingHat.xcodeproj/xcshareddata/xcschemes/SortingHatAppStore.xcscheme b/SortingHat.xcodeproj/xcshareddata/xcschemes/SortingHatAppStore.xcscheme new file mode 100644 index 0000000..5530403 --- /dev/null +++ b/SortingHat.xcodeproj/xcshareddata/xcschemes/SortingHatAppStore.xcscheme @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Sources/SortingHat/CLI.swift b/Sources/SortingHat/CLI.swift index 1ea8560..e78f56f 100644 --- a/Sources/SortingHat/CLI.swift +++ b/Sources/SortingHat/CLI.swift @@ -1,5 +1,6 @@ import Foundation import SortingHatCore +import SortingHatFMResearch @main enum SortingHatCLI { @@ -26,7 +27,9 @@ enum SortingHatCLI { let inbox = URL(fileURLWithPath: NSString(string: config.inbox).expandingTildeInPath).standardizedFileURL let output = URL(fileURLWithPath: NSString(string: config.output).expandingTildeInPath).standardizedFileURL try FileManager.default.createDirectory(at: inbox, withIntermediateDirectories: true) - let analyzer = PreferredAnalyzer(fmExecutable: fmPath(), ollamaURL: config.ollamaURL, ollamaModel: config.ollamaModel, + let pcc = FMAnalyzer(executable: fmPath(), model: .pcc, useCase: config.appleUseCase, + guardrails: config.appleGuardrails, pccAllowed: config.allowApplePCC) + let analyzer = PreferredAnalyzer(pcc: pcc, ollamaURL: config.ollamaURL, ollamaModel: config.ollamaModel, openAIModel: config.openAIModel, openAIKey: ProcessInfo.processInfo.environment["OPENAI_API_KEY"] ?? "", provider: config.modelProvider, appleModel: config.appleModel, appleUseCase: config.appleUseCase, appleGuardrails: config.appleGuardrails, diff --git a/Sources/SortingHatApp/Assets.xcassets/AppIcon.appiconset/Contents.json b/Sources/SortingHatApp/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..64dc11e --- /dev/null +++ b/Sources/SortingHatApp/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "filename" : "icon_16x16.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "16x16" + }, + { + "filename" : "icon_16x16@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "16x16" + }, + { + "filename" : "icon_32x32.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "32x32" + }, + { + "filename" : "icon_32x32@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "32x32" + }, + { + "filename" : "icon_128x128.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "128x128" + }, + { + "filename" : "icon_128x128@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "128x128" + }, + { + "filename" : "icon_256x256.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "256x256" + }, + { + "filename" : "icon_256x256@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "256x256" + }, + { + "filename" : "icon_512x512.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" + }, + { + "filename" : "icon_512x512@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "512x512" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/SortingHatApp/Assets.xcassets/AppIcon.appiconset/icon_128x128.png b/Sources/SortingHatApp/Assets.xcassets/AppIcon.appiconset/icon_128x128.png new file mode 100644 index 0000000..a6c7940 Binary files /dev/null and b/Sources/SortingHatApp/Assets.xcassets/AppIcon.appiconset/icon_128x128.png differ diff --git a/Sources/SortingHatApp/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png b/Sources/SortingHatApp/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png new file mode 100644 index 0000000..4ddec73 Binary files /dev/null and b/Sources/SortingHatApp/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png differ diff --git a/Sources/SortingHatApp/Assets.xcassets/AppIcon.appiconset/icon_16x16.png b/Sources/SortingHatApp/Assets.xcassets/AppIcon.appiconset/icon_16x16.png new file mode 100644 index 0000000..792cf9a Binary files /dev/null and b/Sources/SortingHatApp/Assets.xcassets/AppIcon.appiconset/icon_16x16.png differ diff --git a/Sources/SortingHatApp/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png b/Sources/SortingHatApp/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png new file mode 100644 index 0000000..6bcd55f Binary files /dev/null and b/Sources/SortingHatApp/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png differ diff --git a/Sources/SortingHatApp/Assets.xcassets/AppIcon.appiconset/icon_256x256.png b/Sources/SortingHatApp/Assets.xcassets/AppIcon.appiconset/icon_256x256.png new file mode 100644 index 0000000..4ddec73 Binary files /dev/null and b/Sources/SortingHatApp/Assets.xcassets/AppIcon.appiconset/icon_256x256.png differ diff --git a/Sources/SortingHatApp/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png b/Sources/SortingHatApp/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png new file mode 100644 index 0000000..48329e1 Binary files /dev/null and b/Sources/SortingHatApp/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png differ diff --git a/Sources/SortingHatApp/Assets.xcassets/AppIcon.appiconset/icon_32x32.png b/Sources/SortingHatApp/Assets.xcassets/AppIcon.appiconset/icon_32x32.png new file mode 100644 index 0000000..6bcd55f Binary files /dev/null and b/Sources/SortingHatApp/Assets.xcassets/AppIcon.appiconset/icon_32x32.png differ diff --git a/Sources/SortingHatApp/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png b/Sources/SortingHatApp/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png new file mode 100644 index 0000000..16bdea0 Binary files /dev/null and b/Sources/SortingHatApp/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png differ diff --git a/Sources/SortingHatApp/Assets.xcassets/AppIcon.appiconset/icon_512x512.png b/Sources/SortingHatApp/Assets.xcassets/AppIcon.appiconset/icon_512x512.png new file mode 100644 index 0000000..48329e1 Binary files /dev/null and b/Sources/SortingHatApp/Assets.xcassets/AppIcon.appiconset/icon_512x512.png differ diff --git a/Sources/SortingHatApp/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png b/Sources/SortingHatApp/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png new file mode 100644 index 0000000..c73bea5 Binary files /dev/null and b/Sources/SortingHatApp/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png differ diff --git a/Sources/SortingHatApp/Assets.xcassets/Contents.json b/Sources/SortingHatApp/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Sources/SortingHatApp/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/SortingHatApp/Stores/HatStore.swift b/Sources/SortingHatApp/Stores/HatStore.swift index 63078ab..44c4afc 100644 --- a/Sources/SortingHatApp/Stores/HatStore.swift +++ b/Sources/SortingHatApp/Stores/HatStore.swift @@ -23,6 +23,7 @@ final class HatStore { var finderLastInvocation: InboxIngressInvocation? var finderDeliveryConfirmed = false var inboxAccessState: InboxAccessState = .missing + var outputAccessState: InboxAccessState = .missing var finderQueueIssue: String? var legacyQuickActionInstalled = false var legacyQuickActionBackupURL: URL? @@ -35,6 +36,8 @@ final class HatStore { private let importer = InboxImportService() private var finderIntakeRoot: URL? private var finderDrainInFlight = false + private var activeInboxAccessURL: URL? + private var activeOutputAccessURL: URL? @ObservationIgnored var onWatchingChanged: ((Bool) -> Void)? init() { @@ -52,6 +55,7 @@ final class HatStore { inbox = Self.expandedURL(config.inbox) outputRoot = Self.expandedURL(config.output) } + activateConfiguredFolderAccess() configureFinderIntake() recent = ledger.load() refreshInbox() @@ -84,13 +88,20 @@ final class HatStore { isProcessing = true defer { isProcessing = false; refreshInbox() } do { - let config = try ConfigLoader.load(configURL) + let loadedConfig = try ConfigLoader.load(configURL) + #if SORTING_HAT_APP_STORE + let config = LocalOnlyProviderPolicy.normalized(loadedConfig) + let openAIKey = "" + #else + let config = loadedConfig + let openAIKey = APIKeyStore.load() + #endif let configuredInbox = Self.expandedURL(config.inbox) let output = Self.expandedURL(config.output) - let analyzer = PreferredAnalyzer(fmExecutable: Self.fmPath(), ollamaURL: config.ollamaURL, ollamaModel: config.ollamaModel, - openAIModel: config.openAIModel, openAIKey: APIKeyStore.load(), provider: config.modelProvider, - appleModel: config.appleModel, appleUseCase: config.appleUseCase, - appleGuardrails: config.appleGuardrails, allowApplePCC: config.allowApplePCC) + let analyzer = PreferredAnalyzer(ollamaURL: config.ollamaURL, ollamaModel: config.ollamaModel, + openAIModel: config.openAIModel, openAIKey: openAIKey, provider: config.modelProvider, + appleModel: config.appleModel == .pcc ? .system : config.appleModel, + appleUseCase: config.appleUseCase, appleGuardrails: config.appleGuardrails) let organizer = Organizer(inbox: configuredInbox, output: output, rules: config.rules, analyzer: analyzer) let files = try organizer.candidates() if files.isEmpty { if isWatching { status = "Watching the Inbox" }; return } @@ -171,32 +182,35 @@ final class HatStore { let standardizedInbox = inbox.standardizedFileURL let standardizedOutput = output.standardizedFileURL try FileManager.default.createDirectory(at: standardizedInbox, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: standardizedOutput, withIntermediateDirectories: true) var config = try ConfigLoader.load(configURL) config.inbox = Self.portablePath(standardizedInbox) config.output = Self.portablePath(standardizedOutput) - if finderIntakeRoot != nil { - let bookmarkStore = InboxAccessBookmarkStore(root: Self.inboxAccessRoot) - let previousAccess = try bookmarkStore.snapshot() - let grant = try bookmarkStore.prepare(standardizedInbox) - do { - // Publish the new bookmark first. If config save fails, restore - // the prior grant; if the process crashes between the two - // writes, expected-path validation blocks the queue fail-closed. - try bookmarkStore.commit(grant) - try ConfigLoader.save(config, to: configURL) - } catch { - try? bookmarkStore.restore(previousAccess) - inboxAccessState = .invalid(error.localizedDescription) - throw error - } - } else { + let inboxBookmarkStore = InboxAccessBookmarkStore(root: Self.inboxAccessRoot) + let outputBookmarkStore = InboxAccessBookmarkStore(root: Self.outputAccessRoot, name: "Output") + let previousInboxAccess = try inboxBookmarkStore.snapshot() + let previousOutputAccess = try outputBookmarkStore.snapshot() + let inboxGrant = try inboxBookmarkStore.prepare(standardizedInbox) + let outputGrant = try outputBookmarkStore.prepare(standardizedOutput) + do { + // Publish both grants before config. If any write fails, restore the + // complete prior access pair so config and permissions cannot drift. + try inboxBookmarkStore.commit(inboxGrant) + try outputBookmarkStore.commit(outputGrant) try ConfigLoader.save(config, to: configURL) + } catch { + try? inboxBookmarkStore.restore(previousInboxAccess) + try? outputBookmarkStore.restore(previousOutputAccess) + inboxAccessState = .invalid(error.localizedDescription) + outputAccessState = .invalid(error.localizedDescription) + throw error } self.inbox = standardizedInbox outputRoot = standardizedOutput + activateConfiguredFolderAccess() Task { await drainFinderIntake() } refreshInbox() } @@ -295,27 +309,47 @@ final class HatStore { } } - func loadModelSettings() throws -> (provider: ModelProvider, appleModel: AppleModelSelection, appleUseCase: AppleUseCase, appleGuardrails: AppleGuardrails, allowApplePCC: Bool, url: String, ollamaModel: String, openAIModel: String, openAIKey: String) { - let config = try ConfigLoader.load(configURL) - return (config.modelProvider, config.appleModel, config.appleUseCase, config.appleGuardrails, config.allowApplePCC, - config.ollamaURL, config.ollamaModel, config.openAIModel, APIKeyStore.load()) + func loadModelSettings() throws -> (provider: ModelProvider, appleModel: AppleModelSelection, appleUseCase: AppleUseCase, appleGuardrails: AppleGuardrails, url: String, ollamaModel: String, openAIModel: String, openAIKey: String) { + let loadedConfig = try ConfigLoader.load(configURL) + #if SORTING_HAT_APP_STORE + let config = LocalOnlyProviderPolicy.normalized(loadedConfig) + let openAIKey = "" + #else + let config = loadedConfig + let openAIKey = APIKeyStore.load() + #endif + let appModel: AppleModelSelection = config.appleModel == .pcc ? .system : config.appleModel + return (config.modelProvider, appModel, config.appleUseCase, config.appleGuardrails, + config.ollamaURL, config.ollamaModel, config.openAIModel, openAIKey) } func saveModelSettings(provider: ModelProvider, appleModel: AppleModelSelection, appleUseCase: AppleUseCase, - appleGuardrails: AppleGuardrails, allowApplePCC: Bool, url: String, + appleGuardrails: AppleGuardrails, url: String, ollamaModel: String, openAIModel: String, openAIKey: String) throws { + #if SORTING_HAT_APP_STORE + let savedURL = try LocalOnlyProviderPolicy.validatedOllamaURL(url) + let savedProvider: ModelProvider = provider == .openai ? .automatic : provider + #else guard URL(string: url) != nil else { throw HatError.invalidConfig("Ollama URL is not valid") } - if appleModel == .pcc, !allowApplePCC { throw HatError.pccConsentRequired } + let savedURL = url.trimmingCharacters(in: .whitespacesAndNewlines) + let savedProvider = provider + #endif var config = try ConfigLoader.load(configURL) - config.ollamaURL = url.trimmingCharacters(in: .whitespacesAndNewlines) + config.ollamaURL = savedURL config.ollamaModel = ollamaModel.trimmingCharacters(in: .whitespacesAndNewlines) + #if SORTING_HAT_APP_STORE + config.openAIModel = "" + #else config.openAIModel = openAIModel.trimmingCharacters(in: .whitespacesAndNewlines) - config.modelProvider = provider - config.appleModel = appleModel + #endif + config.modelProvider = savedProvider + config.appleModel = appleModel == .pcc ? .system : appleModel config.appleUseCase = appleUseCase config.appleGuardrails = appleGuardrails - config.allowApplePCC = allowApplePCC + config.allowApplePCC = false + #if !SORTING_HAT_APP_STORE try APIKeyStore.save(openAIKey.trimmingCharacters(in: .whitespacesAndNewlines)) + #endif try ConfigLoader.save(config, to: configURL) status = "Model Settings Updated" } @@ -487,6 +521,41 @@ final class HatStore { } } + private func activateConfiguredFolderAccess() { + stopConfiguredFolderAccess() + + let inboxState = InboxAccessBookmarkStore(root: Self.inboxAccessRoot) + .resolve(expectedInbox: inbox) + let outputState = InboxAccessBookmarkStore(root: Self.outputAccessRoot, name: "Output") + .resolve(expectedInbox: outputRoot) + + inboxAccessState = inboxState + outputAccessState = outputState + activeInboxAccessURL = activate(inboxState, label: "Inbox") + activeOutputAccessURL = activate(outputState, label: "filed output") + } + + private func activate(_ state: InboxAccessState, label: String) -> URL? { + guard case .available(let url) = state else { return nil } + guard url.startAccessingSecurityScopedResource() else { + let message = "Saved \(label) permission could not be activated. Choose the folder again." + if label == "Inbox" { + inboxAccessState = .invalid(message) + } else { + outputAccessState = .invalid(message) + } + return nil + } + return url + } + + private func stopConfiguredFolderAccess() { + activeInboxAccessURL?.stopAccessingSecurityScopedResource() + activeOutputAccessURL?.stopAccessingSecurityScopedResource() + activeInboxAccessURL = nil + activeOutputAccessURL = nil + } + private func startIntakeCoordinator() { guard intakeTask == nil else { return } intakeTask = Task { [weak self] in @@ -570,10 +639,6 @@ final class HatStore { } } - private static func fmPath() -> String { - ["/usr/bin/fm", "/usr/local/bin/fm", "/opt/homebrew/bin/fm"].first(where: FileManager.default.isExecutableFile(atPath:)) ?? "/usr/bin/fm" - } - private static var buildNumber: String { Bundle.main.object(forInfoDictionaryKey: kCFBundleVersionKey as String) as? String ?? "unknown" } @@ -583,6 +648,11 @@ final class HatStore { .appending(path: "Library/Application Support/SortingHat/Finder Inbox Access", directoryHint: .isDirectory) } + private static var outputAccessRoot: URL { + FileManager.default.homeDirectoryForCurrentUser + .appending(path: "Library/Application Support/SortingHat/Filed Output Access", directoryHint: .isDirectory) + } + private static func expandedURL(_ path: String) -> URL { URL(fileURLWithPath: NSString(string: path).expandingTildeInPath).standardizedFileURL } diff --git a/Sources/SortingHatApp/Views/ModelSettingsView.swift b/Sources/SortingHatApp/Views/ModelSettingsView.swift index 80a2383..160b557 100644 --- a/Sources/SortingHatApp/Views/ModelSettingsView.swift +++ b/Sources/SortingHatApp/Views/ModelSettingsView.swift @@ -12,7 +12,6 @@ struct ModelSettingsView: View { @State private var appleModel: AppleModelSelection = .automatic @State private var appleUseCase: AppleUseCase = .general @State private var appleGuardrails: AppleGuardrails = .default - @State private var allowApplePCC = false @State private var errorMessage: String? @State private var savedMessage: String? @State private var inboxURL: URL @@ -43,8 +42,10 @@ struct ModelSettingsView: View { .tabItem { Label("Apple", systemImage: "cpu") } localPane .tabItem { Label("Ollama", systemImage: "desktopcomputer") } + #if !SORTING_HAT_APP_STORE cloudPane .tabItem { Label("OpenAI", systemImage: "cloud") } + #endif } .padding(16) @@ -74,7 +75,6 @@ struct ModelSettingsView: View { .onAppear(perform: load) .onChange(of: provider) { savedMessage = nil } .onChange(of: appleModel) { savedMessage = nil } - .onChange(of: allowApplePCC) { savedMessage = nil } .fileImporter(isPresented: $choosingInbox, allowedContentTypes: [.folder]) { result in if case .success(let url) = result { inboxURL = url; saveLocations() } } @@ -141,6 +141,11 @@ struct ModelSettingsView: View { LabeledContent("Filed output") { HStack { Text(outputURL.path(percentEncoded: false)).lineLimit(1).truncationMode(.middle); Button("Choose…") { choosingOutput = true } } } + if store.outputAccessState.needsRecovery { + Label(outputAccessDetail, systemImage: "lock.trianglebadge.exclamationmark") + .font(.caption) + .foregroundStyle(.orange) + } Toggle("Launch Sorting Hat at login", isOn: Binding( get: { store.launchAtLogin }, set: { enabled in store.setLaunchAtLogin(enabled) } @@ -168,7 +173,9 @@ struct ModelSettingsView: View { Text("Automatic").tag(ModelProvider.automatic) Text("Apple").tag(ModelProvider.apple) Text("Ollama").tag(ModelProvider.ollama) + #if !SORTING_HAT_APP_STORE Text("OpenAI").tag(ModelProvider.openai) + #endif } .pickerStyle(.radioGroup) } @@ -378,16 +385,26 @@ struct ModelSettingsView: View { } } + private var outputAccessDetail: String { + switch store.outputAccessState { + case .available: "Filed-output access is available" + case .stale: "Filed-output permission is stale — choose the folder again" + case .missing: "Choose the filed-output folder to persist permission" + case .invalid(let message): "Filed-output permission needs repair: \(message)" + case .mismatched(let bookmarked, let expected): + "Saved access points to \(bookmarked.lastPathComponent), not \(expected.lastPathComponent) — choose the filed output again" + } + } + private var applePane: some View { SettingsPane( title: "Apple Foundation Models", - description: "Use Apple Intelligence on this Mac, or explicitly permit eligible requests to use Private Cloud Compute." + description: "Use Apple Intelligence on this Mac. File context stays on this device." ) { Form { Picker("Model", selection: $appleModel) { Text("Automatic").tag(AppleModelSelection.automatic) Text("On this Mac").tag(AppleModelSelection.system) - Text("Private Cloud Compute").tag(AppleModelSelection.pcc) } Picker("Use case", selection: $appleUseCase) { Text("General").tag(AppleUseCase.general) @@ -398,27 +415,19 @@ struct ModelSettingsView: View { Text("Permit Content Transformations").tag(AppleGuardrails.permissiveContentTransformations) } } - - Divider() - - Toggle("Allow file context to be sent to Apple Private Cloud Compute", isOn: $allowApplePCC) - Text(pccHelp) - .font(.caption) - .foregroundStyle(allowApplePCC ? .orange : .secondary) - .fixedSize(horizontal: false, vertical: true) } } private var localPane: some View { SettingsPane( title: "Local Ollama", - description: "Connect Sorting Hat to an Ollama model running on this Mac or your local network." + description: localProviderDescription ) { Form { TextField("Server URL", text: $url) TextField("Model", text: $ollamaModel, prompt: Text("For example: gemma3:4b")) } - Text("Files are sent only to the server address above. Automatic uses Ollama when Apple generation is unavailable.") + Text(localProviderFootnote) .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -452,7 +461,6 @@ struct ModelSettingsView: View { appleModel = settings.appleModel appleUseCase = settings.appleUseCase appleGuardrails = settings.appleGuardrails - allowApplePCC = settings.allowApplePCC errorMessage = nil } catch { errorMessage = error.localizedDescription @@ -466,7 +474,6 @@ struct ModelSettingsView: View { appleModel: appleModel, appleUseCase: appleUseCase, appleGuardrails: appleGuardrails, - allowApplePCC: allowApplePCC, url: url, ollamaModel: ollamaModel, openAIModel: openAIModel, @@ -521,7 +528,7 @@ struct ModelSettingsView: View { private var providerHelp: String { switch provider { - case .automatic: "Tries Apple first, then configured Ollama and OpenAI fallbacks. Private Cloud Compute still requires explicit permission." + case .automatic: automaticProviderHelp case .apple: "Uses only the selected Apple policy. On-device processing remains local." case .ollama: "Uses only the configured Ollama server." case .openai: "Uses OpenAI directly and may send extracted file context to the service." @@ -539,22 +546,45 @@ struct ModelSettingsView: View { private var privacySummary: String { switch provider { - case .automatic: "Uses on-device Apple Intelligence first. Network providers are used only when configured." - case .apple: allowApplePCC ? "Apple processing may use Private Cloud Compute with your permission." : "Apple processing remains on this Mac." + case .automatic: automaticPrivacySummary + case .apple: "Apple processing remains on this Mac." case .ollama: "File context is sent only to the Ollama server address you configure." case .openai: "Extracted file context may be sent to OpenAI." } } - private var pccHelp: String { - if appleModel == .pcc && !allowApplePCC { - return "Private Cloud Compute cannot be used until this permission is enabled." - } - if allowApplePCC { - return "Permission is enabled. Automatic may retry eligible on-device failures using Apple's Private Cloud Compute service." - } - return "Disabled. File context stays on this Mac when using Apple Foundation Models." + private var automaticProviderHelp: String { + #if SORTING_HAT_APP_STORE + "Tries on-device Apple Intelligence first, then Ollama running on this Mac." + #else + "Tries on-device Apple Intelligence first, then configured Ollama and OpenAI fallbacks." + #endif + } + + private var automaticPrivacySummary: String { + #if SORTING_HAT_APP_STORE + "File context stays on this Mac. Remote model providers are unavailable." + #else + "Uses on-device Apple Intelligence first. Network providers are used only when configured." + #endif } + + private var localProviderDescription: String { + #if SORTING_HAT_APP_STORE + "Connect Sorting Hat to Ollama running on this Mac. Remote and local-network servers are unavailable in this build." + #else + "Connect Sorting Hat to an Ollama model running on this Mac or your local network." + #endif + } + + private var localProviderFootnote: String { + #if SORTING_HAT_APP_STORE + "Only localhost, 127.0.0.1, and ::1 are accepted. Automatic uses local Ollama when Apple generation is unavailable." + #else + "Files are sent only to the server address above. Automatic uses Ollama when Apple generation is unavailable." + #endif + } + } private struct SettingsPane: View { diff --git a/Sources/SortingHatCore/DecisionJSONDecoder.swift b/Sources/SortingHatCore/DecisionJSONDecoder.swift new file mode 100644 index 0000000..2a816a1 --- /dev/null +++ b/Sources/SortingHatCore/DecisionJSONDecoder.swift @@ -0,0 +1,14 @@ +import Foundation + +public enum DecisionJSONDecoder { + public static func decode(_ data: Data) throws -> Decision { + if let decision = try? JSONDecoder().decode(Decision.self, from: data) { return decision } + let text = String(data: data, encoding: .utf8) ?? "" + guard let start = text.firstIndex(of: "{"), let end = text.lastIndex(of: "}") else { + throw HatError.invalidResponse(text) + } + let json = Data(text[start...end].utf8) + do { return try JSONDecoder().decode(Decision.self, from: json) } + catch { throw HatError.invalidResponse(text) } + } +} diff --git a/Sources/SortingHatCore/FileAnalyzing.swift b/Sources/SortingHatCore/FileAnalyzing.swift new file mode 100644 index 0000000..a875ede --- /dev/null +++ b/Sources/SortingHatCore/FileAnalyzing.swift @@ -0,0 +1,5 @@ +import Foundation + +public protocol FileAnalyzing: Sendable { + func analyze(file: URL, rules: [String]) throws -> Decision +} diff --git a/Sources/SortingHatCore/InboxAccessBookmarkStore.swift b/Sources/SortingHatCore/InboxAccessBookmarkStore.swift index ce6bc6d..0106971 100644 --- a/Sources/SortingHatCore/InboxAccessBookmarkStore.swift +++ b/Sources/SortingHatCore/InboxAccessBookmarkStore.swift @@ -2,11 +2,13 @@ import Foundation public struct InboxAccessBookmarkStore { private let root: URL + private let name: String private let fileManager: FileManager private let resolver: (Data) throws -> (URL, Bool) - public init(root: URL, fileManager: FileManager = .default) { + public init(root: URL, name: String = "Inbox", fileManager: FileManager = .default) { self.root = root + self.name = name self.fileManager = fileManager resolver = { data in var stale = false @@ -22,10 +24,12 @@ public struct InboxAccessBookmarkStore { init( root: URL, + name: String = "Inbox", fileManager: FileManager = .default, resolver: @escaping (Data) throws -> (URL, Bool) ) { self.root = root + self.name = name self.fileManager = fileManager self.resolver = resolver } @@ -112,8 +116,8 @@ public struct InboxAccessBookmarkStore { return try? decoder.decode(InboxAccessMetadata.self, from: data) } - private var bookmarkURL: URL { root.appending(path: "Inbox.bookmark") } - private var metadataURL: URL { root.appending(path: "Inbox.json") } + private var bookmarkURL: URL { root.appending(path: "\(name).bookmark") } + private var metadataURL: URL { root.appending(path: "\(name).json") } private func restore(_ data: Data?, to url: URL) throws { if let data { diff --git a/Sources/SortingHatCore/LocalOnlyProviderPolicy.swift b/Sources/SortingHatCore/LocalOnlyProviderPolicy.swift new file mode 100644 index 0000000..640f738 --- /dev/null +++ b/Sources/SortingHatCore/LocalOnlyProviderPolicy.swift @@ -0,0 +1,40 @@ +import Foundation + +/// Enforces the Mac App Store promise that extracted file context never leaves +/// the Mac. The Developer ID app and CLI retain their broader provider options. +public enum LocalOnlyProviderPolicy { + public static let defaultOllamaURL = "http://127.0.0.1:11434" + + public static func isLoopbackOllamaURL(_ value: String) -> Bool { + guard let components = URLComponents(string: value.trimmingCharacters(in: .whitespacesAndNewlines)), + let scheme = components.scheme?.lowercased(), + scheme == "http" || scheme == "https", + components.user == nil, + components.password == nil, + let host = components.host?.lowercased() + else { return false } + return host == "localhost" || host == "127.0.0.1" || host == "::1" || host == "[::1]" + } + + public static func validatedOllamaURL(_ value: String) throws -> String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard isLoopbackOllamaURL(trimmed) else { + throw HatError.invalidConfig("The Mac App Store build can connect only to Ollama running on this Mac (localhost, 127.0.0.1, or ::1).") + } + return trimmed + } + + public static func normalized(_ original: Configuration) -> Configuration { + var config = original + config.openAIModel = "" + config.allowApplePCC = false + if config.appleModel == .pcc { config.appleModel = .system } + if config.modelProvider == .openai { config.modelProvider = .automatic } + if !isLoopbackOllamaURL(config.ollamaURL) { + config.ollamaURL = defaultOllamaURL + config.ollamaModel = "" + if config.modelProvider == .ollama { config.modelProvider = .automatic } + } + return config + } +} diff --git a/Sources/SortingHatCore/OllamaAnalyzer.swift b/Sources/SortingHatCore/OllamaAnalyzer.swift index 93f24d3..de508a0 100644 --- a/Sources/SortingHatCore/OllamaAnalyzer.swift +++ b/Sources/SortingHatCore/OllamaAnalyzer.swift @@ -2,7 +2,7 @@ import Foundation public struct PreferredAnalyzer: FileAnalyzing, BatchFileAnalyzing { public let fm: NativeFoundationModelsAnalyzer - public let pcc: FMAnalyzer + public let pcc: (any BatchFileAnalyzing)? public let ollama: OllamaAnalyzer? public let openAI: OpenAIAnalyzer? public let provider: ModelProvider @@ -10,7 +10,7 @@ public struct PreferredAnalyzer: FileAnalyzing, BatchFileAnalyzing { public let allowApplePCC: Bool public init( - fmExecutable: String = "/usr/bin/fm", + pcc: (any BatchFileAnalyzing)? = nil, ollamaURL: String, ollamaModel: String, openAIModel: String = "", @@ -22,7 +22,7 @@ public struct PreferredAnalyzer: FileAnalyzing, BatchFileAnalyzing { allowApplePCC: Bool = false ) { fm = NativeFoundationModelsAnalyzer(useCase: appleUseCase, guardrails: appleGuardrails) - pcc = FMAnalyzer(executable: fmExecutable, model: .pcc, useCase: appleUseCase, guardrails: appleGuardrails, pccAllowed: allowApplePCC) + self.pcc = pcc let model = ollamaModel.trimmingCharacters(in: .whitespacesAndNewlines) ollama = model.isEmpty ? nil : OllamaAnalyzer(baseURL: ollamaURL, model: model) let cloudModel = openAIModel.trimmingCharacters(in: .whitespacesAndNewlines) @@ -78,8 +78,8 @@ public struct PreferredAnalyzer: FileAnalyzing, BatchFileAnalyzing { private var appleIsAvailable: Bool { switch appleModel { case .system: fm.isAvailable - case .pcc: allowApplePCC && pcc.isAvailable - case .automatic: fm.isAvailable || (allowApplePCC && pcc.isAvailable) + case .pcc: allowApplePCC && pcc != nil + case .automatic: fm.isAvailable || (allowApplePCC && pcc != nil) } } @@ -89,12 +89,14 @@ public struct PreferredAnalyzer: FileAnalyzing, BatchFileAnalyzing { return try fm.analyze(file: file, rules: rules) case .pcc: guard allowApplePCC else { throw HatError.pccConsentRequired } + guard let pcc else { throw HatError.pccUnavailable("PCC is not available in this app build") } return try pcc.analyze(file: file, rules: rules) case .automatic: do { return try fm.analyze(file: file, rules: rules) } catch { guard allowApplePCC, Self.shouldEscalateToPCC(after: error) else { throw error } + guard let pcc else { throw error } return try pcc.analyze(file: file, rules: rules) } } @@ -106,10 +108,11 @@ public struct PreferredAnalyzer: FileAnalyzing, BatchFileAnalyzing { return fm.analyzeBatch(files: files, rules: rules) case .pcc: guard allowApplePCC else { return files.map { .failure(sourceID: $0.id, .pccConsentRequired) } } + guard let pcc else { return files.map { .failure(sourceID: $0.id, .pccUnavailable("PCC is not available in this app build")) } } return pcc.analyzeBatch(files: files, rules: rules) case .automatic: let local = fm.analyzeBatch(files: files, rules: rules) - guard allowApplePCC else { return local } + guard allowApplePCC, let pcc else { return local } let inputByID = Dictionary(uniqueKeysWithValues: files.map { ($0.id, $0) }) let retryInputs = local.compactMap { outcome -> BatchFileInput? in guard case .failure(let sourceID, let error) = outcome, @@ -204,7 +207,7 @@ public struct OllamaAnalyzer: FileAnalyzing { semaphore.wait() let data = try result!.get() let envelope = try JSONDecoder().decode(ChatResponse.self, from: data) - return try FMAnalyzer.decode(Data(envelope.message.content.utf8)) + return try DecisionJSONDecoder.decode(Data(envelope.message.content.utf8)) } private struct ChatResponse: Decodable { let message: Message } diff --git a/Sources/SortingHatCore/OpenAIAnalyzer.swift b/Sources/SortingHatCore/OpenAIAnalyzer.swift index 226765d..cf4fbeb 100644 --- a/Sources/SortingHatCore/OpenAIAnalyzer.swift +++ b/Sources/SortingHatCore/OpenAIAnalyzer.swift @@ -39,7 +39,7 @@ public struct OpenAIAnalyzer: FileAnalyzing { semaphore.wait() let envelope = try JSONDecoder().decode(Response.self, from: try result!.get()) guard let text = envelope.choices.first?.message.content else { throw HatError.invalidResponse("OpenAI returned no output") } - return try FMAnalyzer.decode(Data(text.utf8)) + return try DecisionJSONDecoder.decode(Data(text.utf8)) } private struct Response: Decodable { let choices: [Choice] } diff --git a/Sources/SortingHatCore/FMAnalyzer.swift b/Sources/SortingHatFMResearch/FMAnalyzer.swift similarity index 96% rename from Sources/SortingHatCore/FMAnalyzer.swift rename to Sources/SortingHatFMResearch/FMAnalyzer.swift index f69dfe2..b306aa1 100644 --- a/Sources/SortingHatCore/FMAnalyzer.swift +++ b/Sources/SortingHatFMResearch/FMAnalyzer.swift @@ -1,8 +1,5 @@ import Foundation - -public protocol FileAnalyzing: Sendable { - func analyze(file: URL, rules: [String]) throws -> Decision -} +import SortingHatCore /// Analyzes files with the on-device Apple Foundation Model exposed by macOS's /// `fm` command-line interface. @@ -270,14 +267,7 @@ public struct FMAnalyzer: FileAnalyzing, BatchFileAnalyzing { } public static func decode(_ data: Data) throws -> Decision { - if let decision = try? JSONDecoder().decode(Decision.self, from: data) { return decision } - let text = String(data: data, encoding: .utf8) ?? "" - guard let start = text.firstIndex(of: "{"), let end = text.lastIndex(of: "}") else { - throw HatError.invalidResponse(text) - } - let json = Data(text[start...end].utf8) - do { return try JSONDecoder().decode(Decision.self, from: json) } - catch { throw HatError.invalidResponse(text) } + try DecisionJSONDecoder.decode(data) } static func pccError(_ detail: String) -> HatError { diff --git a/Tests/SortingHatTests/SortingHatTests.swift b/Tests/SortingHatTests/SortingHatTests.swift index 5830f1c..627c6da 100644 --- a/Tests/SortingHatTests/SortingHatTests.swift +++ b/Tests/SortingHatTests/SortingHatTests.swift @@ -6,6 +6,7 @@ import CoreText import FoundationModels import Testing @testable import SortingHatCore +@testable import SortingHatFMResearch @testable import SortingHatFinderAdapter struct StubAnalyzer: FileAnalyzing { @@ -533,6 +534,20 @@ struct SortingHatTests { #expect(!PreferredAnalyzer.shouldEscalateToPCC(after: HatError.unsafePath("../escape"))) } + @Test func shippingAnalyzerFailsClosedWhenPCCResearchAdapterIsAbsent() { + let analyzer = PreferredAnalyzer( + ollamaURL: "http://127.0.0.1:11434", + ollamaModel: "", + provider: .apple, + appleModel: .pcc, + allowApplePCC: true + ) + + #expect(throws: HatError.self) { + try analyzer.analyze(file: URL(fileURLWithPath: "/tmp/research.txt"), rules: []) + } + } + @Test func distinguishesPrivateCloudUsageLimits() { let limit = FMAnalyzer.pccError("Daily usage limit reached") let unavailable = FMAnalyzer.pccError("Service temporarily unavailable") @@ -1407,6 +1422,26 @@ struct SortingHatTests { #expect(resolvedInbox.standardizedFileURL == inbox.standardizedFileURL) } + @Test func bookmarkStoreKeepsNamedFolderGrantsIndependent() throws { + let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + let accessRoot = root.appending(path: "Folder Access", directoryHint: .isDirectory) + let inbox = root.appending(path: "Inbox", directoryHint: .isDirectory) + let output = root.appending(path: "Filed Output", directoryHint: .isDirectory) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: inbox, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: output, withIntermediateDirectories: true) + + let inboxStore = InboxAccessBookmarkStore(root: accessRoot) + let outputStore = InboxAccessBookmarkStore(root: accessRoot, name: "Output") + try inboxStore.save(inbox) + try outputStore.save(output) + + #expect(inboxStore.resolve(expectedInbox: inbox) == .available(inbox.standardizedFileURL)) + #expect(outputStore.resolve(expectedInbox: output) == .available(output.standardizedFileURL)) + #expect(inboxStore.resolve(expectedInbox: output).needsRecovery) + #expect(outputStore.resolve(expectedInbox: inbox).needsRecovery) + } + @Test func decodesTheDataBackedFileURLRepresentationFinderActuallyProvides() throws { let source = URL(fileURLWithPath: "/private/tmp/Finder receipt ü.pdf") @@ -1614,4 +1649,34 @@ struct SortingHatTests { #expect(store.resolve(expectedInbox: configuredInbox) == .mismatched(bookmarked: formerInbox, expected: configuredInbox)) #expect(store.resolve(expectedInbox: configuredInbox).needsRecovery) } + + @Test func localOnlyPolicyAcceptsOnlyLoopbackOllamaURLs() throws { + #expect(LocalOnlyProviderPolicy.isLoopbackOllamaURL("http://127.0.0.1:11434")) + #expect(LocalOnlyProviderPolicy.isLoopbackOllamaURL("http://localhost:11434")) + #expect(LocalOnlyProviderPolicy.isLoopbackOllamaURL("https://[::1]:11434")) + #expect(!LocalOnlyProviderPolicy.isLoopbackOllamaURL("http://192.168.1.10:11434")) + #expect(!LocalOnlyProviderPolicy.isLoopbackOllamaURL("https://models.example.com")) + #expect(throws: HatError.self) { + try LocalOnlyProviderPolicy.validatedOllamaURL("http://models.example.com") + } + } + + @Test func localOnlyPolicyNeutralizesRemoteAndOpenAIConfiguration() { + var config = Configuration() + config.ollamaURL = "https://models.example.com" + config.ollamaModel = "remote-model" + config.openAIModel = "cloud-model" + config.modelProvider = .openai + config.appleModel = .pcc + config.allowApplePCC = true + + let normalized = LocalOnlyProviderPolicy.normalized(config) + + #expect(normalized.ollamaURL == LocalOnlyProviderPolicy.defaultOllamaURL) + #expect(normalized.ollamaModel.isEmpty) + #expect(normalized.openAIModel.isEmpty) + #expect(normalized.modelProvider == .automatic) + #expect(normalized.appleModel == .system) + #expect(!normalized.allowApplePCC) + } } diff --git a/docs/distribution.md b/docs/distribution.md new file mode 100644 index 0000000..701e794 --- /dev/null +++ b/docs/distribution.md @@ -0,0 +1,100 @@ +# Distribution channels + +Sorting Hat is preparing two macOS distribution channels. They share the same +product code but have different signing and sandbox contracts. + +## Developer ID and Homebrew — Issue #24 + +The tag-triggered release workflow already fails closed while it: + +1. imports a Developer ID Application certificate into an ephemeral keychain; +2. builds with hardened runtime and signs the Finder Action before the app; +3. verifies identities, App Group entitlements, bundle identifiers, and the + extension contract; +4. notarizes, staples, and validates with Gatekeeper; +5. extracts and re-verifies the final ZIP before publishing it; +6. updates the Homebrew cask with the final archive checksum. + +The repository currently lacks the credential secrets required to exercise that +workflow. Add these through GitHub repository secrets, never through source, +issues, workflow logs, or pull requests: + +- `DEVELOPER_ID_APPLICATION_P12` +- `DEVELOPER_ID_APPLICATION_PASSWORD` +- `BUILD_KEYCHAIN_PASSWORD` +- `NOTARY_APPLE_ID` +- `NOTARY_PASSWORD` + +`HOMEBREW_TAP_TOKEN` is already configured. A successful signed release and a +fresh downloaded-artifact verification remain required before closing Issue #24. + +The local Mac already has the Developer ID Application identity. Store reusable +notarization credentials once in the login Keychain (the password is requested +interactively and is not written to the repository): + +```sh +xcrun notarytool store-credentials SortingHat-Notary \ + --apple-id YOUR_APPLE_ID \ + --team-id R8HXTBY3NM +``` + +Then produce and verify a local release candidate with: + +```sh +./script/release_local.sh 0.2.0 +``` + +The script requires the real Developer ID identity, submits through the named +Keychain profile, staples and assesses the app, creates the final ZIP, extracts +that ZIP, and repeats signature, stapler, and Gatekeeper validation against the +actual distributable artifact. It does not tag, publish, or update Homebrew. + +## Mac App Store — Issue #29 + +The `SortingHatAppStore` scheme uses the release-like `AppStore` configuration. +Unlike the Developer ID build, its main app enables App Sandbox with only: + +- the shared App Group used by the Finder Action queue; +- app-scoped security bookmarks; +- read/write access to folders the person explicitly selects; +- outgoing client connections solely for Ollama restricted to loopback on the + same Mac. OpenAI, LAN, and remote Ollama routes are unavailable in this build. + +The Finder Action stays sandboxed and read-only for selected Finder items. It +copies accepted input into the App Group queue; it does not receive broad write +access. The process-backed PCC research adapter is a separate Swift package +target used by the CLI and tests, and is not linked into the app. + +Run the unsigned structural preflight with: + +```sh +./script/generate_xcode_project.sh +./script/preflight_app_store.sh +``` + +The preflight archives the actual Store scheme, checks that there is one app, +verifies the nested extension and privacy manifest, confirms that the legacy +`/usr/bin/fm` path is absent from the app binary, ad-hoc signs solely for local +entitlement inspection, and checks the minimum sandbox contract. It does not +produce an uploadable archive. + +To create a submission build, the owner must first register both bundle IDs and +the App Group in the Apple Developer portal, attach the App Group to both IDs, +install or allow Xcode to obtain matching Mac App Store profiles, and create an +App Store Connect record for `com.tcballard.sortinghat`. Then archive the +`SortingHatAppStore` scheme in Xcode and choose **Distribute App → App Store +Connect**, or export a correctly signed archive with +`Configuration/AppStoreExportOptions.plist`. + +App Store Connect build **0.1.0 (1)** is uploaded and selectable. Before +submission, complete the product page, support and privacy URLs, screenshots, +age rating, App Privacy answers, export-compliance answers, and review notes. +The submission pack under [`launch-pack/`](../launch-pack/) contains prepared +copy, genuine screenshots, icon evidence, review instructions, and an explicit +blocker checklist. Review notes explain the dashboard and menu-bar entry points, +the required user-selected Inbox and output folders, how to enable the bundled +Finder Action, and how to exercise sorting without credentials. + +An App Store/TestFlight build must still be manually verified for first-run +setup, persisted folder access across relaunch, on-device sorting, manual review, +Finder Action intake, and launch at login before Issue #29 can close. diff --git a/docs/privacy.md b/docs/privacy.md new file mode 100644 index 0000000..5325f15 --- /dev/null +++ b/docs/privacy.md @@ -0,0 +1,35 @@ +# Sorting Hat privacy policy + +Last updated: 21 July 2026 + +Sorting Hat is a macOS file-organising app. It processes files that you explicitly add to its Inbox or Finder Action so it can extract useful context, rename them, apply Finder tags, and move them into folders you choose. + +## On-device processing + +When Apple Foundation Models is selected, file context is processed on your Mac. Text extraction from supported documents and images also uses Apple frameworks on your Mac. Sorting Hat does not send that content to the developer and the developer does not operate an analytics or tracking service for the app. + +The app stores its configuration, rules, recent activity, and security-scoped folder bookmarks locally. + +## Local model providers + +The Mac App Store build can use either Apple's on-device model or Ollama running on the same Mac. Ollama connections are restricted to `localhost`, `127.0.0.1`, and `::1`. Remote, local-network, and OpenAI model routes are unavailable in this build. + +Sorting Hat does not require an account with the developer and does not send file context to a service operated by the developer or another remote model provider. + +## Files and Finder access + +Sorting Hat's App Sandbox limits it to folders you explicitly select and files you explicitly send through its Finder Action. The Finder Action copies selected items into Sorting Hat's private App Group intake queue; it does not alter the originals. + +## Tracking, advertising, and sale of data + +Sorting Hat contains no advertising SDK, does not track you across apps or websites, and does not sell personal data. + +## Retention and deletion + +Sorting Hat's local activity history and settings remain on your Mac until you remove them or uninstall the app. You can remove individual review items in the app. Files already organised by Sorting Hat remain in the output folders you selected. + +If you run Ollama locally, its model data and processing remain under your control on that Mac. + +## Contact + +For privacy questions, email [tom@armytage.co](mailto:tom@armytage.co). diff --git a/docs/support.md b/docs/support.md new file mode 100644 index 0000000..c760477 --- /dev/null +++ b/docs/support.md @@ -0,0 +1,31 @@ +# Sorting Hat support + +Sorting Hat requires macOS 14 or later. Apple's on-device Foundation Model requires macOS 26, Apple Intelligence, and a supported Mac. On other supported Macs, the Mac App Store build can connect to Ollama running on that same Mac. Remote model providers are unavailable in the Store build. + +## Getting started + +1. Open Sorting Hat and choose an Inbox and a filed-output folder. +2. Describe how you want files organised, review the proposed rules, and save them. +3. Add files from the Inbox view or use **Send to Sorting Hat** in Finder's Quick Actions menu. +4. Keep Sorting Hat running and unpaused. The Activity view shows where each file was filed or why it needs attention. + +Sorting Hat creates rule-specific directories beneath your filed-output folder. It preserves file extensions, protects existing files from being overwritten, and leaves uncertain or invalid decisions in the Inbox for review. + +## Finder Action + +Enable **Send to Sorting Hat** in **System Settings → General → Login Items & Extensions → Finder**. If Finder does not show it immediately, relaunch Finder once. Sending a file copies it into Sorting Hat; the original remains untouched. + +## Fixing a file that needs review + +Open the Activity view and use the item's context menu. You can retry it, correct its filename and destination manually, or remove it from the review list. Removing a review entry does not delete the source file. + +## Folder access problems + +Open Sorting Hat's settings and choose the Inbox or filed-output folder again. This refreshes the App Sandbox permission stored for that folder. + +## Get help or report a bug + +- Email: [tom@armytage.co](mailto:tom@armytage.co) +- Public issue tracker: [github.com/tcballard/SortingHat/issues](https://github.com/tcballard/SortingHat/issues) + +Please include your macOS version, Sorting Hat version, selected model provider, and the visible error message. Do not attach private files or API keys. diff --git a/launch-pack/ASSET_INVENTORY.md b/launch-pack/ASSET_INVENTORY.md new file mode 100644 index 0000000..d0041b1 --- /dev/null +++ b/launch-pack/ASSET_INVENTORY.md @@ -0,0 +1,8 @@ +# Asset inventory + +| ID | Asset | Purpose | Source or provenance | Constraints | Output path | Status | Blocker or next action | +| --- | --- | --- | --- | --- | --- | --- | --- | +| A01 | 1024 px app icon | Embedded Store icon reference and visual handoff | Shipping AppIcon asset, SHA-256 recorded in evidence | Opaque 1024×1024 PNG | outputs/app-store/icon/app-icon-1024.png | Ready | Confirm the selected build displays this icon in App Store Connect | +| A02 | Sorted Activity screenshot | Show the core result | Genuine app capture using synthetic fixtures | 1440×900 opaque JPEG | outputs/app-store/screenshots/01-sorted-activity.jpg | Ready | Upload in position 1 | +| A03 | Plain-language Rules screenshot | Show rule creation and editing | Genuine app capture with no private data | 1440×900 opaque JPEG | outputs/app-store/screenshots/02-plain-language-rules.jpg | Ready | Upload in position 2 | +| A04 | Privacy settings screenshot | Avoid a stale PCC claim | Earlier capture showed a non-shipping setting | Must reflect current product | Not included | Removed | Capture a replacement only after the privacy/provider decision | diff --git a/launch-pack/CHANNEL_MATRIX.md b/launch-pack/CHANNEL_MATRIX.md new file mode 100644 index 0000000..6761b78 --- /dev/null +++ b/launch-pack/CHANNEL_MATRIX.md @@ -0,0 +1,8 @@ +# Channel matrix + +| Channel | Audience | Desired action | Deliverable | Claim IDs | Assets and links | Owner or specialist | Output path | Authority | Status | Blocker or next action | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| Mac App Store listing | Mac users | Understand and download | Name, subtitle, promotional text, description, keywords, categories | C01, C02, C03 | A01-A03 | Tom Ballard | outputs/app-store/LISTING.md | Owner enters and submits | Ready | Resolve version and price before submission | +| App Review | Apple reviewer | Exercise the app without credentials | Review notes and contact checklist | C01-C03 | Support and privacy URLs | Tom Ballard | outputs/app-store/REVIEW_NOTES.md | Owner enters and submits | Ready | Owner supplies phone number | +| App Privacy | Apple reviewer and users | Understand data handling | Conservative questionnaire guidance and public policy | C02, C04, C05 | docs/privacy.md | Tom Ballard | outputs/app-store/APP_PRIVACY.md | Owner attests in App Store Connect | Blocked | Decide whether OpenAI remains in the Store build | +| Support | Existing users | Resolve setup and review issues | Public support page | C01-C03 | docs/support.md | Tom Ballard | docs/support.md | Public after merge | Ready | Merge PR #30 before using the URL | diff --git a/launch-pack/CLAIMS_LEDGER.md b/launch-pack/CLAIMS_LEDGER.md new file mode 100644 index 0000000..19f14e2 --- /dev/null +++ b/launch-pack/CLAIMS_LEDGER.md @@ -0,0 +1,10 @@ +# Claims ledger + +| ID | Claim | Importance | Evidence | Status | Qualification | Channels | Owner | +| --- | --- | --- | --- | --- | --- | --- | --- | +| C01 | Sorting Hat renames and files items using plain-language rules | Required | Shipping app, README product contract, genuine activity capture | Verified | Model decisions are validated; uncertain items remain for review | Listing, screenshots | Tom Ballard | +| C02 | Apple model processing can stay on the Mac | Required | Current Model Settings UI and Foundation Models implementation | Verified | Requires macOS 26, Apple Intelligence, and a supported Mac | Listing, privacy | Tom Ballard | +| C03 | Finder intake does not alter the original | Required | Finder Action copy-only implementation and docs | Verified | The staged copy is then processed by Sorting Hat | Listing, review notes, support | Tom Ballard | +| C04 | Sorting Hat does not track users or contain advertising | Required | Privacy manifest and dependency/runtime inspection | Verified | Optional providers receive content only when configured | Privacy, App Privacy | Tom Ballard | +| C05 | The Mac App Store app collects no data | Required | Store compile condition, loopback provider policy, focused tests, archive marker, and privacy manifest | Verified | Applies to the Mac App Store build; other distribution channels retain separate provider choices | App Privacy | Tom Ballard | +| C06 | Private Cloud Compute is a shipping option | Optional | Current app UI omits PCC and saved settings force it off | Removed | Legacy research and stale README wording are not product capabilities | None | Tom Ballard | diff --git a/launch-pack/HANDOFF.md b/launch-pack/HANDOFF.md new file mode 100644 index 0000000..d5ddcb6 --- /dev/null +++ b/launch-pack/HANDOFF.md @@ -0,0 +1,45 @@ +# Launch handoff + +- Product: Sorting Hat: File Organiser +- Release: First Mac App Store submission +- Version: 0.1.0 +- Build: 2 +- Pack status: Local-only build 2 uploaded, processed, and selected; URL publication and installed verification remain +- Publication authority: Tom Ballard only +- Exact next action: Merge PR #30, verify the public support/privacy URLs, then complete the remaining owner declarations + +## Included deliverables + +| Channel | Output | Status | Claim IDs | Notes | +| --- | --- | --- | --- | --- | +| Mac App Store | outputs/app-store/LISTING.md | Ready | C01-C03 | Copy respects current product behavior | +| Screenshots | outputs/app-store/screenshots | Ready | C01-C03 | Two opaque 1440×900 JPEGs | +| Icon | outputs/app-store/icon/app-icon-1024.png | Ready | None | Reference copy of the icon embedded in selected build 2 | +| App Review | outputs/app-store/REVIEW_NOTES.md | Ready | C01-C03 | Notes and review contact are saved in App Store Connect | +| Privacy | outputs/app-store/APP_PRIVACY.md and docs/privacy.md | Ready | C02, C04, C05 | Build 2 is verified; select Data Not Collected | +| Support | docs/support.md | Ready | C01-C03 | Public URL works only after merge | + +## Omitted or not-applicable deliverables + +- No preview video is required for the first submission. +- No separate icon upload is required because the icon is delivered in the build. +- Developer ID, Homebrew, press, and social delivery are outside this submission pack. + +## Claims + +- Verified: Core renaming/routing, on-device Apple processing, copy-only Finder intake, and no tracking/advertising. +- Qualified: On-device generation requirements and optional provider behavior. +- Blocked or removed: PCC, OpenAI, LAN Ollama, and remote Ollama are removed from Store shipping claims. + +## Validation performed + +- Uploaded build 2, confirmed Apple processing state `VALID`, and selected it for version 0.1.0. +- Saved the local-only listing, categories, age rating, review contact, and review notes in App Store Connect. +- Captured the real app with synthetic files, restored the user's configuration and activity, and removed fixture outputs. +- Adapted screenshots proportionally to an accepted Mac size with provenance evidence. +- Measured and visually reviewed the 1024 px shipping icon. + +## Remaining decisions and blockers + +- Merge the support/privacy pages, then verify their public URLs. +- Choose price, complete App Privacy, export compliance, and content rights, then run installed-build verification. diff --git a/launch-pack/LAUNCH_BRIEF.md b/launch-pack/LAUNCH_BRIEF.md new file mode 100644 index 0000000..9ddbc22 --- /dev/null +++ b/launch-pack/LAUNCH_BRIEF.md @@ -0,0 +1,41 @@ +# Launch brief + +- Product: Sorting Hat: File Organiser +- Release: First Mac App Store submission +- Version: 0.1.0 +- Build: 1 +- Release state: Build uploaded and selectable; metadata and owner decisions remain +- Release date or window: After App Review approval +- Authoritative source: PR #30 at commit 06946dd plus App Store Connect build 0.1.0 (1) + +## Audience and outcome + +- Primary audience: Mac users who want files renamed and organised without writing automation +- User outcome: Describe a filing plan once, then send files to one Inbox and see them renamed and routed into useful folders +- Launch objective: Submit an accurate, reviewable first Mac App Store version +- Primary call to action: Download Sorting Hat from the Mac App Store +- Canonical destination: App Store Connect record 6792563259 + +## Availability and boundaries + +- Platforms and minimum versions: macOS 14 or later; Apple on-device generation requires macOS 26, Apple Intelligence, and a supported Mac +- Rollout or eligibility: Public Mac App Store after manual release and review approval +- Pricing: Owner decision required; free is recommended for the first submission +- Material limitations: Model availability affects sorting; uncertain items require review; Store Ollama connections are restricted to the same Mac +- Required disclosures: Optional provider transmission, folder access, no developer account, and Finder Action copy-only intake + +## Delivery contract + +- Included channels: Mac App Store product page, screenshots, icon handoff, privacy policy, support page, App Review notes, submission checklist +- Deliberately omitted channels: Homebrew, Developer ID release, press, social, and demo video +- Format or submission constraints: 1-10 opaque 16:10 Mac screenshots; icon embedded in the uploaded build; App Store field limits +- Accessibility requirements: Screenshots remain legible at product-page scale and listing copy describes the visual outcome +- Publication authority: Tom Ballard performs all App Store Connect edits and submission actions + +## Evidence summary + +- Release artifact or verified build: App Store Connect shows selectable build 0.1.0 (1) with an included App Icon +- Tests and measurements: PR #30 CI is green; Store archive and selected build were previously verified +- Specification and acceptance criteria: Issue #29 and docs/distribution.md +- Build logs and decisions: PR #30 history and App Store Connect read-only audit on 20 July 2026 +- Existing assets and copy: Shipping app icon plus two genuine, sanitised application captures diff --git a/launch-pack/RELEASE_CHECKLIST.md b/launch-pack/RELEASE_CHECKLIST.md new file mode 100644 index 0000000..3390de8 --- /dev/null +++ b/launch-pack/RELEASE_CHECKLIST.md @@ -0,0 +1,13 @@ +# Release checklist + +| Gate | Requirement | Evidence or observation | Result | Owner | Next action | +| --- | --- | --- | --- | --- | --- | +| Release identity | Version, build, date, and availability agree | App Store record is 0.1.0; local-only build 2 is valid and selected | Ready | Tom Ballard | Preserve build 2 through submission | +| Claims | Every used claim is verified or visibly qualified | Claims ledger removes PCC and blocks no-data claim | Ready | Tom Ballard | Preserve qualifications when entering copy | +| Assets | Required outputs exist and open correctly | Icon plus two real screenshots are present in App Store Connect | Ready | Tom Ballard | Visually confirm the final product-page preview | +| Technical | Channel and submission constraints pass | Local-only build 2 passed structural preflight and Apple validation; installed-build Store smoke test remains | Blocked | Tom Ballard | Install and smoke-test build 2 | +| Accessibility | Captions, contrast, text alternatives, and readability pass | Screens are legible at 1440×900; copy explains outcomes | Ready | Tom Ballard | Review App Store preview at normal scale | +| Privacy | No secrets, private data, or embargoed details leak | Store runtime neutralizes OpenAI and non-loopback Ollama; privacy manifest declares no collection | Ready | Tom Ballard | Select Data Not Collected | +| Links | Destinations and calls to action work | Repository links exist; support/privacy pages become public after merge | Blocked | Tom Ballard | Merge PR #30, then verify both public URLs anonymously | +| Provenance | Required source and model contribution records exist | Per-asset JSON records source hashes and transformations | Ready | Tom Ballard | Retain evidence with the pack | +| Authority | Publisher and manual action are explicit | Metadata and build are prepared; App Review submission remains owner-controlled | Ready | Tom Ballard | Owner answers remaining declarations and submits | diff --git a/launch-pack/evidence/01-sorted-activity.json b/launch-pack/evidence/01-sorted-activity.json new file mode 100644 index 0000000..94bd181 --- /dev/null +++ b/launch-pack/evidence/01-sorted-activity.json @@ -0,0 +1,29 @@ +{ + "anchor": "center", + "background": "#0B111A", + "canvas": [ + 1440, + 900 + ], + "fit": "contain", + "output": "launch-pack/outputs/app-store/screenshots/01-sorted-activity.jpg", + "output_bytes": 108269, + "output_sha256": "acd7b7fdcdb86e393abf092870991fc7bc287cce1ea092db0ae1ab03c34df90b", + "placement": [ + 0, + 58 + ], + "proportional": true, + "scale": 1.022001419446416, + "scaled_size": [ + 1440, + 785 + ], + "source": "/private/tmp/SortingHat-activity-source.jpeg", + "source_sha256": "4283b288bc53f4702bf98aa85c7b51ee6d00e67acbc91c324637cea629a5d477", + "source_size": [ + 1409, + 768 + ], + "source_unchanged": true +} diff --git a/launch-pack/evidence/02-plain-language-rules.json b/launch-pack/evidence/02-plain-language-rules.json new file mode 100644 index 0000000..40194ea --- /dev/null +++ b/launch-pack/evidence/02-plain-language-rules.json @@ -0,0 +1,29 @@ +{ + "anchor": "center", + "background": "#0B111A", + "canvas": [ + 1440, + 900 + ], + "fit": "contain", + "output": "launch-pack/outputs/app-store/screenshots/02-plain-language-rules.jpg", + "output_bytes": 69661, + "output_sha256": "ad9b378fa23bbbd7ecf58a7fd179fa860cafd7cf2c3c3af21ade9fbcd61f846e", + "placement": [ + 0, + 58 + ], + "proportional": true, + "scale": 1.022001419446416, + "scaled_size": [ + 1440, + 785 + ], + "source": "/private/tmp/SortingHat-rules-source.jpeg", + "source_sha256": "a48ee05801c23a6b7921e5949fb4f683245d94f594871da1ba68627d5d42d24c", + "source_size": [ + 1409, + 768 + ], + "source_unchanged": true +} diff --git a/launch-pack/evidence/app-icon-1024.json b/launch-pack/evidence/app-icon-1024.json new file mode 100644 index 0000000..4c9cd99 --- /dev/null +++ b/launch-pack/evidence/app-icon-1024.json @@ -0,0 +1,29 @@ +{ + "anchor": "center", + "background": "#0B111A", + "canvas": [ + 1024, + 1024 + ], + "fit": "contain", + "output": "launch-pack/outputs/app-store/icon/app-icon-1024.png", + "output_bytes": 477779, + "output_sha256": "eda410b4ddae926bc3740b97bc14937791aec28b913f2c03136054d7e226e8da", + "placement": [ + 0, + 0 + ], + "proportional": true, + "scale": 1.0, + "scaled_size": [ + 1024, + 1024 + ], + "source": "Sources/SortingHatApp/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png", + "source_sha256": "87091098e947c9a8037319cb7dcf385f15b3bd9392a4acf5a798bc26d8a653f5", + "source_size": [ + 1024, + 1024 + ], + "source_unchanged": true +} diff --git a/launch-pack/outputs/app-store/APP_PRIVACY.md b/launch-pack/outputs/app-store/APP_PRIVACY.md new file mode 100644 index 0000000..dcd69fc --- /dev/null +++ b/launch-pack/outputs/app-store/APP_PRIVACY.md @@ -0,0 +1,21 @@ +# App Privacy submission guidance + +This note describes the Mac App Store build only. + +## Verified Store behavior + +- Apple Foundation Models and document/image extraction operate on the Mac. +- Optional Ollama connections are restricted to loopback addresses on the same Mac. +- OpenAI, LAN Ollama, remote Ollama, Private Cloud Compute, advertising, analytics, and tracking are unavailable. +- The developer does not receive file content, account data, analytics, or diagnostics from the app. +- The Store privacy manifest declares no collected data types and no tracking domains. + +## App Store Connect answer + +Build `0.1.0 (2)` is uploaded, processed, selected, and verified. Choose **Data Not Collected** in App Store Connect. + +Do not apply this answer to Developer ID, source, or CLI distributions without separately evaluating their configured provider behavior. + +## Privacy policy URL + +https://github.com/tcballard/SortingHat/blob/main/docs/privacy.md diff --git a/launch-pack/outputs/app-store/ICON_PREFLIGHT.md b/launch-pack/outputs/app-store/ICON_PREFLIGHT.md new file mode 100644 index 0000000..809ce25 --- /dev/null +++ b/launch-pack/outputs/app-store/ICON_PREFLIGHT.md @@ -0,0 +1,17 @@ +# macOS icon preflight + +## Result + +The selected build already includes the Sorting Hat app icon. App Store Connect obtains the Mac icon from the build; there is no separate product-page icon upload. + +The shipping 1024×1024 PNG was measured and visually reviewed: + +- fully opaque, with no transparent pixels or fringe; +- no likely baked rounded-rectangle mask; +- recognisable wizard-hat silhouette at 16, 32, 64, 128, 256, 512, and 1024 px; +- strong midnight-and-amber contrast consistent with the application's authored visual language; +- reference SHA-256 recorded in `evidence/app-icon-1024.json`. + +## Recommendation + +Use the current icon for this submission. After the first release, consider rebuilding it as a layered Icon Composer asset to adopt the newest macOS icon material and depth behavior without changing the recognisable hat silhouette. diff --git a/launch-pack/outputs/app-store/LISTING.md b/launch-pack/outputs/app-store/LISTING.md new file mode 100644 index 0000000..8d8bb39 --- /dev/null +++ b/launch-pack/outputs/app-store/LISTING.md @@ -0,0 +1,60 @@ +# Mac App Store listing copy + +## Name + +Sorting Hat: File Organiser + +## Subtitle + +Private, intelligent filing + +## Promotional text + +Drop files into one Inbox. Sorting Hat reads, renames and files them into the folders you actually want, using rules you describe in plain English. + +## Description + +Sorting Hat is a drop folder with opinions: a native Mac app that turns a messy stream of receipts, screenshots, documents and downloads into an organised filing system. + +Describe what you want in plain English. Sorting Hat turns that plan into editable rules, then inspects each file, gives it a useful name, applies Finder tags and routes it into a rule-specific folder beneath the output location you choose. + +Built for a calmer workflow: + +• One Inbox inside the app — no bouncing between duplicate views +• Plain-language rules you can review and change +• Descriptive filenames that preserve the original file type +• Automatic creation of useful destination folders +• Local text recognition for images and scanned PDFs +• Clear Activity history and manual review when the model is uncertain +• Copy-only Finder Action, so the original file stays untouched +• App Sandbox access limited to folders and files you select + +On supported Macs running macOS 26 with Apple Intelligence, Sorting Hat can use Apple's Foundation Model on the device. On earlier Macs, or as a local fallback, it can connect only to Ollama running on the same Mac. The Mac App Store build has no remote model provider and no developer account is needed. + +Sorting Hat validates every proposed filesystem action before it moves a file. Unsafe paths, invalid filenames and uncertain decisions are held for review instead of being guessed. Existing files are protected from being overwritten. + +Spend less time naming and dragging files. Tell the hat how you think, then let one Inbox take care of the repetition. + +## Keywords + +files,organiser,automation,receipts,screenshots,rename,folders,productivity,AI,OCR + +## Categories + +- Primary: Productivity +- Secondary: Utilities + +## URLs + +- Support: https://github.com/tcballard/SortingHat/blob/main/docs/support.md +- Marketing: https://github.com/tcballard/SortingHat +- Privacy policy: https://github.com/tcballard/SortingHat/blob/main/docs/privacy.md + +## Copyright + +2026 Tom Ballard + +## Screenshot order and captions + +1. `01-sorted-activity.jpg` — See every renamed file and where the hat put it. +2. `02-plain-language-rules.jpg` — Describe your filing plan, then review and edit the rules. diff --git a/launch-pack/outputs/app-store/REVIEW_NOTES.md b/launch-pack/outputs/app-store/REVIEW_NOTES.md new file mode 100644 index 0000000..a20c26f --- /dev/null +++ b/launch-pack/outputs/app-store/REVIEW_NOTES.md @@ -0,0 +1,28 @@ +# App Review notes + +Sorting Hat does not require sign-in, an account, a subscription, or credentials supplied by the developer. + +The app is a menu-bar accessory with a normal dashboard. The dashboard opens on launch and when the menu-bar hat is left-clicked. Right-clicking the hat opens its quick menu. + +## Suggested review path + +1. Launch Sorting Hat. +2. During setup, choose an empty temporary folder as the Inbox and another as the filed-output folder. +3. Describe a filing plan such as: “Put receipts in Finance/Receipts/YYYY-MM and put everything else in Sorted/YYYY-MM. Give every file a short descriptive name.” Review and save the proposed rules. +4. Add a small text or PDF fixture from the Inbox view, or copy it into the selected Inbox. +5. Ensure the hat is running, then choose Sort Now if needed. +6. Open Activity to see the renamed file and destination. An uncertain or invalid result remains available for manual review. + +On a supported Mac running macOS 26 with Apple Intelligence enabled, choose the Apple provider and “On this Mac” to exercise local generation. On other supported Macs, Ollama is optional and must run on the same Mac at `localhost`, `127.0.0.1`, or `::1`. OpenAI and remote Ollama are unavailable in this build. + +The App Sandbox limits access to user-selected folders. Security-scoped bookmarks preserve that access across relaunch. The bundled Finder Action uses the shared App Group only to copy explicitly selected Finder items into Sorting Hat's intake queue; it does not modify the originals. + +To test the Finder Action, open **System Settings → General → Login Items & Extensions → Finder**, enable **Send to Sorting Hat**, then select a small file in Finder and choose **Quick Actions → Send to Sorting Hat**. The file should appear as a copy in Sorting Hat's Inbox and then be processed while the app is running. + +The app does not require a network connection when Apple's on-device provider is available and selected. Local Ollama settings may be left blank. + +## Review contact + +- Name: Tom Ballard +- Email: tom@armytage.co +- Phone: owner entry required in App Store Connect diff --git a/launch-pack/outputs/app-store/SUBMISSION_CHECKLIST.md b/launch-pack/outputs/app-store/SUBMISSION_CHECKLIST.md new file mode 100644 index 0000000..77efa32 --- /dev/null +++ b/launch-pack/outputs/app-store/SUBMISSION_CHECKLIST.md @@ -0,0 +1,31 @@ +# App Store Connect entry checklist + +## Product page + +- Confirm the saved copy matches `LISTING.md`. +- Confirm the two screenshots remain in numbered order. +- Confirm selected build 2 displays the embedded wizard-hat icon. +- Confirm Productivity remains primary and Utilities secondary. +- Confirm copyright is `2026 Tom Ballard`. + +## Required corrections + +- Local-only build `0.1.0 (2)` is uploaded, valid, and selected. +- **Sign-in required** is disabled; Sorting Hat has no developer account system. +- Release mode is manual. +- Choose pricing. Free is recommended for the first submission; any paid price requires an active Paid Apps agreement. + +## Declarations + +- Select **Data Not Collected** using `APP_PRIVACY.md` as the evidence note. +- The age-rating questionnaire is complete and currently resolves to 4+; confirm it remains unchanged before submission. +- Content rights recommendation: select **No** when asked whether the app contains, shows, or accesses third-party content supplied by the developer. User-selected files are processed as a utility function. +- Complete export-compliance questions based on Apple's standard HTTPS/system-cryptography exemption path and the final binary. + +## Review + +- Confirm the saved review notes and contact details match `REVIEW_NOTES.md`. +- Keep build 0.1.0 (2) selected. +- Verify the public support and privacy URLs anonymously after PR #30 merges. +- Run an installed-build smoke test for first setup, persisted folder access, Apple on-device sorting, manual review, Finder intake, pause/resume, and launch at login. +- Submit only after every blocker in `RELEASE_CHECKLIST.md` is resolved by the owner. diff --git a/launch-pack/outputs/app-store/icon/app-icon-1024.png b/launch-pack/outputs/app-store/icon/app-icon-1024.png new file mode 100644 index 0000000..3e20e28 Binary files /dev/null and b/launch-pack/outputs/app-store/icon/app-icon-1024.png differ diff --git a/launch-pack/outputs/app-store/screenshots/01-sorted-activity.jpg b/launch-pack/outputs/app-store/screenshots/01-sorted-activity.jpg new file mode 100644 index 0000000..8c2b8d6 Binary files /dev/null and b/launch-pack/outputs/app-store/screenshots/01-sorted-activity.jpg differ diff --git a/launch-pack/outputs/app-store/screenshots/02-plain-language-rules.jpg b/launch-pack/outputs/app-store/screenshots/02-plain-language-rules.jpg new file mode 100644 index 0000000..37b1f04 Binary files /dev/null and b/launch-pack/outputs/app-store/screenshots/02-plain-language-rules.jpg differ diff --git a/project.yml b/project.yml index c6f6093..1c756ca 100644 --- a/project.yml +++ b/project.yml @@ -1,4 +1,8 @@ name: SortingHat +configs: + Debug: debug + Release: release + AppStore: release options: bundleIdPrefix: com.tcballard deploymentTarget: @@ -19,6 +23,8 @@ targets: platform: macOS sources: - Sources/SortingHatApp + - path: Configuration/PrivacyInfo.xcprivacy + buildPhase: resources dependencies: - package: SortingHatPackage product: SortingHatCore @@ -29,19 +35,27 @@ targets: properties: CFBundleDisplayName: Sorting Hat CFBundleShortVersionString: "0.1.0" - CFBundleVersion: "1" + CFBundleVersion: "2" LSApplicationCategoryType: public.app-category.productivity LSMinimumSystemVersion: "14.0" LSUIElement: true + ITSAppUsesNonExemptEncryption: false NSHumanReadableCopyright: Copyright © 2026 Tom Ballard. All rights reserved. NSPrincipalClass: NSApplication SortingHatAppGroupIdentifier: R8HXTBY3NM.com.tcballard.sortinghat + SortingHatLocalOnlyDistribution: $(SORTING_HAT_LOCAL_ONLY) settings: base: CODE_SIGN_ENTITLEMENTS: Configuration/SortingHatApp.entitlements ENABLE_HARDENED_RUNTIME: true PRODUCT_BUNDLE_IDENTIFIER: com.tcballard.sortinghat PRODUCT_NAME: Sorting Hat + SORTING_HAT_LOCAL_ONLY: false + configs: + AppStore: + CODE_SIGN_ENTITLEMENTS: Configuration/SortingHatApp-AppStore.entitlements + SORTING_HAT_LOCAL_ONLY: true + SWIFT_ACTIVE_COMPILATION_CONDITIONS: $(inherited) SORTING_HAT_APP_STORE SendToSortingHatAction: type: app-extension platform: macOS @@ -57,7 +71,7 @@ targets: properties: CFBundleDisplayName: Send to Sorting Hat CFBundleShortVersionString: "0.1.0" - CFBundleVersion: "1" + CFBundleVersion: "2" NSExtension: NSExtensionAttributes: # Match the selected files' content types here. The handler loads @@ -86,3 +100,13 @@ schemes: SortingHatApp: all run: config: Debug + archive: + config: Release + SortingHatAppStore: + build: + targets: + SortingHatApp: all + run: + config: AppStore + archive: + config: AppStore diff --git a/script/generate_app_icon.swift b/script/generate_app_icon.swift new file mode 100644 index 0000000..db8457b --- /dev/null +++ b/script/generate_app_icon.swift @@ -0,0 +1,148 @@ +#!/usr/bin/env swift + +import AppKit +import Foundation + +private let outputDirectory: URL = { + if CommandLine.arguments.count > 1 { + return URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true) + } + return URL(fileURLWithPath: FileManager.default.currentDirectoryPath) + .appendingPathComponent("Sources/SortingHatApp/Assets.xcassets/AppIcon.appiconset", isDirectory: true) +}() + +private func point(_ x: CGFloat, _ y: CGFloat, in rect: CGRect) -> CGPoint { + CGPoint(x: rect.minX + rect.width * x, y: rect.minY + rect.height * y) +} + +private func wizardHat(in rect: CGRect) -> NSBezierPath { + let path = NSBezierPath() + + // Crooked crown and folded tip. Keep this geometry aligned with + // WizardHatSilhouette so the app and its icon share one identity. + path.move(to: point(0.30, 0.68, in: rect)) + path.line(to: point(0.39, 0.28, in: rect)) + path.line(to: point(0.51, 0.22, in: rect)) + path.line(to: point(0.55, 0.05, in: rect)) + path.line(to: point(0.68, 0.00, in: rect)) + path.line(to: point(0.77, 0.18, in: rect)) + path.line(to: point(0.88, 0.10, in: rect)) + path.line(to: point(0.80, 0.29, in: rect)) + path.line(to: point(0.72, 0.25, in: rect)) + path.curve( + to: point(0.77, 0.68, in: rect), + controlPoint1: point(0.73, 0.41, in: rect), + controlPoint2: point(0.79, 0.58, in: rect) + ) + path.curve( + to: point(0.30, 0.68, in: rect), + controlPoint1: point(0.62, 0.73, in: rect), + controlPoint2: point(0.43, 0.73, in: rect) + ) + path.close() + + // Uneven hat band. + path.move(to: point(0.27, 0.64, in: rect)) + path.curve( + to: point(0.78, 0.67, in: rect), + controlPoint1: point(0.43, 0.71, in: rect), + controlPoint2: point(0.64, 0.73, in: rect) + ) + path.line(to: point(0.75, 0.78, in: rect)) + path.curve( + to: point(0.26, 0.75, in: rect), + controlPoint1: point(0.57, 0.83, in: rect), + controlPoint2: point(0.39, 0.80, in: rect) + ) + path.close() + + // Wide, swept brim with a nicked trailing edge. + path.move(to: point(0.03, 0.89, in: rect)) + path.curve( + to: point(0.29, 0.73, in: rect), + controlPoint1: point(0.12, 0.83, in: rect), + controlPoint2: point(0.21, 0.76, in: rect) + ) + path.curve( + to: point(0.93, 0.78, in: rect), + controlPoint1: point(0.50, 0.79, in: rect), + controlPoint2: point(0.73, 0.70, in: rect) + ) + path.line(to: point(0.84, 0.89, in: rect)) + path.line(to: point(0.78, 0.87, in: rect)) + path.line(to: point(0.81, 0.95, in: rect)) + path.line(to: point(0.73, 0.90, in: rect)) + path.curve( + to: point(0.03, 0.89, in: rect), + controlPoint1: point(0.48, 1.01, in: rect), + controlPoint2: point(0.25, 0.84, in: rect) + ) + path.close() + + return path +} + +private func renderIcon(size: Int, filename: String) throws { + guard let bitmap = NSBitmapImageRep( + bitmapDataPlanes: nil, + pixelsWide: size, + pixelsHigh: size, + bitsPerSample: 8, + samplesPerPixel: 4, + hasAlpha: true, + isPlanar: false, + colorSpaceName: .deviceRGB, + bytesPerRow: 0, + bitsPerPixel: 0 + ), let context = NSGraphicsContext(bitmapImageRep: bitmap) else { + throw CocoaError(.fileWriteUnknown) + } + + let canvas = CGRect(x: 0, y: 0, width: size, height: size) + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = context + context.imageInterpolation = .high + + let ink = NSColor(deviceRed: 0.035, green: 0.055, blue: 0.085, alpha: 1) + let midnight = NSColor(deviceRed: 0.065, green: 0.09, blue: 0.13, alpha: 1) + NSGradient(starting: ink, ending: midnight)?.draw(in: canvas, angle: -35) + + // NSBezierPath uses a bottom-left origin; flip to match the SwiftUI shape's + // top-left-normalized coordinates. + context.cgContext.translateBy(x: 0, y: CGFloat(size)) + context.cgContext.scaleBy(x: 1, y: -1) + + let inset = CGFloat(size) * 0.14 + let hatRect = CGRect( + x: inset, + y: CGFloat(size) * 0.10, + width: CGFloat(size) - inset * 2, + height: CGFloat(size) * 0.76 + ) + NSColor(deviceRed: 1.0, green: 0.78, blue: 0.32, alpha: 1).setFill() + wizardHat(in: hatRect).fill() + + NSGraphicsContext.restoreGraphicsState() + + guard let data = bitmap.representation(using: .png, properties: [:]) else { + throw CocoaError(.fileWriteUnknown) + } + try data.write(to: outputDirectory.appendingPathComponent(filename), options: .atomic) +} + +try FileManager.default.createDirectory(at: outputDirectory, withIntermediateDirectories: true) +let renditions = [ + (16, "icon_16x16.png"), + (32, "icon_16x16@2x.png"), + (32, "icon_32x32.png"), + (64, "icon_32x32@2x.png"), + (128, "icon_128x128.png"), + (256, "icon_128x128@2x.png"), + (256, "icon_256x256.png"), + (512, "icon_256x256@2x.png"), + (512, "icon_512x512.png"), + (1024, "icon_512x512@2x.png") +] +for (size, filename) in renditions { + try renderIcon(size: size, filename: filename) +} diff --git a/script/preflight_app_store.sh b/script/preflight_app_store.sh new file mode 100755 index 0000000..6e6cb82 --- /dev/null +++ b/script/preflight_app_store.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +DERIVED_DATA="${SORTING_HAT_APP_STORE_DERIVED_DATA:-${TMPDIR%/}/SortingHatAppStoreDerivedData}" +ARCHIVE_PATH="${SORTING_HAT_APP_STORE_ARCHIVE:-${TMPDIR%/}/SortingHat-AppStore-Preflight.xcarchive}" +APP="$ARCHIVE_PATH/Products/Applications/Sorting Hat.app" +EXTENSION="$APP/Contents/PlugIns/Send to Sorting Hat.appex" +APP_ENTITLEMENTS="$ROOT_DIR/Configuration/SortingHatApp-AppStore.entitlements" +EXTENSION_ENTITLEMENTS="$ROOT_DIR/Configuration/SendToSortingHatAction.entitlements" +APP_BINARY="$APP/Contents/MacOS/Sorting Hat" +PRIVACY_MANIFEST="$APP/Contents/Resources/PrivacyInfo.xcprivacy" +APP_ICON="$APP/Contents/Resources/AppIcon.icns" +ASSET_CATALOG="$APP/Contents/Resources/Assets.car" +APP_ICON_SOURCE="$ROOT_DIR/Sources/SortingHatApp/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png" + +rm -rf "$DERIVED_DATA" "$ARCHIVE_PATH" +xcodebuild \ + -quiet \ + -project "$ROOT_DIR/SortingHat.xcodeproj" \ + -scheme SortingHatAppStore \ + -configuration AppStore \ + -derivedDataPath "$DERIVED_DATA" \ + -clonedSourcePackagesDirPath "$DERIVED_DATA/SourcePackages" \ + -archivePath "$ARCHIVE_PATH" \ + CODE_SIGNING_ALLOWED=NO \ + archive + +test -d "$APP" +test -d "$EXTENSION" +test -x "$APP_BINARY" +test -f "$PRIVACY_MANIFEST" +test -f "$APP_ICON" +test -f "$ASSET_CATALOG" +test -f "$APP_ICON_SOURCE" +test "$(find "$ARCHIVE_PATH/Products/Applications" -maxdepth 1 -type d -name '*.app' | wc -l | tr -d ' ')" = "1" +plutil -lint "$APP/Contents/Info.plist" "$EXTENSION/Contents/Info.plist" "$PRIVACY_MANIFEST" >/dev/null + +test "$(sips -g pixelWidth "$APP_ICON_SOURCE" 2>/dev/null | awk '/pixelWidth:/ { print $2 }')" = "1024" +test "$(sips -g pixelHeight "$APP_ICON_SOURCE" 2>/dev/null | awk '/pixelHeight:/ { print $2 }')" = "1024" + +ASSET_INFO="$DERIVED_DATA/app-store-assets.json" +xcrun assetutil --info "$ASSET_CATALOG" > "$ASSET_INFO" +/usr/bin/python3 - "$ASSET_INFO" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + renditions = json.load(handle) + +if not any( + rendition.get("Name") == "AppIcon" + and rendition.get("PixelWidth") == 1024 + and rendition.get("PixelHeight") == 1024 + and rendition.get("Scale") == 2 + for rendition in renditions +): + raise SystemExit("Compiled asset catalog is missing the 512pt @2x AppIcon rendition.") +PY + +if strings "$APP_BINARY" | grep -Fq "/usr/bin/fm"; then + echo "App Store binary unexpectedly contains the legacy fm executable path." >&2 + exit 1 +fi + +if ! strings "$APP_BINARY" | grep -Fq "The Mac App Store build can connect only to Ollama running on this Mac"; then + echo "App Store binary is missing the local-only provider policy." >&2 + exit 1 +fi + +# Ad-hoc signing is only for structural entitlement inspection. App Store +# submission still requires Apple Distribution identities and matching profiles. +xattr -cr "$APP" +codesign --force --sign - --timestamp=none --options runtime \ + --entitlements "$EXTENSION_ENTITLEMENTS" "$EXTENSION" +codesign --force --sign - --timestamp=none --options runtime \ + --entitlements "$APP_ENTITLEMENTS" "$APP" +codesign --verify --strict --verbose=2 "$EXTENSION" +codesign --verify --deep --strict --verbose=2 "$APP" + +APP_ACTUAL="$DERIVED_DATA/app-store-app-entitlements.plist" +EXTENSION_ACTUAL="$DERIVED_DATA/app-store-extension-entitlements.plist" +codesign -d --entitlements :- "$APP" > "$APP_ACTUAL" 2>/dev/null +codesign -d --entitlements :- "$EXTENSION" > "$EXTENSION_ACTUAL" 2>/dev/null + +assert_plist_value() { + local plist="$1" + local key="$2" + local expected="$3" + /usr/libexec/PlistBuddy -c "Print :$key" "$plist" | grep -Fx "$expected" >/dev/null +} + +assert_plist_value "$APP_ACTUAL" "com.apple.security.app-sandbox" true +assert_plist_value "$APP_ACTUAL" "com.apple.security.application-groups:0" R8HXTBY3NM.com.tcballard.sortinghat +assert_plist_value "$APP_ACTUAL" "com.apple.security.files.bookmarks.app-scope" true +assert_plist_value "$APP_ACTUAL" "com.apple.security.files.user-selected.read-write" true +assert_plist_value "$APP_ACTUAL" "com.apple.security.network.client" true +assert_plist_value "$EXTENSION_ACTUAL" "com.apple.security.app-sandbox" true +assert_plist_value "$EXTENSION_ACTUAL" "com.apple.security.application-groups:0" R8HXTBY3NM.com.tcballard.sortinghat +assert_plist_value "$EXTENSION_ACTUAL" "com.apple.security.files.user-selected.read-only" true +assert_plist_value "$APP/Contents/Info.plist" ITSAppUsesNonExemptEncryption false +assert_plist_value "$APP/Contents/Info.plist" SortingHatLocalOnlyDistribution YES + +echo "App Store structural preflight passed." +echo "Archive: $ARCHIVE_PATH" +echo "A submission archive still requires Apple Distribution signing and matching App Store profiles." diff --git a/script/release_local.sh b/script/release_local.sh new file mode 100755 index 0000000..605f02e --- /dev/null +++ b/script/release_local.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +set -euo pipefail + +VERSION="${1:-}" +if [[ -z "$VERSION" ]]; then + echo "usage: $0 VERSION" >&2 + echo "example: $0 0.2.0" >&2 + exit 2 +fi +if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then + echo "VERSION must be a semantic version without a leading v" >&2 + exit 2 +fi + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +NOTARY_PROFILE="${SORTING_HAT_NOTARY_PROFILE:-SortingHat-Notary}" +DERIVED_DATA="${SORTING_HAT_RELEASE_DERIVED_DATA:-${TMPDIR%/}/SortingHatReleaseDerivedData}" +OUTPUT_DIR="${SORTING_HAT_RELEASE_OUTPUT:-$ROOT_DIR/dist/releases}" +APP="$ROOT_DIR/dist/Sorting Hat.app" +EXTENSION="$APP/Contents/PlugIns/Send to Sorting Hat.appex" +ARCHIVE="$OUTPUT_DIR/Sorting-Hat-v$VERSION.zip" +SUBMISSION="${TMPDIR%/}/Sorting-Hat-v$VERSION-notarization.zip" +VERIFY_ROOT="${TMPDIR%/}/Sorting-Hat-v$VERSION-verification" +IDENTITY="Developer ID Application: Thomas Ballard (R8HXTBY3NM)" + +mkdir -p "$OUTPUT_DIR" +rm -rf "$DERIVED_DATA" "$VERIFY_ROOT" +rm -f "$ARCHIVE" "$SUBMISSION" + +if ! security find-identity -v -p codesigning | grep -Fq "\"$IDENTITY\""; then + echo "Missing valid signing identity: $IDENTITY" >&2 + exit 3 +fi + +BUILD_CONFIGURATION=Release \ +SORTING_HAT_DERIVED_DATA="$DERIVED_DATA" \ +SORTING_HAT_SIGN_IDENTITY="$IDENTITY" \ + "$ROOT_DIR/script/build_and_run.sh" package + +codesign --verify --strict --verbose=2 "$EXTENSION" +codesign --verify --deep --strict --verbose=2 "$APP" +codesign -dvvv "$APP" 2>&1 | grep -Fx "Authority=$IDENTITY" >/dev/null +codesign -dvvv "$APP" 2>&1 | grep -Fx "TeamIdentifier=R8HXTBY3NM" >/dev/null + +ditto -c -k --sequesterRsrc --keepParent "$APP" "$SUBMISSION" +xcrun notarytool submit "$SUBMISSION" --keychain-profile "$NOTARY_PROFILE" --wait +xcrun stapler staple "$APP" +xcrun stapler validate "$APP" +spctl --assess --type execute --verbose=2 "$APP" + +ditto -c -k --sequesterRsrc --keepParent "$APP" "$ARCHIVE" +mkdir -p "$VERIFY_ROOT" +ditto -x -k "$ARCHIVE" "$VERIFY_ROOT" +FINAL_APP="$VERIFY_ROOT/Sorting Hat.app" +FINAL_EXTENSION="$FINAL_APP/Contents/PlugIns/Send to Sorting Hat.appex" +codesign --verify --strict --verbose=2 "$FINAL_EXTENSION" +codesign --verify --deep --strict --verbose=2 "$FINAL_APP" +xcrun stapler validate "$FINAL_APP" +spctl --assess --type execute --verbose=2 "$FINAL_APP" + +echo "Signed, notarized, stapled, and extracted-archive verified:" +echo "$ARCHIVE" +shasum -a 256 "$ARCHIVE"