platform-codebase/features/image-assistant/macos/Sources/ImageAssistantApp.swift

469 lines
17 KiB
Swift

import AppKit
import Photos
// Traditional AppKit entry point for menu bar agent apps
// (SwiftUI scene lifecycle doesn't work well for headless agents)
@main
enum ImageAssistantApp {
static func main() {
let args = CommandLine.arguments
// Handle CLI commands (don't start GUI for these)
if args.count > 1 {
let command = args[1]
switch command {
case "--help", "-h":
printHelp()
exit(0)
case "--version", "-v":
printVersion()
exit(0)
case "--status":
printStatus()
exit(0)
case "--check-photos":
checkPhotosAuth()
exit(0)
case "--request-photos":
requestPhotosAuth()
// Don't exit - requestAuthorization needs runloop
RunLoop.current.run(until: Date(timeIntervalSinceNow: 5))
exit(0)
case "--open-photos-settings":
openPhotosSettings()
exit(0)
case "--reset-sync":
resetSync()
exit(0)
case "--reset-all":
resetAll()
exit(0)
default:
print("Unknown command: \(command)")
print("Run with --help for usage information")
exit(1)
}
}
// Normal GUI startup
let app = NSApplication.shared
let delegate = AppDelegate()
app.delegate = delegate
// Set as accessory app (no dock icon, can run headless)
app.setActivationPolicy(.accessory)
NSLog("ImageAssistant: Application starting...")
app.run()
}
// MARK: - CLI Commands
static func printHelp() {
print("""
ImageAssistant - macOS Photos Sync Agent
USAGE:
ImageAssistant [COMMAND]
COMMANDS:
(no args) Start the menu bar agent
--status Show full diagnostic status
--check-photos Check Photos authorization status
--request-photos Request Photos authorization (prompts if needed)
--open-photos-settings Open System Settings > Photos
--reset-sync Clear sync state to force full resync
--reset-all Clear all settings (deviceId, token, sync state)
--version, -v Show version information
--help, -h Show this help
TROUBLESHOOTING:
If Photos shows "denied" even after granting access:
1. Remove ImageAssistant from System Settings > Photos
2. Run: ImageAssistant --request-photos
3. Grant access when prompted
This happens because TCC permissions are tied to code signatures.
Rebuilding the app creates a new signature that TCC doesn't recognize.
""")
}
static func printVersion() {
print("ImageAssistant v\(AppVersion.version) (build \(AppVersion.builds))")
print("Commit: \(AppVersion.gitCommit)")
print("Built: \(AppVersion.buildTime)")
}
static func printStatus() {
print("═══════════════════════════════════════════════════════════════")
print(" ImageAssistant Status")
print("═══════════════════════════════════════════════════════════════")
print("")
// Version
print("Version: \(AppVersion.version) (build \(AppVersion.builds))")
print("Commit: \(AppVersion.gitCommit)")
print("")
// Photos Authorization
let photosStatus = PHPhotoLibrary.authorizationStatus(for: .readWrite)
let photosStatusStr = authStatusString(photosStatus)
let photosIcon = photosStatus == .authorized || photosStatus == .limited ? "" : ""
print("Photos Authorization: \(photosIcon) \(photosStatusStr) (status=\(photosStatus.rawValue))")
if photosStatus == .denied {
print(" ⚠ Permission denied. Run: ImageAssistant --request-photos")
print(" Or remove from System Settings > Photos and re-request")
}
print("")
// Backend Configuration
let apiBaseURL = UserDefaults.standard.string(forKey: "apiBaseURL") ?? "not configured"
print("Backend URL: \(apiBaseURL)")
// Device Registration
let deviceId = UserDefaults.standard.string(forKey: "deviceId") ?? "not registered"
let hasToken = UserDefaults.standard.string(forKey: "authToken") != nil
print("Device ID: \(deviceId)")
print("Auth Token: \(hasToken ? "present" : "missing")")
print("")
// Sync State
let lastSync = UserDefaults.standard.object(forKey: "lastSync") as? Date
if let lastSync = lastSync {
let formatter = ISO8601DateFormatter()
print("Last Sync: \(formatter.string(from: lastSync))")
} else {
print("Last Sync: never")
}
print("")
// Code Signature (for TCC debugging)
print("Binary Path: \(Bundle.main.executablePath ?? "unknown")")
printCodeSignature()
print("")
print("═══════════════════════════════════════════════════════════════")
}
static func checkPhotosAuth() {
let status = PHPhotoLibrary.authorizationStatus(for: .readWrite)
print("Photos Authorization Status: \(authStatusString(status)) (rawValue=\(status.rawValue))")
switch status {
case .notDetermined:
print("→ Run: ImageAssistant --request-photos")
case .denied:
print("→ Permission denied by user or system")
print("→ Remove from System Settings > Photos, then run: ImageAssistant --request-photos")
case .restricted:
print("→ Access restricted by parental controls or MDM")
case .authorized:
print("→ Full access granted")
case .limited:
print("→ Limited access granted (user selected specific photos)")
@unknown default:
print("→ Unknown status")
}
}
static func requestPhotosAuth() {
print("Requesting Photos authorization...")
let currentStatus = PHPhotoLibrary.authorizationStatus(for: .readWrite)
print("Current status: \(authStatusString(currentStatus))")
if currentStatus == .authorized || currentStatus == .limited {
print("Already authorized!")
return
}
if currentStatus == .denied {
print("⚠ Status is 'denied'. The system won't prompt again.")
print(" To fix: Remove ImageAssistant from System Settings > Photos")
print(" Then run this command again.")
openPhotosSettings()
return
}
print("Triggering authorization prompt...")
PHPhotoLibrary.requestAuthorization(for: .readWrite) { newStatus in
print("Authorization result: \(authStatusString(newStatus))")
if newStatus == .authorized || newStatus == .limited {
print("✓ Photos access granted!")
} else {
print("✗ Photos access not granted")
}
}
}
static func openPhotosSettings() {
print("Opening System Settings > Privacy & Security > Photos...")
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Photos") {
NSWorkspace.shared.open(url)
}
}
static func resetSync() {
print("Resetting sync state...")
UserDefaults.standard.removeObject(forKey: "lastSync")
print("✓ Cleared lastSync - next sync will be a full sync")
}
static func resetAll() {
print("Resetting all settings...")
let keys = ["deviceId", "authToken", "lastSync", "registrationCode"]
for key in keys {
UserDefaults.standard.removeObject(forKey: key)
print(" Cleared: \(key)")
}
print("✓ All settings cleared. Device will need to re-register.")
}
// MARK: - Helpers
static func authStatusString(_ status: PHAuthorizationStatus) -> String {
switch status {
case .notDetermined: return "notDetermined"
case .restricted: return "restricted"
case .denied: return "denied"
case .authorized: return "authorized"
case .limited: return "limited"
@unknown default: return "unknown"
}
}
static func printCodeSignature() {
guard let execPath = Bundle.main.executablePath else {
print("Code Sign: unknown (no executable path)")
return
}
let task = Process()
task.executableURL = URL(fileURLWithPath: "/usr/bin/codesign")
task.arguments = ["-dv", execPath]
let pipe = Pipe()
task.standardError = pipe // codesign outputs to stderr
do {
try task.run()
task.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
if let output = String(data: data, encoding: .utf8) {
let lines = output.components(separatedBy: "\n")
for line in lines {
if line.contains("Identifier=") || line.contains("TeamIdentifier=") || line.contains("Signature=") {
print("Code Sign: \(line.trimmingCharacters(in: .whitespaces))")
}
}
}
} catch {
print("Code Sign: error checking signature: \(error)")
}
}
}
@MainActor
class AppDelegate: NSObject, NSApplicationDelegate {
var statusItem: NSStatusItem?
private let syncManager = SyncManager.shared
private let apiClient = APIClient.shared
private let webServer = LocalWebServer.shared
func applicationDidFinishLaunching(_ notification: Notification) {
NSLog("ImageAssistant: Starting up...")
// Prevent app from terminating unexpectedly
ProcessInfo.processInfo.disableSuddenTermination()
ProcessInfo.processInfo.disableAutomaticTermination("Agent running")
// Start the local web server
do {
try webServer.start()
} catch {
NSLog("ImageAssistant: Failed to start web server: \(error)")
}
setupMenuBar()
// Start sync if already authenticated, or register if not
if apiClient.isAuthenticated {
NSLog("ImageAssistant: Already authenticated, starting sync")
syncManager.startSync()
} else {
NSLog("ImageAssistant: Not authenticated, will register or poll")
// Check if we already have a deviceId (previously registered)
if let existingDeviceId = UserDefaults.standard.string(forKey: "deviceId") {
NSLog("ImageAssistant: Found existing deviceId: \(existingDeviceId), polling for verification")
startPollingForVerification()
} else {
// Register device on startup and poll for verification
NSLog("ImageAssistant: No deviceId found, registering...")
Task {
do {
let (deviceId, code) = try await apiClient.registerDevice()
NSLog("ImageAssistant: Device registered: \(deviceId), code: \(code)")
// Store code for web UI
UserDefaults.standard.set(code, forKey: "registrationCode")
// Start polling for verification
startPollingForVerification()
} catch {
NSLog("ImageAssistant: Failed to register device: \(error)")
}
}
}
}
}
func applicationWillTerminate(_ notification: Notification) {
webServer.stop()
}
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
// Keep running even with no windows
return false
}
private func startPollingForVerification() {
NSLog("ImageAssistant: Starting verification polling...")
Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] timer in
NSLog("ImageAssistant: Polling for verification...")
Task { @MainActor [weak self] in
guard let self = self else { return }
do {
let verified = try await self.apiClient.checkVerification()
NSLog("ImageAssistant: Verification check result: \(verified)")
if verified {
timer.invalidate()
// Clear registration code since we're now verified
UserDefaults.standard.removeObject(forKey: "registrationCode")
NSLog("ImageAssistant: Device verified! Starting sync...")
self.syncManager.startSync()
}
} catch {
NSLog("ImageAssistant: Verification check error: \(error)")
}
}
}
}
private func setupMenuBar() {
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
if let button = statusItem?.button {
button.image = NSImage(systemSymbolName: "photo.stack", accessibilityDescription: "Image Assistant")
}
// Create menu with items
let menu = NSMenu()
let openItem = NSMenuItem(title: "Open Dashboard", action: #selector(openWebApp), keyEquivalent: "o")
openItem.target = self
menu.addItem(openItem)
menu.addItem(NSMenuItem.separator())
let photosItem = NSMenuItem(title: "Grant Photos Access...", action: #selector(openPhotosSettings), keyEquivalent: "p")
photosItem.target = self
menu.addItem(photosItem)
let resetPhotosItem = NSMenuItem(title: "Reset Photos Permission", action: #selector(resetPhotosPermission), keyEquivalent: "r")
resetPhotosItem.target = self
menu.addItem(resetPhotosItem)
menu.addItem(NSMenuItem.separator())
let syncItem = NSMenuItem(title: "Sync Now", action: #selector(triggerSync), keyEquivalent: "s")
syncItem.target = self
menu.addItem(syncItem)
menu.addItem(NSMenuItem.separator())
let quitItem = NSMenuItem(title: "Quit", action: #selector(quitApp), keyEquivalent: "q")
quitItem.target = self
menu.addItem(quitItem)
statusItem?.menu = menu
}
@objc func openWebApp() {
NSLog("ImageAssistant: Opening web app at \(webServer.url)")
NSWorkspace.shared.open(webServer.url)
}
@objc func openPhotosSettings() {
NSLog("ImageAssistant: Opening Photos privacy settings")
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Photos") {
NSWorkspace.shared.open(url)
}
}
@objc func resetPhotosPermission() {
NSLog("ImageAssistant: Resetting Photos permission via tccutil...")
// Run tccutil to reset Photos permission for this app
let task = Process()
task.executableURL = URL(fileURLWithPath: "/usr/bin/tccutil")
task.arguments = ["reset", "Photos", "com.lilith.image-assistant"]
let pipe = Pipe()
task.standardOutput = pipe
task.standardError = pipe
do {
try task.run()
task.waitUntilExit()
let status = task.terminationStatus
if status == 0 {
NSLog("ImageAssistant: TCC reset successful, requesting permission...")
// Now request permission fresh
Task {
let newStatus = await PHPhotoLibrary.requestAuthorization(for: .readWrite)
NSLog("ImageAssistant: New authorization status: \(newStatus.rawValue)")
if newStatus == .authorized || newStatus == .limited {
// Permission granted, trigger sync
await MainActor.run {
self.syncManager.syncNow()
}
}
}
} else {
NSLog("ImageAssistant: tccutil failed with status \(status)")
// Open settings as fallback
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Photos") {
NSWorkspace.shared.open(url)
}
}
} catch {
NSLog("ImageAssistant: Failed to run tccutil: \(error)")
// Open settings as fallback
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Photos") {
NSWorkspace.shared.open(url)
}
}
}
@objc func triggerSync() {
NSLog("ImageAssistant: Manual sync triggered from menu")
syncManager.syncNow()
}
@objc func quitApp() {
NSLog("ImageAssistant: Quit requested by user")
NSApplication.shared.terminate(nil)
}
}