From 71bdc9dbdd41b66d2827f5c8ce7454f0352dc85f Mon Sep 17 00:00:00 2001 From: Michael Cerisano Date: Tue, 20 Oct 2026 06:08:48 -0400 Subject: [PATCH 1/4] Prepare 0.2.10 and explain notification permission failures --- BitMatch-iPad/Views/ModularContentView.swift | 16 ++- BitMatch.xcodeproj/project.pbxproj | 48 ++++---- BitMatch/Views/PreferencesWindow.swift | 23 +++- BitMatchTests/GeneralSettingsTests.swift | 111 +++++++++++++++++- CHANGELOG.md | 4 + DEVELOPMENT.md | 4 +- Shared/Core/Models/GeneralSettings.swift | 9 +- Shared/Core/Services/TransferNotifier.swift | 30 ++++- .../Views/NotificationPermissionBanner.swift | 1 + 9 files changed, 205 insertions(+), 41 deletions(-) diff --git a/BitMatch-iPad/Views/ModularContentView.swift b/BitMatch-iPad/Views/ModularContentView.swift index a912af78..f2e3ad64 100644 --- a/BitMatch-iPad/Views/ModularContentView.swift +++ b/BitMatch-iPad/Views/ModularContentView.swift @@ -324,7 +324,21 @@ struct SettingsSheetView: View { Text("\(GeneralSettingsPresentation.systemPermission): \(notifier.authorization.title)") .font(.footnote) .foregroundColor(.secondary) - if notifier.authorization.showsSettingsButton, + if notifier.isRequestingAuthorization { + ProgressView("Requesting notification permission") + } + if notifier.authorization.showsEnableButton { + Button(NotificationPermissionPromptPresentation.enableTitle) { + Task { await coordinator.enableNotificationsFromPrompt() } + } + .disabled(notifier.isRequestingAuthorization) + } + if notifier.authorizationRequestFailed { + Text(GeneralSettingsPresentation.notificationRequestFailed) + .font(.footnote) + .foregroundStyle(.secondary) + } + if notifier.authorization.showsSettingsButton || notifier.authorizationRequestFailed, let settingsURL = URL(string: UIApplication.openSettingsURLString) { Button(GeneralSettingsPresentation.openNotificationSettings) { openURL(settingsURL) diff --git a/BitMatch.xcodeproj/project.pbxproj b/BitMatch.xcodeproj/project.pbxproj index 795af373..7d0e94e1 100644 --- a/BitMatch.xcodeproj/project.pbxproj +++ b/BitMatch.xcodeproj/project.pbxproj @@ -643,7 +643,7 @@ CODE_SIGN_ENTITLEMENTS = BitMatch/BitMatch.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 19; + CURRENT_PROJECT_VERSION = 20; DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; @@ -654,7 +654,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 0.2.9; + MARKETING_VERSION = 0.2.10; PRODUCT_BUNDLE_IDENTIFIER = BitMatchApp.BitMatch; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -672,7 +672,7 @@ CODE_SIGN_ENTITLEMENTS = BitMatch/BitMatch.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 19; + CURRENT_PROJECT_VERSION = 20; DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; @@ -683,7 +683,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 0.2.9; + MARKETING_VERSION = 0.2.10; PRODUCT_BUNDLE_IDENTIFIER = BitMatchApp.BitMatch; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -698,11 +698,11 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 19; + CURRENT_PROJECT_VERSION = 20; DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 15.5; - MARKETING_VERSION = 0.2.9; + MARKETING_VERSION = 0.2.10; PRODUCT_BUNDLE_IDENTIFIER = BitMatchApp.BitMatchTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; @@ -716,11 +716,11 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 19; + CURRENT_PROJECT_VERSION = 20; DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 15.5; - MARKETING_VERSION = 0.2.9; + MARKETING_VERSION = 0.2.10; PRODUCT_BUNDLE_IDENTIFIER = BitMatchApp.BitMatchTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; @@ -733,10 +733,10 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 19; + CURRENT_PROJECT_VERSION = 20; DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 0.2.9; + MARKETING_VERSION = 0.2.10; PRODUCT_BUNDLE_IDENTIFIER = BitMatchApp.BitMatchUITests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; @@ -749,10 +749,10 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 19; + CURRENT_PROJECT_VERSION = 20; DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 0.2.9; + MARKETING_VERSION = 0.2.10; PRODUCT_BUNDLE_IDENTIFIER = BitMatchApp.BitMatchUITests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; @@ -767,7 +767,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 19; + CURRENT_PROJECT_VERSION = 20; DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -784,7 +784,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.2.9; + MARKETING_VERSION = 0.2.10; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "BitMatchiOS.BitMatch-iPad"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -802,7 +802,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 19; + CURRENT_PROJECT_VERSION = 20; DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -819,7 +819,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.2.9; + MARKETING_VERSION = 0.2.10; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "BitMatchiOS.BitMatch-iPad"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -837,11 +837,11 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 19; + CURRENT_PROJECT_VERSION = 20; DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 18.5; - MARKETING_VERSION = 0.2.9; + MARKETING_VERSION = 0.2.10; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "BitMatchiOS.BitMatch-iPadTests"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -858,11 +858,11 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 19; + CURRENT_PROJECT_VERSION = 20; DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 18.5; - MARKETING_VERSION = 0.2.9; + MARKETING_VERSION = 0.2.10; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "BitMatchiOS.BitMatch-iPadTests"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -879,11 +879,11 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 19; + CURRENT_PROJECT_VERSION = 20; DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 18.5; - MARKETING_VERSION = 0.2.9; + MARKETING_VERSION = 0.2.10; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "BitMatchiOS.BitMatch-iPadUITests"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -899,11 +899,11 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 19; + CURRENT_PROJECT_VERSION = 20; DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 18.5; - MARKETING_VERSION = 0.2.9; + MARKETING_VERSION = 0.2.10; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "BitMatchiOS.BitMatch-iPadUITests"; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/BitMatch/Views/PreferencesWindow.swift b/BitMatch/Views/PreferencesWindow.swift index 1875e243..fdf61397 100644 --- a/BitMatch/Views/PreferencesWindow.swift +++ b/BitMatch/Views/PreferencesWindow.swift @@ -127,17 +127,28 @@ struct PreferencesWindow: View { LabeledContent(GeneralSettingsPresentation.systemPermission) { HStack(spacing: 10) { Text(notifier.authorization.title).foregroundStyle(.secondary) - if notifier.authorization.showsSettingsButton { - Button(GeneralSettingsPresentation.openNotificationSettings) { - openURL(GeneralSettingsPresentation.macNotificationSettingsURL) - } - } else if notifier.authorization.showsEnableButton { - Button("Enable Notifications") { + if notifier.isRequestingAuthorization { + ProgressView().controlSize(.small) + .accessibilityLabel("Requesting notification permission") + } + if notifier.authorization.showsEnableButton { + Button(NotificationPermissionPromptPresentation.enableTitle) { Task { await coordinator.enableNotificationsFromPrompt() } } + .disabled(notifier.isRequestingAuthorization) + } + if notifier.authorization.showsSettingsButton || notifier.authorizationRequestFailed { + Button(GeneralSettingsPresentation.openNotificationSettings) { + openURL(GeneralSettingsPresentation.macNotificationSettingsURL()) + } } } } + if notifier.authorizationRequestFailed { + Text(GeneralSettingsPresentation.notificationRequestFailed) + .font(.caption) + .foregroundStyle(.secondary) + } } Section(GeneralSettingsPresentation.queueSection) { diff --git a/BitMatchTests/GeneralSettingsTests.swift b/BitMatchTests/GeneralSettingsTests.swift index 8c5b3f4a..7924b45b 100644 --- a/BitMatchTests/GeneralSettingsTests.swift +++ b/BitMatchTests/GeneralSettingsTests.swift @@ -7,6 +7,10 @@ import UserNotifications struct GeneralSettingsTests { private final class FakeAuthorizationClient: NotificationAuthorizationClient { let granted: Bool + var status: UNAuthorizationStatus? + var failure: Error? + var pendingRequest: CheckedContinuation? + var suspendRequest = false private(set) var requestCount = 0 init(granted: Bool) { @@ -15,11 +19,15 @@ struct GeneralSettingsTests { func requestAuthorization(options: UNAuthorizationOptions) async throws -> Bool { requestCount += 1 + if let failure { throw failure } + if suspendRequest { + return try await withCheckedThrowingContinuation { pendingRequest = $0 } + } return granted } func authorizationStatus() async -> UNAuthorizationStatus { - granted ? .authorized : .denied + status ?? (granted ? .authorized : .denied) } } @@ -138,6 +146,107 @@ struct GeneralSettingsTests { #expect(!decide(true, false, false, false, false)) } + @Test func failedPermissionRequestIsVisibleAndCanBeRetried() async throws { + let box = try DefaultsBox() + let settings = GeneralSettings(defaults: box.defaults) + let before = preferenceValues(settings) + let client = FakeAuthorizationClient(granted: true) + client.status = .notDetermined + client.failure = NSError(domain: UNErrorDomain, code: 1) + let notifier = TransferNotifier(settings: settings, authorizationClient: client) + + #expect(await notifier.enable() == false) + #expect(notifier.authorization == .notAsked) + #expect(notifier.authorizationRequestFailed) + #expect(!notifier.isRequestingAuthorization) + #expect(preferenceValues(settings) == before) + + client.failure = nil + client.status = .authorized + #expect(await notifier.enable()) + #expect(notifier.authorization == .authorized) + #expect(!notifier.authorizationRequestFailed) + #expect(!notifier.isRequestingAuthorization) + #expect(client.requestCount == 2) + #expect(preferenceValues(settings) == before) + } + + @Test(arguments: [UNAuthorizationStatus.notDetermined, .denied]) + func declinedPermissionDistinguishesSystemFailureFromUserDenial(status: UNAuthorizationStatus) async throws { + let box = try DefaultsBox() + let client = FakeAuthorizationClient(granted: false) + client.status = status + let notifier = TransferNotifier(settings: GeneralSettings(defaults: box.defaults), authorizationClient: client) + + #expect(await notifier.enable() == false) + #expect(notifier.authorizationRequestFailed == (status == .notDetermined)) + #expect(notifier.authorization.showsSettingsButton == (status == .denied)) + #expect(!notifier.isRequestingAuthorization) + } + + @Test func cancelledPermissionRequestClearsBusyState() async throws { + let box = try DefaultsBox() + let client = FakeAuthorizationClient(granted: false) + client.failure = CancellationError() + let notifier = TransferNotifier(settings: GeneralSettings(defaults: box.defaults), authorizationClient: client) + + #expect(await notifier.enable() == false) + #expect(!notifier.authorizationRequestFailed) + #expect(!notifier.isRequestingAuthorization) + } + + @Test(arguments: [UNAuthorizationStatus.authorized, .denied]) + func returningFromSystemSettingsClearsStaleRequestFailure(status: UNAuthorizationStatus) async throws { + let box = try DefaultsBox() + let client = FakeAuthorizationClient(granted: false) + client.failure = NSError(domain: UNErrorDomain, code: 1) + client.status = .notDetermined + let notifier = TransferNotifier(settings: GeneralSettings(defaults: box.defaults), authorizationClient: client) + #expect(await notifier.enable() == false) + #expect(notifier.authorizationRequestFailed) + + client.status = status + await notifier.refreshAuthorizationStatus() + #expect(notifier.authorization == NotificationAuthorizationPresentation.make(status: status)) + #expect(!notifier.authorizationRequestFailed) + } + + @Test func repeatedEnableDoesNotStartAnotherSystemRequest() async throws { + let box = try DefaultsBox() + let client = FakeAuthorizationClient(granted: true) + client.suspendRequest = true + let notifier = TransferNotifier(settings: GeneralSettings(defaults: box.defaults), authorizationClient: client) + let first = Task { await notifier.enable() } + let deadline = ContinuousClock.now + .seconds(2) + while client.pendingRequest == nil && ContinuousClock.now < deadline { await Task.yield() } + guard let pending = client.pendingRequest else { + first.cancel() + Issue.record("The permission request did not start") + return + } + #expect(notifier.isRequestingAuthorization) + #expect(await notifier.enable() == false) + #expect(client.requestCount == 1) + client.pendingRequest = nil + pending.resume(returning: true) + #expect(await first.value) + #expect(!notifier.isRequestingAuthorization) + } + + @Test(arguments: ["BitMatchApp.BitMatch", "BitMatchApp.BitMatch.Overnight", "test.id&other=value"]) + func notificationSettingsTargetsTheRunningBundle(bundleIdentifier: String) throws { + let url = GeneralSettingsPresentation.macNotificationSettingsURL(bundleIdentifier: bundleIdentifier) + let components = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)) + #expect(components.scheme == "x-apple.systempreferences") + #expect(components.path == "com.apple.Notifications-Settings.extension") + #expect(components.queryItems == [URLQueryItem(name: "id", value: bundleIdentifier)]) + } + + @Test func notificationSettingsWithoutBundleOpensTheGeneralPane() { + #expect(GeneralSettingsPresentation.macNotificationSettingsURL(bundleIdentifier: nil).query == nil) + #expect(GeneralSettingsPresentation.macNotificationSettingsURL(bundleIdentifier: "").query == nil) + } + @Test func notificationKindsFollowOnlyTheirOwnSettingsAndNeverPostInFront() { let allows: (TransferNotificationKind, Bool, Bool, Bool, Bool) -> Bool = { kind, front, attention, finish, eachCard in TransferNotificationPolicy.shouldPost( diff --git a/CHANGELOG.md b/CHANGELOG.md index 2892c885..f30df536 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [0.2.10] - 2026-10-20 + +- Notification permission requests show when they are waiting or fail, with a link to the running app’s system settings. iPhone and iPad Settings also offer Enable Notifications. + - Fixed crashes caused by Unicode-equivalent filenames. Conflicting source names are refused before copying; conflicting existing backup names prevent a successful result. - Queued transfers can move on to the next card when every selected file verified and only the AppleDouble metadata files you chose to ignore were excluded. Exclusions stay in the report, and other problems still stop the queue for review. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 41441cbd..ee874d11 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -151,7 +151,7 @@ To restore on a new Mac, copy the backup into a temporary file, run `generate_ke Before tagging, run the signed app from `dist/BitMatch-.xcarchive` against real or disk-image volumes: allow drive access, copy a card to two destinations, check "safe to erase", Eject, and relaunch. Debug builds and tests do not exercise the sandboxed release paths. -After publishing the GitHub release, publish its signed appcast entry: +After publishing the GitHub release, run `Scripts/bump_cask.sh ` for Homebrew, then publish the signed appcast entry last: ```bash Scripts/publish_appcast.sh 0.1.7 @@ -161,7 +161,7 @@ The script reads the archived app's build, short version and minimum macOS versi Run `python3 Scripts/test_appcast_minimum.py` for offline metadata and script checks. These use disposable fixtures and stub every signing, GitHub, Git and Xcode command; they do not publish an update or change the app's deployment target. -Then run `Scripts/bump_cask.sh `. It checks the published DMG against the local checksum, updates `Casks/bitmatch.rb` in [BitmatchApp/homebrew-tap](https://github.com/BitmatchApp/homebrew-tap), pushes it, and runs `brew audit`. `brew upgrade bitmatch` sees the release from then on. +`Scripts/bump_cask.sh ` checks the published DMG against the local checksum, updates `Casks/bitmatch.rb` in [BitmatchApp/homebrew-tap](https://github.com/BitmatchApp/homebrew-tap), pushes it, and runs `brew audit`. `brew upgrade bitmatch` sees the release from then on. ## Conventions diff --git a/Shared/Core/Models/GeneralSettings.swift b/Shared/Core/Models/GeneralSettings.swift index bcb95e63..dc89e50a 100644 --- a/Shared/Core/Models/GeneralSettings.swift +++ b/Shared/Core/Models/GeneralSettings.swift @@ -150,9 +150,12 @@ enum GeneralSettingsPresentation { static let autoEject = "Eject cards automatically when safe to erase" static let soundsSection = "Sounds" static let playSounds = "Play sounds" - static let macNotificationSettingsURL = URL( - string: "x-apple.systempreferences:com.apple.Notifications-Settings.extension?id=BitMatchApp.BitMatch" - )! + static let notificationRequestFailed = "Couldn’t request permission. Try again or open Notification Settings." + static func macNotificationSettingsURL(bundleIdentifier: String? = Bundle.main.bundleIdentifier) -> URL { + let base = URL(string: "x-apple.systempreferences:com.apple.Notifications-Settings.extension")! + guard let bundleIdentifier, !bundleIdentifier.isEmpty else { return base } + return base.appending(queryItems: [URLQueryItem(name: "id", value: bundleIdentifier)]) + } } enum TransferNotificationKind: Equatable, Sendable { diff --git a/Shared/Core/Services/TransferNotifier.swift b/Shared/Core/Services/TransferNotifier.swift index 0a80280b..45319baa 100644 --- a/Shared/Core/Services/TransferNotifier.swift +++ b/Shared/Core/Services/TransferNotifier.swift @@ -46,6 +46,8 @@ final class TransferNotifier: ObservableObject { private let settings: GeneralSettings private let authorizationClient: any NotificationAuthorizationClient @Published private(set) var authorization = NotificationAuthorizationPresentation.notAsked + @Published private(set) var isRequestingAuthorization = false + @Published private(set) var authorizationRequestFailed = false init( settings: GeneralSettings, @@ -63,10 +65,29 @@ final class TransferNotifier: ObservableObject { /// explicit Enable Notifications action, never during launch or Start. @discardableResult func enable() async -> Bool { - let granted = (try? await authorizationClient - .requestAuthorization(options: [.alert, .sound, .badge])) ?? false - await refreshAuthorizationStatus() - return granted + guard !isRequestingAuthorization else { return false } + isRequestingAuthorization = true + authorizationRequestFailed = false + defer { isRequestingAuthorization = false } + do { + let granted = try await authorizationClient + .requestAuthorization(options: [.alert, .sound, .badge]) + await refreshAuthorizationStatus() + // Some system failures return false without changing permission. + authorizationRequestFailed = !granted && authorization == .notAsked + return granted + } catch is CancellationError { + return false + } catch { + await refreshAuthorizationStatus() + authorizationRequestFailed = authorization == .notAsked + let failure = error as NSError + SharedLogger.warning( + "Notification permission request failed (\(failure.domain), code \(failure.code))", + category: .transfer + ) + return false + } } func disable() { @@ -77,6 +98,7 @@ final class TransferNotifier: ObservableObject { authorization = NotificationAuthorizationPresentation.make( status: await authorizationClient.authorizationStatus() ) + if authorization != .notAsked { authorizationRequestFailed = false } } func post(_ notice: TransferFinishNotice) { diff --git a/Shared/Views/NotificationPermissionBanner.swift b/Shared/Views/NotificationPermissionBanner.swift index 0c4ff852..bce2713f 100644 --- a/Shared/Views/NotificationPermissionBanner.swift +++ b/Shared/Views/NotificationPermissionBanner.swift @@ -21,6 +21,7 @@ struct NotificationPermissionBanner: View { Task { await coordinator.enableNotificationsFromPrompt() } } .buttonStyle(.borderedProminent) + .disabled(coordinator.transferNotifier.isRequestingAuthorization) } .padding(12) .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12)) From 7f077258b03e66a1fa788a778ffd4bb72f1dcf15 Mon Sep 17 00:00:00 2001 From: Michael Cerisano Date: Tue, 20 Oct 2026 06:43:36 -0400 Subject: [PATCH 2/4] Record 0.2.10 validation and workflow guidance --- CHANGELOG.md | 1 + DEVELOPMENT.md | 3 ++- docs/GUIDE.md | 2 +- docs/validation/RELEASE_0_2_10.md | 42 +++++++++++++++++++++++++++++++ 4 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 docs/validation/RELEASE_0_2_10.md diff --git a/CHANGELOG.md b/CHANGELOG.md index f30df536..ac4c43cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to this project will be documented in this file. ## [0.2.10] - 2026-10-20 - Notification permission requests show when they are waiting or fail, with a link to the running app’s system settings. iPhone and iPad Settings also offer Enable Notifications. +- Mac updater requirements now match the built app. - Fixed crashes caused by Unicode-equivalent filenames. Conflicting source names are refused before copying; conflicting existing backup names prevent a successful result. - Queued transfers can move on to the next card when every selected file verified and only the AppleDouble metadata files you chose to ignore were excluded. Exclusions stay in the report, and other problems still stop the queue for review. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index ee874d11..956f088e 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -6,7 +6,8 @@ Read [docs/THESIS.md](docs/THESIS.md) for what BitMatch promises and the current ## Building -- Use Xcode 26.3 or newer for the app concurrency settings; CI selects Xcode 26.3. Open `BitMatch.xcodeproj`. +- The 0.2.10 release is built and tested locally with Xcode 27.1 (27A9275), Swift 6.4. Open `BitMatch.xcodeproj`. +- CI still selects Xcode 26.3. That toolchain currently aborts while compiling Mac Settings and rejects some engine-test isolation patterns; those jobs do not establish a runtime test result. Older-toolchain build compatibility remains open. This does not change the app's macOS 15.5 minimum. - Schemes: - `BitMatch`: the macOS app (macOS 15.5 or newer). - `BitMatch-iPad`: the iPhone and iPad app (iOS/iPadOS 18.5 or newer). Despite the name it targets both device families. diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 465a859a..b6a5c34a 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -2,7 +2,7 @@ ## Queue, Recovery, and Handoff -Choose the next source, backups, and settings, then use **Add to queue** while another transfer is running, right on the main screen. Open **History** from the toolbar to see what happened earlier. Each queued transfer keeps its own source, backups, and settings. The queue stops when something needs attention. A retry keeps the old attempt in history and checks the original folders before starting again; verified existing files can be reused after checking them. +Choose the next source, backups, and settings, then use **Add to queue** while another transfer is running, right on the main screen. Open **History** from the toolbar to see what happened earlier. Each queued transfer keeps its own source, backups, and settings. The queue stops when something needs attention. Reviewed AppleDouble exclusions can let it continue when every selected file verified; the exclusions stay in the report, and the whole card stays unsafe to erase. A retry keeps the old attempt in history and checks the original folders before starting again; verified existing files can be reused after checking them. On iPhone and iPad, keep BitMatch open while it works. iOS can interrupt a transfer; the saved attempt will be marked interrupted when you reopen the app. Project cards stay with their project and need review there before another ingest. diff --git a/docs/validation/RELEASE_0_2_10.md b/docs/validation/RELEASE_0_2_10.md new file mode 100644 index 00000000..d805968a --- /dev/null +++ b/docs/validation/RELEASE_0_2_10.md @@ -0,0 +1,42 @@ +# 0.2.10 release validation + +Candidate source: `71bdc9dbdd41b66d2827f5c8ce7454f0352dc85f`, version 0.2.10, build 20. Later documentation edits do not change the 451 recorded source/configuration hashes. Validation uses local Xcode 27.1 (27A9275), Swift 6.4. + +## Completed local checks + +| Check | Result | +| --- | --- | +| Engine Debug, XCTest | 205 passed, 43 opt-in skips, zero failures | +| Engine Debug, Swift Testing | 217 passed, six opt-in skips, zero failures | +| Engine Release, XCTest | 205 passed, 43 opt-in skips, zero failures | +| Engine Release, Swift Testing | 217 passed, six opt-in skips, zero failures | +| Mac app suite | 1,308 passed, 23 skips, zero failures | +| iPad Air M4 simulator | 75 passed, ten skips, zero failures | +| iPhone 18 Pro simulator | 75 passed, ten skips, zero failures | +| iOS device Release build | Succeeded; actual arm64 binary, iOS 18.5 minimum | +| Offline updater tests | Eight passed, zero failures or skips | +| Signed Mac app and DMG | Apple notarization accepted; stapled; Gatekeeper accepted both | + +Mac and mobile structured results and raw logs contain no detected runtime state-update or continuation warnings. Existing compiler warnings remain. Both reused, owned mobile simulators were shut down after their tests. This is simulator coverage, not physical mobile file-provider or background testing. + +Independent reviews found no blocker in the transfer/evidence, Unicode filename, queue-exclusion or updater changes. The notification review found stale failure text after returning from System Settings; the correction and regression are included in the passing Mac suite. + +The first notification-focused attempt failed the newly added stale-error cases while a follow-up edit was being compiled. That attempt remains failed; the committed, frozen candidate passed the complete suite afterward. It is not claimed as a controlled red-state experiment. + +## Notifications and native walkthrough + +The temporary diagnostic app's button returned an immediate system error with no visible explanation. The notification service also logged that it could not validate that diagnostic bundle. The new UI shows pending and failed requests, offers System Settings for the running bundle, and allows retry without changing notification preferences. + +In the notarized production app, Enable Notifications entered the pending state and disabled duplicate requests. Mike handled the protected macOS prompt and confirmed that Settings showed **Authorized**. The automation tool refuses to control that system dialog. This does not establish the cause of every notification failure. + +The production app preserved an older paused queue and correctly blocked settings import while work needed attention. A separate, locally signed copy uses an isolated data profile for the remaining walkthrough; it is not separately notarized. The original notarized app and DMG remain unchanged. + +Native settings export passed. Mike confirmed that the actual two-change import opened a compact review with Cancel and Apply. The controller still disconnects when that Settings sheet comes forward; Cancel/Apply, progress and menu-bar checks remain pending. Hosted layout and model tests are separate evidence and do not close the remaining native gap. + +## Limits + +The [overnight validation](../OVERNIGHT_VALIDATION_20261019.md) records the mounted-filesystem and physical SSD/SD tests, including their individual source revisions and failed attempts. Those earlier hardware runs are not presented as new runs of this release candidate. + +GitHub's Xcode 26.3 jobs currently fail during Mac compiler IR generation and compilation of engine-test isolation patterns. They are not runtime test passes or billing failures. The local shipping-toolchain results do not prove source-build compatibility with that older compiler. + +Neither synthetic passes nor this release establish the cause of the open field reports. Issues #10 and #23 remain open. From b4e23c252b0f1f2bbc9e95b1d42393b1dc06c354 Mon Sep 17 00:00:00 2001 From: Michael Cerisano Date: Tue, 20 Oct 2026 07:14:57 -0400 Subject: [PATCH 3/4] Render Mac menu bar status as template images --- BitMatch/App/BitMatchApp.swift | 3 +- BitMatch/App/MacMenuBarCompanion.swift | 93 +++++++++++++++---- BitMatchTests/MacMenuBarCompanionTests.swift | 94 ++++++++++++++++++++ CHANGELOG.md | 1 + docs/validation/RELEASE_0_2_10.md | 4 +- 5 files changed, 174 insertions(+), 21 deletions(-) diff --git a/BitMatch/App/BitMatchApp.swift b/BitMatch/App/BitMatchApp.swift index 58cdcf3b..5bd318aa 100644 --- a/BitMatch/App/BitMatchApp.swift +++ b/BitMatch/App/BitMatchApp.swift @@ -42,6 +42,7 @@ struct BitMatchApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @StateObject private var environment: MacAppEnvironment @StateObject private var updater: UpdaterController + @State private var menuBarExtraIsInserted = true #if DEBUG @ObservedObject private var devModeManager = DevModeManager.shared #endif @@ -176,7 +177,7 @@ struct BitMatchApp: App { #endif } - MenuBarExtra { + MenuBarExtra(isInserted: $menuBarExtraIsInserted) { MacMenuBarCompanion( coordinator: environment.coordinator, appDelegate: appDelegate diff --git a/BitMatch/App/MacMenuBarCompanion.swift b/BitMatch/App/MacMenuBarCompanion.swift index df3907bf..8b6dad0e 100644 --- a/BitMatch/App/MacMenuBarCompanion.swift +++ b/BitMatch/App/MacMenuBarCompanion.swift @@ -128,28 +128,83 @@ struct MacMenuBarStatusLabel: View { } var body: some View { - Group { - if coordinator.isOperationInProgress && coordinator.currentMode == .copyAndVerify { - let presentation = TransferProgressPresentation.make(coordinator: coordinator) - ZStack { - Circle().stroke(Color.secondary.opacity(0.4), lineWidth: 2.5) - if let fraction = presentation.fraction { - Circle() - .trim(from: 0, to: fraction) - .stroke(Color.primary, style: StrokeStyle(lineWidth: 2.5, lineCap: .round)) - .rotationEffect(.degrees(-90)) - } else { - Circle().fill(Color.primary).frame(width: 4, height: 4) - } - } - .accessibilityLabel("BitMatch, \(presentation.title), \(presentation.percentText ?? "progress not yet measured")") + let isActive = coordinator.isOperationInProgress && coordinator.currentMode == .copyAndVerify + let presentation = isActive ? TransferProgressPresentation.make(coordinator: coordinator) : nil + Image(nsImage: MenuBarStatusIcon.image(isActive: isActive, fraction: presentation?.fraction)) + .frame(width: 18, height: 18) + .accessibilityLabel(presentation.map { + "BitMatch, \($0.title), \($0.percentText ?? "progress not yet measured")" + } ?? "BitMatch") + } +} + +/// Supply an image for the system menu bar, rather than relying on a hosted +/// Shape hierarchy. At most 102 small vector images are retained. +@MainActor +enum MenuBarStatusIcon { + private nonisolated enum State: Hashable, Sendable { + case idle + case indeterminate + case progress(Int) + } + + private static var images: [State: NSImage] = [:] + + static func image(isActive: Bool, fraction: Double?) -> NSImage { + let state: State + if !isActive { + state = .idle + } else if let fraction, fraction.isFinite { + // Finishing work must not draw a completed progress ring. + state = .progress(Int(min(max(fraction, 0), 0.99) * 100)) + } else { + state = .indeterminate + } + if let image = images[state] { return image } + + let brandPath = state == .idle + ? MenuBarBrandMark().path(in: CGRect(x: 0, y: 0, width: 18, height: 18)).cgPath + : nil + // AppKit may draw on another thread; capture only immutable values, + // with no actor-isolated SwiftUI work inside the callback. + let image = NSImage(size: NSSize(width: 18, height: 18), flipped: true) { @Sendable rect in + guard let context = NSGraphicsContext.current?.cgContext else { return false } + context.setLineCap(.round) + context.setLineJoin(.round) + + if let brandPath { + context.setStrokeColor(CGColor(gray: 0, alpha: 1)) + context.setLineWidth(1.7) + context.addPath(brandPath) + context.strokePath() } else { - MenuBarBrandMark() - .stroke(Color.primary, style: StrokeStyle(lineWidth: 1.7, lineCap: .round, lineJoin: .round)) - .accessibilityLabel("BitMatch") + let ring = rect.insetBy(dx: 2, dy: 2) + context.setLineWidth(2.5) + context.setStrokeColor(CGColor(gray: 0, alpha: 0.4)) + context.strokeEllipse(in: ring) + + switch state { + case .progress(let percent) where percent > 0: + context.setStrokeColor(CGColor(gray: 0, alpha: 1)) + context.addArc(center: CGPoint(x: ring.midX, y: ring.midY), + radius: ring.width / 2, + startAngle: -.pi / 2, + endAngle: -.pi / 2 + 2 * .pi * CGFloat(percent) / 100, + clockwise: false) + context.strokePath() + case .indeterminate: + context.setFillColor(CGColor(gray: 0, alpha: 1)) + context.fillEllipse(in: CGRect(x: rect.midX - 2, y: rect.midY - 2, + width: 4, height: 4)) + default: + break + } } + return true } - .frame(width: 18, height: 18) + image.isTemplate = true + images[state] = image + return image } } diff --git a/BitMatchTests/MacMenuBarCompanionTests.swift b/BitMatchTests/MacMenuBarCompanionTests.swift index a7a6a1f8..49ccf3fc 100644 --- a/BitMatchTests/MacMenuBarCompanionTests.swift +++ b/BitMatchTests/MacMenuBarCompanionTests.swift @@ -4,6 +4,100 @@ import Testing import BitMatchEngine struct MacMenuBarCompanionTests { + @Test @MainActor func idleIconHasVisibleTemplatePixelsAtMenuBarSize() throws { + try expectVisibleTemplate(MenuBarStatusIcon.image(isActive: false, fraction: nil)) + } + + @Test(arguments: [0.0, 0.5, 0.99]) @MainActor + func measuredProgressIconHasVisibleTemplatePixels(_ fraction: Double) throws { + try expectVisibleTemplate(MenuBarStatusIcon.image(isActive: true, fraction: fraction)) + } + + @Test @MainActor func indeterminateIconHasVisibleTemplatePixels() throws { + try expectVisibleTemplate(MenuBarStatusIcon.image(isActive: true, fraction: nil)) + } + + @Test @MainActor func idleAndProgressStatesDrawDifferentContent() throws { + let images = [ + MenuBarStatusIcon.image(isActive: false, fraction: nil), + MenuBarStatusIcon.image(isActive: true, fraction: 0), + MenuBarStatusIcon.image(isActive: true, fraction: 0.5), + MenuBarStatusIcon.image(isActive: true, fraction: 0.99), + MenuBarStatusIcon.image(isActive: true, fraction: nil) + ] + let masks = try images.map { try alphaMask(of: $0) } + #expect(Set(masks).count == images.count) + } + + @Test @MainActor func progressImagesAreQuantizedAndNeverClaimCompletion() { + let middle = MenuBarStatusIcon.image(isActive: true, fraction: 0.5) + #expect(middle === MenuBarStatusIcon.image(isActive: true, fraction: 0.509)) + let finishing = MenuBarStatusIcon.image(isActive: true, fraction: 0.99) + #expect(finishing === MenuBarStatusIcon.image(isActive: true, fraction: 0.9999)) + #expect(finishing === MenuBarStatusIcon.image(isActive: true, fraction: 1)) + #expect(finishing === MenuBarStatusIcon.image(isActive: true, fraction: 2)) + #expect(MenuBarStatusIcon.image(isActive: true, fraction: -1) + === MenuBarStatusIcon.image(isActive: true, fraction: 0)) + let unknown = MenuBarStatusIcon.image(isActive: true, fraction: nil) + #expect(unknown === MenuBarStatusIcon.image(isActive: true, fraction: .nan)) + #expect(unknown === MenuBarStatusIcon.image(isActive: true, fraction: .infinity)) + } + + @Test(arguments: [false, true]) @MainActor + func iconCanRenderAwayFromMainActor(_ isActive: Bool) async throws { + let cached = MenuBarStatusIcon.image(isActive: isActive, fraction: 0.37) + let image = try #require(cached.copy() as? NSImage) + image.cacheMode = .never + image.recache() + let isolatedImage = BackgroundImage(image: image) + let rendered = await Task.detached { + autoreleasepool { + (Thread.isMainThread, isolatedImage.image.tiffRepresentation) + } + }.value + + #expect(!rendered.0) + let data = try #require(rendered.1) + let bitmap = try #require(NSBitmapImageRep(data: data)) + var hasVisiblePixel = false + for y in 0.. 0 + } + } + #expect(hasVisiblePixel) + } + + // The private copy is configured on MainActor, then read only by its one + // background task. No NSImage mutation crosses threads. + private struct BackgroundImage: @unchecked Sendable { + let image: NSImage + } + + @MainActor private func expectVisibleTemplate(_ image: NSImage) throws { + #expect(image.size == NSSize(width: 18, height: 18)) + #expect(image.isTemplate) + let alpha = try alphaMask(of: image) + #expect(alpha.contains { $0 > 0 }) + #expect(alpha.contains(0)) + } + + @MainActor private func alphaMask(of image: NSImage) throws -> [UInt8] { + let cgImage = try #require(image.cgImage(forProposedRect: nil, context: nil, hints: nil)) + let bitmap = NSBitmapImageRep(cgImage: cgImage) + #expect(bitmap.pixelsWide >= 18) + #expect(bitmap.pixelsWide <= 72) + #expect(bitmap.pixelsHigh == bitmap.pixelsWide) + var alpha: [UInt8] = [] + for y in 0.. Date: Tue, 20 Oct 2026 07:29:27 -0400 Subject: [PATCH 4/4] Record final 0.2.10 walkthrough --- docs/TODO.md | 18 +++++++++--------- docs/validation/RELEASE_0_2_10.md | 8 ++++---- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/docs/TODO.md b/docs/TODO.md index e1b93fb4..e8de5b6c 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -1,12 +1,12 @@ # To do -The working list for BitMatch, reprioritized on October 16 and reviewed on October 20, 2026 against 0.2.9 and the unreleased validation branch. These are roadmap candidates, not instructions to implement every feature or a promised release scope. The report and transfer-details batch shipped in 0.2.7; see [validation](TODO_REPORTS_VALIDATION.md). When something ships, record it in the [changelog](../CHANGELOG.md) and remove the finished task here. +The working list for BitMatch, reprioritized on October 16 and reviewed on October 20, 2026 with the 0.2.10 validation batch. These are roadmap candidates, not instructions to implement every feature or a promised release scope. The report and transfer-details batch shipped in 0.2.7; see [validation](TODO_REPORTS_VALIDATION.md). When something ships, record it in the [changelog](../CHANGELOG.md) and remove the finished task here. **Source media → trustworthy copies → independent verification → accurate evidence → safe-to-erase decision.** Follow evidence and actual user reports. The AppleDouble request [#16](https://github.com/BitmatchApp/Bitmatch/issues/16) is closed; the [Guide](GUIDE.md) describes the current setting, and [the investigation](ISSUE16_APPLEDOUBLE.md) retains its earlier validation. Preserve source protection, no-overwrite guarantees, complete verification and honest verdicts. Core workflows apply to Mac, iPad and iPhone; keep platform limits explicit. See the [thesis](THESIS.md) and [architecture](../ARCHITECTURE.md). -Unreleased review work from October 19–20 is recorded in the [overnight validation](OVERNIGHT_VALIDATION_20261019.md): saved-evidence and report fixes, portable preferences, broader filesystem/physical tests, a reproduced filename crash, and queue continuation after reviewed metadata exclusions. These changes are in draft PRs, with the native release walkthrough and active user reports still open. +The October 19–20 batch includes saved-evidence and report fixes, portable preferences, broader filesystem/physical tests, a reproduced filename crash, and queue continuation after reviewed metadata exclusions. The [overnight record](OVERNIGHT_VALIDATION_20261019.md) and [0.2.10 release checks](validation/RELEASE_0_2_10.md) distinguish source revisions, synthetic tests, physical transfers and native checks. The active user reports remain open. ## Ranked work queue @@ -14,11 +14,11 @@ This is the order to **investigate or start a bounded slice**, not a promise to The two active reports come first as **casework**, but neither is a ready-to-ship fix: [#23](https://github.com/BitmatchApp/Bitmatch/issues/23) needs the reporter's version/phase or a reproduced memory path, and [#10](https://github.com/BitmatchApp/Bitmatch/issues/10) needs decisive diagnostics or a physical reproduction. Synthetic passes alone cannot close either report. -Unreleased work on `main`: terminal/History presentation, byte-weighted progress, Mac card-event reconciliation, recorded verification scope, unknown-drive Setup wording, the MHL corpus and read-only inventory-chain checks, the Mac menu bar companion and per-drive I/O detail have landed. SFTP first-upload and subprocess-cancellation fixes, a compact menu bar icon, bounded camera XML reads, FAT copy/history publication guards and opt-in local crash evidence have also merged. The engine test runner now executes both test frameworks. The menu bar and speed display still need a signed-app walkthrough, and crash evidence needs signed-app and device checks before release. None of this closes the active user reports or replaces the physical checks below. +Implemented for 0.2.10: terminal/History presentation, byte-weighted progress, Mac card-event reconciliation, recorded verification scope, unknown-drive Setup wording, the MHL corpus and read-only inventory-chain checks, the Mac menu bar companion and per-drive I/O detail have landed. SFTP first-upload and subprocess-cancellation fixes, a compact menu bar icon, bounded camera XML reads, FAT copy/history publication guards and opt-in local crash evidence have also merged. The engine test runner now executes both test frameworks. The final Mac walkthrough checked settings export/import and persistence, notification authorization, copy → Finishing → saved results on two SSDs, and the replacement menu icon/status/Open action. Active-window hide/Quit, per-drive speed details, physical card events and Apple crash-summary delivery still need their separate native/device checks. None of this closes the active user reports or replaces the physical checks below. | Rank | Build or decision work | Usefulness | Difficulty | Blast radius | Shippability | | ---: | --- | --- | --- | --- | --- | -| 1 | Terminal progress and outcome parity (merged; signed-app release check open) | High | Low–medium | Medium | Quick | +| 1 | Terminal progress and outcome parity (native completion checked; fault walkthrough open) | High | Low–medium | Medium | Quick | | 2 | Verification coverage and default-policy audit (recorded-scope wording merged; hardware/default decisions open) | High | Medium | Medium | Scoped | | 3 | Honest claims when physical drive identity is unknown (Setup note merged; verdict decision open) | High | Medium | Medium | Scoped | | 4 | Distinguish local-source, local-backup and SFTP assurance (upload guards merged; broader assurance audit open) | High | Low–medium | Low–medium | Quick | @@ -26,13 +26,13 @@ Unreleased work on `main`: terminal/History presentation, byte-weighted progress | 6 | Reconcile missed Mac card events (merged; physical check open) | Medium–high | Low | Low | Quick | | 7 | Eliminate the exFAT/FAT media-file publication race (implemented; FAT32/image and exFAT hardware results recorded) | Very high | High | High | Field validation open | | 8 | Mark failed readback output unmistakably on disk | High | High | High | After publication ownership | -| 9 | Mac menu bar companion and icon (merged; signed-app walkthrough open) | High | Medium | Medium | Scoped | +| 9 | Mac menu bar companion and icon (native status/Open checked; active exit follow-up open) | High | Medium | Medium | Scoped | | 10 | Per-drive copy/readback speed (merged; signed-app walkthrough open) | Medium–high | Medium | Low–medium | Scoped | | 11 | Byte-weighted transfer and queue progress (merged; reuse refinement and walkthrough open) | High | High | Medium | Field validation open | | 12 | Local, opt-in crash and hang evidence (merged; signed-app and device checks open) | Medium–high | Medium | Low–medium | Scoped | | 13 | History discovered from connected authorized drives | High | High | Medium | Long | | 14 | Investigate a macOS 15.0 minimum | Medium | Low–medium | Medium | Scoped; older OS test | -| 15 | Portable settings and workflow import/export | Medium | Medium | Medium | Scoped | +| 15 | Portable settings (transfer options validated; recipes/design open) | Medium | Medium | Medium | Scoped | | 16 | Measure before changing the transfer hash | Medium–high | Medium | Low | After physical benchmark | | 17 | Storage-aware verification scheduling | High | High | Medium–high | After physical benchmark | | 18 | Bounded source-read retry with card-health warning | High | High | High | Long; fault model first | @@ -122,7 +122,7 @@ The priority buckets are not a strict implementation sequence: investigate P0 re ## P2 — Evidence and interoperability -The independent ASC corpus covers unsupported file hashes, XXH64 boundaries and an official two-generation file/directory-rename history; its reference runner passes 82 checks. October 19 adds read-only inventory-chain validation and a separately reference-checked extended FAT-history fixture (#57), after full engine/Mac tests and iPhone/iPad simulator suites passed. Physical-media and signed-app checks still remain before a release. Interoperability and structural/history work below remain open. +The independent ASC corpus covers unsupported file hashes, XXH64 boundaries and an official two-generation file/directory-rename history; its reference runner passes 82 checks. October 19 adds read-only inventory-chain validation and a separately reference-checked extended FAT-history fixture (#57), after full engine/Mac tests and iPhone/iPad simulator suites passed. The October 19–20 physical-media matrix and final Mac transfer passed; representative third-party evidence, mobile-provider/device checks and the structural/history work below remain open. - [ ] **Upstream MHL / source provenance.** Reuse `SavedChecksumCheck` where appropriate to offer source verification against accompanying classic MHL or ASC MHL evidence. Record “source arrived intact” separately from “BitMatch created verified backups.” Preserve original manifests. Current Check discovers ASC inventories under `ascmhl/`; standalone classic-MHL discovery and an upstream transfer verdict still need work. **Acceptance:** incomplete, mismatched, malformed and unsupported evidence gets an honest separate verdict; a failed provenance check never becomes success and never prevents preservation of damaged-but-irreplaceable media. @@ -140,7 +140,7 @@ The independent ASC corpus covers unsupported file hashes, XXH64 boundaries and Keep the existing universal iPhone/iPad app's three local modes—Copy & Verify, Check and Master Report—fully functional, including standalone USB-C offloading. Add **Remote Mac** as a fourth mode in that same app, starting with read-only monitoring; choose a host inside Remote rather than adding a host selector to every local screen. Optimize iPad for a persistent DIT-cart control surface and iPhone for compact monitoring. The Mac remains the sole authority for its storage, transfers, verification, queue, journal, reports and safe-to-erase verdicts. Reuse `SharedAppCoordinator`, shared readiness and presentation models; do not create a second transfer engine or simulate UI clicks. Remote hosting remains free and open source on Mac. A **$5.99 lifetime** universal iOS/iPadOS price is a hypothesis to test, not a settled release decision; avoid subscriptions, feature tiers, accounts and an internet requirement, and use TestFlight before release. -- [ ] **Phase 1 — Mac menu bar companion.** The first implementation and compact copy-and-check icon are merged: `MenuBarExtra` shows active stage, progress and per-destination status or the most recent result; closing the main window during a transfer hides it, Open BitMatch restores that window, and Quit retains the transfer guard. Local automated tests cover a transfer completing while hidden and reopening the intended window. Finish a signed-app walkthrough of the menu and icon, window close/reopen, Quit, and a newly detected card before calling this release-ready. Later show connected clients inside the menu; a lost remote connection is not a transfer failure. Keep one normal Mac application, without a helper or daemon. +- [ ] **Phase 1 — Mac menu bar companion.** The first implementation and compact copy-and-check icon are merged: `MenuBarExtra` shows active stage, progress and per-destination status or the most recent result; closing the main window during a transfer hides it, Open BitMatch restores that window, and Quit retains the transfer guard. Local automated tests cover a transfer completing while hidden and reopening the intended window. The 0.2.10 walkthrough confirmed the replacement icon, idle status/last card and Open BitMatch. Keep the active-window hide/reopen, Quit settlement and newly detected-card native walkthrough open; automated checks cover the first two. Later show connected clients inside the menu; a lost remote connection is not a transfer failure. Keep one normal Mac application, without a helper or daemon. - [ ] **Phase 2 — Read-only remote monitoring.** The fourth mobile mode discovers and selects a Mac over Bonjour, then uses an authenticated, encrypted local-network connection over Wi-Fi or wired Ethernet; explicit Mac opt-in, one-time code/QR pairing, persistent device identity, connected-client visibility and revocation. Handle networks that block discovery or device-to-device traffic explicitly. Send an authoritative initial snapshot, bounded/coalesced live updates and paginated/on-demand recent history. **Acceptance:** client disconnection or mobile background suspension never affects Mac work; Mac sleep/quit makes the remote view visibly stale, never falsely complete. Reconnect obtains a fresh snapshot, and host/session identity plus revisions prevent missed or old events from becoming current results. Never infer completion from 100% progress or attach a cached safe verdict to a newly connected card. No unauthenticated control or automatic diagnostic/report uploads; minimize private names and paths, and consider display-only privacy mode. - [ ] **Phase 3 — Remote setup, queue and Start.** Display Mac-attached sources, authorized destinations, capacity, availability and the Mac's readiness result. Select, enqueue and start through existing coordinator operations using opaque host-validated storage identities, not raw paths. Any required macOS folder authorization stays on the Mac. **Acceptance:** authenticated commands target the intended host and current operation/queue revision, revalidate source/destination and physical-device identities at execution, and preserve preflight, no-overwrite and independence rules. Stable request IDs make duplicate/delayed Start requests return the existing result rather than create work twice. Disconnected clients cannot submit commands. - [ ] **Phase 4 — Operational controls and outcomes.** Add supported pause/resume, retry, queue management and cancellation with explicit confirmation. Show actual engine stages, destination-specific progress, measured throughput/ETA where available, warnings and authoritative final outcomes. Browse recent history without automatically fetching PDFs. **Acceptance:** stale or out-of-order mutations are rejected or safely reconciled; retries preserve the old attempt, and remote controls cannot bypass any Mac safety rule. @@ -163,7 +163,7 @@ Remote scope excludes screen sharing, arbitrary filesystem browsing, remote shel - [ ] **Portable settings/workflow export and import.** The first bounded slice is implemented and locally validated on Mac, iPad and iPhone simulator: verification, ASC MHL, the global AppleDouble preference, report/thumbnail switches and basic naming options move through Settings as a small versioned JSON file. It requires a changes preview, rejects unsupported or oversized files, and blocks Apply during active, queued or prepared work. It omits labels, paths, per-source exclusions, private report fields, credentials, bookmarks, history and identities. **Remaining:** explicit portable folder-recipe and reusable destination configuration designs; never serialize existing job/preset records or activate imported destinations automatically. Preview changes and revalidate destinations on the receiving machine. No facility configuration-management system. - [ ] **Simplified operator mode, only if users ask.** Investigate preconfigured destinations, naming and verification with an everyday source → start → review flow. Warnings stay visible and validation still runs; no facility administration or permission system. - [ ] **Organize and rename.** Extend existing project naming and folder recipes with remaining naming-pattern and organize-by-date workflows. Preserve original card contents and test destination collisions before adding new options. -- [ ] **Honest byte-weighted progress.** Implemented in #56. Integration passed full engine/Mac tests, the universal iOS build and iPhone/iPad simulator suites. The shared implementation budgets selected-file bytes for source reads, writes/reuse checks, required verification and optional ASC MHL work; live chunk callbacks advance large clips, and finishing reports never reset the bar or show active 100%. Destination bars use their own byte work. Queue cards use persisted size estimates, destination counts and verification mode; a queue with unknown sizes stays indeterminate. Estimates stay separate from copy evidence and remain stable per attempt. Synthetic regressions cover tiny-plus-large files, uneven destinations, reuse, cancellation/failure, MHL mapping and 10,000 small files. **Remaining:** signed-app/physical-drive walkthroughs and finer progress through extra source-hashing passes on reused files. The current retry budget remains deliberately logical rather than an exact tally of every physical read. Reserve 100% for the authoritative result. +- [ ] **Honest byte-weighted progress.** Implemented in #56. Integration passed full engine/Mac tests, the universal iOS build and iPhone/iPad simulator suites. The shared implementation budgets selected-file bytes for source reads, writes/reuse checks, required verification and optional ASC MHL work; live chunk callbacks advance large clips, and finishing reports never reset the bar or show active 100%. Destination bars use their own byte work. Queue cards use persisted size estimates, destination counts and verification mode; a queue with unknown sizes stays indeterminate. Estimates stay separate from copy evidence and remain stable per attempt. Synthetic regressions cover tiny-plus-large files, uneven destinations, reuse, cancellation/failure, MHL mapping and 10,000 small files. **Remaining:** broader native progress/fault walkthroughs and finer progress through extra source-hashing passes on reused files. The final Mac SD-to-two-SSD run showed Copying, then Finishing at 89%, before its authoritative result. The current retry budget remains deliberately logical rather than an exact tally of every physical read. Reserve 100% for the authoritative result. - [x] **Speed for each drive.** Expanded transfer details show each local destination's written and read-back bytes with separate live I/O rates. These are measured write/read calls, not a durability benchmark or verification verdict. The telemetry is shared with future storage-scheduling work; this change does not alter scheduling. - [x] **Reconcile missed Mac card events.** Mount notifications and manual rescan now reconcile the mounted filesystem's identity with published cards and drives. Deterministic event-gap tests cover unmount/reinsert, same-path replacement, stale asynchronous analysis and source selection; stale discovery cannot authorize auto-queue. This is a bounded event-triggered backstop, not a new polling service. A real removable-card walkthrough remains in the release gate below. - [x] **Slow-connection hint.** Mac Advanced now shows a quiet note for a selected backup whose USB ancestry reports USB 2. Nested selected folders resolve to their volume. The note was checked with two actual connections; faster or unknown links stay quiet. It suggests checking the cable, hub or port without assigning the cause or promising a speed multiplier. No background survey of unselected drives or scheduling change. diff --git a/docs/validation/RELEASE_0_2_10.md b/docs/validation/RELEASE_0_2_10.md index 2657a914..216e3979 100644 --- a/docs/validation/RELEASE_0_2_10.md +++ b/docs/validation/RELEASE_0_2_10.md @@ -1,6 +1,6 @@ # 0.2.10 release validation -Candidate source: `71bdc9dbdd41b66d2827f5c8ce7454f0352dc85f`, version 0.2.10, build 20. Later documentation edits do not change the 451 recorded source/configuration hashes. Validation uses local Xcode 27.1 (27A9275), Swift 6.4. +Final Mac candidate: `b4e23c252b0f1f2bbc9e95b1d42393b1dc06c354`, version 0.2.10, build 20. Validation uses local Xcode 27.1 (27A9275), Swift 6.4. Engine and mobile checks ran at `71bdc9dbdd41b66d2827f5c8ce7454f0352dc85f`; only the Mac app scene, Mac menu-bar drawing and its Mac tests changed afterward. The other 448 recorded source/configuration hashes, including all engine and mobile inputs, match. The final Mac suite and replacement archive use the later candidate. ## Completed local checks @@ -10,7 +10,7 @@ Candidate source: `71bdc9dbdd41b66d2827f5c8ce7454f0352dc85f`, version 0.2.10, bu | Engine Debug, Swift Testing | 217 passed, six opt-in skips, zero failures | | Engine Release, XCTest | 205 passed, 43 opt-in skips, zero failures | | Engine Release, Swift Testing | 217 passed, six opt-in skips, zero failures | -| Mac app suite | 1,308 passed, 23 skips, zero failures | +| Final Mac app suite | 1,314 passed, 23 skips, zero failures | | iPad Air M4 simulator | 75 passed, ten skips, zero failures | | iPhone 18 Pro simulator | 75 passed, ten skips, zero failures | | iOS device Release build | Succeeded; actual arm64 binary, iOS 18.5 minimum | @@ -27,13 +27,13 @@ The first notification-focused attempt failed the newly added stale-error cases The temporary diagnostic app's button returned an immediate system error with no visible explanation. The notification service also logged that it could not validate that diagnostic bundle. The new UI shows pending and failed requests, offers System Settings for the running bundle, and allows retry without changing notification preferences. -In the notarized production app, Enable Notifications entered the pending state and disabled duplicate requests. Mike handled the protected macOS prompt and confirmed that Settings showed **Authorized**. The automation tool refuses to control that system dialog. This does not establish the cause of every notification failure. +In the initial notarized production candidate, Enable Notifications entered the pending state and disabled duplicate requests. Mike handled the protected macOS prompt and confirmed that Settings showed **Authorized**. The automation tool refuses to control that system dialog. This does not establish the cause of every notification failure. The production app preserved an older paused queue and correctly blocked settings import while work needed attention. A separate, locally signed copy uses an isolated data profile for the remaining walkthrough; it is not separately notarized. The original notarized app and DMG remain unchanged. Native settings export passed. Mike confirmed that the actual two-change import opened a compact review, Cancel retained the original settings, and Apply worked. An initial post-check still found Thorough with previews off, so that observation was not counted as a persistence pass. After reopening the exact file and Mike applying it again, both the saved preferences and the native UI showed Standard with clip thumbnails on. No import/reset defect was established. The controller disconnects while that review sheet is open; the human actions and subsequent independent checks are recorded separately. -The isolated copy also completed a real Thorough transfer from the disposable SD-card fixture to separate folders on T9 and WheeDrive, with ASC MHL and reports enabled. Each destination showed three verified files, zero unverified files and three intentionally excluded AppleDouble companions. The live row showed Copying, then Finishing at 89%, before the final result. The selected-file queue finished; the whole-card verdict remained conservative because files were excluded. Advanced also identified the selected WheeDrive's reported USB 2 connection. Independent checks matched all six source files against the pre-transfer hashes, inode identities, sizes, modification times and modes, plus all six selected backup copies. Both three-file MHL inventories, inventory-chain hashes and completion-record digests validated. PDF/CSV/JSON/checksum reports validated, and both rendered PDF pages were readable. The checker's initial file-URL/path comparison error is retained separately from its corrected passing run; these independent XML/hash checks are not a new formal ASC schema-validation run. Previews were off for this transfer. Mike reported no menu-bar icon in the running candidate. Publication is on hold while a template-image label and explicit companion-scene insertion are tested. Crowding can also hide inserted items; the report alone does not establish which cause applies. The existing signed artifact is retained and will not be published as the final candidate. +The isolated copy also completed a real Thorough transfer from the disposable SD-card fixture to separate folders on T9 and WheeDrive, with ASC MHL and reports enabled. Each destination showed three verified files, zero unverified files and three intentionally excluded AppleDouble companions. The live row showed Copying, then Finishing at 89%, before the final result. The selected-file queue finished; the whole-card verdict remained conservative because files were excluded. Advanced also identified the selected WheeDrive's reported USB 2 connection. Independent checks matched all six source files against the pre-transfer hashes, inode identities, sizes, modification times and modes, plus all six selected backup copies. Both three-file MHL inventories, inventory-chain hashes and completion-record digests validated. PDF/CSV/JSON/checksum reports validated, and both rendered PDF pages were readable. The checker's initial file-URL/path comparison error is retained separately from its corrected passing run; these independent XML/hash checks are not a new formal ASC schema-validation run. Previews were off for this transfer. Mike reported no menu-bar icon in the initial candidate. The replacement supplies real template images and uses explicit companion-scene insertion, following [Apple's multi-scene API](https://developer.apple.com/documentation/swiftui/menubarextra/init(isinserted:content:label:)). Image tests check visible pixels, distinct progress states, bounded caching and off-main drawing. Review caught a callback-isolation risk before the final build; the corrected callback is explicitly Sendable and captures only immutable data. No corresponding crash was reproduced. The replacement passed the full Mac suite, notarization and Gatekeeper. The initial signed artifact is retained separately. Mike confirmed that the replacement icon is visible, the menu shows the idle status and last card, and Open BitMatch works. This establishes the replacement's native behavior; the two changes were tested together, so it does not isolate one cause. The active-window hide/Quit guard and new-card event paths retain automated coverage but were not separately exercised in this final native check. ## Limits