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