469 lines
17 KiB
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)
|
|
}
|
|
}
|