diff --git a/README.md b/README.md index 9d08066..7f515b6 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,12 @@ swift run ZoomIt The self-test covers viewport math, annotation lifecycle/rendering, settings persistence, and panorama stitcher regressions. +Run the sandbox product-surface tests with the App Store compiler condition: + +```sh +swift run -Xswiftc -DZOOMIT_APP_STORE ZoomItMacSelfTest +``` + Launch at login requires running ZoomIt as an app bundle so macOS attributes the login item to ZoomIt instead of the host process used for development. Build the bundle with: ```sh @@ -134,6 +140,39 @@ entered when the pipeline is queued. Values must contain two or three numeric components, such as `1.2` or `1.2.0`; malformed values fail before compilation or signing begins. +## Build Variants + +One source tree produces the standard unsandboxed application and the sandboxed +product surface used for Mac App Store releases. + +| Variant | Product surface | +| --- | --- | +| Homebrew | Unsandboxed, including DemoType | +| Mac App Store | App Sandbox; DemoType is compiled out | + +`ZOOMIT_DISTRIBUTION` selects the channel and defaults to `homebrew`, preserving +the existing local build command. `ZOOMIT_BUILD_NUMBER` independently stamps a +numeric `CFBundleVersion` when a specific local value is needed. + +```sh +# Existing contributor/Homebrew behavior +zsh Scripts/build-app.sh release + +# Explicit Homebrew variant +ZOOMIT_DISTRIBUTION=homebrew ZOOMIT_BUILD_NUMBER=101 \ + zsh Scripts/build-app.sh release + +# Sandboxed local prototype (still uses the separate contributor identity) +ZOOMIT_DISTRIBUTION=appstore ZOOMIT_BUILD_NUMBER=101 \ + zsh Scripts/build-app.sh release +``` + +The App Store variant uses security-scoped bookmarks for the selected break +sound, break background, and automatic snip folder. Re-select a resource in +Settings if macOS reports that its authorization can no longer be restored. +Screen recording remains controlled by macOS privacy consent and has no app +entitlement. + By default this produces `.build/ZoomIt (Dev).app` with the app icon, bundled resources, and an `Info.plist` declaring the microphone and camera usage descriptions. `release` builds are **Universal** (Apple Silicon + Intel) by default; `debug` builds are native to the build machine for speed. Override the architectures with `ZOOMIT_ARCHS` (e.g. `ZOOMIT_ARCHS=arm64`). A Universal build routes through Xcode's build system, so it requires a **full Xcode** install — with only the Command Line Tools the script warns and falls back to a native build. The build summary prints the resulting architectures. ### Create and install a local development DMG diff --git a/Scripts/ZoomIt-AppStore.entitlements b/Scripts/ZoomIt-AppStore.entitlements new file mode 100644 index 0000000..b9f36fb --- /dev/null +++ b/Scripts/ZoomIt-AppStore.entitlements @@ -0,0 +1,16 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.files.user-selected.read-write + + com.apple.security.files.bookmarks.app-scope + + com.apple.security.device.camera + + com.apple.security.device.audio-input + + + \ No newline at end of file diff --git a/Scripts/build-app.sh b/Scripts/build-app.sh index ba00b29..958ccfe 100755 --- a/Scripts/build-app.sh +++ b/Scripts/build-app.sh @@ -4,7 +4,22 @@ set -euo pipefail ROOT_DIR="${0:A:h:h}" CONFIGURATION="${1:-debug}" ICON_SOURCE="$ROOT_DIR/Sources/ZoomItMacCore/Resources/ZoomItColorIcon.png" -ENTITLEMENTS="$ROOT_DIR/Scripts/ZoomIt.entitlements" + +DIST_DISTRIBUTION="${ZOOMIT_DISTRIBUTION:-homebrew}" +case "$DIST_DISTRIBUTION" in + homebrew) + ENTITLEMENTS="$ROOT_DIR/Scripts/ZoomIt.entitlements" + distribution_swift_flags=() + ;; + appstore) + ENTITLEMENTS="$ROOT_DIR/Scripts/ZoomIt-AppStore.entitlements" + distribution_swift_flags=(-Xswiftc -DZOOMIT_APP_STORE) + ;; + *) + echo "error: ZOOMIT_DISTRIBUTION must be 'homebrew' or 'appstore' (got '$DIST_DISTRIBUTION')." >&2 + exit 2 + ;; +esac # Local builds default to 1.0 when ZOOMIT_VERSION is absent. An explicitly # empty value still fails, which prevents a queued official build from silently @@ -17,6 +32,12 @@ if [[ ! "$VERSION" =~ $VERSION_PATTERN ]]; then echo "error: ZOOMIT_VERSION must be a dotted numeric version such as 1.0 or 1.0.0 (got '$VERSION')." >&2 exit 2 fi +BUILD_NUMBER="${ZOOMIT_BUILD_NUMBER:-$VERSION}" +BUILD_NUMBER_PATTERN='^[0-9]+(\.[0-9]+){0,2}$' +if [[ ! "$BUILD_NUMBER" =~ $BUILD_NUMBER_PATTERN ]]; then + echo "error: ZOOMIT_BUILD_NUMBER must contain one to three numeric components (got '$BUILD_NUMBER')." >&2 + exit 2 +fi case "${ZOOMIT_REQUIRE_RELEASE_VERSION:-false}" in true|True|TRUE|1) if [[ "$VERSION" == "0.0.0" ]]; then @@ -82,8 +103,8 @@ if (( ${#arch_flags} > 0 )) && ! xcodebuild -version >/dev/null 2>&1; then arch_flags=() fi -swift build -c "$CONFIGURATION" $arch_flags -BIN_DIR="$(swift build -c "$CONFIGURATION" $arch_flags --show-bin-path)" +swift build -c "$CONFIGURATION" $arch_flags $distribution_swift_flags +BIN_DIR="$(swift build -c "$CONFIGURATION" $arch_flags $distribution_swift_flags --show-bin-path)" rm -rf "$APP_PATH" mkdir -p "$APP_PATH/Contents/MacOS" "$APP_PATH/Contents/Resources" @@ -150,7 +171,7 @@ cat > "$APP_PATH/Contents/Info.plist" <CFBundleShortVersionString $VERSION CFBundleVersion - $VERSION + $BUILD_NUMBER LSMinimumSystemVersion 14.0 LSUIElement @@ -159,6 +180,8 @@ cat > "$APP_PATH/Contents/Info.plist" <ZoomIt shows your webcam as a picture-in-picture overlay when you enable it for screen recordings. NSMicrophoneUsageDescription ZoomIt records your microphone when you enable microphone capture for screen recordings. + ZoomItDistribution + $DIST_DISTRIBUTION PLIST @@ -191,5 +214,7 @@ echo "$APP_PATH" echo " bundle id: $BUNDLE_ID" >&2 echo " display name: $DISPLAY_NAME" >&2 echo " version: $VERSION" >&2 +echo " build: $BUILD_NUMBER" >&2 +echo " distribution: $DIST_DISTRIBUTION" >&2 echo " signed with: $SIGN_DESC" >&2 echo " architectures: $(lipo -archs "$APP_PATH/Contents/MacOS/ZoomIt" 2>/dev/null || echo unknown)" >&2 \ No newline at end of file diff --git a/Sources/ZoomItMacCore/App/AppController.swift b/Sources/ZoomItMacCore/App/AppController.swift index 91fda0c..bccc237 100644 --- a/Sources/ZoomItMacCore/App/AppController.swift +++ b/Sources/ZoomItMacCore/App/AppController.swift @@ -6,6 +6,7 @@ final class AppController: NSObject { private let permissionService: PermissionService private let hotkeyService: HotkeyService private let modeCoordinator: ModeCoordinator + private let userSelectedResourceAccess: UserSelectedResourceAccess /// One-shot observer used to re-present the permissions dialog when the user /// returns to ZoomIt after being sent to System Settings. private var permissionReactivationObserver: NSObjectProtocol? @@ -16,19 +17,22 @@ final class AppController: NSObject { onResumeHotkeys: { [weak self] in self?.hotkeyService.start() }, onRequestMicrophone: { [weak self] in self?.permissionService.requestMicrophoneAccess(completion: nil) }, onRequestCamera: { [weak self] in self?.permissionService.requestCameraAccess(completion: nil) }, - onOpenTrimEditor: { [weak self] in self?.modeCoordinator.openTrimEditor() } + onOpenTrimEditor: { [weak self] in self?.modeCoordinator.openTrimEditor() }, + userSelectedResourceAccess: userSelectedResourceAccess ) init( settingsStore: SettingsStore, permissionService: PermissionService, hotkeyService: HotkeyService, - modeCoordinator: ModeCoordinator + modeCoordinator: ModeCoordinator, + userSelectedResourceAccess: UserSelectedResourceAccess ) { self.settingsStore = settingsStore self.permissionService = permissionService self.hotkeyService = hotkeyService self.modeCoordinator = modeCoordinator + self.userSelectedResourceAccess = userSelectedResourceAccess super.init() } diff --git a/Sources/ZoomItMacCore/App/AppDelegate.swift b/Sources/ZoomItMacCore/App/AppDelegate.swift index b50f7cc..fa2709a 100644 --- a/Sources/ZoomItMacCore/App/AppDelegate.swift +++ b/Sources/ZoomItMacCore/App/AppDelegate.swift @@ -27,9 +27,10 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { settingsStore.save(migratedSettings) } let permissionService = SystemPermissionService() + let userSelectedResourceAccess = UserDefaultsUserSelectedResourceAccess() let displayManager = SystemDisplayManager() let captureService = ScreenCaptureKitCaptureService(displayManager: displayManager) - let overlayController = OverlayWindowController() + let overlayController = OverlayWindowController(userSelectedResourceAccess: userSelectedResourceAccess) let annotationController = AnnotationController() let viewportController = ZoomViewportController() @@ -40,7 +41,8 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { captureService: captureService, overlayController: overlayController, annotationController: annotationController, - viewportController: viewportController + viewportController: viewportController, + userSelectedResourceAccess: userSelectedResourceAccess ) let hotkeyService = HotkeyService(settingsStore: settingsStore) { command in @@ -61,7 +63,8 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { settingsStore: settingsStore, permissionService: permissionService, hotkeyService: hotkeyService, - modeCoordinator: modeCoordinator + modeCoordinator: modeCoordinator, + userSelectedResourceAccess: userSelectedResourceAccess ) DistributedNotificationCenter.default().addObserver( diff --git a/Sources/ZoomItMacCore/App/DemoTypeController.swift b/Sources/ZoomItMacCore/App/DemoTypeController.swift index 4c1c251..43164b2 100644 --- a/Sources/ZoomItMacCore/App/DemoTypeController.swift +++ b/Sources/ZoomItMacCore/App/DemoTypeController.swift @@ -1,3 +1,4 @@ +#if !ZOOMIT_APP_STORE import AppKit import Carbon.HIToolbox @@ -570,3 +571,4 @@ final class DemoTypeController { alert.runModal() } } +#endif diff --git a/Sources/ZoomItMacCore/App/DistributionChannel.swift b/Sources/ZoomItMacCore/App/DistributionChannel.swift new file mode 100644 index 0000000..854c2a3 --- /dev/null +++ b/Sources/ZoomItMacCore/App/DistributionChannel.swift @@ -0,0 +1,7 @@ +enum DistributionChannel { + #if ZOOMIT_APP_STORE + static let isAppStore = true + #else + static let isAppStore = false + #endif +} \ No newline at end of file diff --git a/Sources/ZoomItMacCore/Capture/DemoMirrorController.swift b/Sources/ZoomItMacCore/Capture/DemoMirrorController.swift index ca0646b..7ba2fa7 100644 --- a/Sources/ZoomItMacCore/Capture/DemoMirrorController.swift +++ b/Sources/ZoomItMacCore/Capture/DemoMirrorController.swift @@ -462,9 +462,11 @@ final class DemoMirrorController { private func startTrackingTimer(source: DisplayDescriptor) { trackingTimer?.invalidate() trackingTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in - guard let self else { return } - Task { @MainActor in - await self.refreshTrackedWindow(source: source) + MainActor.assumeIsolated { + guard let self else { return } + Task { @MainActor in + await self.refreshTrackedWindow(source: source) + } } } } diff --git a/Sources/ZoomItMacCore/Capture/ImageExporter.swift b/Sources/ZoomItMacCore/Capture/ImageExporter.swift index 8ba542e..1c3b27f 100644 --- a/Sources/ZoomItMacCore/Capture/ImageExporter.swift +++ b/Sources/ZoomItMacCore/Capture/ImageExporter.swift @@ -27,17 +27,23 @@ enum ImageExporter { static func saveImage( _ image: CGImage, settings: AppSettings, + userSelectedResourceAccess: UserSelectedResourceAccess, onWillShowSaveDialog: (() -> Void)? = nil ) { if settings.copySnipToClipboardOnSave { copyToPasteboard(image) } if settings.saveSnipToDirectory { - writeToDirectory(image, directoryPath: settings.snipSaveDirectory) - } else { - onWillShowSaveDialog?() - presentSavePanel(for: image) + if writeToDirectory( + image, + directoryPath: settings.snipSaveDirectory, + userSelectedResourceAccess: userSelectedResourceAccess + ) { + return + } } + onWillShowSaveDialog?() + presentSavePanel(for: image) } /// Presents a Save dialog defaulting to a timestamped PNG name and writes @@ -62,25 +68,30 @@ enum ImageExporter { /// Writes the image as a timestamped PNG into `directoryPath` (or the user's /// Documents folder when it is empty), creating the directory if needed. - static func writeToDirectory(_ image: CGImage, directoryPath: String) { - let directoryURL = resolvedSaveDirectory(directoryPath) - let fileManager = FileManager.default + @discardableResult + static func writeToDirectory( + _ image: CGImage, + directoryPath: String, + userSelectedResourceAccess: UserSelectedResourceAccess + ) -> Bool { do { - try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true) - } catch { - NSApp.activate(ignoringOtherApps: true) - NSAlert(error: error).runModal() - return - } + try userSelectedResourceAccess.withAccess( + to: .snipDirectory, + legacyPath: resolvedSaveDirectory(directoryPath).path + ) { directoryURL in + let fileManager = FileManager.default + try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true) - let url = directoryURL.appendingPathComponent(suggestedFilename()) - let rep = NSBitmapImageRep(cgImage: image) - guard let png = rep.representation(using: .png, properties: [:]) else { return } - do { - try png.write(to: url) + let url = directoryURL.appendingPathComponent(suggestedFilename()) + let rep = NSBitmapImageRep(cgImage: image) + guard let png = rep.representation(using: .png, properties: [:]) else { throw CocoaError(.fileWriteUnknown) } + try png.write(to: url) + } + return true } catch { NSApp.activate(ignoringOtherApps: true) NSAlert(error: error).runModal() + return false } } diff --git a/Sources/ZoomItMacCore/Capture/PanoramaController.swift b/Sources/ZoomItMacCore/Capture/PanoramaController.swift index ea11b22..0f47eb9 100644 --- a/Sources/ZoomItMacCore/Capture/PanoramaController.swift +++ b/Sources/ZoomItMacCore/Capture/PanoramaController.swift @@ -50,6 +50,7 @@ final class PanoramaController { private let displayManager: DisplayManager private let permissionService: PermissionService private let settingsStore: SettingsStore + private let userSelectedResourceAccess: UserSelectedResourceAccess private(set) var isCapturing = false private var stopRequested = false @@ -86,11 +87,13 @@ final class PanoramaController { init( displayManager: DisplayManager, permissionService: PermissionService, - settingsStore: SettingsStore + settingsStore: SettingsStore, + userSelectedResourceAccess: UserSelectedResourceAccess ) { self.displayManager = displayManager self.permissionService = permissionService self.settingsStore = settingsStore + self.userSelectedResourceAccess = userSelectedResourceAccess } /// Toggles panorama capture. The first call selects a region and begins @@ -323,7 +326,11 @@ final class PanoramaController { } if save { - ImageExporter.saveImage(cgImage, settings: settingsStore.load()) { [weak self] in + ImageExporter.saveImage( + cgImage, + settings: settingsStore.load(), + userSelectedResourceAccess: userSelectedResourceAccess + ) { [weak self] in self?.onWillShowSaveDialog?() } return "Panorama ready to save" diff --git a/Sources/ZoomItMacCore/Capture/SnipController.swift b/Sources/ZoomItMacCore/Capture/SnipController.swift index 9be3038..fed1932 100644 --- a/Sources/ZoomItMacCore/Capture/SnipController.swift +++ b/Sources/ZoomItMacCore/Capture/SnipController.swift @@ -202,6 +202,7 @@ final class SnipController { private let displayManager: DisplayManager private let permissionService: PermissionService private let settingsStore: SettingsStore + private let userSelectedResourceAccess: UserSelectedResourceAccess private var window: NSWindow? private var capturedFrame: CapturedFrame? @@ -213,12 +214,14 @@ final class SnipController { captureService: ScreenCaptureService, displayManager: DisplayManager, permissionService: PermissionService, - settingsStore: SettingsStore + settingsStore: SettingsStore, + userSelectedResourceAccess: UserSelectedResourceAccess ) { self.captureService = captureService self.displayManager = displayManager self.permissionService = permissionService self.settingsStore = settingsStore + self.userSelectedResourceAccess = userSelectedResourceAccess } /// Begins a region selection. `action` chooses what to do with the selected @@ -314,7 +317,11 @@ final class SnipController { switch action { case .saveImage: - ImageExporter.saveImage(cropped, settings: settingsStore.load()) + ImageExporter.saveImage( + cropped, + settings: settingsStore.load(), + userSelectedResourceAccess: userSelectedResourceAccess + ) case .copyImage: ImageExporter.copyToPasteboard(cropped) case .recognizeText: diff --git a/Sources/ZoomItMacCore/Core/AppCommand.swift b/Sources/ZoomItMacCore/Core/AppCommand.swift index ccd6d53..e0fef65 100644 --- a/Sources/ZoomItMacCore/Core/AppCommand.swift +++ b/Sources/ZoomItMacCore/Core/AppCommand.swift @@ -18,8 +18,10 @@ enum AppCommand: Equatable { case snipOcr case startPanorama(save: Bool) case toggleRecording(region: Bool) + #if !ZOOMIT_APP_STORE case startDemoType case resetDemoType + #endif case toggleBreakTimer case toggleDemoMirror(scope: DemoMirrorScope) case exit diff --git a/Sources/ZoomItMacCore/Core/ModeCoordinator.swift b/Sources/ZoomItMacCore/Core/ModeCoordinator.swift index 89a7044..3a89f69 100644 --- a/Sources/ZoomItMacCore/Core/ModeCoordinator.swift +++ b/Sources/ZoomItMacCore/Core/ModeCoordinator.swift @@ -9,6 +9,7 @@ final class ModeCoordinator { private let overlayController: OverlayWindowController private let annotationController: AnnotationController private let viewportController: ZoomViewportController + private let userSelectedResourceAccess: UserSelectedResourceAccess private(set) var mode: AppMode = .idle private var isExiting = false @@ -21,7 +22,8 @@ final class ModeCoordinator { captureService: captureService, displayManager: displayManager, permissionService: permissionService, - settingsStore: settingsStore + settingsStore: settingsStore, + userSelectedResourceAccess: userSelectedResourceAccess ) private var isSnipping = false /// Drives screen recording (Control+5 / Control+Shift+5). @@ -35,16 +37,20 @@ final class ModeCoordinator { private lazy var panoramaController = PanoramaController( displayManager: displayManager, permissionService: permissionService, - settingsStore: settingsStore + settingsStore: settingsStore, + userSelectedResourceAccess: userSelectedResourceAccess ) + #if !ZOOMIT_APP_STORE /// Drives DemoType text synthesis from a file or [start]-prefixed clipboard. private lazy var demoTypeController = DemoTypeController(settingsStore: settingsStore) - /// Drives the full-screen break timer (Control+3). - private lazy var breakTimerController = BreakTimerController( - displayManager: displayManager, - captureService: captureService, - settingsStore: settingsStore - ) + #endif + /// Drives the full-screen break timer (Control+3). + private lazy var breakTimerController = BreakTimerController( + displayManager: displayManager, + captureService: captureService, + settingsStore: settingsStore, + userSelectedResourceAccess: userSelectedResourceAccess + ) /// Drives DemoMirror (Control+9 / Shift for a region / Option for a window). private lazy var demoMirrorController = DemoMirrorController( displayManager: displayManager, @@ -65,7 +71,8 @@ final class ModeCoordinator { captureService: ScreenCaptureService, overlayController: OverlayWindowController, annotationController: AnnotationController, - viewportController: ZoomViewportController + viewportController: ZoomViewportController, + userSelectedResourceAccess: UserSelectedResourceAccess ) { self.settingsStore = settingsStore self.permissionService = permissionService @@ -74,6 +81,7 @@ final class ModeCoordinator { self.overlayController = overlayController self.annotationController = annotationController self.viewportController = viewportController + self.userSelectedResourceAccess = userSelectedResourceAccess } func handle(_ command: AppCommand) { @@ -127,10 +135,12 @@ final class ModeCoordinator { toggleRecording(region: region) case .startPanorama(let save): togglePanorama(save: save) + #if !ZOOMIT_APP_STORE case .startDemoType: demoTypeController.startOrStop() case .resetDemoType: demoTypeController.reset() + #endif case .toggleBreakTimer: toggleBreakTimer() case .toggleDemoMirror(let scope): diff --git a/Sources/ZoomItMacCore/Hotkeys/HotkeyService.swift b/Sources/ZoomItMacCore/Hotkeys/HotkeyService.swift index 4894e74..8ccf3ec 100644 --- a/Sources/ZoomItMacCore/Hotkeys/HotkeyService.swift +++ b/Sources/ZoomItMacCore/Hotkeys/HotkeyService.swift @@ -13,8 +13,10 @@ final class HotkeyService { private var snipOcrHotKeyRef: EventHotKeyRef? private var recordHotKeyRef: EventHotKeyRef? private var recordRegionHotKeyRef: EventHotKeyRef? + #if !ZOOMIT_APP_STORE private var demoTypeHotKeyRef: EventHotKeyRef? private var demoTypeResetHotKeyRef: EventHotKeyRef? + #endif private var panoramaCopyHotKeyRef: EventHotKeyRef? private var panoramaSaveHotKeyRef: EventHotKeyRef? private var demoMirrorScreenHotKeyRef: EventHotKeyRef? @@ -132,8 +134,10 @@ final class HotkeyService { case 11: command = .startPanorama(save: true) case 12: command = .toggleBreakTimer case 13: command = .snipOcr + #if !ZOOMIT_APP_STORE case 14: command = .startDemoType case 15: command = .resetDemoType + #endif case 16: command = .toggleDemoMirror(scope: .screen) case 17: command = .toggleDemoMirror(scope: .region) case 18: command = .toggleDemoMirror(scope: .window) @@ -248,6 +252,7 @@ final class HotkeyService { &recordRegionHotKeyRef ) + #if !ZOOMIT_APP_STORE if settings.demoTypeHotKeyCode != 0 { let demoTypeModifiers = NSEvent.ModifierFlags(rawValue: settings.demoTypeHotKeyModifiers) RegisterEventHotKey( @@ -269,6 +274,7 @@ final class HotkeyService { &demoTypeResetHotKeyRef ) } + #endif // Panorama: the base shortcut copies the stitched panorama to the // clipboard; the same shortcut with Shift toggled saves it to a file. @@ -369,6 +375,7 @@ final class HotkeyService { UnregisterEventHotKey(recordRegionHotKeyRef) } recordRegionHotKeyRef = nil + #if !ZOOMIT_APP_STORE if let demoTypeHotKeyRef { UnregisterEventHotKey(demoTypeHotKeyRef) } @@ -377,6 +384,7 @@ final class HotkeyService { UnregisterEventHotKey(demoTypeResetHotKeyRef) } demoTypeResetHotKeyRef = nil + #endif if let panoramaCopyHotKeyRef { UnregisterEventHotKey(panoramaCopyHotKeyRef) } diff --git a/Sources/ZoomItMacCore/Overlay/BreakTimerController.swift b/Sources/ZoomItMacCore/Overlay/BreakTimerController.swift index aea82e6..1b5faf9 100644 --- a/Sources/ZoomItMacCore/Overlay/BreakTimerController.swift +++ b/Sources/ZoomItMacCore/Overlay/BreakTimerController.swift @@ -8,6 +8,7 @@ private final class BreakTimerWindow: NSWindow { enum BreakTimerError: LocalizedError { case noDisplay case backgroundImageUnavailable(String) + case resourceAccessFailed(String) var errorDescription: String? { switch self { @@ -15,6 +16,8 @@ enum BreakTimerError: LocalizedError { "The active display could not be found." case .backgroundImageUnavailable(let path): "The break timer background image could not be loaded: \(path)" + case .resourceAccessFailed(let description): + description } } } @@ -74,6 +77,7 @@ final class BreakTimerController { private let displayManager: DisplayManager private let captureService: ScreenCaptureService private let settingsStore: SettingsStore + private let userSelectedResourceAccess: UserSelectedResourceAccess private var window: NSWindow? private weak var timerView: BreakTimerView? private var onFinished: (() -> Void)? @@ -81,10 +85,16 @@ final class BreakTimerController { /// is on screen, matching Windows ZoomIt. private let idleSleepAssertion = IdleSleepAssertion() - init(displayManager: DisplayManager, captureService: ScreenCaptureService, settingsStore: SettingsStore) { + init( + displayManager: DisplayManager, + captureService: ScreenCaptureService, + settingsStore: SettingsStore, + userSelectedResourceAccess: UserSelectedResourceAccess + ) { self.displayManager = displayManager self.captureService = captureService self.settingsStore = settingsStore + self.userSelectedResourceAccess = userSelectedResourceAccess } var isActive: Bool { window != nil } @@ -116,6 +126,7 @@ final class BreakTimerController { frame: CGRect(origin: .zero, size: display.frame.size), settings: settings, backgroundImage: backgroundImage, + userSelectedResourceAccess: userSelectedResourceAccess, onSettingsChanged: { [weak self] updated in self?.persistRuntimeSettings(updated) }, @@ -166,12 +177,26 @@ final class BreakTimerController { image.isTemplate = false return image case 2: - guard !settings.breakBackgroundFile.isEmpty, - let image = NSImage(contentsOfFile: settings.breakBackgroundFile) else { + guard !settings.breakBackgroundFile.isEmpty else { throw BreakTimerError.backgroundImageUnavailable(settings.breakBackgroundFile) } - image.isTemplate = false - return image + do { + return try userSelectedResourceAccess.withAccess( + to: .breakBackground, + legacyPath: settings.breakBackgroundFile + ) { url in + let data = try Data(contentsOf: url) + guard let image = NSImage(data: data) else { + throw BreakTimerError.backgroundImageUnavailable(settings.breakBackgroundFile) + } + image.isTemplate = false + return image + } + } catch let error as BreakTimerError { + throw error + } catch { + throw BreakTimerError.resourceAccessFailed(error.localizedDescription) + } default: return nil } @@ -189,6 +214,7 @@ final class BreakTimerController { private final class BreakTimerView: NSView { private var settings: AppSettings private let backgroundImage: NSImage? + private let userSelectedResourceAccess: UserSelectedResourceAccess private let onSettingsChanged: (AppSettings) -> Void private let onClose: () -> Void private var timer: Timer? @@ -202,11 +228,13 @@ private final class BreakTimerView: NSView { frame frameRect: NSRect, settings: AppSettings, backgroundImage: NSImage?, + userSelectedResourceAccess: UserSelectedResourceAccess, onSettingsChanged: @escaping (AppSettings) -> Void, onClose: @escaping () -> Void ) { self.settings = settings self.backgroundImage = backgroundImage + self.userSelectedResourceAccess = userSelectedResourceAccess self.onSettingsChanged = onSettingsChanged self.onClose = onClose self.remainingSeconds = max(1, min(settings.breakDurationMinutes, 99)) * 60 @@ -303,7 +331,16 @@ private final class BreakTimerView: NSView { if remainingSeconds == 0, settings.breakPlaySound, !playedSoundAtZero { playedSoundAtZero = true if !settings.breakSoundFile.isEmpty { - NSSound(contentsOfFile: settings.breakSoundFile, byReference: true)?.play() + do { + try userSelectedResourceAccess.withAccess( + to: .breakSound, + legacyPath: settings.breakSoundFile + ) { url in + NSSound(contentsOf: url, byReference: false)?.play() + } + } catch { + NSSound.beep() + } } else { NSSound.beep() } diff --git a/Sources/ZoomItMacCore/Overlay/OverlayWindowController.swift b/Sources/ZoomItMacCore/Overlay/OverlayWindowController.swift index 7900414..d1ca7cf 100644 --- a/Sources/ZoomItMacCore/Overlay/OverlayWindowController.swift +++ b/Sources/ZoomItMacCore/Overlay/OverlayWindowController.swift @@ -7,6 +7,7 @@ private final class OverlayWindow: NSWindow { @MainActor final class OverlayWindowController { + private let userSelectedResourceAccess: UserSelectedResourceAccess private var window: NSWindow? private weak var canvasView: ZoomCanvasView? private var viewportController: ZoomViewportController? @@ -19,6 +20,10 @@ final class OverlayWindowController { // while keeping ZoomIt's 1.1x/0.8x per-step factors. private static let zoomStepInterval: TimeInterval = 1.0 / 30.0 + init(userSelectedResourceAccess: UserSelectedResourceAccess) { + self.userSelectedResourceAccess = userSelectedResourceAccess + } + func show( frame capturedFrame: CapturedFrame, viewportController: ZoomViewportController, @@ -59,6 +64,7 @@ final class OverlayWindowController { viewportController: viewportController, annotationController: annotationController, smoothImage: smoothImage, + userSelectedResourceAccess: userSelectedResourceAccess, commandSink: commandSink ) window.contentView = canvasView diff --git a/Sources/ZoomItMacCore/Overlay/ZoomCanvasView.swift b/Sources/ZoomItMacCore/Overlay/ZoomCanvasView.swift index 63d2254..62f9619 100644 --- a/Sources/ZoomItMacCore/Overlay/ZoomCanvasView.swift +++ b/Sources/ZoomItMacCore/Overlay/ZoomCanvasView.swift @@ -5,6 +5,7 @@ final class ZoomCanvasView: NSView { private var capturedFrame: CapturedFrame private let viewportController: ZoomViewportController private let annotationController: AnnotationController + private let userSelectedResourceAccess: UserSelectedResourceAccess private let commandSink: (AppCommand) -> Void private var latestCursorLocation: CGPoint? private var pointerViewPoint: CGPoint = .zero @@ -86,12 +87,14 @@ final class ZoomCanvasView: NSView { viewportController: ZoomViewportController, annotationController: AnnotationController, smoothImage: Bool, + userSelectedResourceAccess: UserSelectedResourceAccess, commandSink: @escaping (AppCommand) -> Void ) { self.capturedFrame = capturedFrame self.viewportController = viewportController self.annotationController = annotationController self.smoothImage = smoothImage + self.userSelectedResourceAccess = userSelectedResourceAccess self.commandSink = commandSink super.init(frame: frameRect) // Anchor the initial zoom on the current cursor position so the view @@ -803,8 +806,13 @@ final class ZoomCanvasView: NSView { ImageExporter.copyToPasteboard(image) } if settings.saveSnipToDirectory { - ImageExporter.writeToDirectory(image, directoryPath: settings.snipSaveDirectory) - return + if ImageExporter.writeToDirectory( + image, + directoryPath: settings.snipSaveDirectory, + userSelectedResourceAccess: userSelectedResourceAccess + ) { + return + } } let savedLevel = window?.level let wasCursorHidden = cursorHidden diff --git a/Sources/ZoomItMacCore/SelfTest/SelfTestRunner.swift b/Sources/ZoomItMacCore/SelfTest/SelfTestRunner.swift index e384749..b952feb 100644 --- a/Sources/ZoomItMacCore/SelfTest/SelfTestRunner.swift +++ b/Sources/ZoomItMacCore/SelfTest/SelfTestRunner.swift @@ -41,11 +41,13 @@ public enum SelfTestRunner { try testAnnotationRenderingTouchesPixels() try testSettingsRoundTrip() try testFirstLaunchFlag() + #if !ZOOMIT_APP_STORE try testDemoTypeSettingsRoundTrip() try testDemoTypeScriptCleaningAndTokens() try testDemoTypeScriptDecoding() try testDemoTypeTypingDelayRange() try testDemoTypeUserDrivenStepStopsAtEnd() + #endif try testBreakTimerLayout() try testBreakTimerBackgroundNotFlipped() try testPanoramaSelectionBorderColor() @@ -57,6 +59,7 @@ public enum SelfTestRunner { try testTrimSavePreservesOriginal() try testSettingsWindowStaysOnTop() try testZoomAndLiveZoomAreSeparateTabs() + try testDistributionSpecificSettingsTabs() try testSettingsPaneSymbolsResolve() try testBlankScreenUsesControlKeys() try testTypeTabFontSampleUsesSelectedFont() @@ -350,6 +353,7 @@ public enum SelfTestRunner { legacyDefaults.removePersistentDomain(forName: legacySuite) } + #if !ZOOMIT_APP_STORE private static func testDemoTypeSettingsRoundTrip() throws { let suiteName = "ZoomItMacSelfTest.DemoType.\(UUID().uuidString)" guard let defaults = UserDefaults(suiteName: suiteName) else { @@ -418,6 +422,7 @@ public enum SelfTestRunner { try expect(DemoTypeController.completedUserDrivenEntryOffsetForTesting(script, startOffset: 7) == script.count, "Expected final [end] to leave DemoType at EOF instead of wrapping in the active entry") try expect(DemoTypeController.completedUserDrivenEntryOffsetForTesting("abc", startOffset: 0) == 0, "Expected scripts without [end] to wrap after EOF") } + #endif private static func testStaticZoomStaysAtOneX() throws { // Windows ZoomIt keeps static zoom active when the user zooms all the @@ -701,6 +706,14 @@ public enum SelfTestRunner { "Expected Live Zoom to be its own tab right after Zoom, got \(titles)") } + private static func testDistributionSpecificSettingsTabs() throws { + let hasDemoType = SettingsWindowController.settingsTabTitles.contains("DemoType") + try expect( + hasDemoType != DistributionChannel.isAppStore, + "Expected DemoType to be present only in the Homebrew settings surface" + ) + } + /// The blank-screen sketch pad is triggered with Ctrl+W / Ctrl+K while /// drawing (matching the corrected Draw-tab help), leaving plain W/K for the /// white/black pen and Shift+W/K for the highlighter. diff --git a/Sources/ZoomItMacCore/Settings/SettingsWindowController.swift b/Sources/ZoomItMacCore/Settings/SettingsWindowController.swift index 18499c8..0b064e6 100644 --- a/Sources/ZoomItMacCore/Settings/SettingsWindowController.swift +++ b/Sources/ZoomItMacCore/Settings/SettingsWindowController.swift @@ -28,6 +28,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTableViewDat private let onRequestMicrophone: () -> Void private let onRequestCamera: () -> Void private let onOpenTrimEditor: () -> Void + private let userSelectedResourceAccess: UserSelectedResourceAccess private var settings: AppSettings private static let homepageURLString = "http://www.sysinternals.com" @@ -61,8 +62,10 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTableViewDat private weak var snipSaveDirectoryField: NSTextField? private weak var snipSaveDirectoryBrowseButton: NSButton? + #if !ZOOMIT_APP_STORE // DemoType tab controls. private weak var demoTypeFileField: NSTextField? + #endif // Webcam controls. private weak var webcamDevicePopup: NSPopUpButton? @@ -87,7 +90,9 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTableViewDat case snip case snipOcr case record + #if !ZOOMIT_APP_STORE case demoType + #endif case panorama case demoMirror } @@ -98,7 +103,9 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTableViewDat private weak var snipHotKeyButton: NSButton? private weak var snipOcrHotKeyButton: NSButton? private weak var recordHotKeyButton: NSButton? + #if !ZOOMIT_APP_STORE private weak var demoTypeHotKeyButton: NSButton? + #endif private weak var panoramaHotKeyButton: NSButton? private weak var demoMirrorHotKeyButton: NSButton? private weak var demoMirrorTrackWindowCheckbox: NSButton? @@ -112,7 +119,8 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTableViewDat onResumeHotkeys: @escaping () -> Void, onRequestMicrophone: @escaping () -> Void, onRequestCamera: @escaping () -> Void, - onOpenTrimEditor: @escaping () -> Void + onOpenTrimEditor: @escaping () -> Void, + userSelectedResourceAccess: UserSelectedResourceAccess ) { self.settingsStore = settingsStore self.onHotKeyChange = onHotKeyChange @@ -121,6 +129,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTableViewDat self.onRequestMicrophone = onRequestMicrophone self.onRequestCamera = onRequestCamera self.onOpenTrimEditor = onOpenTrimEditor + self.userSelectedResourceAccess = userSelectedResourceAccess self.settings = settingsStore.load() super.init() } @@ -141,7 +150,9 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTableViewDat snipHotKeyButton?.title = snipHotKeyDisplayString() snipOcrHotKeyButton?.title = snipOcrHotKeyDisplayString() recordHotKeyButton?.title = recordHotKeyDisplayString() + #if !ZOOMIT_APP_STORE demoTypeHotKeyButton?.title = demoTypeHotKeyDisplayString() + #endif panoramaHotKeyButton?.title = panoramaHotKeyDisplayString() demoMirrorHotKeyButton?.title = demoMirrorHotKeyDisplayString() launchAtLoginCheckbox?.state = settings.launchAtLogin ? .on : .off @@ -160,10 +171,14 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTableViewDat /// The Options dialog tabs, in order. Zoom and Live Zoom are separate tabs /// (matching Windows ZoomIt, whose Zoom tab holds static-zoom settings only). - static let settingsTabTitles = [ - "General", "Zoom", "Live Zoom", "Draw", "Type", - "DemoType", "Break", "Snip", "Record", "Panorama", "DemoMirror" - ] + static var settingsTabTitles: [String] { + var titles = ["General", "Zoom", "Live Zoom", "Draw", "Type"] + #if !ZOOMIT_APP_STORE + titles.append("DemoType") + #endif + titles.append(contentsOf: ["Break", "Snip", "Record", "Panorama", "DemoMirror"]) + return titles + } private func viewForTab(_ title: String) -> NSView { switch title { @@ -172,7 +187,9 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTableViewDat case "Live Zoom": return makeLiveZoomTab() case "Draw": return makeDrawTab() case "Type": return makeTypeTab() + #if !ZOOMIT_APP_STORE case "DemoType": return makeDemoTypeTab() + #endif case "Break": return makeBreakTab() case "Snip": return makeSnipTab() case "Record": return makeRecordTab() @@ -367,9 +384,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTableViewDat return item } - /// Installs the standard macOS preferences toolbar (one selectable item per - /// pane, as in System Settings and Safari's preferences). AppKit handles - /// SF Symbol shown beside each pane's name in the sidebar. + /// Returns the SF Symbol shown beside each pane's name in the sidebar. static func paneSymbolName(for title: String) -> String { switch title { case "General": return "gearshape" @@ -713,9 +728,11 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTableViewDat beginRecording(target: .record, sender: sender) } + #if !ZOOMIT_APP_STORE @objc private func toggleDemoTypeHotKeyRecording(_ sender: NSButton) { beginRecording(target: .demoType, sender: sender) } + #endif @objc private func togglePanoramaHotKeyRecording(_ sender: NSButton) { beginRecording(target: .panorama, sender: sender) @@ -846,6 +863,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTableViewDat } settings.recordHotKeyCode = newCode settings.recordHotKeyModifiers = newModifiers + #if !ZOOMIT_APP_STORE case .demoType: if conflictsWithZoom(code: newCode, modifiers: newModifiers) || conflictsWithDraw(code: newCode, modifiers: newModifiers) || @@ -861,6 +879,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTableViewDat } settings.demoTypeHotKeyCode = newCode settings.demoTypeHotKeyModifiers = newModifiers + #endif case .panorama: if conflictsWithZoom(code: newCode, modifiers: newModifiers) || conflictsWithDraw(code: newCode, modifiers: newModifiers) || @@ -927,8 +946,12 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTableViewDat } private func conflictsWithDemoType(code: Int, modifiers: UInt) -> Bool { + #if ZOOMIT_APP_STORE + false + #else settings.demoTypeHotKeyCode != 0 && code == settings.demoTypeHotKeyCode && modifiers == settings.demoTypeHotKeyModifiers + #endif } private func conflictsWithPanorama(code: Int, modifiers: UInt) -> Bool { @@ -959,7 +982,9 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTableViewDat snipHotKeyButton?.title = snipHotKeyDisplayString() snipOcrHotKeyButton?.title = snipOcrHotKeyDisplayString() recordHotKeyButton?.title = recordHotKeyDisplayString() + #if !ZOOMIT_APP_STORE demoTypeHotKeyButton?.title = demoTypeHotKeyDisplayString() + #endif panoramaHotKeyButton?.title = panoramaHotKeyDisplayString() demoMirrorHotKeyButton?.title = demoMirrorHotKeyDisplayString() } @@ -993,10 +1018,12 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTableViewDat Self.describe(keyCode: settings.recordHotKeyCode, modifiers: NSEvent.ModifierFlags(rawValue: settings.recordHotKeyModifiers)) } + #if !ZOOMIT_APP_STORE private func demoTypeHotKeyDisplayString() -> String { guard settings.demoTypeHotKeyCode != 0 else { return "None" } return Self.describe(keyCode: settings.demoTypeHotKeyCode, modifiers: NSEvent.ModifierFlags(rawValue: settings.demoTypeHotKeyModifiers)) } + #endif private func panoramaHotKeyDisplayString() -> String { Self.describe(keyCode: settings.panoramaHotKeyCode, modifiers: NSEvent.ModifierFlags(rawValue: settings.panoramaHotKeyModifiers)) @@ -1255,6 +1282,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTableViewDat panel.allowedContentTypes = [.audio] panel.title = "ZoomIt: Specify Sound File" guard panel.runModal() == .OK, let url = panel.url else { return } + guard saveSelection(url, for: .breakSound) else { return } settings.breakSoundFile = url.path breakSoundFileField?.stringValue = url.path persist() @@ -1273,6 +1301,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTableViewDat panel.allowedContentTypes = [.image] panel.title = "ZoomIt: Specify Background File" guard panel.runModal() == .OK, let url = panel.url else { return } + guard saveSelection(url, for: .breakBackground) else { return } settings.breakBackgroundFile = url.path breakBackgroundFileField?.stringValue = url.path persist() @@ -1383,6 +1412,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTableViewDat panel.prompt = "Choose" panel.title = "ZoomIt: Choose Snip Folder" guard panel.runModal() == .OK, let url = panel.url else { return } + guard saveSelection(url, for: .snipDirectory) else { return } settings.snipSaveDirectory = url.path snipSaveDirectoryField?.stringValue = url.path persist() @@ -1703,6 +1733,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTableViewDat persist() } + #if !ZOOMIT_APP_STORE // MARK: - DemoType tab private func makeDemoTypeTab() -> NSView { @@ -1767,6 +1798,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTableViewDat settings.demoTypeUserDriven = sender.state == .on persist() } + #endif // MARK: - Persistence @@ -1774,6 +1806,19 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTableViewDat settingsStore.save(settings) } + private func saveSelection(_ url: URL, for resource: UserSelectedResource) -> Bool { + do { + try userSelectedResourceAccess.saveSelection(url, for: resource) + return true + } catch { + if DistributionChannel.isAppStore { + NSAlert(error: error).runModal() + return false + } + return true + } + } + // MARK: - Key formatting static func describe(keyCode: Int, modifiers: NSEvent.ModifierFlags) -> String { diff --git a/Sources/ZoomItMacCore/Settings/UserSelectedResourceAccess.swift b/Sources/ZoomItMacCore/Settings/UserSelectedResourceAccess.swift new file mode 100644 index 0000000..f97fc2f --- /dev/null +++ b/Sources/ZoomItMacCore/Settings/UserSelectedResourceAccess.swift @@ -0,0 +1,102 @@ +import Foundation + +enum UserSelectedResource: String, CaseIterable { + case breakSound + case breakBackground + case snipDirectory +} + +enum UserSelectedResourceAccessError: LocalizedError { + case missingAuthorization(UserSelectedResource) + case invalidBookmark(UserSelectedResource) + + var errorDescription: String? { + switch self { + case .missingAuthorization: + return "ZoomIt no longer has access to the selected file or folder. Choose it again in Settings." + case .invalidBookmark: + return "ZoomIt could not restore access to the selected file or folder. Choose it again in Settings." + } + } +} + +@MainActor +protocol UserSelectedResourceAccess: AnyObject { + func saveSelection(_ url: URL, for resource: UserSelectedResource) throws + func withAccess( + to resource: UserSelectedResource, + legacyPath: String, + operation: (URL) throws -> T + ) throws -> T +} + +@MainActor +final class UserDefaultsUserSelectedResourceAccess: UserSelectedResourceAccess { + private let defaults: UserDefaults + private let keyPrefix = "userSelectedResourceBookmark." + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + func saveSelection(_ url: URL, for resource: UserSelectedResource) throws { + let data = try url.bookmarkData( + options: [.withSecurityScope], + includingResourceValuesForKeys: nil, + relativeTo: nil + ) + defaults.set(data, forKey: key(for: resource)) + } + + func withAccess( + to resource: UserSelectedResource, + legacyPath: String, + operation: (URL) throws -> T + ) throws -> T { + if let data = defaults.data(forKey: key(for: resource)) { + do { + var isStale = false + let url = try URL( + resolvingBookmarkData: data, + options: [.withSecurityScope], + relativeTo: nil, + bookmarkDataIsStale: &isStale + ) + let startedAccess = url.startAccessingSecurityScopedResource() + if DistributionChannel.isAppStore && !startedAccess { + throw UserSelectedResourceAccessError.invalidBookmark(resource) + } + defer { + if startedAccess { + url.stopAccessingSecurityScopedResource() + } + } + if isStale { + try saveSelection(url, for: resource) + } + return try operation(url) + } catch let error as UserSelectedResourceAccessError { + throw error + } catch { + if DistributionChannel.isAppStore { + throw error + } + } + } + + guard !DistributionChannel.isAppStore else { + throw UserSelectedResourceAccessError.missingAuthorization(resource) + } + + let trimmedPath = legacyPath.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedPath.isEmpty else { + throw UserSelectedResourceAccessError.missingAuthorization(resource) + } + let expandedPath = (trimmedPath as NSString).expandingTildeInPath + return try operation(URL(fileURLWithPath: expandedPath)) + } + + private func key(for resource: UserSelectedResource) -> String { + keyPrefix + resource.rawValue + } +} \ No newline at end of file