Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions Scripts/ZoomIt-AppStore.entitlements
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.files.bookmarks.app-scope</key>
<true/>
<key>com.apple.security.device.camera</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
</dict>
</plist>
33 changes: 29 additions & 4 deletions Scripts/build-app.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -150,7 +171,7 @@ cat > "$APP_PATH/Contents/Info.plist" <<PLIST
<key>CFBundleShortVersionString</key>
<string>$VERSION</string>
<key>CFBundleVersion</key>
<string>$VERSION</string>
<string>$BUILD_NUMBER</string>
<key>LSMinimumSystemVersion</key>
<string>14.0</string>
<key>LSUIElement</key>
Expand All @@ -159,6 +180,8 @@ cat > "$APP_PATH/Contents/Info.plist" <<PLIST
<string>ZoomIt shows your webcam as a picture-in-picture overlay when you enable it for screen recordings.</string>
<key>NSMicrophoneUsageDescription</key>
<string>ZoomIt records your microphone when you enable microphone capture for screen recordings.</string>
<key>ZoomItDistribution</key>
<string>$DIST_DISTRIBUTION</string>
</dict>
</plist>
PLIST
Expand Down Expand Up @@ -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
8 changes: 6 additions & 2 deletions Sources/ZoomItMacCore/App/AppController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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()
}

Expand Down
9 changes: 6 additions & 3 deletions Sources/ZoomItMacCore/App/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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
Expand All @@ -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(
Expand Down
2 changes: 2 additions & 0 deletions Sources/ZoomItMacCore/App/DemoTypeController.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
#if !ZOOMIT_APP_STORE
import AppKit
import Carbon.HIToolbox

Expand Down Expand Up @@ -570,3 +571,4 @@ final class DemoTypeController {
alert.runModal()
}
}
#endif
7 changes: 7 additions & 0 deletions Sources/ZoomItMacCore/App/DistributionChannel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
enum DistributionChannel {
#if ZOOMIT_APP_STORE
static let isAppStore = true
#else
static let isAppStore = false
#endif
}
8 changes: 5 additions & 3 deletions Sources/ZoomItMacCore/Capture/DemoMirrorController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
}
Expand Down
47 changes: 29 additions & 18 deletions Sources/ZoomItMacCore/Capture/ImageExporter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Comment thread
Copilot marked this conversation as resolved.
}
return true
} catch {
NSApp.activate(ignoringOtherApps: true)
NSAlert(error: error).runModal()
return false
}
}

Expand Down
11 changes: 9 additions & 2 deletions Sources/ZoomItMacCore/Capture/PanoramaController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down
11 changes: 9 additions & 2 deletions Sources/ZoomItMacCore/Capture/SnipController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down
Loading