diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 9dff7aec..a0688261 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -15,7 +15,9 @@ 2AF9238E2F540B1300F467FD /* Scribe in Frameworks */ = {isa = PBXBuildFile; productRef = 2AF9238D2F540B1300F467FD /* Scribe */; }; 2AF923902F540B2200F467FD /* Scribe in Frameworks */ = {isa = PBXBuildFile; productRef = 2AF9238F2F540B2200F467FD /* Scribe */; }; 3ED0A7B92F21DF6800A58629 /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = 3ED0A7B82F21DF6800A58629 /* ZIPFoundation */; }; - B1AA00412F30000100AABBCC /* LoopUpdaterHelper in CopyFiles */ = {isa = PBXBuildFile; fileRef = B1AA00012F30000100AABBCC /* LoopUpdaterHelper */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; + B1AA00412F30000100AABBCC /* LoopPrivilegedHelper in CopyFiles */ = {isa = PBXBuildFile; fileRef = B1AA00012F30000100AABBCC /* LoopPrivilegedHelper */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; + C1BB00412F30000200AABBCC /* loop-cli in CopyFiles */ = {isa = PBXBuildFile; fileRef = C1BB00012F30000200AABBCC /* loop-cli */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; + C1BB00A52F40000200AABBCC /* ArgumentParser in Frameworks */ = {isa = PBXBuildFile; productRef = C1BB00A42F40000200AABBCC /* ArgumentParser */; }; F06D768A2DFF7A77007EEDA9 /* SkyLight.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F06D76892DFF7A77007EEDA9 /* SkyLight.framework */; }; /* End PBXBuildFile section */ @@ -32,7 +34,14 @@ containerPortal = A8E59C2D297F5E9A0064D4BA /* Project object */; proxyType = 1; remoteGlobalIDString = B1AA00112F30000100AABBCC; - remoteInfo = LoopUpdaterHelper; + remoteInfo = LoopPrivilegedHelper; + }; + C1BB00512F30000200AABBCC /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = A8E59C2D297F5E9A0064D4BA /* Project object */; + proxyType = 1; + remoteGlobalIDString = C1BB00112F30000200AABBCC; + remoteInfo = LoopCLI; }; /* End PBXContainerItemProxy section */ @@ -53,7 +62,17 @@ dstPath = Contents/Library/LaunchServices; dstSubfolderSpec = 1; files = ( - B1AA00412F30000100AABBCC /* LoopUpdaterHelper in CopyFiles */, + B1AA00412F30000100AABBCC /* LoopPrivilegedHelper in CopyFiles */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + C1BB00612F30000200AABBCC /* CopyFiles */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 6; + files = ( + C1BB00412F30000200AABBCC /* loop-cli in CopyFiles */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -67,8 +86,9 @@ A894B0C52C4B31AA00B4CE6F /* CONTRIBUTING.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = CONTRIBUTING.md; sourceTree = ""; }; A8E59C35297F5E9A0064D4BA /* Loop.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Loop.app; sourceTree = BUILT_PRODUCTS_DIR; }; A8E6D1FC2A4155DC005751D4 /* .gitignore */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = .gitignore; sourceTree = ""; }; - B1AA00012F30000100AABBCC /* LoopUpdaterHelper */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = LoopUpdaterHelper; sourceTree = BUILT_PRODUCTS_DIR; }; + B1AA00012F30000100AABBCC /* LoopPrivilegedHelper */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = LoopPrivilegedHelper; sourceTree = BUILT_PRODUCTS_DIR; }; B1AA00802F30000100AABBCC /* Security.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Security.framework; path = System/Library/Frameworks/Security.framework; sourceTree = SDKROOT; }; + C1BB00012F30000200AABBCC /* loop-cli */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = "loop-cli"; sourceTree = BUILT_PRODUCTS_DIR; }; F06D76892DFF7A77007EEDA9 /* SkyLight.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SkyLight.framework; path = ../../../../System/Library/PrivateFrameworks/SkyLight.framework; sourceTree = ""; }; /* End PBXFileReference section */ @@ -78,15 +98,15 @@ membershipExceptions = ( Info.plist, ); - target = B1AA00112F30000100AABBCC /* LoopUpdaterHelper */; + target = B1AA00112F30000100AABBCC /* LoopPrivilegedHelper */; }; 2A6A87F22F4D20F4004E995D /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( LoopSupportPaths.swift, - PrivilegedInstallerProtocol.swift, + PrivilegedHelperProtocol.swift, ); - target = B1AA00112F30000100AABBCC /* LoopUpdaterHelper */; + target = B1AA00112F30000100AABBCC /* LoopPrivilegedHelper */; }; A8C751FF2D7BA98600B58784 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; @@ -112,7 +132,8 @@ 2A6A87F02F4D20D2004E995D /* Shared */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (2A6A87F22F4D20F4004E995D /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Shared; sourceTree = ""; }; 2A86890E2F809700005B521B /* LoopDockTile */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = LoopDockTile; sourceTree = ""; }; A8C751AC2D7BA98600B58784 /* Loop */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (A8C751FF2D7BA98600B58784 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Loop; sourceTree = ""; }; - B1AA00022F30000100AABBCC /* LoopUpdaterHelper */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (2A5DD5CC2F5D270C0077AB3C /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = LoopUpdaterHelper; sourceTree = ""; }; + B1AA00022F30000100AABBCC /* LoopPrivilegedHelper */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (2A5DD5CC2F5D270C0077AB3C /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = LoopPrivilegedHelper; sourceTree = ""; }; + C1BB00022F30000200AABBCC /* LoopCLI */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = LoopCLI; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -145,6 +166,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + C1BB00102F30000200AABBCC /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + C1BB00A52F40000200AABBCC /* ArgumentParser in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -166,7 +195,8 @@ A86AFD7529888B29008F4892 /* README.md */, A894B0C52C4B31AA00B4CE6F /* CONTRIBUTING.md */, A8C751AC2D7BA98600B58784 /* Loop */, - B1AA00022F30000100AABBCC /* LoopUpdaterHelper */, + B1AA00022F30000100AABBCC /* LoopPrivilegedHelper */, + C1BB00022F30000200AABBCC /* LoopCLI */, 2A86890E2F809700005B521B /* LoopDockTile */, A8E59C36297F5E9A0064D4BA /* Products */, A883642D298B7288005D6C19 /* Frameworks */, @@ -178,7 +208,8 @@ isa = PBXGroup; children = ( A8E59C35297F5E9A0064D4BA /* Loop.app */, - B1AA00012F30000100AABBCC /* LoopUpdaterHelper */, + B1AA00012F30000100AABBCC /* LoopPrivilegedHelper */, + C1BB00012F30000200AABBCC /* loop-cli */, 2A8689072F809625005B521B /* LoopDockTile.plugin */, ); name = Products; @@ -217,6 +248,7 @@ A8E59C31297F5E9A0064D4BA /* Sources */, A8E59C32297F5E9A0064D4BA /* Frameworks */, B1AA00612F30000100AABBCC /* CopyFiles */, + C1BB00612F30000200AABBCC /* CopyFiles */, 2A86890C2F80967B005B521B /* CopyFiles */, A8E59C33297F5E9A0064D4BA /* Resources */, ); @@ -224,6 +256,7 @@ ); dependencies = ( B1AA00712F30000100AABBCC /* PBXTargetDependency */, + C1BB00712F30000200AABBCC /* PBXTargetDependency */, 2A8689112F809800005B521B /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( @@ -242,9 +275,9 @@ productReference = A8E59C35297F5E9A0064D4BA /* Loop.app */; productType = "com.apple.product-type.application"; }; - B1AA00112F30000100AABBCC /* LoopUpdaterHelper */ = { + B1AA00112F30000100AABBCC /* LoopPrivilegedHelper */ = { isa = PBXNativeTarget; - buildConfigurationList = B1AA00212F30000100AABBCC /* Build configuration list for PBXNativeTarget "LoopUpdaterHelper" */; + buildConfigurationList = B1AA00212F30000100AABBCC /* Build configuration list for PBXNativeTarget "LoopPrivilegedHelper" */; buildPhases = ( B1AA00122F30000100AABBCC /* Sources */, B1AA00102F30000100AABBCC /* Frameworks */, @@ -255,14 +288,37 @@ dependencies = ( ); fileSystemSynchronizedGroups = ( - B1AA00022F30000100AABBCC /* LoopUpdaterHelper */, + B1AA00022F30000100AABBCC /* LoopPrivilegedHelper */, ); - name = LoopUpdaterHelper; + name = LoopPrivilegedHelper; packageProductDependencies = ( 2AF9238F2F540B2200F467FD /* Scribe */, ); - productName = LoopUpdaterHelper; - productReference = B1AA00012F30000100AABBCC /* LoopUpdaterHelper */; + productName = LoopPrivilegedHelper; + productReference = B1AA00012F30000100AABBCC /* LoopPrivilegedHelper */; + productType = "com.apple.product-type.tool"; + }; + C1BB00112F30000200AABBCC /* LoopCLI */ = { + isa = PBXNativeTarget; + buildConfigurationList = C1BB00212F30000200AABBCC /* Build configuration list for PBXNativeTarget "LoopCLI" */; + buildPhases = ( + C1BB00122F30000200AABBCC /* Sources */, + C1BB00102F30000200AABBCC /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 2A6A87F02F4D20D2004E995D /* Shared */, + C1BB00022F30000200AABBCC /* LoopCLI */, + ); + name = LoopCLI; + packageProductDependencies = ( + C1BB00A42F40000200AABBCC /* ArgumentParser */, + ); + productName = "loop-cli"; + productReference = C1BB00012F30000200AABBCC /* loop-cli */; productType = "com.apple.product-type.tool"; }; /* End PBXNativeTarget section */ @@ -284,6 +340,9 @@ B1AA00112F30000100AABBCC = { CreatedOnToolsVersion = 16.0; }; + C1BB00112F30000200AABBCC = { + CreatedOnToolsVersion = 16.0; + }; }; }; buildConfigurationList = A8E59C30297F5E9A0064D4BA /* Build configuration list for PBXProject "Loop" */; @@ -312,13 +371,15 @@ 2A28B62A2EE5057C00A1E26B /* XCRemoteSwiftPackageReference "luminare" */, 3ED0A7B72F21DF6800A58629 /* XCRemoteSwiftPackageReference "ZIPFoundation" */, 2AF9238C2F540B1300F467FD /* XCRemoteSwiftPackageReference "Scribe" */, + C1BB00A32F40000200AABBCC /* XCRemoteSwiftPackageReference "swift-argument-parser" */, ); productRefGroup = A8E59C36297F5E9A0064D4BA /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( A8E59C34297F5E9A0064D4BA /* Loop */, - B1AA00112F30000100AABBCC /* LoopUpdaterHelper */, + B1AA00112F30000100AABBCC /* LoopPrivilegedHelper */, + C1BB00112F30000200AABBCC /* LoopCLI */, 2A8689062F809625005B521B /* LoopDockTile */, ); }; @@ -370,6 +431,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + C1BB00122F30000200AABBCC /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -380,9 +448,14 @@ }; B1AA00712F30000100AABBCC /* PBXTargetDependency */ = { isa = PBXTargetDependency; - target = B1AA00112F30000100AABBCC /* LoopUpdaterHelper */; + target = B1AA00112F30000100AABBCC /* LoopPrivilegedHelper */; targetProxy = B1AA00512F30000100AABBCC /* PBXContainerItemProxy */; }; + C1BB00712F30000200AABBCC /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = C1BB00112F30000200AABBCC /* LoopCLI */; + targetProxy = C1BB00512F30000200AABBCC /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -784,10 +857,10 @@ ENABLE_RESOURCE_ACCESS_LOCATION = NO; ENABLE_RESOURCE_ACCESS_PHOTO_LIBRARY = NO; GENERATE_INFOPLIST_FILE = NO; - INFOPLIST_FILE = LoopUpdaterHelper/Info.plist; + INFOPLIST_FILE = LoopPrivilegedHelper/Info.plist; MACOSX_DEPLOYMENT_TARGET = 13.0; - PRODUCT_BUNDLE_IDENTIFIER = com.MrKai77.Loop.UpdaterHelper; - PRODUCT_NAME = LoopUpdaterHelper; + PRODUCT_BUNDLE_IDENTIFIER = com.MrKai77.Loop.PrivilegedHelper; + PRODUCT_NAME = LoopPrivilegedHelper; RUNTIME_EXCEPTION_ALLOW_DYLD_ENVIRONMENT_VARIABLES = NO; RUNTIME_EXCEPTION_ALLOW_JIT = NO; RUNTIME_EXCEPTION_ALLOW_UNSIGNED_EXECUTABLE_MEMORY = NO; @@ -815,10 +888,10 @@ ENABLE_RESOURCE_ACCESS_LOCATION = NO; ENABLE_RESOURCE_ACCESS_PHOTO_LIBRARY = NO; GENERATE_INFOPLIST_FILE = NO; - INFOPLIST_FILE = LoopUpdaterHelper/Info.plist; + INFOPLIST_FILE = LoopPrivilegedHelper/Info.plist; MACOSX_DEPLOYMENT_TARGET = 13.0; - PRODUCT_BUNDLE_IDENTIFIER = com.MrKai77.Loop.UpdaterHelper; - PRODUCT_NAME = LoopUpdaterHelper; + PRODUCT_BUNDLE_IDENTIFIER = com.MrKai77.Loop.PrivilegedHelper; + PRODUCT_NAME = LoopPrivilegedHelper; RUNTIME_EXCEPTION_ALLOW_DYLD_ENVIRONMENT_VARIABLES = NO; RUNTIME_EXCEPTION_ALLOW_JIT = NO; RUNTIME_EXCEPTION_ALLOW_UNSIGNED_EXECUTABLE_MEMORY = NO; @@ -847,10 +920,10 @@ ENABLE_RESOURCE_ACCESS_LOCATION = NO; ENABLE_RESOURCE_ACCESS_PHOTO_LIBRARY = NO; GENERATE_INFOPLIST_FILE = NO; - INFOPLIST_FILE = LoopUpdaterHelper/Info.plist; + INFOPLIST_FILE = LoopPrivilegedHelper/Info.plist; MACOSX_DEPLOYMENT_TARGET = 13.0; - PRODUCT_BUNDLE_IDENTIFIER = com.MrKai77.Loop.UpdaterHelper; - PRODUCT_NAME = LoopUpdaterHelper; + PRODUCT_BUNDLE_IDENTIFIER = com.MrKai77.Loop.PrivilegedHelper; + PRODUCT_NAME = LoopPrivilegedHelper; RUNTIME_EXCEPTION_ALLOW_DYLD_ENVIRONMENT_VARIABLES = NO; RUNTIME_EXCEPTION_ALLOW_JIT = NO; RUNTIME_EXCEPTION_ALLOW_UNSIGNED_EXECUTABLE_MEMORY = NO; @@ -864,6 +937,56 @@ }; name = Development; }; + C1BB00312F30000200AABBCC /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 5F967GYF84; + ENABLE_HARDENED_RUNTIME = YES; + MACOSX_DEPLOYMENT_TARGET = 13.0; + PRODUCT_BUNDLE_IDENTIFIER = com.MrKai77.Loop.CLI; + PRODUCT_NAME = "loop-cli"; + SDKROOT = macosx; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + C1BB00322F30000200AABBCC /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 5F967GYF84; + ENABLE_HARDENED_RUNTIME = YES; + MACOSX_DEPLOYMENT_TARGET = 13.0; + PRODUCT_BUNDLE_IDENTIFIER = com.MrKai77.Loop.CLI; + PRODUCT_NAME = "loop-cli"; + SDKROOT = macosx; + SKIP_INSTALL = YES; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + C1BB00332F30000200AABBCC /* Development */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 5F967GYF84; + ENABLE_HARDENED_RUNTIME = YES; + MACOSX_DEPLOYMENT_TARGET = 13.0; + PRODUCT_BUNDLE_IDENTIFIER = com.MrKai77.Loop.CLI; + PRODUCT_NAME = "loop-cli"; + SDKROOT = macosx; + SKIP_INSTALL = YES; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + }; + name = Development; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -897,7 +1020,7 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - B1AA00212F30000100AABBCC /* Build configuration list for PBXNativeTarget "LoopUpdaterHelper" */ = { + B1AA00212F30000100AABBCC /* Build configuration list for PBXNativeTarget "LoopPrivilegedHelper" */ = { isa = XCConfigurationList; buildConfigurations = ( B1AA00312F30000100AABBCC /* Debug */, @@ -907,6 +1030,16 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + C1BB00212F30000200AABBCC /* Build configuration list for PBXNativeTarget "LoopCLI" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C1BB00312F30000200AABBCC /* Debug */, + C1BB00322F30000200AABBCC /* Release */, + C1BB00332F30000200AABBCC /* Development */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ @@ -942,6 +1075,14 @@ minimumVersion = 0.9.20; }; }; + C1BB00A32F40000200AABBCC /* XCRemoteSwiftPackageReference "swift-argument-parser" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/apple/swift-argument-parser"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.0.0; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -979,6 +1120,11 @@ package = 3ED0A7B72F21DF6800A58629 /* XCRemoteSwiftPackageReference "ZIPFoundation" */; productName = ZIPFoundation; }; + C1BB00A42F40000200AABBCC /* ArgumentParser */ = { + isa = XCSwiftPackageProductDependency; + package = C1BB00A32F40000200AABBCC /* XCRemoteSwiftPackageReference "swift-argument-parser" */; + productName = ArgumentParser; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = A8E59C2D297F5E9A0064D4BA /* Project object */; diff --git a/Loop/App/AppDelegate.swift b/Loop/App/AppDelegate.swift index 9701971e..47a87b1b 100644 --- a/Loop/App/AppDelegate.swift +++ b/Loop/App/AppDelegate.swift @@ -12,7 +12,9 @@ import UserNotifications @Loggable final class AppDelegate: NSObject, NSApplicationDelegate { - private let urlCommandHandler = URLCommandHandler() + private let loopCommandHandler = LoopCommandHandler() + private lazy var loopSocketManager = LoopSocketManager(handler: loopCommandHandler) + private var pendingSettingsWindowOpen: Task<(), Never>? private var shutdownTask: Task<(), Never>? private var launchedAsLoginItem: Bool { @@ -32,11 +34,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate { await Defaults.iCloud.waitForSyncCompletion() } - // Show settings window only if not launched as login item AND startHidden is disabled + // Normal user-facing launches should open Settings, but URL-driven launches need a chance + // to cancel that presentation when their URL event arrives immediately after startup. if !launchedAsLoginItem, !Defaults[.startHidden] { - SettingsWindowManager.shared.show() + scheduleSettingsWindowOpen() } else { - // Closing also hides the dock icon if needed. SettingsWindowManager.shared.close() } @@ -70,6 +72,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { forEventClass: AEEventClass(kInternetEventClass), andEventID: AEEventID(kAEGetURL) ) + + // Start the Unix socket listener for loop-cli + loopSocketManager.start() } /// Terminates any other running instances of Loop to prevent accessibility permission conflicts. @@ -113,15 +118,24 @@ final class AppDelegate: NSObject, NSApplicationDelegate { LogManager.shared.configuration.includeFileAndLineNumber = false } - @objc func handleGetURLEvent(_ event: NSAppleEventDescriptor, withReplyEvent _: NSAppleEventDescriptor) { + @objc func handleGetURLEvent(_ event: NSAppleEventDescriptor, withReplyEvent replyEvent: NSAppleEventDescriptor) { guard let urlString = event.paramDescriptor(forKeyword: keyDirectObject)?.stringValue, let url = URL(string: urlString) else { log.info("Failed to get URL from event") return } - log.info("Received URL: \(url)") - urlCommandHandler.handle(url) + processIncomingURL(url, replyEvent: replyEvent) + } + + func applicationShouldOpenUntitledFile(_: NSApplication) -> Bool { + !launchedAsLoginItem && !Defaults[.startHidden] + } + + func applicationOpenUntitledFile(_: NSApplication) -> Bool { + cancelPendingSettingsWindowOpen() + SettingsWindowManager.shared.show() + return true } func applicationShouldTerminateAfterLastWindowClosed(_: NSApplication) -> Bool { @@ -129,8 +143,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate { return false } - func applicationShouldHandleReopen(_: NSApplication, hasVisibleWindows _: Bool) -> Bool { - SettingsWindowManager.shared.show() + func applicationShouldHandleReopen(_: NSApplication, hasVisibleWindows: Bool) -> Bool { + guard !hasVisibleWindows else { + return false + } + + scheduleSettingsWindowOpen() return true } @@ -140,6 +158,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } shutdownTask = Task { @MainActor in + loopSocketManager.stop() await StashManager.shared.shutdown() self.shutdownTask = nil sender.reply(toApplicationShouldTerminate: true) @@ -150,7 +169,47 @@ final class AppDelegate: NSObject, NSApplicationDelegate { func application(_: NSApplication, open urls: [URL]) { for url in urls { - urlCommandHandler.handle(url) + processIncomingURL(url) + } + } + + private func processIncomingURL(_ url: URL, replyEvent: NSAppleEventDescriptor? = nil) { + cancelPendingSettingsWindowOpen() + log.info("Received URL: \(url)") + + let result = loopCommandHandler.handle(url) + log.info("Response: \(result.jsonResponse)") + + replyEvent?.setDescriptor( + NSAppleEventDescriptor(string: result.jsonResponse), + forKeyword: keyDirectObject + ) + + Task { @MainActor in + result.presentIfNeeded() + } + } + + private func scheduleSettingsWindowOpen() { + guard !launchedAsLoginItem, !Defaults[.startHidden] else { + return } + + cancelPendingSettingsWindowOpen() + + pendingSettingsWindowOpen = Task { @MainActor [weak self] in + await Task.yield() + guard !Task.isCancelled else { + return + } + + self?.pendingSettingsWindowOpen = nil + SettingsWindowManager.shared.show() + } + } + + private func cancelPendingSettingsWindowOpen() { + pendingSettingsWindowOpen?.cancel() + pendingSettingsWindowOpen = nil } } diff --git a/Loop/Core/URLCommandHandler.swift b/Loop/Core/URLCommandHandler.swift deleted file mode 100644 index 81fb00c5..00000000 --- a/Loop/Core/URLCommandHandler.swift +++ /dev/null @@ -1,691 +0,0 @@ -// -// URLCommandHandler.swift -// Loop -// -// Created by Kami on 06/03/2025. -// - -/* - Loop URL Scheme Documentation - =========================== - - The Loop app supports URL scheme commands for window management and automation. - Base URL format: loop:/// - - Available Commands: - ----------------- - - 1. Window Direction Commands: - Format: loop://direction/ - Examples: - - loop://direction/left (Move window to left half) - - loop://direction/right (Move window to right half) - - loop://direction/top (Move window to top half) - - loop://direction/bottom (Move window to bottom half) - - loop://direction/maximize (Maximize window) - - loop://direction/center (Center window) - - 2. Screen Management: - Format: loop://screen/ - Examples: - - loop://screen/next (Move window to next screen) - - loop://screen/previous (Move window to previous screen) - - 3. Action Commands: - Format: loop://action/ - Examples: - - loop://action/maximize (Maximize window) - - loop://action/leftHalf (Move to left half) - Note: See 'loop://list/actions' for all available actions - - 4. Keybind Commands: - Format: loop://keybind/ - Examples: - - loop://keybind/myCustomLayout - Note: See 'loop://list/keybinds' for available keybinds - - 5. List Commands: - Format: loop://list/ - Types: - - actions (List all window actions) - - keybinds (List all custom keybinds) - - all (List everything) - - Usage Tips: - ---------- - 1. All commands are case-insensitive - 2. Parameters with spaces must be URL encoded - 3. Window commands operate on the frontmost non-terminal window - 4. Use list commands to discover available options - - Examples: - -------- - # Move current window to right half - open "loop://direction/right" - - # List all available actions - open "loop://list/actions" - - # Execute custom keybind - open "loop://keybind/myLayout" - - Error Examples: - ------------- - # Invalid command - open "loop://invalid" -> Returns available commands - - # Missing parameter - open "loop://direction" -> Returns available directions - - # Invalid keybind - open "loop://keybind/nonexistent" -> Returns available keybinds - */ - -import Defaults -import Foundation -import Scribe -import SwiftUI - -/// Handles URL scheme commands for the Loop application -@Loggable -final class URLCommandHandler { - // MARK: - Types - - /// Available URL scheme commands with their descriptions - enum Command: String, CaseIterable { - /// Window positioning commands (left, right, top, bottom, etc.) - case direction - /// Multi-screen management commands (next, previous) - case screen - /// Predefined window actions - case action - /// Custom keybind actions - case keybind - /// List available commands and options - case list - - /// Human-readable description of each command type - var description: String { - switch self { - case .direction: "Window direction command" - case .screen: "Screen management" - case .action: "Execute predefined window action" - case .keybind: "Execute custom keybind action" - case .list: "List available commands" - } - } - } - - // MARK: - Properties - - /// Tracks the last active window for context preservation - private var lastActiveWindow: Window? - - /// Timestamp of last window activation - private var lastActiveTime: Date? - - /// Current command being processed - private var currentCommand: String? - - /// Buffer for collecting output before writing - private var outputBuffer: [String] = [] - - // MARK: - Output Handling - - /// Writes a message to either the buffer (for list commands) or stdout - /// - Parameter message: The message to write - private func writeToOutput(_ message: String) { - // Remove [URLHandler] prefix and clean up the message - let cleanMessage = message.replacingOccurrences(of: "[URLHandler] ", with: "") - - // Skip debug-only messages for regular output - if cleanMessage.hasPrefix("Path components:") || - cleanMessage.hasPrefix("Found") || - cleanMessage.hasPrefix("Window:") || - (cleanMessage.hasPrefix("Processing") && !cleanMessage.contains("command:")) { - log.info(cleanMessage) - return - } - - let output = cleanMessage - if currentCommand?.contains("/list") == true { - outputBuffer.append(output) - } else { - log.info("\(output)") - } - log.info(cleanMessage) - } - - /// Writes a titled list of items to output - /// - Parameters: - /// - title: The title for the list - /// - items: Array of items to list - private func writeList(_ title: String, _ items: [String]) { - let formattedItems = items.map { item in - if item.hasPrefix("\n") { - return item.replacingOccurrences(of: "\n", with: "") - } - return item - } - - if currentCommand?.contains("/list") == true { - outputBuffer.append(title) - outputBuffer.append(contentsOf: formattedItems) - } else { - log.info("\n\(title)") - formattedItems.forEach { log.info("\($0)") } - } - } - - /// Flushes the output buffer to a file for list commands - /// - Note: Due to limitations with terminal output formatting and the complexity of the list output, - /// we use a temporary file to display the formatted list. This allows for proper spacing, - /// sections, and formatting that would be difficult to achieve with direct terminal output. - /// The file is automatically opened and then deleted after 60 seconds to keep the system clean. - private func flushOutput() { - guard currentCommand?.contains("/list") == true, - !outputBuffer.isEmpty else { - outputBuffer.removeAll() - return - } - - // Create a unique temporary file that will be automatically cleaned up - let timestamp = Date().timeIntervalSince1970 - let tempFile = FileManager.default.temporaryDirectory - .appendingPathComponent("loop_output_\(timestamp).txt") - - do { - try outputBuffer.joined(separator: "\n").write(to: tempFile, atomically: true, encoding: .utf8) - NSWorkspace.shared.open(tempFile) - - // Schedule file deletion after a delay - // We use a longer delay (60s) to ensure the user has time to read the content - Task { - try? await Task.sleep(for: .seconds(60)) - - do { - try FileManager.default.removeItem(at: tempFile) - log.info("Cleaned up temporary file: \(tempFile.lastPathComponent)") - } catch { - log.error("Failed to clean up temporary file: \(error.localizedDescription)") - } - } - } catch { - log.error("Failed to write output: \(error.localizedDescription)") - - // Fallback to direct console output if file operations fail - log.info("\(outputBuffer.joined(separator: "\n"))") - } - - outputBuffer.removeAll() - } - - // MARK: - Public Methods - - /// Handles incoming URL scheme requests - /// - Parameter url: The URL to process - /// - Throws: URLError for invalid URLs or commands - func handle(_ url: URL) { - currentCommand = url.absoluteString - writeToOutput("[URLHandler] Processing URL: \(url)") - - guard url.scheme?.lowercased() == "loop" else { - writeToOutput("[URLHandler] Invalid scheme: \(url.scheme ?? "nil")") - writeToOutput("[URLHandler] Required format: loop:///") - return - } - - let components = (url.host.map { [$0] } ?? []) + url.pathComponents.filter { $0 != "/" && !$0.isEmpty } - writeToOutput("[URLHandler] Path components: \(components)") - - guard let commandString = components.first, - let command = Command(rawValue: commandString.lowercased()) else { - writeToOutput("[URLHandler] Invalid command: \(components.first ?? "nil")") - writeToOutput("[URLHandler] Available commands: \(Command.allCases.map(\.rawValue).joined(separator: ", "))") - return - } - - processCommand(command, Array(components.dropFirst())) - } - - // MARK: - Command Processing - - /// Processes a command with its parameters - /// - Parameters: - /// - command: The command to process - /// - parameters: Array of command parameters - private func processCommand(_ command: Command, _ parameters: [String]) { - log.info(command.rawValue) - log.info(parameters.description) - - switch command { - case .direction: handleDirectionCommand(parameters) - case .screen: handleScreenCommand(parameters) - case .action: handleActionCommand(parameters) - case .keybind: handleKeybindCommand(parameters) - case .list: handleListCommand(parameters) - } - - flushOutput() - } - - /// Handles window direction commands - /// - Parameter parameters: Direction parameters - private func handleDirectionCommand(_ parameters: [String]) { - guard let directionStr = parameters.first?.lowercased() else { - writeToOutput("No direction specified") - writeToOutput("Available directions:") - writeToOutput(" Basic: left, right, top, bottom") - writeToOutput(" Full names: \(WindowDirection.allCases.map { $0.rawValue.lowercased() }.joined(separator: ", "))") - return - } - - // If this is a list command, redirect to the action handler - if directionStr == "list" { - handleListCommand(["actions"]) - return - } - - writeToOutput("Processing direction: \(directionStr)") - - // First check if this is a custom action being called via direction - if directionStr.hasPrefix("custom") || directionStr.hasPrefix("stash") { - handleActionCommand(parameters) - return - } - - let direction: WindowDirection? = WindowDirection.allCases.first { $0.rawValue.lowercased() == directionStr } ?? { - switch directionStr { - case "left": return WindowDirection.leftHalf - case "right": return WindowDirection.rightHalf - case "top": return WindowDirection.topHalf - case "bottom": return WindowDirection.bottomHalf - default: - let withoutHalf = directionStr.replacingOccurrences(of: "half", with: "") - return WindowDirection.allCases.first { $0.rawValue.lowercased() == withoutHalf } - } - }() - - if let direction { - executeWindowAction(direction) - } else { - writeToOutput("Invalid direction: \(directionStr)") - writeToOutput("Available directions:") - writeToOutput(" Basic: left, right, top, bottom") - writeToOutput(" Full names: \(WindowDirection.allCases.map { $0.rawValue.lowercased() }.joined(separator: ", "))") - } - } - - /// Executes a window action for a given direction - /// - Parameter direction: The direction to move/resize the window - private func executeWindowAction(_ direction: WindowDirection) { - writeToOutput("[URLHandler] Executing direction: \(direction.rawValue)") - - let allWindows = WindowUtility.windowList() - writeToOutput("[URLHandler] Found \(allWindows.count) total windows") - - let visibleWindows = allWindows.filter { win in - guard let app = win.nsRunningApplication else { - writeToOutput("[URLHandler] Window has no application: \(win.title ?? "unknown")") - return false - } - - let isLoop = app.bundleIdentifier == Bundle.main.bundleIdentifier - let isRegular = app.activationPolicy == .regular - let isVisible = !win.isApplicationHidden && !win.minimized - - logWindowDetails(win, app, isLoop, isRegular, isVisible) - - return !isLoop && isRegular && isVisible - } - - writeToOutput("[URLHandler] Found \(visibleWindows.count) eligible windows") - - guard let window = findTargetWindow(from: visibleWindows), - let screen = NSScreen.main else { - writeToOutput("[URLHandler] No suitable windows or screen found") - return - } - - logSelectedWindow(window, screen) - - let action = WindowAction(direction) - writeToOutput("[URLHandler] Resizing window with action: \(direction.rawValue)") - - activateAndResizeWindow(window, action, screen) - } - - /// Handles screen management commands - /// - Parameter parameters: Screen command parameters - private func handleScreenCommand(_ parameters: [String]) { - guard let command = parameters.first?.lowercased(), - let window = try? WindowUtility.frontmostWindow() else { - writeToOutput("[URLHandler] No screen command or window") - return - } - - writeToOutput("[URLHandler] Processing screen command: \(command)") - - let direction: WindowDirection = command == "next" ? .nextScreen : .previousScreen - moveWindowToScreen(window, direction) - } - - /// Handles predefined window actions - /// - Parameter parameters: Action parameters - private func handleActionCommand(_ parameters: [String]) { - guard let actionStr = parameters.first?.lowercased() else { - printAvailableActions() - return - } - - // First check for custom actions by name - let customKeybinds = Defaults[.keybinds].filter { $0.direction.isCustomizable && $0.name != nil } - if let customAction = customKeybinds.first(where: { ($0.name?.lowercased() ?? "") == actionStr }) { - writeToOutput("Executing custom action: \(customAction.name ?? "unnamed")") - - // Try multiple methods to get the target window - let targetWindow = findTargetWindow(from: WindowUtility.windowList().filter { win in - guard let app = win.nsRunningApplication else { return false } - return app.activationPolicy == .regular && !win.isApplicationHidden && !win.minimized - }) - - if let window = targetWindow, - let screen = NSScreen.main { - writeToOutput("Found target window: \(window.title ?? "unknown")") - activateAndResizeWindow(window, customAction, screen) - } else { - writeToOutput("Error: Could not find a suitable window to apply the custom action") - } - } else if actionStr == "list" { - // For list command, just show the actions without the invalid message - printAvailableActions() - } else if let direction = WindowDirection.allCases.first(where: { $0.rawValue.lowercased() == actionStr }), - let window = findTargetWindow(from: WindowUtility.windowList()), - let screen = NSScreen.main { - writeToOutput("Executing action: \(direction.rawValue)") - activateAndResizeWindow(window, .init(direction), screen) - } else { - writeToOutput("Invalid action: \(actionStr)") - printAvailableActions() - } - } - - /// Prints all available window actions in categories - private func printAvailableActions() { - var items: [String] = [] - - // Get any custom keybinds with names and custom direction - let customKeybinds = Defaults[.keybinds].filter { $0.direction == .custom && $0.name?.isEmpty == false } - if !customKeybinds.isEmpty { - items.append("Custom Actions:") - items.append(contentsOf: customKeybinds.compactMap { keybind in - guard let name = keybind.name else { return nil } - return " • loop://action/\(name.lowercased())" - }) - items.append("") - } - - // Get any stash keybinds with names and custom direction - let stashKeybinds = Defaults[.keybinds].filter { $0.direction == .stash && $0.name?.isEmpty == false } - if !stashKeybinds.isEmpty { - items.append("Stash Actions:") - items.append(contentsOf: stashKeybinds.compactMap { keybind in - guard let name = keybind.name else { return nil } - return " • loop://action/\(name.lowercased())" - }) - items.append("") - } - - let categories: [(String, [WindowDirection])] = [ - ("General Actions", Array(WindowDirection.general.dropFirst(3))), // Drop first 3 actions - ("Halves", WindowDirection.halves), - ("Quarters", WindowDirection.quarters), - ("Horizontal Thirds", WindowDirection.horizontalThirds), - ("Vertical Thirds", WindowDirection.verticalThirds), - ("Screen Switching", WindowDirection.screenSwitching), - ("Size Adjustment", WindowDirection.sizeAdjustment), - ("Shrink", WindowDirection.shrink), - ("Grow", WindowDirection.grow), - ("Move", WindowDirection.move), - ("Other", WindowDirection.more) - ] - - for (title, actions) in categories { - if !actions.isEmpty { - items.append("\(title):") - items.append(contentsOf: actions.map { " • loop://action/\($0.rawValue.lowercased())" }) - items.append("") - } - } - - // Remove the last empty line if it exists - if items.last?.isEmpty == true { - items.removeLast() - } - - writeList("", items) - } - - /// Handles custom keybind execution - /// - Parameter parameters: Keybind parameters - private func handleKeybindCommand(_ parameters: [String]) { - guard let keybindName = parameters.first else { - writeToOutput("[URLHandler] No keybind specified") - return - } - - let keybinds = Defaults[.keybinds] - - if keybindName.lowercased() == "list" { - writeToOutput("[URLHandler] Available keybinds:") - keybinds.compactMap(\.name).forEach { writeToOutput(" - \($0)") } - return - } - - if let keybind = keybinds.first(where: { $0.name?.lowercased() == keybindName.lowercased() }) { - writeToOutput("[URLHandler] Executing keybind: \(keybind.name ?? "unnamed")") - if let window = WindowUtility.userDefinedTargetWindow(), - let screen = NSScreen.main { - Task { - _ = try await WindowActionEngine.shared.apply( - keybind, - window: window, - screen: screen - ) - } - } - } else { - writeToOutput("[URLHandler] Keybind not found: \(keybindName)") - writeToOutput("[URLHandler] Available keybinds:") - keybinds.compactMap(\.name).forEach { writeToOutput(" - \($0)") } - } - } - - /// Handles list commands for viewing available options - /// - Parameter parameters: List parameters - private func handleListCommand(_ parameters: [String]) { - let type = parameters.first?.lowercased() ?? "all" - var items: [String] = [] - - switch type { - case "actions": - items.append("Available Actions:") - // Get any custom keybinds with names and custom direction - let customKeybinds = Defaults[.keybinds].filter { $0.direction == .custom && $0.name?.isEmpty == false } - if !customKeybinds.isEmpty { - items.append("\nCustom Actions:") - items.append(contentsOf: customKeybinds.compactMap { keybind in - guard let name = keybind.name else { return nil } - return " • loop://action/\(name.lowercased())" - }) - } - - // Get any stash keybinds with names and custom direction - let stashKeybinds = Defaults[.keybinds].filter { $0.direction == .stash && $0.name?.isEmpty == false } - if !stashKeybinds.isEmpty { - items.append("\nStash Actions:") - items.append(contentsOf: stashKeybinds.compactMap { keybind in - guard let name = keybind.name else { return nil } - return " • loop://action/\(name.lowercased())" - }) - } - - let categories: [(String, [WindowDirection])] = [ - ("General Actions", Array(WindowDirection.general.dropFirst(3))), - ("Halves", WindowDirection.halves), - ("Quarters", WindowDirection.quarters), - ("Horizontal Thirds", WindowDirection.horizontalThirds), - ("Vertical Thirds", WindowDirection.verticalThirds), - ("Screen Switching", WindowDirection.screenSwitching), - ("Size Adjustment", WindowDirection.sizeAdjustment), - ("Shrink", WindowDirection.shrink), - ("Grow", WindowDirection.grow), - ("Move", WindowDirection.move), - ("Other", WindowDirection.more) - ] - - for (title, actions) in categories { - if !actions.isEmpty { - items.append("\n\(title):") - items.append(contentsOf: actions.map { " • loop://action/\($0.rawValue.lowercased())" }) - } - } - - case "keybinds": - items.append("Available Keybinds:") - items.append(contentsOf: Defaults[.keybinds].compactMap { keybind in - guard let name = keybind.name else { return nil } - return " • loop://keybind/\(name)" - }) - - default: - items.append("Available Commands:") - - items.append("\nDirection Commands:") - items.append(contentsOf: WindowDirection.allCases.map { " • loop://direction/\($0.rawValue.lowercased())" }) - - items.append("\nScreen Commands:") - items.append(" • loop://screen/next") - items.append(" • loop://screen/previous") - - items.append("\nActions:") - // Get any custom keybinds with names and custom direction - let customKeybinds = Defaults[.keybinds].filter { $0.direction == .custom && $0.name?.isEmpty == false } - if !customKeybinds.isEmpty { - items.append("\nCustom Actions:") - items.append(contentsOf: customKeybinds.compactMap { keybind in - guard let name = keybind.name else { return nil } - return " • loop://action/\(name.lowercased())" - }) - } - - // Get any stash keybinds with names and custom direction - let stashKeybinds = Defaults[.keybinds].filter { $0.direction == .stash && $0.name?.isEmpty == false } - if !stashKeybinds.isEmpty { - items.append("\nStash Actions:") - items.append(contentsOf: stashKeybinds.compactMap { keybind in - guard let name = keybind.name else { return nil } - return " • loop://action/\(name.lowercased())" - }) - } - - items.append("\nKeybind Commands:") - items.append(contentsOf: Defaults[.keybinds].compactMap { keybind in - guard let name = keybind.name else { return nil } - return " • loop://keybind/\(name)" - }) - - items.append("\nList Commands:") - items.append(" • loop://list/actions") - items.append(" • loop://list/keybinds") - items.append(" • loop://list/all") - } - - writeList(type == "all" ? "All Commands" : items.removeFirst(), Array(items)) - } - - // MARK: - Helper Methods - - /// Finds the most appropriate target window for an action - /// - Parameter visibleWindows: Array of visible windows to choose from - /// - Returns: The most appropriate window or nil if none found - private func findTargetWindow(from visibleWindows: [Window]) -> Window? { - if let targetWindow = WindowUtility.userDefinedTargetWindow() { - writeToOutput("[URLHandler] Using WindowEngine.getTargetWindow(): \(targetWindow.title ?? "unknown")") - return targetWindow - } - - if let lastWindow = lastActiveWindow, - let app = lastWindow.nsRunningApplication, - app.bundleIdentifier != Bundle.main.bundleIdentifier, - !lastWindow.isApplicationHidden, !lastWindow.minimized, - let lastTime = lastActiveTime, - lastTime.timeIntervalSinceNow > -5 { - writeToOutput("[URLHandler] Using last active window: \(lastWindow.title ?? "unknown")") - return lastWindow - } - - return visibleWindows.first - } - - /// Logs window details for debugging - private func logWindowDetails(_ window: Window, _ app: NSRunningApplication, _ isLoop: Bool, _ isRegular: Bool, _ isVisible: Bool) { - writeToOutput("[URLHandler] Window: \(window.title ?? "unknown")") - writeToOutput(" - App: \(app.localizedName ?? "unknown")") - writeToOutput(" - Bundle ID: \(app.bundleIdentifier ?? "unknown")") - writeToOutput(" - Is Loop: \(isLoop)") - writeToOutput(" - Is Regular: \(isRegular)") - writeToOutput(" - Is Visible: \(isVisible)") - } - - /// Logs selected window details - private func logSelectedWindow(_ window: Window, _ screen: NSScreen) { - writeToOutput("[URLHandler] Selected window for action:") - writeToOutput(" - Title: \(window.title ?? "unknown")") - writeToOutput(" - App: \(window.nsRunningApplication?.localizedName ?? "unknown")") - writeToOutput(" - Screen: \(screen.localizedName)") - writeToOutput(" - Current Frame: \(window.frame)") - } - - /// Activates and resizes a window - private func activateAndResizeWindow(_ window: Window, _ action: WindowAction, _ screen: NSScreen) { - lastActiveWindow = window - lastActiveTime = Date() - - if let app = window.nsRunningApplication { - writeToOutput("[URLHandler] Activating application: \(app.localizedName ?? "unknown")") - app.activate(options: .activateIgnoringOtherApps) - } - - Task { - try? await Task.sleep(for: .seconds(0.1)) - - writeToOutput("[URLHandler] Executing resize operation") - _ = try await WindowActionEngine.shared.apply( - action, - window: window, - screen: screen - ) - writeToOutput("[URLHandler] New window frame: \(window.frame)") - } - } - - /// Moves a window to another screen - private func moveWindowToScreen(_ window: Window, _ direction: WindowDirection) { - if let currentScreen = ScreenUtility.screenContaining(window), - let targetScreen = direction == .nextScreen ? - ScreenUtility.nextScreen(from: currentScreen) : - ScreenUtility.previousScreen(from: currentScreen) { - writeToOutput("[URLHandler] Moving window to screen: \(targetScreen.localizedName)") - Task { - _ = try await WindowActionEngine.shared.apply( - .init(direction), - window: window, - screen: targetScreen - ) - } - } else { - writeToOutput("[URLHandler] Failed to find target screen") - } - } -} diff --git a/Loop/Localizable.xcstrings b/Loop/Localizable.xcstrings index 0f72a46a..890670a8 100644 --- a/Loop/Localizable.xcstrings +++ b/Loop/Localizable.xcstrings @@ -620,6 +620,17 @@ } } } + }, + "`/usr/local/bin/loop` is already in use by another file or symlink. Remove it manually before installing Loop CLI." : { + "comment" : "Description of a `Status` case when the `/usr/local/bin/loop` symlink is blocked.", + "isCommentAutoGenerated" : true + }, + "`/usr/local/bin/loop` is installed and points to this version of Loop." : { + "comment" : "Description of a `Status` case when the installed `/usr/local/bin/loop` is the same as the one currently running.", + "isCommentAutoGenerated" : true + }, + "`/usr/local/bin/loop` points to a different or moved version of Loop. Repair it to update the symlink." : { + }, "A single %@ action can only track one window. To stash\nmultiple windows, add additional %@ actions." : { "comment" : "Information in a popover displaying how a stash action can only keep track of a single window. Both %1$@ and %2$@ are replaced with the language's localization of the \"Stash\" action.", @@ -4075,6 +4086,12 @@ } } } + }, + "Command Line Interface" : { + "comment" : "Section header shown in settings" + }, + "Command-Line Tool Error" : { + }, "Configure…" : { "comment" : "Button label that opens a view to configure padding settings.", @@ -12488,6 +12505,14 @@ } } }, + "Install…" : { + "comment" : "The title of a button that installs the Loop command-line tool.", + "isCommentAutoGenerated" : true + }, + "Installs `/usr/local/bin/loop` to run Loop from the shell." : { + "comment" : "Description of the status when the Loop CLI is not installed.", + "isCommentAutoGenerated" : true + }, "Instant" : { "comment" : "Animation speed setting", "localizations" : { @@ -16461,6 +16486,10 @@ } } }, + "Loop CLI" : { + "comment" : "The name of the command-line interface for the app.", + "isCommentAutoGenerated" : true + }, "Loops left to unlock new icon" : { "localizations" : { "ar" : { @@ -25308,6 +25337,10 @@ } } }, + "Repair…" : { + "comment" : "The text that appears when the \"Repair…\" button is tapped, indicating that the user is being prompted to reinstall the command-line tool.", + "isCommentAutoGenerated" : true + }, "Request…" : { "comment" : "Button to request accessibility access", "localizations" : { diff --git a/Loop/Scripting/CommandLineToolInstaller.swift b/Loop/Scripting/CommandLineToolInstaller.swift new file mode 100644 index 00000000..da9ff923 --- /dev/null +++ b/Loop/Scripting/CommandLineToolInstaller.swift @@ -0,0 +1,138 @@ +// +// CommandLineToolInstaller.swift +// Loop +// +// Created by Kai Azim on 2026-03-29. +// + +import Darwin +import SwiftUI + +final class CommandLineToolInstaller { + enum Status: Equatable { + case notInstalled + case installedCurrent + case installedStale + case blocked + + var description: LocalizedStringKey { + switch self { + case .notInstalled: + "Installs `/usr/local/bin/loop` to run Loop from the shell." + case .installedCurrent: + "`/usr/local/bin/loop` is installed and points to this version of Loop." + case .installedStale: + "`/usr/local/bin/loop` points to a different or moved version of Loop. Repair it to update the symlink." + case .blocked: + "`/usr/local/bin/loop` is already in use by another file or symlink. Remove it manually before installing Loop CLI." + } + } + } + + private enum ExistingFilesystemEntry { + case missing + case symbolicLink + case other + } + + private let privilegedHelperCoordinator: PrivilegedHelperCoordinator + private let fileManager: FileManager + + init( + privilegedHelperCoordinator: PrivilegedHelperCoordinator = PrivilegedHelperCoordinator(), + fileManager: FileManager = .default + ) { + self.privilegedHelperCoordinator = privilegedHelperCoordinator + self.fileManager = fileManager + } + + func status() throws -> Status { + let symlinkURL = PrivilegedHelperConstants.commandLineToolSymlinkURL + + switch try filesystemEntry(at: symlinkURL) { + case .missing: + return .notInstalled + case .other: + return .blocked + case .symbolicLink: + let rawTarget = try fileManager.destinationOfSymbolicLink(atPath: symlinkURL.path) + guard isLoopManagedCommandLineToolTarget(rawTarget) else { + return .blocked + } + + let currentCLIURL = currentBundledCommandLineToolURL() + let installedCLIURL = resolvedSymbolicLinkDestinationURL( + rawTarget, + relativeTo: symlinkURL.deletingLastPathComponent() + ) + + return installedCLIURL.path == currentCLIURL.path ? .installedCurrent : .installedStale + } + } + + func install() async throws { + try await privilegedHelperCoordinator.withPrivilegedSession( + prompt: "\(Bundle.main.appName) needs administrator permission to install its command-line tool." + ) { session in + try await session.installCommandLineTool() + } + } + + func reinstall() async throws { + try await privilegedHelperCoordinator.withPrivilegedSession( + prompt: "\(Bundle.main.appName) needs administrator permission to install its command-line tool." + ) { session in + try await session.reinstallCommandLineTool() + } + } + + private func currentBundledCommandLineToolURL() -> URL { + LoopSupportPaths.canonical( + Bundle.main.bundleURL + .appendingPathComponent("Contents/MacOS", isDirectory: true) + .appendingPathComponent(PrivilegedHelperConstants.commandLineToolExecutableName, isDirectory: false) + ) + } + + private func resolvedSymbolicLinkDestinationURL(_ targetPath: String, relativeTo baseURL: URL) -> URL { + if targetPath.hasPrefix("/") { + return URL(fileURLWithPath: targetPath).resolvingSymlinksInPath().standardizedFileURL + } + + return URL(fileURLWithPath: targetPath, relativeTo: baseURL) + .resolvingSymlinksInPath() + .standardizedFileURL + } + + private func isLoopManagedCommandLineToolTarget(_ targetPath: String) -> Bool { + resolvedSymbolicLinkDestinationURL( + targetPath, + relativeTo: PrivilegedHelperConstants.commandLineToolInstallDirectoryURL + ) + .path + .hasSuffix(PrivilegedHelperConstants.loopManagedCommandLineToolSuffix) + } + + private func filesystemEntry(at url: URL) throws -> ExistingFilesystemEntry { + var statBuffer = stat() + let result = url.withUnsafeFileSystemRepresentation { path in + guard let path else { return -1 } + return Int(lstat(path, &statBuffer)) + } + + if result == 0 { + let fileType = statBuffer.st_mode & S_IFMT + return fileType == S_IFLNK ? .symbolicLink : .other + } + + if errno == ENOENT { + return .missing + } + + throw NSError( + domain: NSPOSIXErrorDomain, + code: Int(errno), + userInfo: [NSLocalizedDescriptionKey: String(cString: strerror(errno))] + ) + } +} diff --git a/Loop/Scripting/CommandOutputWindowController.swift b/Loop/Scripting/CommandOutputWindowController.swift new file mode 100644 index 00000000..6a3723ba --- /dev/null +++ b/Loop/Scripting/CommandOutputWindowController.swift @@ -0,0 +1,153 @@ +// +// CommandOutputWindowController.swift +// Loop +// +// Created by Kai Azim on 2026-03-28. +// + +import AppKit + +@MainActor +final class CommandOutputWindowController: NSWindowController, NSWindowDelegate, NSToolbarDelegate { + private enum ToolbarIdentifier { + static let toolbar = NSToolbar.Identifier("LoopCommandOutputToolbar") + static let copy = NSToolbarItem.Identifier("LoopCommandOutputCopy") + } + + private let output: String + private let onClose: () -> () + + init(title: String, content: String, onClose: @escaping () -> ()) { + self.output = content + self.onClose = onClose + + let scrollView = NSScrollView() + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.borderType = .noBorder + scrollView.hasVerticalScroller = true + scrollView.hasHorizontalScroller = false + scrollView.autohidesScrollers = true + scrollView.drawsBackground = false + + let textView = NSTextView(frame: .zero) + textView.isEditable = false + textView.isSelectable = true + textView.isRichText = false + textView.allowsUndo = false + textView.usesFindBar = true + textView.drawsBackground = false + textView.font = .monospacedSystemFont(ofSize: 12, weight: .regular) + textView.string = content + textView.textContainerInset = NSSize(width: 12, height: 12) + textView.minSize = .zero + textView.maxSize = NSSize( + width: CGFloat.greatestFiniteMagnitude, + height: CGFloat.greatestFiniteMagnitude + ) + textView.isHorizontallyResizable = false + textView.isVerticallyResizable = true + textView.textContainer?.containerSize = NSSize( + width: CGFloat.greatestFiniteMagnitude, + height: CGFloat.greatestFiniteMagnitude + ) + textView.textContainer?.widthTracksTextView = true + textView.textContainer?.heightTracksTextView = false + + scrollView.documentView = textView + + let contentView = NSVisualEffectView(frame: NSRect(x: 0, y: 0, width: 720, height: 560)) + contentView.material = .popover + contentView.blendingMode = .behindWindow + contentView.state = .active + contentView.addSubview(scrollView) + NSLayoutConstraint.activate([ + scrollView.topAnchor.constraint(equalTo: contentView.topAnchor), + scrollView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + scrollView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor) + ]) + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 720, height: 560), + styleMask: [.titled, .closable, .resizable, .miniaturizable, .fullSizeContentView], + backing: .buffered, + defer: false + ) + window.title = title + window.minSize = NSSize(width: 480, height: 320) + window.isReleasedWhenClosed = false + window.contentView = contentView + + super.init(window: window) + + self.window?.delegate = self + self.window?.toolbar = makeToolbar() + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func show() { + showWindow(self) + window?.makeKeyAndOrderFront(nil) + window?.orderFrontRegardless() + + if #available(macOS 14.0, *) { + NSApp.activate() + } else { + NSApp.activate(ignoringOtherApps: true) + } + } + + func windowWillClose(_: Notification) { + onClose() + } + + @objc private func copyOutput(_: Any?) { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(output, forType: .string) + } + + private func makeToolbar() -> NSToolbar { + let toolbar = NSToolbar(identifier: ToolbarIdentifier.toolbar) + toolbar.delegate = self + toolbar.allowsUserCustomization = false + toolbar.autosavesConfiguration = false + toolbar.displayMode = .iconOnly + return toolbar + } + + func toolbarAllowedItemIdentifiers(_: NSToolbar) -> [NSToolbarItem.Identifier] { + [ToolbarIdentifier.copy] + } + + func toolbarDefaultItemIdentifiers(_: NSToolbar) -> [NSToolbarItem.Identifier] { + [ToolbarIdentifier.copy] + } + + func toolbar( + _: NSToolbar, + itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier, + willBeInsertedIntoToolbar _: Bool + ) -> NSToolbarItem? { + guard itemIdentifier == ToolbarIdentifier.copy else { + return nil + } + + let item = NSToolbarItem(itemIdentifier: itemIdentifier) + item.label = "Copy" + item.paletteLabel = "Copy" + item.toolTip = "Copy output to the clipboard" + item.target = self + item.action = #selector(copyOutput(_:)) + + if let image = NSImage(systemSymbolName: "doc.on.doc", accessibilityDescription: "Copy") { + item.image = image + } + + return item + } +} diff --git a/Loop/Scripting/CommandOutputWindowManager.swift b/Loop/Scripting/CommandOutputWindowManager.swift new file mode 100644 index 00000000..5cc6a185 --- /dev/null +++ b/Loop/Scripting/CommandOutputWindowManager.swift @@ -0,0 +1,56 @@ +// +// CommandOutputWindowManager.swift +// Loop +// +// Created by Kai Azim on 2026-03-28. +// + +import AppKit + +@MainActor +final class CommandOutputWindowManager { + static let shared = CommandOutputWindowManager() + + private var controllers: [UUID: CommandOutputWindowController] = [:] + private var cascadePoint: NSPoint? + + private init() {} + + func show(title: String, content: String) { + let identifier = UUID() + let controller = CommandOutputWindowController( + title: title, + content: content + ) { [weak self] in + self?.removeController(for: identifier) + } + + controllers[identifier] = controller + + if let window = controller.window { + let origin = cascadePoint ?? initialCascadePoint() + cascadePoint = window.cascadeTopLeft(from: origin) + } + + controller.show() + } + + private func removeController(for identifier: UUID) { + controllers.removeValue(forKey: identifier) + + if controllers.isEmpty { + cascadePoint = nil + } + } + + private func initialCascadePoint() -> NSPoint { + if let screen = NSScreen.main ?? NSScreen.screens.first { + return NSPoint( + x: screen.visibleFrame.minX + 80, + y: screen.visibleFrame.maxY - 80 + ) + } + + return NSPoint(x: 120, y: 800) + } +} diff --git a/Loop/Scripting/LoopCommandHandler.swift b/Loop/Scripting/LoopCommandHandler.swift new file mode 100644 index 00000000..33412e6a --- /dev/null +++ b/Loop/Scripting/LoopCommandHandler.swift @@ -0,0 +1,1023 @@ +// +// LoopCommandHandler.swift +// Loop +// +// Created by Kami on 06/03/2025. +// + +/* + Loop Automation API + =================== + + Public URL commands: + - loop://list/windows + - loop://list/screens + - loop://list/actions + - loop://list/actions/directions + - loop://list/actions/keybinds + - loop://direction/ + - loop://keybind/ + - loop://id/ + + Socket / CLI transport: + - loop-cli parses CLI arguments locally and sends canonical loop:// URLs over the socket + + Response JSON: + - success responses use `{ "success": true, "result": { ... } }` + - failures use `{ "success": false, "error": { "message": "...", ... } }` + + Query parameters: + - ?windowID= + - ?bundleID= + - ?screenID= + */ + +import AppKit +import Defaults +import Foundation +import Scribe + +/// Handles Loop automation commands for both the URL scheme and `loop-cli`. +@Loggable +final class LoopCommandHandler { + // MARK: - Types + + enum InvocationSource { + case urlScheme + case cli + } + + enum CommandKind { + case read + case write + } + + struct CommandExecutionResult { + let source: InvocationSource + let kind: CommandKind + let title: String + let jsonResponse: String + let isSuccess: Bool + let errorMessage: String? + + @MainActor + func presentIfNeeded() { + guard source == .urlScheme else { + return + } + + switch kind { + case .read: + CommandOutputWindowManager.shared.show( + title: title, + content: jsonResponse + ) + case .write: + guard !isSuccess else { + return + } + + let alert = NSAlert() + alert.messageText = "Loop Command Failed" + alert.informativeText = errorMessage ?? jsonResponse + alert.alertStyle = .warning + + let button = alert.addButton(withTitle: "OK") + if #available(macOS 26.0, *) { + button.tintProminence = .primary + } + + if #available(macOS 14.0, *) { + NSApp.activate() + } else { + NSApp.activate(ignoringOtherApps: true) + } + + if let window = NSApp.keyWindow ?? NSApp.mainWindow { + alert.beginSheetModal(for: window) + } else { + alert.runModal() + } + } + } + } + + private enum ListActionFilter: Equatable { + case all + case directionsOnly + case keybindsOnly + + var automationFilter: LoopActionListFilter { + switch self { + case .all: + .all + case .directionsOnly: + .directionsOnly + case .keybindsOnly: + .keybindsOnly + } + } + } + + private enum ResponseResult { + case success(Value) + case failure(LoopAutomationResponse) + } + + private enum MessageResult { + case success(Value) + case failure(String) + } + + /// Parameters parsed from URL query string / CLI flags for targeting specific windows and screens. + private struct TargetParams { + var windowID: CGWindowID? + var bundleID: String? + var screenID: CGDirectDisplayID? + } + + private struct DirectionActionDescriptor { + let direction: WindowDirection + let name: String + let title: String + + var urlPath: String { + "direction/\(name)" + } + } + + private struct KeybindActionDescriptor { + let action: WindowAction + let id: UUID + let name: String + let title: String + + var idString: String { + id.uuidString.lowercased() + } + + var urlPath: String { + "keybind/\(name)" + } + + var idPath: String { + "id/\(idString)" + } + } + + private enum ExecutableActionDescriptor { + case direction(DirectionActionDescriptor) + case keybind(KeybindActionDescriptor) + + var id: UUID? { + switch self { + case .direction: + nil + case let .keybind(descriptor): + descriptor.id + } + } + + var name: String { + switch self { + case let .direction(descriptor): + descriptor.name + case let .keybind(descriptor): + descriptor.name + } + } + + var title: String { + switch self { + case let .direction(descriptor): + descriptor.title + case let .keybind(descriptor): + descriptor.title + } + } + + var actionKind: LoopActionKind { + switch self { + case .direction: + .direction + case .keybind: + .keybind + } + } + + var urlPath: String { + switch self { + case let .direction(descriptor): + descriptor.urlPath + case let .keybind(descriptor): + descriptor.urlPath + } + } + + var idPath: String? { + switch self { + case .direction: + nil + case let .keybind(descriptor): + descriptor.idPath + } + } + + var windowAction: WindowAction { + switch self { + case let .direction(descriptor): + WindowAction(descriptor.direction) + case let .keybind(descriptor): + descriptor.action + } + } + } + + // MARK: - Constants + + private static let directionCategories: [(String, [WindowDirection])] = [ + ("General Actions", WindowDirection.general), + ("Halves", WindowDirection.halves), + ("Quarters", WindowDirection.quarters), + ("Horizontal Thirds", WindowDirection.horizontalThirds), + ("Horizontal Fourths", WindowDirection.horizontalFourths), + ("Vertical Thirds", WindowDirection.verticalThirds), + ("Screen Switching", WindowDirection.screenSwitching), + ("Size Adjustment", WindowDirection.sizeAdjustment), + ("Shrink", WindowDirection.shrink), + ("Grow", WindowDirection.grow), + ("Move", WindowDirection.move), + ("Focus", WindowDirection.focus), + ("Other", [.initialFrame, .undo]) + ] + + // MARK: - Public Methods + + /// Handles incoming `loop://` requests and returns command metadata. + @discardableResult + func handle(_ url: URL) -> CommandExecutionResult { + handle(url, source: .urlScheme) + } + + @discardableResult + func handle(_ url: URL, source: InvocationSource) -> CommandExecutionResult { + log.info("Processing request: \(url.absoluteString)") + + guard url.scheme?.lowercased() == "loop" else { + return makeExecutionResult( + source: source, + kind: .write, + components: [], + response: failureResponse( + message: "Invalid scheme: \(url.scheme ?? "nil"). Required: loop://" + ) + ) + } + + let urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false) + let queryItems = urlComponents?.queryItems + + let params = TargetParams( + windowID: queryItems?.first(where: { $0.name == "windowID" })?.value.flatMap(UInt32.init), + bundleID: queryItems?.first(where: { $0.name == "bundleID" })?.value, + screenID: queryItems?.first(where: { $0.name == "screenID" })?.value.flatMap(UInt32.init) + ) + + let components = (url.host.map { [$0] } ?? []) + url.pathComponents.filter { $0 != "/" && !$0.isEmpty } + return execute(components, params: params, source: source) + } + + @discardableResult + func handleRequestURLString(_ request: String, source: InvocationSource) -> CommandExecutionResult { + log.info("Processing request string: \(request)") + + guard let url = URL(string: request) else { + return makeExecutionResult( + source: source, + kind: .write, + components: [], + response: failureResponse(message: "Invalid request URL: \(request)") + ) + } + + return handle(url, source: source) + } + + // MARK: - Command Execution + + private func execute( + _ components: [String], + params: TargetParams, + source: InvocationSource + ) -> CommandExecutionResult { + if params.windowID != nil, params.bundleID != nil { + return makeExecutionResult( + source: source, + kind: .write, + components: components, + response: failureResponse(message: "windowID and bundleID are mutually exclusive") + ) + } + + guard let commandString = components.first?.lowercased() else { + return makeExecutionResult( + source: source, + kind: .write, + components: components, + response: unknownCommandResponse(nil) + ) + } + + let parameters = Array(components.dropFirst()) + + switch commandString { + case "list": + return makeExecutionResult( + source: source, + kind: .read, + components: components, + response: handleListCommand(parameters) + ) + + case "direction": + return makeExecutionResult( + source: source, + kind: .write, + components: components, + response: handleDirectionCommand(parameters, params: params) + ) + + case "keybind": + return makeExecutionResult( + source: source, + kind: .write, + components: components, + response: handleKeybindCommand(parameters, params: params) + ) + + case "id": + return makeExecutionResult( + source: source, + kind: .write, + components: components, + response: handleIDCommand(parameters, params: params) + ) + + default: + return makeExecutionResult( + source: source, + kind: .write, + components: components, + response: unknownCommandResponse(commandString) + ) + } + } + + // MARK: - List Commands + + private func handleListCommand(_ parameters: [String]) -> LoopAutomationResponse { + guard let type = parameters.first?.lowercased() else { + return invalidListRootResponse() + } + + switch type { + case "windows": + guard parameters.count == 1 else { + return invalidListRouteResponse(parameters) + } + return buildWindowListResponse() + + case "screens": + guard parameters.count == 1 else { + return invalidListRouteResponse(parameters) + } + return buildScreenListResponse() + + case "actions": + switch parseListActionFilter(parameters) { + case let .success(filter): + return buildActionsResponse(filter: filter) + case let .failure(error): + return error + } + + default: + return invalidListRouteResponse(parameters) + } + } + + private func parseListActionFilter(_ parameters: [String]) -> ResponseResult { + let tail = Array(parameters.dropFirst()) + + guard tail.count <= 1 else { + return .failure(invalidListRouteResponse(parameters)) + } + + guard let subtype = tail.first?.lowercased() else { + return .success(.all) + } + + switch subtype { + case "directions": + return .success(.directionsOnly) + case "keybinds": + return .success(.keybindsOnly) + default: + return .failure(invalidListRouteResponse(parameters)) + } + } + + private func buildActionsResponse(filter: ListActionFilter) -> LoopAutomationResponse { + let allDirectionCategories = buildDirectionActionCategories() + let allKeybindActions = keybindActionDescriptors().map { descriptor in + sharedActionDescriptor(.keybind(descriptor)) + } + + let result = LoopActionListResult( + filter: filter.automationFilter, + directionCategories: filter == .keybindsOnly ? [] : allDirectionCategories, + keybindActions: filter == .directionsOnly ? [] : allKeybindActions + ) + + return LoopAutomationResponse(result: .actionList(result)) + } + + private func buildWindowListResponse() -> LoopAutomationResponse { + let visibleWindows = WindowUtility.windowList().filter { window in + guard let app = window.nsRunningApplication else { + return false + } + + return app.bundleIdentifier != Bundle.main.bundleIdentifier + && app.activationPolicy == .regular + && !window.isApplicationHidden + && !window.minimized + } + + return LoopAutomationResponse( + result: .windowList( + LoopWindowListResult( + windows: visibleWindows.map(windowSummary) + ) + ) + ) + } + + private func buildScreenListResponse() -> LoopAutomationResponse { + let screens = NSScreen.screens + return LoopAutomationResponse( + result: .screenList( + LoopScreenListResult( + screens: screens.map(screenSummary) + ) + ) + ) + } + + // MARK: - Write Commands + + private func handleDirectionCommand(_ parameters: [String], params: TargetParams) -> LoopAutomationResponse { + guard parameters.count == 1 else { + return failureResponse( + message: "Direction execution requires exactly one name", + replacementRoute: urlCommandString(["list", "actions", "directions"]) + ) + } + + let token = parameters[0] + guard let descriptor = directionActionDescriptor(name: token) else { + return failureResponse( + message: "Unknown direction name: \(token)", + replacementRoute: urlCommandString(["list", "actions", "directions"]) + ) + } + + return executeAction(.direction(descriptor), params: params) + } + + private func handleKeybindCommand(_ parameters: [String], params: TargetParams) -> LoopAutomationResponse { + guard parameters.count == 1 else { + return failureResponse( + message: "Keybind execution requires exactly one name", + replacementRoute: urlCommandString(["list", "actions", "keybinds"]) + ) + } + + let token = parameters[0] + guard let descriptor = keybindActionDescriptor(name: token) else { + return failureResponse( + message: "Unknown keybind name: \(token)", + replacementRoute: urlCommandString(["list", "actions", "keybinds"]) + ) + } + + return executeAction(.keybind(descriptor), params: params) + } + + private func handleIDCommand(_ parameters: [String], params: TargetParams) -> LoopAutomationResponse { + guard parameters.count == 1 else { + return failureResponse( + message: "ID execution requires exactly one UUID", + replacementRoute: urlCommandString(["list", "actions"]) + ) + } + + let token = parameters[0] + guard let identifier = UUID(uuidString: token) else { + return failureResponse( + message: "Invalid UUID: \(token)", + replacementRoute: urlCommandString(["list", "actions"]) + ) + } + + guard let descriptor = executableActionDescriptor(id: identifier) else { + return failureResponse( + message: "Unknown action ID: \(token)", + replacementRoute: urlCommandString(["list", "actions"]) + ) + } + + return executeAction(descriptor, params: params) + } + + private func executeAction( + _ descriptor: ExecutableActionDescriptor, + params: TargetParams + ) -> LoopAutomationResponse { + let action = descriptor.windowAction + let resolvedWindow = resolveWindow(params: params) + let resolvedAction = resolveActionForCommandExecution(action, window: resolvedWindow) + + if resolvedAction.direction.isNoOp || resolvedAction.direction == .cycle { + return failureResponse(message: "Action is not executable: \(descriptor.name)") + } + + if !resolvedAction.direction.willFocusWindow, resolvedWindow == nil { + return failureResponse(message: windowResolveError(params)) + } + + let targetScreen: NSScreen + switch resolveTargetScreen(for: resolvedAction, window: resolvedWindow, params: params) { + case let .success(screen): + targetScreen = screen + case let .failure(error): + return failureResponse(message: error) + } + + dispatchAction(resolvedAction, on: resolvedWindow, screen: targetScreen) + + return LoopAutomationResponse( + result: .execution( + LoopExecutionResult( + action: sharedActionDescriptor(descriptor), + targetWindow: resolvedWindow.map(executionTargetWindowSummary) + ) + ) + ) + } + + // MARK: - Action Catalog + + private func buildDirectionActionCategories() -> [LoopActionCategory] { + Self.directionCategories.map { category, directions in + LoopActionCategory( + name: category, + actions: directions.map { direction in + sharedActionDescriptor(.direction(directionActionDescriptor(for: direction))) + } + ) + } + } + + private func allDirectionActionDescriptors() -> [DirectionActionDescriptor] { + Self.directionCategories.flatMap { _, directions in + directions.map { direction in + DirectionActionDescriptor( + direction: direction, + name: canonicalDirectionName(for: direction), + title: direction.name + ) + } + } + } + + private func directionActionDescriptor(for direction: WindowDirection) -> DirectionActionDescriptor { + DirectionActionDescriptor( + direction: direction, + name: canonicalDirectionName(for: direction), + title: direction.name + ) + } + + private func directionActionDescriptor(name: String) -> DirectionActionDescriptor? { + allDirectionActionDescriptors().first { $0.name == name.lowercased() } + } + + private func keybindActionDescriptors() -> [KeybindActionDescriptor] { + let candidates: [(WindowAction, String, String)] = Defaults[.keybinds].compactMap { action in + guard + !action.keybind.isEmpty, + isExecutableKeybindAction(action) + else { + return nil + } + + let displayName = action.getName().trimmingCharacters(in: .whitespacesAndNewlines) + guard !displayName.isEmpty else { + return nil + } + + return (action, displayName, slugifyDisplayString(displayName)) + } + + let groupedByBaseName = Dictionary(grouping: candidates, by: \.2) + + return candidates.map { action, displayName, baseName in + let finalName: String = if groupedByBaseName[baseName, default: []].count > 1 { + "\(baseName)_\(shortIdentifier(for: action.id))" + } else { + baseName + } + + return KeybindActionDescriptor( + action: action, + id: action.id, + name: finalName, + title: displayName + ) + } + } + + private func keybindActionDescriptor(name: String) -> KeybindActionDescriptor? { + keybindActionDescriptors().first { $0.name == name.lowercased() } + } + + private func keybindActionDescriptor(id: UUID) -> KeybindActionDescriptor? { + keybindActionDescriptors().first { $0.id == id } + } + + private func executableActionDescriptor(id: UUID) -> ExecutableActionDescriptor? { + if let descriptor = keybindActionDescriptor(id: id) { + return .keybind(descriptor) + } + + return nil + } + + // MARK: - Response Helpers + + private func publicRoutes() -> [String] { + publicListRoutes() + publicWriteRoutes() + } + + private func publicListRoutes() -> [String] { + [ + urlCommandString(["list", "windows"]), + urlCommandString(["list", "screens"]), + urlCommandString(["list", "actions"]), + urlCommandString(["list", "actions", "directions"]), + urlCommandString(["list", "actions", "keybinds"]) + ] + } + + private func publicWriteRoutes() -> [String] { + [ + urlCommandString(["direction", "right"]), + urlCommandString(["direction", "maximize"]), + urlCommandString(["direction", "next_screen"]), + urlCommandString(["keybind", "my_layout"]), + urlCommandString(["id", ""]) + ] + } + + private func urlCommandString(_ components: [String]) -> String { + "loop://\(components.joined(separator: "/"))" + } + + private func failureResponse( + message: String, + replacementRoute: String? = nil, + availableRoutes: [String] = [] + ) -> LoopAutomationResponse { + LoopAutomationResponse( + error: LoopAutomationError( + message: message, + replacementRoute: replacementRoute, + availableRoutes: availableRoutes.isEmpty ? nil : availableRoutes + ) + ) + } + + private func invalidListRootResponse() -> LoopAutomationResponse { + failureResponse( + message: "No list type specified", + availableRoutes: publicListRoutes() + ) + } + + private func invalidListRouteResponse(_ parameters: [String]) -> LoopAutomationResponse { + failureResponse( + message: "Unknown list route: list/\(parameters.joined(separator: "/"))", + availableRoutes: publicListRoutes() + ) + } + + private func unknownCommandResponse(_ command: String?) -> LoopAutomationResponse { + failureResponse( + message: "Unknown command: \(command ?? "nil")", + availableRoutes: publicRoutes() + ) + } + + // MARK: - JSON Helpers + + private func jsonString(_ response: LoopAutomationResponse) -> String { + do { + return try LoopAutomationJSON.encodeString(response) + } catch { + return #"{"error":{"message":"Failed to serialize response"},"success":false}"# + } + } + + private func makeExecutionResult( + source: InvocationSource, + kind: CommandKind, + components: [String], + response: LoopAutomationResponse + ) -> CommandExecutionResult { + CommandExecutionResult( + source: source, + kind: kind, + title: outputTitle(for: components), + jsonResponse: jsonString(response), + isSuccess: response.success, + errorMessage: response.error?.message + ) + } + + private func outputTitle(for components: [String]) -> String { + let commandPath = components.joined(separator: " ") + return commandPath.isEmpty ? "Loop Output" : "Loop Output: \(commandPath)" + } + + private func windowSummary(_ window: Window) -> LoopWindowSummary { + let app = window.nsRunningApplication + return LoopWindowSummary( + id: window.cgWindowID, + bundleID: app?.bundleIdentifier ?? "", + appName: app?.localizedName ?? "", + title: window.title ?? "", + frame: LoopRect(window.frame) + ) + } + + private func executionTargetWindowSummary(_ window: Window) -> LoopExecutionTargetWindow { + let app = window.nsRunningApplication + return LoopExecutionTargetWindow( + id: window.cgWindowID, + bundleID: app?.bundleIdentifier ?? "", + appName: app?.localizedName ?? "", + title: window.title ?? "" + ) + } + + private func screenSummary(_ screen: NSScreen) -> LoopScreenSummary { + LoopScreenSummary( + id: screen.displayID ?? 0, + name: screen.localizedName, + frame: LoopRect(screen.frame), + isMain: screen == NSScreen.main + ) + } + + private func sharedActionDescriptor(_ descriptor: ExecutableActionDescriptor) -> LoopActionDescriptor { + LoopActionDescriptor( + id: descriptor.id, + kind: descriptor.actionKind, + title: descriptor.title, + name: descriptor.name, + route: urlCommandString(descriptor.urlPath.split(separator: "/").map(String.init)), + idRoute: descriptor.idPath.map { urlCommandString($0.split(separator: "/").map(String.init)) } + ) + } + + // MARK: - Name and ID Helpers + + private func slugifyDisplayString(_ string: String, treatCamelCaseAsWords: Bool = false) -> String { + let source = if treatCamelCaseAsWords { + string + .replacingOccurrences( + of: "([A-Z]+)([A-Z][a-z])", + with: "$1_$2", + options: .regularExpression + ) + .replacingOccurrences( + of: "([a-z0-9])([A-Z])", + with: "$1_$2", + options: .regularExpression + ) + } else { + string + } + + let slug = source + .replacingOccurrences(of: "[^A-Za-z0-9]+", with: "_", options: .regularExpression) + .replacingOccurrences(of: "_{2,}", with: "_", options: .regularExpression) + .trimmingCharacters(in: CharacterSet(charactersIn: "_")) + .lowercased() + + return slug.isEmpty ? "unnamed" : slug + } + + private func canonicalDirectionName(for direction: WindowDirection) -> String { + slugifyDisplayString(direction.rawValue, treatCamelCaseAsWords: true) + } + + private func shortIdentifier(for uuid: UUID) -> String { + String(uuid.uuidString.replacingOccurrences(of: "-", with: "").lowercased().prefix(8)) + } + + // MARK: - Execution Helpers + + private func isExecutableKeybindAction(_ action: WindowAction) -> Bool { + switch action.direction { + case .noAction, .noSelection: + false + case .cycle: + !(action.cycle?.isEmpty ?? true) + case .stash: + action.stashEdge != nil + default: + true + } + } + + private func resolveActionForCommandExecution(_ action: WindowAction, window: Window?) -> WindowAction { + var currentAction = action + var depth = 0 + + while currentAction.direction == .cycle { + guard depth < 8, let cycle = currentAction.cycle, !cycle.isEmpty else { + return currentAction + } + + if let window, + let latestRecord = WindowRecords.getCurrentAction(for: window), + let currentIndex = cycle.firstIndex(of: latestRecord) { + currentAction = cycle[(currentIndex + 1) % cycle.count] + } else { + currentAction = cycle[0] + } + + depth += 1 + } + + return currentAction + } + + private func dispatchAction(_ action: WindowAction, on window: Window?, screen: NSScreen) { + if let app = window?.nsRunningApplication { + log.info("Activating application: \(app.localizedName ?? "unknown")") + app.activate(options: .activateIgnoringOtherApps) + } + + Task { + try? await Task.sleep(for: .seconds(0.1)) + + log.info("Executing action: \(action) on \(window?.title ?? "unknown")") + _ = try await WindowActionEngine.shared.apply( + action, + window: window, + screen: screen + ) + if let window { + log.info("New window frame: \(window.frame)") + } + } + } + + private func resolveTargetScreen( + for action: WindowAction, + window: Window?, + params: TargetParams + ) -> MessageResult { + if action.direction.willChangeScreen { + guard let window else { + return .failure(windowResolveError(params)) + } + + guard let currentScreen = ScreenUtility.screenContaining(window) ?? NSScreen.main else { + return .failure("No current screen found") + } + + let targetScreen: NSScreen? = switch action.direction { + case .nextScreen: + ScreenUtility.nextScreen(from: currentScreen) + case .previousScreen: + ScreenUtility.previousScreen(from: currentScreen) + case .leftScreen: + ScreenUtility.directionalScreen(from: currentScreen, direction: .left) + case .rightScreen: + ScreenUtility.directionalScreen(from: currentScreen, direction: .right) + case .topScreen: + ScreenUtility.directionalScreen(from: currentScreen, direction: .top) + case .bottomScreen: + ScreenUtility.directionalScreen(from: currentScreen, direction: .bottom) + default: + currentScreen + } + + guard let targetScreen else { + return .failure("No target screen found for \(action.direction.name)") + } + + return .success(targetScreen) + } + + guard let screen = resolveScreen(screenID: params.screenID) else { + return .failure("No screen found with ID \(params.screenID!)") + } + + return .success(screen) + } + + // MARK: - Window/Screen Helpers + + /// Finds a window by its CGWindowID from the current window list. + private func findWindowByID(_ windowID: CGWindowID) -> Window? { + WindowUtility.windowList().first { $0.cgWindowID == windowID } + } + + /// Resolves the target window from targeting parameters. + /// Priority: windowID > bundleID > frontmost window. + private func resolveWindow(params: TargetParams = .init()) -> Window? { + if let windowID = params.windowID { + return findWindowByID(windowID) + } + if let bundleID = params.bundleID { + return resolveWindowByBundleID(bundleID) + } + return try? WindowUtility.frontmostWindow() + } + + /// Resolves a window by bundle ID, launching the app if needed. + private func resolveWindowByBundleID(_ bundleID: String) -> Window? { + if let app = NSWorkspace.shared.runningApplications.first(where: { $0.bundleIdentifier == bundleID }) { + app.activate(options: .activateIgnoringOtherApps) + Thread.sleep(forTimeInterval: 0.1) + return try? Window(pid: app.processIdentifier) + } + + guard let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID) else { + log.error("No app found for bundle ID: \(bundleID)") + return nil + } + + let config = NSWorkspace.OpenConfiguration() + config.activates = true + + let semaphore = DispatchSemaphore(value: 0) + var launchedApp: NSRunningApplication? + NSWorkspace.shared.openApplication(at: appURL, configuration: config) { app, error in + if let error { + self.log.error("Failed to launch \(bundleID): \(error.localizedDescription)") + } + launchedApp = app + semaphore.signal() + } + _ = semaphore.wait(timeout: .now() + 5) + + guard let app = launchedApp else { + return nil + } + + for _ in 0 ..< 30 { + Thread.sleep(forTimeInterval: 0.1) + if let window = try? Window(pid: app.processIdentifier) { + return window + } + } + + log.error("App launched but no window appeared: \(bundleID)") + return nil + } + + /// Resolves a screen by display ID, falling back to the main screen. + private func resolveScreen(screenID: CGDirectDisplayID? = nil) -> NSScreen? { + if let screenID { + return NSScreen.screens.first { $0.displayID == screenID } + } + return NSScreen.main + } + + /// Builds a human-readable error message for window resolution failure. + private func windowResolveError(_ params: TargetParams) -> String { + if let windowID = params.windowID { + return "No window found with ID \(windowID)" + } + if let bundleID = params.bundleID { + return "Could not find or launch app: \(bundleID)" + } + return "No frontmost window found" + } +} diff --git a/Loop/Scripting/LoopSocketManager.swift b/Loop/Scripting/LoopSocketManager.swift new file mode 100644 index 00000000..04baaad7 --- /dev/null +++ b/Loop/Scripting/LoopSocketManager.swift @@ -0,0 +1,225 @@ +// +// LoopSocketManager.swift +// Loop +// +// Created by Kai Azim on 2026-03-18. +// + +import Foundation +import Scribe + +/// Listens on a Unix domain socket for commands from loop-cli. +/// +/// The server accepts connections, reads a newline-terminated canonical `loop://...` +/// request URL, dispatches it to `LoopCommandHandler` on the main thread, and writes +/// the JSON response back before closing the connection. +/// +/// Request format: `loop://[?windowID=&bundleID=&screenID=]` +/// Example: `loop://direction/right?bundleID=com.apple.Safari` +@Loggable +final class LoopSocketManager { + // MARK: - Properties + + private let socketPath: String + private let handler: LoopCommandHandler + private var serverFD: Int32 = -1 + private var isRunning = false + + private let acceptQueue = DispatchQueue( + label: "com.MrKai77.Loop.server.accept", + qos: .userInitiated + ) + + private static let maxRequestSize = 4096 + private static let connectionTimeout: TimeInterval = 5 + + // MARK: - Initialization + + init(handler: LoopCommandHandler) { + self.handler = handler + self.socketPath = "/tmp/loop-\(getuid()).socket" + } + + // MARK: - Public Methods + + func start() { + // Clean up stale socket from a previous crash + unlink(socketPath) + + // Create socket + serverFD = socket(AF_UNIX, SOCK_STREAM, 0) + guard serverFD >= 0 else { + log.error("Failed to create socket: \(String(cString: strerror(errno)))") + return + } + + // Bind to path + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + + let pathBytes = socketPath.utf8CString + guard pathBytes.count <= MemoryLayout.size(ofValue: addr.sun_path) else { + log.error("Socket path too long: \(socketPath)") + close(serverFD) + serverFD = -1 + return + } + + withUnsafeMutablePointer(to: &addr.sun_path) { ptr in + ptr.withMemoryRebound(to: CChar.self, capacity: pathBytes.count) { dest in + pathBytes.withUnsafeBufferPointer { src in + _ = memcpy(dest, src.baseAddress!, src.count) + } + } + } + + let bindResult = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + bind(serverFD, sockaddrPtr, socklen_t(MemoryLayout.size)) + } + } + + guard bindResult == 0 else { + log.error("Failed to bind socket: \(String(cString: strerror(errno)))") + close(serverFD) + serverFD = -1 + return + } + + // Set permissions to owner-only + chmod(socketPath, 0o600) + + // Start listening + guard listen(serverFD, 5) == 0 else { + log.error("Failed to listen on socket: \(String(cString: strerror(errno)))") + close(serverFD) + unlink(socketPath) + serverFD = -1 + return + } + + isRunning = true + log.info("Listening on \(socketPath)") + + // Accept loop on background queue + acceptQueue.async { [weak self] in + self?.acceptLoop() + } + } + + func stop() { + isRunning = false + if serverFD >= 0 { + close(serverFD) + serverFD = -1 + } + unlink(socketPath) + log.info("Server stopped") + } + + // MARK: - Private Methods + + private func acceptLoop() { + while isRunning { + var clientAddr = sockaddr_un() + var clientAddrLen = socklen_t(MemoryLayout.size) + + let clientFD = withUnsafeMutablePointer(to: &clientAddr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + accept(serverFD, sockaddrPtr, &clientAddrLen) + } + } + + guard clientFD >= 0 else { + if isRunning { + log.error("Accept failed: \(String(cString: strerror(errno)))") + } + continue + } + + // Set receive timeout + var timeout = timeval(tv_sec: Int(Self.connectionTimeout), tv_usec: 0) + setsockopt(clientFD, SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout.size)) + + handleConnection(clientFD) + } + } + + private func handleConnection(_ clientFD: Int32) { + defer { close(clientFD) } + + // Read request (until newline or max size) + var buffer = [UInt8](repeating: 0, count: Self.maxRequestSize) + var totalRead = 0 + + while totalRead < Self.maxRequestSize { + let bytesRead = read(clientFD, &buffer[totalRead], Self.maxRequestSize - totalRead) + if bytesRead <= 0 { break } + totalRead += bytesRead + + // Check for newline delimiter + if buffer[.. 0 else { + writeResponse(clientFD, encodedErrorResponse(message: "Empty request")) + return + } + + // Trim newline and parse + let requestString = String(bytes: buffer[.. String { + do { + return try LoopAutomationJSON.encodeString( + LoopAutomationResponse( + error: LoopAutomationError(message: message) + ) + ) + } catch { + return Self.fallbackEncodedErrorResponse(message: message) + } + } + + private static func fallbackEncodedErrorResponse(message: String) -> String { + #"{"error":{"message":"\#(message)"},"success":false}"# + } + + private func writeResponse(_ fd: Int32, _ response: String) { + let data = response + "\n" + data.utf8.withContiguousStorageIfAvailable { buffer in + _ = Darwin.write(fd, buffer.baseAddress!, buffer.count) + } + } +} diff --git a/Loop/Settings Window/Loop/AdvancedConfiguration.swift b/Loop/Settings Window/Loop/AdvancedConfiguration.swift index 9887138c..538834b2 100644 --- a/Loop/Settings Window/Loop/AdvancedConfiguration.swift +++ b/Loop/Settings Window/Loop/AdvancedConfiguration.swift @@ -21,13 +21,18 @@ final class AdvancedConfigurationModel: ObservableObject { @Published private(set) var isLowPowerModeEnabled: Bool = ProcessInfo.processInfo.isLowPowerModeEnabled @Published private(set) var isAccessibilityAccessGranted = AccessibilityManager.shared.isGranted + @Published private(set) var commandLineToolInstallStatus: CommandLineToolInstaller.Status = .notInstalled + @Published private(set) var isCommandLineToolOperationInProgress = false + @Published var commandLineToolErrorMessage: String? private var lowPowerModeCheckerTask: Task<(), Never>? private var accessibilityCheckerTask: Task<(), Never>? + private let commandLineToolInstaller = CommandLineToolInstaller() func startTracking() { trackLowPowerMode() trackAccessibilityStatus() + refreshCommandLineToolInstallStatus() } func stopTracking() { @@ -102,6 +107,46 @@ final class AdvancedConfigurationModel: ObservableObject { showSuccessIndicator(\.showResetRadialMenuActionsSuccessIndicator) } + var canPerformCommandLineToolAction: Bool { + !isCommandLineToolOperationInProgress && ( + commandLineToolInstallStatus == .notInstalled + || commandLineToolInstallStatus == .installedStale + ) + } + + var isCommandLineToolInstalled: Bool { + commandLineToolInstallStatus == .installedCurrent + } + + var commandLineToolActionTitle: LocalizedStringKey { + switch commandLineToolInstallStatus { + case .installedStale: + "Repair…" + case .notInstalled, .installedCurrent, .blocked: + "Install…" + } + } + + func clearCommandLineToolError() { + commandLineToolErrorMessage = nil + } + + func installOrRepairCommandLineTool() { + guard canPerformCommandLineToolAction else { return } + let status = commandLineToolInstallStatus + + performCommandLineToolOperation { installer in + switch status { + case .notInstalled: + try await installer.install() + case .installedStale: + try await installer.reinstall() + case .installedCurrent, .blocked: + break + } + } + } + private func showSuccessIndicator(_ keyPath: ReferenceWritableKeyPath) { Task { withAnimation(.smooth(duration: 0.5)) { @@ -115,11 +160,42 @@ final class AdvancedConfigurationModel: ObservableObject { } } } + + private func performCommandLineToolOperation(_ operation: @escaping (CommandLineToolInstaller) async throws -> ()) { + Task { @MainActor in + guard !isCommandLineToolOperationInProgress else { return } + + isCommandLineToolOperationInProgress = true + commandLineToolErrorMessage = nil + + defer { + isCommandLineToolOperationInProgress = false + refreshCommandLineToolInstallStatus() + } + + do { + try await operation(commandLineToolInstaller) + } catch { + log.error("Error installing Loop CLI: \(error.localizedDescription)") + commandLineToolErrorMessage = error.localizedDescription + } + } + } + + private func refreshCommandLineToolInstallStatus() { + do { + commandLineToolInstallStatus = try commandLineToolInstaller.status() + } catch { + log.error("Error checking CLI installation status: \(error.localizedDescription)") + commandLineToolInstallStatus = .blocked + } + } } struct AdvancedConfigurationView: View { @EnvironmentObject private var windowModel: SettingsWindowManager @Environment(\.luminareAnimation) var luminareAnimation + @Environment(\.luminareSectionHorizontalPadding) private var sectionHorizontalPadding @Environment(\.openURL) private var openURL @StateObject private var model = AdvancedConfigurationModel() @@ -146,11 +222,17 @@ struct AdvancedConfigurationView: View { generalSection radialMenuSection keybindsSection + commandLineToolSection permissionsSection .onAppear(perform: model.startTracking) .onDisappear(perform: model.stopTracking) } .animation(luminareAnimation, value: enableRadialMenuCustomization) + .alert("Command-Line Tool Error", isPresented: commandLineToolErrorIsPresented) { + Button("OK", role: .cancel, action: model.clearCommandLineToolError) + } message: { + Text(model.commandLineToolErrorMessage ?? "") + } } private var generalSection: some View { @@ -303,6 +385,37 @@ struct AdvancedConfigurationView: View { .animation(luminareAnimation, value: model.isAccessibilityAccessGranted) } + private var commandLineToolSection: some View { + LuminareSection(String(localized: "Command Line Interface", comment: "Section header shown in settings")) { + LuminareCompose { + Button { + model.installOrRepairCommandLineTool() + } label: { + Text(model.commandLineToolActionTitle) + .padding(.horizontal, sectionHorizontalPadding) + } + .luminareRoundingBehavior(top: true, bottom: true) + .luminareContentSize(contentMode: .fit, hasFixedHeight: true) + .luminareComposeIgnoreSafeArea(edges: .trailing) + .disabled(!model.canPerformCommandLineToolAction) + } label: { + HStack { + if model.isCommandLineToolInstalled { + Image(systemName: "checkmark.seal.fill") + .foregroundStyle(.green) + } + + Text("Loop CLI") + .padding(.trailing, 4) + .luminareToolTip(attachedTo: .topTrailing) { + Text(model.commandLineToolInstallStatus.description) + .padding(6) + } + } + } + } + } + private func accessibilityComponent() -> some View { LuminareButton { HStack { @@ -320,4 +433,15 @@ struct AdvancedConfigurationView: View { } .disabled(model.isAccessibilityAccessGranted) } + + private var commandLineToolErrorIsPresented: Binding { + Binding( + get: { model.commandLineToolErrorMessage != nil }, + set: { isPresented in + if !isPresented { + model.clearCommandLineToolError() + } + } + ) + } } diff --git a/Loop/Updater/UpdaterAuthorizationCoordinator.swift b/Loop/Updater/PrivilegedHelperCoordinator.swift similarity index 67% rename from Loop/Updater/UpdaterAuthorizationCoordinator.swift rename to Loop/Updater/PrivilegedHelperCoordinator.swift index 0da48dc1..e4e45525 100644 --- a/Loop/Updater/UpdaterAuthorizationCoordinator.swift +++ b/Loop/Updater/PrivilegedHelperCoordinator.swift @@ -1,5 +1,5 @@ // -// UpdaterAuthorizationCoordinator.swift +// PrivilegedHelperCoordinator.swift // Loop // // Created by Kai Azim on 2026-02-23. @@ -10,39 +10,54 @@ import Scribe import Security import ServiceManagement +private struct PrivilegedHelperCoordinatorError: LocalizedError { + let message: String + + var errorDescription: String? { + message + } +} + @Loggable -final class UpdaterAuthorizationCoordinator { +final class PrivilegedHelperCoordinator { enum PrivilegedHelperReadiness { case available case unavailable(reason: String) } final class PrivilegedSession { - private unowned let coordinator: UpdaterAuthorizationCoordinator + private unowned let coordinator: PrivilegedHelperCoordinator private let connection: NSXPCConnection - fileprivate init(coordinator: UpdaterAuthorizationCoordinator, connection: NSXPCConnection) { + fileprivate init(coordinator: PrivilegedHelperCoordinator, connection: NSXPCConnection) { self.coordinator = coordinator self.connection = connection } - /// Invokes the helper atomic swap using a rollback token instead of caller-provided paths. func atomicSwap(rollbackID: String) async throws { let operation = PrivilegedOperation.atomicSwap(rollbackID: rollbackID) try await coordinator.performXPCOperation(connection: connection, operation: operation) } - /// Invokes helper restore for the rollback token selected by the caller. func restoreFromBackup(rollbackID: String) async throws { let operation = PrivilegedOperation.restore(rollbackID: rollbackID) try await coordinator.performXPCOperation(connection: connection, operation: operation) } - /// Removes the authenticated client's current app bundle. func removeCurrentBundle() async throws { let operation = PrivilegedOperation.removeCurrentBundle try await coordinator.performXPCOperation(connection: connection, operation: operation) } + + func installCommandLineTool() async throws { + let operation = PrivilegedOperation.installCommandLineTool + try await coordinator.performXPCOperation(connection: connection, operation: operation) + } + + func reinstallCommandLineTool() async throws { + let operation = PrivilegedOperation.reinstallCommandLineTool + try await coordinator.performXPCOperation(connection: connection, operation: operation) + } } private final class ContinuationCompletion: @unchecked Sendable { @@ -59,7 +74,6 @@ final class UpdaterAuthorizationCoordinator { } private let fileManager: FileManager - private let operationTimeout: Duration = .seconds(90) init(fileManager: FileManager = .default) { @@ -76,6 +90,7 @@ final class UpdaterAuthorizationCoordinator { } func withPrivilegedSession( + prompt: String = "\(Bundle.main.appName) needs administrator permission to perform this action.", _ body: (PrivilegedSession) async throws -> T ) async throws -> T { let helperURL = try helperExecutableURL() @@ -84,8 +99,8 @@ final class UpdaterAuthorizationCoordinator { let createStatus = AuthorizationCreate(nil, nil, [], &authRef) guard createStatus == errAuthorizationSuccess, let authRef else { - throw UpdateError.installationFailed( - "Could not request installation authorization: \(authorizationErrorMessage(for: createStatus))" + throw operationFailed( + "Could not request administrator authorization: \(authorizationErrorMessage(for: createStatus))" ) } @@ -93,9 +108,9 @@ final class UpdaterAuthorizationCoordinator { AuthorizationFree(authRef, [.destroyRights]) } - try requestInstallerAuthorizationRight(authRef) + try requestPrivilegedHelperAuthorizationRight(authRef, prompt: prompt) - let serviceName = PrivilegedInstallerConstants.serviceName + let serviceName = PrivilegedHelperConstants.serviceName let jobDictionary = makeJobDictionary(serviceName: serviceName, helperPath: helperURL.path) try submit(jobDictionary, authRef: authRef) @@ -103,11 +118,10 @@ final class UpdaterAuthorizationCoordinator { removeSubmittedJob(serviceName: serviceName, authRef: authRef) } - // Give launchd a brief moment to bootstrap the helper listener. try await Task.sleep(for: .milliseconds(250)) let connection = NSXPCConnection(machServiceName: serviceName, options: .privileged) - connection.remoteObjectInterface = NSXPCInterface(with: PrivilegedInstallerProtocol.self) + connection.remoteObjectInterface = NSXPCInterface(with: PrivilegedHelperProtocol.self) connection.resume() defer { @@ -125,7 +139,6 @@ final class UpdaterAuthorizationCoordinator { let completion = ContinuationCompletion() var timeoutTask: Task<(), Never>? - // Keep completion synchronous so competing callbacks cannot resume more than once. let finish: (Result<(), Error>) -> () = { result in guard completion.tryComplete() else { return } timeoutTask?.cancel() @@ -140,27 +153,26 @@ final class UpdaterAuthorizationCoordinator { } connection.interruptionHandler = { - self.log.warn("Privileged installer \(operation.name) interrupted during shared session") - finish(.failure(UpdateError.installationFailed("Privileged installer \(operation.name) interrupted"))) + self.log.warn("Privileged helper \(operation.name) interrupted during shared session") + finish(.failure(self.operationFailed("Privileged helper \(operation.name) interrupted"))) } connection.invalidationHandler = { - self.log.warn("Privileged installer \(operation.name) invalidated during shared session") - finish(.failure(UpdateError.installationFailed("Privileged installer \(operation.name) invalidated"))) + self.log.warn("Privileged helper \(operation.name) invalidated during shared session") + finish(.failure(self.operationFailed("Privileged helper \(operation.name) invalidated"))) } guard let proxy = connection.remoteObjectProxyWithErrorHandler({ error in - self.log.warn("Privileged installer \(operation.name) transport failed during shared session: \(error.localizedDescription)") - finish(.failure(UpdateError.installationFailed("Privileged installer \(operation.name) transport failed: \(error.localizedDescription)"))) - }) as? PrivilegedInstallerProtocol else { - self.log.warn("Failed to connect to privileged installer helper for \(operation.name)") - finish(.failure(UpdateError.installationFailed("Failed to connect to privileged installer helper"))) + self.log.warn("Privileged helper \(operation.name) transport failed during shared session: \(error.localizedDescription)") + finish(.failure(self.operationFailed("Privileged helper \(operation.name) transport failed: \(error.localizedDescription)"))) + }) as? PrivilegedHelperProtocol else { + self.log.warn("Failed to connect to privileged helper for \(operation.name)") + finish(.failure(self.operationFailed("Failed to connect to privileged helper"))) return } - // NSXPC reports remote failures via callbacks; direct throwing proxy calls can raise uncaught Objective-C exceptions. operation.invoke(on: proxy) { error in if let error { - finish(.failure(UpdateError.installationFailed(error.localizedDescription))) + finish(.failure(self.operationFailed(error.localizedDescription))) } else { finish(.success(())) } @@ -173,8 +185,8 @@ final class UpdaterAuthorizationCoordinator { return } - self.log.warn("Privileged installer \(operation.name) timed out during shared session") - finish(.failure(UpdateError.installationFailed("Privileged installer \(operation.name) timed out"))) + self.log.warn("Privileged helper \(operation.name) timed out during shared session") + finish(.failure(self.operationFailed("Privileged helper \(operation.name) timed out"))) } } } @@ -182,31 +194,28 @@ final class UpdaterAuthorizationCoordinator { private func helperExecutableURL() throws -> URL { let helperURL = Bundle.main.bundleURL .appendingPathComponent("Contents/Library/LaunchServices", isDirectory: true) - .appendingPathComponent(PrivilegedInstallerConstants.helperExecutableName, isDirectory: false) + .appendingPathComponent(PrivilegedHelperConstants.helperExecutableName, isDirectory: false) let canonicalHelperURL = helperURL.resolvingSymlinksInPath().standardizedFileURL let helperPath = canonicalHelperURL.path guard fileManager.fileExists(atPath: helperPath) else { - throw UpdateError.installationFailed("Privileged installer executable was not found at \(helperPath)") + throw operationFailed("Privileged helper executable was not found at \(helperPath)") } guard fileManager.isExecutableFile(atPath: helperPath) else { - throw UpdateError.installationFailed("Privileged installer executable is not executable at \(helperPath)") + throw operationFailed("Privileged helper executable is not executable at \(helperPath)") } return canonicalHelperURL } - private func requestInstallerAuthorizationRight(_ authRef: AuthorizationRef) throws { - let rightName = installerAuthorizationRightName() - let prompt = "\(Bundle.main.appName) needs administrator permission to install this update." + private func requestPrivilegedHelperAuthorizationRight(_ authRef: AuthorizationRef, prompt: String) throws { + let rightName = privilegedHelperAuthorizationRightName() let getStatus = rightName.withCString { AuthorizationRightGet($0, nil) } if getStatus == errAuthorizationDenied { let setStatus = rightName.withCString { rightNameCString in - // Mirrors Sparkle's code. If kSMRightModifySystemDaemons is added, - // the permission prompt changes, seems to change the wording. AuthorizationRightSet( authRef, rightNameCString, @@ -218,10 +227,10 @@ final class UpdaterAuthorizationCoordinator { } if setStatus != errAuthorizationSuccess { - log.warn("Failed to set installer authorization right \(rightName): \(authorizationErrorMessage(for: setStatus))") + log.warn("Failed to set privileged helper authorization right \(rightName): \(authorizationErrorMessage(for: setStatus))") } } else if getStatus != errAuthorizationSuccess { - log.warn("Failed to retrieve installer authorization right \(rightName): \(authorizationErrorMessage(for: getStatus))") + log.warn("Failed to retrieve privileged helper authorization right \(rightName): \(authorizationErrorMessage(for: getStatus))") } let rightsStatus: OSStatus = rightName.withCString { rightNameCString in @@ -245,17 +254,17 @@ final class UpdaterAuthorizationCoordinator { } guard rightsStatus == errAuthorizationSuccess else { - throw UpdateError.installationFailed( + throw operationFailed( "Authorization rights request failed: \(authorizationErrorMessage(for: rightsStatus))" ) } - log.info("Authorization rights granted for one-shot privileged installer") + log.info("Authorization rights granted for one-shot privileged helper") } - private func installerAuthorizationRightName() -> String { - let bundleIdentifier = Bundle.main.bundleIdentifier ?? "com.MrKai77.Loop" - return "\(bundleIdentifier).updater-auth" + private func privilegedHelperAuthorizationRightName() -> String { + let bundleIdentifier = Bundle.main.bundleIdentifier ?? PrivilegedHelperConstants.appBundleIdentifier + return "\(bundleIdentifier).privileged-helper-auth" } private func makeJobDictionary(serviceName: String, helperPath: String) -> [String: Any] { @@ -283,7 +292,7 @@ final class UpdaterAuthorizationCoordinator { guard success else { let details = error?.takeRetainedValue().localizedDescription ?? "Unknown privileged submission failure" - throw UpdateError.installationFailed("Privileged installer submission failed: \(details)") + throw operationFailed("Privileged helper submission failed: \(details)") } } @@ -302,7 +311,7 @@ final class UpdaterAuthorizationCoordinator { } let details = error?.takeRetainedValue().localizedDescription ?? "Unknown cleanup failure" - log.warn("Failed to remove privileged updater job \(serviceName): \(details)") + log.warn("Failed to remove privileged helper job \(serviceName): \(details)") } private func authorizationErrorMessage(for status: OSStatus) -> String { @@ -316,12 +325,18 @@ final class UpdaterAuthorizationCoordinator { return "OSStatus \(status)" } + + private func operationFailed(_ message: String) -> Error { + PrivilegedHelperCoordinatorError(message: message) + } } private enum PrivilegedOperation { case atomicSwap(rollbackID: String) case restore(rollbackID: String) case removeCurrentBundle + case installCommandLineTool + case reinstallCommandLineTool var name: String { switch self { @@ -331,11 +346,14 @@ private enum PrivilegedOperation { "restore" case .removeCurrentBundle: "remove current bundle" + case .installCommandLineTool: + "install command-line tool" + case .reinstallCommandLineTool: + "reinstall command-line tool" } } - /// Dispatches the selected privileged operation on the typed helper proxy. - func invoke(on proxy: PrivilegedInstallerProtocol, reply: @escaping (NSError?) -> ()) { + func invoke(on proxy: PrivilegedHelperProtocol, reply: @escaping (NSError?) -> ()) { switch self { case let .atomicSwap(rollbackID): proxy.atomicSwap(rollbackID: rollbackID, withReply: reply) @@ -343,6 +361,10 @@ private enum PrivilegedOperation { proxy.restoreFromBackup(rollbackID: rollbackID, withReply: reply) case .removeCurrentBundle: proxy.removeCurrentBundle(withReply: reply) + case .installCommandLineTool: + proxy.installCommandLineTool(withReply: reply) + case .reinstallCommandLineTool: + proxy.reinstallCommandLineTool(withReply: reply) } } } diff --git a/Loop/Updater/UpdateInstaller.swift b/Loop/Updater/UpdateInstaller.swift index 3cee1c0b..a30709c8 100644 --- a/Loop/Updater/UpdateInstaller.swift +++ b/Loop/Updater/UpdateInstaller.swift @@ -22,7 +22,7 @@ actor UpdateInstaller { private let backupManager: BackupManager private let fileManager: FileManager - private let authorizationCoordinator: UpdaterAuthorizationCoordinator + private let privilegedHelperCoordinator: PrivilegedHelperCoordinator private var isCancelled = false private var relocateToApplications = false @@ -44,7 +44,7 @@ actor UpdateInstaller { init(fileManager: FileManager = .default) { self.fileManager = fileManager self.backupManager = BackupManager(fileManager: fileManager) - self.authorizationCoordinator = UpdaterAuthorizationCoordinator() + self.privilegedHelperCoordinator = PrivilegedHelperCoordinator() } func installUpdate( @@ -233,7 +233,7 @@ actor UpdateInstaller { } private func permissionStateForRestrictedInstallLocation(baseReason: String) -> InstallationPermissionState { - switch authorizationCoordinator.privilegedHelperReadiness() { + switch privilegedHelperCoordinator.privilegedHelperReadiness() { case .available: .needsElevation(reason: baseReason) case let .unavailable(helperReason): @@ -483,7 +483,9 @@ actor UpdateInstaller { log.info("Requesting administrator authorization for privileged installation") var enteredPrivilegedSession = false do { - try await authorizationCoordinator.withPrivilegedSession { session in + try await privilegedHelperCoordinator.withPrivilegedSession( + prompt: "\(Bundle.main.appName) needs administrator permission to install this update." + ) { session in enteredPrivilegedSession = true log.success("Administrator authorization granted; privileged session established") @@ -544,7 +546,7 @@ actor UpdateInstaller { private func performRelocationInstall( from appBundle: URL, manifest: UpdateManifest, - cleanupSession: UpdaterAuthorizationCoordinator.PrivilegedSession? = nil + cleanupSession: PrivilegedHelperCoordinator.PrivilegedSession? = nil ) async throws { log.info("Installing to Applications folder") @@ -586,7 +588,7 @@ actor UpdateInstaller { private func cleanupOldRelocatedCopyIfNeeded( source sourceAppURL: URL, destination destinationURL: URL, - cleanupSession: UpdaterAuthorizationCoordinator.PrivilegedSession? + cleanupSession: PrivilegedHelperCoordinator.PrivilegedSession? ) async { let canonicalSource = LoopSupportPaths.canonical(sourceAppURL) let canonicalDestination = LoopSupportPaths.canonical(destinationURL) @@ -700,7 +702,7 @@ actor UpdateInstaller { from sourceURL: URL, to destinationURL: URL, manifest: UpdateManifest, - session: UpdaterAuthorizationCoordinator.PrivilegedSession + session: PrivilegedHelperCoordinator.PrivilegedSession ) async throws { let stagingURL = stagingRootDirectory .appendingPathComponent("\(destinationURL.lastPathComponent).staging", isDirectory: true) @@ -801,7 +803,7 @@ actor UpdateInstaller { private func atomicSwapPrivileged( staged stagingURL: URL, current currentURL: URL, - session: UpdaterAuthorizationCoordinator.PrivilegedSession + session: PrivilegedHelperCoordinator.PrivilegedSession ) async throws { try checkCancellation() @@ -840,7 +842,7 @@ actor UpdateInstaller { staged stagingURL: URL, rollbackID: String, originalError: Error, - session: UpdaterAuthorizationCoordinator.PrivilegedSession + session: PrivilegedHelperCoordinator.PrivilegedSession ) async throws { let rollbackContainerURL = rollbackRootDirectory.appendingPathComponent(rollbackID, isDirectory: true) let backupBundleURL = rollbackContainerURL.appendingPathComponent( diff --git a/LoopCLI/CLIErrorFormatter.swift b/LoopCLI/CLIErrorFormatter.swift new file mode 100644 index 00000000..f9813a59 --- /dev/null +++ b/LoopCLI/CLIErrorFormatter.swift @@ -0,0 +1,89 @@ +// +// CLIErrorFormatter.swift +// LoopCLI +// +// Created by Kai Azim on 2026-03-29. +// + +import Foundation + +struct CLICommandError: LocalizedError, CustomStringConvertible { + let message: String + + var errorDescription: String? { + message + } + + var description: String { + message + } +} + +struct CLIErrorFormatter { + private let executableName: String + + init(executableName: String) { + self.executableName = executableName + } + + func error(from response: CLIResponse) -> CLICommandError { + var lines: [String] = [] + + if let errorMessage = response.automationError?.message, !errorMessage.isEmpty { + lines.append(errorMessage) + } else if !response.rawOutput.isEmpty { + lines.append(response.rawOutput) + } else { + lines.append("Command failed") + } + + if let replacement = response.automationError?.replacementRoute, !replacement.isEmpty { + lines.append("Try: \(displayString(for: replacement))") + } + + let availableRoutes = response.automationError?.availableRoutes ?? [] + if !availableRoutes.isEmpty { + let displayedRoutes = availableRoutes.map(displayString) + lines.append("Available routes: \(displayedRoutes.joined(separator: ", "))") + } + + return CLICommandError(message: lines.joined(separator: "\n")) + } + + private func displayString(for route: String) -> String { + guard + let url = URL(string: route), + url.scheme?.lowercased() == "loop" + else { + return route + } + + let components = (url.host.map { [$0.lowercased()] } ?? []) + + url.pathComponents.filter { $0 != "/" && !$0.isEmpty } + + switch components { + case ["list", "windows"]: + return "\(executableName) list windows" + case ["list", "screens"]: + return "\(executableName) list screens" + case ["list", "actions"]: + return "\(executableName) list actions" + case ["list", "actions", "directions"]: + return "\(executableName) list actions --directions" + case ["list", "actions", "keybinds"]: + return "\(executableName) list actions --keybinds" + default: + break + } + + if components.count == 2, components[0] == "direction" { + return "\(executableName) exec --direction \(components[1])" + } + + if components.count == 2, components[0] == "id" { + return "\(executableName) exec --id \(components[1])" + } + + return route + } +} diff --git a/LoopCLI/CLIOutputFormatter.swift b/LoopCLI/CLIOutputFormatter.swift new file mode 100644 index 00000000..19024a87 --- /dev/null +++ b/LoopCLI/CLIOutputFormatter.swift @@ -0,0 +1,265 @@ +// +// CLIOutputFormatter.swift +// LoopCLI +// +// Created by Kai Azim on 2026-03-30. +// + +import CoreGraphics +import Darwin +import Foundation + +struct CLIOutputFormatter { + private let supportsANSIStyle = isatty(STDOUT_FILENO) != 0 + && ProcessInfo.processInfo.environment["NO_COLOR"] == nil + && ProcessInfo.processInfo.environment["TERM"]?.lowercased() != "dumb" + + func format(_ response: CLIResponse, configuration: CLIOutputConfiguration) -> String { + switch configuration.mode { + case .json: + return response.rawOutput + case .human: + guard let result = response.result else { + return response.rawOutput + } + + switch result { + case let .windowList(result): + return formatWindows(result.windows) + case let .screenList(result): + return formatScreens(result.screens) + case let .actionList(result): + return formatActions(result, showIDs: configuration.showIDs) + case let .execution(result): + return formatExecution(result) + } + } + } + + private func formatWindows(_ windows: [LoopWindowSummary]) -> String { + guard !windows.isEmpty else { + return "No windows" + } + + return windows.enumerated().map { index, window in + var lines = [windowPrimaryLine(appName: window.appName, title: window.title, fallback: "Window \(index + 1)")] + + if let metadata = windowMetadataLine( + bundleID: window.bundleID, + idLabel: "Window ID", + id: window.id, + frame: window.frame + ) { + lines.append(dim(metadata)) + } + + return lines.joined(separator: "\n") + }.joined(separator: "\n\n") + } + + private func formatScreens(_ screens: [LoopScreenSummary]) -> String { + guard !screens.isEmpty else { + return "No screens" + } + + return screens.enumerated().map { index, screen in + var lines = [screenPrimaryLine(screen, fallback: "Screen \(index + 1)")] + + if let metadata = screenMetadataLine(screen) { + lines.append(dim(metadata)) + } + + return lines.joined(separator: "\n") + }.joined(separator: "\n\n") + } + + private func formatActions(_ result: LoopActionListResult, showIDs: Bool) -> String { + var sections: [String] = [] + + if !result.directionCategories.isEmpty { + sections.append(formatDirectionSections(result.directionCategories, showIDs: showIDs)) + } + + if !result.keybindActions.isEmpty || result.filter == .keybindsOnly || result.filter == .all { + sections.append(formatKeybindSection(result.keybindActions, showIDs: showIDs)) + } + + let nonEmptySections = sections.filter { !$0.isEmpty } + if nonEmptySections.isEmpty { + return "No actions" + } + + return nonEmptySections.joined(separator: "\n\n") + } + + private func formatDirectionSections(_ categories: [LoopActionCategory], showIDs: Bool) -> String { + guard !categories.isEmpty else { + return "\(bold("- Direction Actions (Built-in) -"))\n\n\(dim("None"))" + } + + var lines = [bold("- Direction Actions (Built-in) -")] + + for category in categories where !category.actions.isEmpty { + lines.append("") + lines.append(bold(sanitizeInline(category.name))) + lines.append(contentsOf: formatActionRows(category.actions, showIDs: showIDs)) + } + + return lines.joined(separator: "\n") + } + + private func formatKeybindSection(_ keybinds: [LoopActionDescriptor], showIDs: Bool) -> String { + var lines = [bold("- User-Configured Keybind Actions -")] + + guard !keybinds.isEmpty else { + lines.append("") + lines.append(dim("None")) + return lines.joined(separator: "\n") + } + + lines.append("") + lines.append(contentsOf: formatActionRows(keybinds, showIDs: showIDs)) + + return lines.joined(separator: "\n") + } + + private func formatExecution(_ result: LoopExecutionResult) -> String { + let name = sanitizeInline(result.action.name) + + guard let window = result.targetWindow else { + return "Successfully executed \(name)" + } + + if let appName = nonEmptyString(window.appName).map(sanitizeInline) { + return "Successfully executed \(name) on \(appName) (Window ID: \(window.id))" + } + + return "Successfully executed \(name) (Window ID: \(window.id))" + } + + private func formatActionRows(_ actions: [LoopActionDescriptor], showIDs: Bool) -> [String] { + let rows = actions.map { action in + (name: sanitizeInline(action.name), id: action.idString) + } + + guard showIDs else { + return rows.map { blue($0.name) } + } + + let nameColumnWidth = rows.map(\.name.count).max() ?? 0 + + return rows.map { row in + let paddedName = row.name.padding(toLength: nameColumnWidth, withPad: " ", startingAt: 0) + if let id = row.id { + return "\(blue(paddedName)) \(dim(id))" + } + return blue(paddedName) + } + } + + private func windowPrimaryLine(appName: String, title: String, fallback: String) -> String { + let sanitizedAppName = nonEmptyString(appName).map(sanitizeInline) + let sanitizedTitle = nonEmptyString(title).map(quotedTitle) + + switch (sanitizedAppName, sanitizedTitle) { + case let (appName?, title?): + return "\(bold(appName)) \(title)" + case let (appName?, nil): + return bold(appName) + case let (nil, title?): + return title + case (nil, nil): + return fallback + } + } + + private func windowMetadataLine( + bundleID: String, + idLabel: String, + id: UInt32, + frame: LoopRect? + ) -> String? { + var parts: [String] = [] + + if let bundleID = nonEmptyString(bundleID) { + parts.append("Bundle ID: \(bundleID)") + } + + parts.append("\(idLabel): \(id)") + + if let frame { + parts.append("Frame: \(formatLength(frame.width))x\(formatLength(frame.height)) @ \(formatCoordinate(frame.x)),\(formatCoordinate(frame.y))") + } + + return parts.isEmpty ? nil : parts.joined(separator: " | ") + } + + private func screenPrimaryLine(_ screen: LoopScreenSummary, fallback: String) -> String { + let name = nonEmptyString(screen.name).map(sanitizeInline) ?? fallback + return screen.isMain ? "\(bold(name)) [main]" : bold(name) + } + + private func screenMetadataLine(_ screen: LoopScreenSummary) -> String? { + "Screen ID: \(screen.id) | Frame: \(formatLength(screen.frame.width))x\(formatLength(screen.frame.height)) @ \(formatCoordinate(screen.frame.x)),\(formatCoordinate(screen.frame.y))" + } + + private func formatLength(_ value: CGFloat) -> String { + formatCGFloat(value) + } + + private func formatCoordinate(_ value: CGFloat) -> String { + formatCGFloat(value) + } + + private func formatCGFloat(_ value: CGFloat) -> String { + if value.rounded() == value { + return String(Int(value)) + } + + let formatted = String(format: "%.2f", Double(value)) + return formatted + .replacingOccurrences(of: #"\.?0+$"#, with: "", options: .regularExpression) + } + + private func nonEmptyString(_ string: String) -> String? { + let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + private func sanitizeInline(_ string: String) -> String { + string + .components(separatedBy: .newlines) + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + .joined(separator: " ") + } + + private func quotedTitle(_ string: String) -> String { + let sanitized = sanitizeInline(string).replacingOccurrences(of: "'", with: "\\'") + return "'\(sanitized)'" + } + + private func bold(_ string: String) -> String { + guard supportsANSIStyle else { + return string + } + + return "\u{001B}[1m\(string)\u{001B}[22m" + } + + private func dim(_ string: String) -> String { + guard supportsANSIStyle else { + return string + } + + return "\u{001B}[2m\(string)\u{001B}[22m" + } + + private func blue(_ string: String) -> String { + guard supportsANSIStyle else { + return string + } + + return "\u{001B}[34m\(string)\u{001B}[39m" + } +} diff --git a/LoopCLI/CLIRequest.swift b/LoopCLI/CLIRequest.swift new file mode 100644 index 00000000..a6802c6b --- /dev/null +++ b/LoopCLI/CLIRequest.swift @@ -0,0 +1,58 @@ +// +// CLIRequest.swift +// LoopCLI +// +// Created by Kai Azim on 2026-03-29. +// + +import ArgumentParser +import Foundation + +protocol CLIRequestCommand: ParsableCommand { + var outputConfiguration: CLIOutputConfiguration { get } + func makeRequest(using application: LoopCLIApplication) throws -> CLIRequest +} + +extension CLIRequestCommand { + func run() throws { + try LoopCLIApplication.shared.execute( + makeRequest(using: .shared), + outputConfiguration: outputConfiguration + ) + } +} + +struct CLIRequest { + let url: URL + + init(routeComponents: [String], queryItems: [URLQueryItem] = []) { + precondition(!routeComponents.isEmpty, "CLIRequest requires at least one route component") + + var components = URLComponents() + components.scheme = "loop" + components.host = routeComponents[0] + + if routeComponents.count > 1 { + components.path = "/" + routeComponents.dropFirst().joined(separator: "/") + } + + if !queryItems.isEmpty { + components.queryItems = queryItems + } + + guard let url = components.url else { + preconditionFailure("Failed to construct loop:// request for \(routeComponents)") + } + + self.url = url + } + + var serializedRequest: String { + url.absoluteString + } +} + +struct CLIOutputConfiguration { + let mode: OutputOptions.Mode + let showIDs: Bool +} diff --git a/LoopCLI/CLIResponse.swift b/LoopCLI/CLIResponse.swift new file mode 100644 index 00000000..2bd28fe3 --- /dev/null +++ b/LoopCLI/CLIResponse.swift @@ -0,0 +1,31 @@ +// +// CLIResponse.swift +// LoopCLI +// +// Created by Kai Azim on 2026-03-29. +// + +import Foundation + +struct CLIResponse { + let rawOutput: String + let automationResponse: LoopAutomationResponse? + + init(rawOutput: String) { + let trimmedOutput = rawOutput.trimmingCharacters(in: .whitespacesAndNewlines) + self.rawOutput = trimmedOutput + self.automationResponse = try? LoopAutomationJSON.decodeResponse(from: trimmedOutput) + } + + var isSuccess: Bool { + automationResponse?.success == true + } + + var result: LoopAutomationResult? { + automationResponse?.result + } + + var automationError: LoopAutomationError? { + automationResponse?.error + } +} diff --git a/LoopCLI/ExecCommand.swift b/LoopCLI/ExecCommand.swift new file mode 100644 index 00000000..3e128144 --- /dev/null +++ b/LoopCLI/ExecCommand.swift @@ -0,0 +1,86 @@ +// +// ExecCommand.swift +// LoopCLI +// +// Created by Kai Azim on 2026-03-29. +// + +import ArgumentParser +import Foundation + +struct ActionIdentifier: ExpressibleByArgument { + let value: UUID + + init?(argument: String) { + guard let value = UUID(uuidString: argument) else { + return nil + } + + self.value = value + } +} + +struct ExecCommand: ParsableCommand, CLIRequestCommand { + static let configuration = CommandConfiguration( + commandName: "exec", + abstract: "Execute a direction action, keybind-backed action, or UUID-addressed action" + ) + + @Option(name: .customLong("direction"), help: "Execute a built-in direction action") + var direction: String? + + @Option(name: .customLong("keybind"), help: "Execute a keybind-backed action by name") + var keybind: String? + + @Option(name: .customLong("id"), help: "Execute an action by UUID") + var actionID: ActionIdentifier? + + @OptionGroup + var targetOptions: TargetOptions + + @OptionGroup + var outputOptions: OutputOptions + + var outputConfiguration: CLIOutputConfiguration { + CLIOutputConfiguration( + mode: outputOptions.outputMode, + showIDs: false + ) + } + + func validate() throws { + let selectorCount = (direction == nil ? 0 : 1) + + (keybind == nil ? 0 : 1) + + (actionID == nil ? 0 : 1) + guard selectorCount == 1 else { + throw ValidationError("Exactly one of --direction, --keybind, or --id is required") + } + } + + func makeRequest(using _: LoopCLIApplication) throws -> CLIRequest { + let queryItems = targetOptions.queryItems + + if let direction { + return CLIRequest( + routeComponents: ["direction", direction], + queryItems: queryItems + ) + } + + if let keybind { + return CLIRequest( + routeComponents: ["keybind", keybind], + queryItems: queryItems + ) + } + + if let actionID { + return CLIRequest( + routeComponents: ["id", actionID.value.uuidString.lowercased()], + queryItems: queryItems + ) + } + + throw ValidationError("Exactly one of --direction, --keybind, or --id is required") + } +} diff --git a/LoopCLI/ListCommand.swift b/LoopCLI/ListCommand.swift new file mode 100644 index 00000000..89b15256 --- /dev/null +++ b/LoopCLI/ListCommand.swift @@ -0,0 +1,68 @@ +// +// ListCommand.swift +// LoopCLI +// +// Created by Kai Azim on 2026-03-29. +// + +import ArgumentParser + +struct ListCommand: ParsableCommand, CLIRequestCommand { + static let configuration = CommandConfiguration( + commandName: "list", + abstract: "List windows, screens, or executable actions" + ) + + @Argument(help: "What to list") + var subject: ListSubject + + @Flag(name: .customLong("directions"), help: "List only built-in direction actions") + var directionsOnly = false + + @Flag(name: .customLong("keybinds"), help: "List only keybind-backed actions") + var keybindsOnly = false + + @Flag(name: .customLong("ids"), help: "Show action UUIDs in `list actions` output") + var ids = false + + @OptionGroup + var outputOptions: OutputOptions + + var outputConfiguration: CLIOutputConfiguration { + CLIOutputConfiguration( + mode: outputOptions.outputMode, + showIDs: ids + ) + } + + func validate() throws { + if directionsOnly, keybindsOnly { + throw ValidationError("--directions and --keybinds are mutually exclusive") + } + + if subject != .actions, directionsOnly || keybindsOnly { + throw ValidationError("--directions and --keybinds are only valid with `list actions`") + } + + if subject != .actions, ids { + throw ValidationError("--ids is only valid with `list actions`") + } + } + + func makeRequest(using _: LoopCLIApplication) throws -> CLIRequest { + let routeComponents: [String] = switch subject { + case .windows: + ["list", "windows"] + case .screens: + ["list", "screens"] + case .actions where directionsOnly: + ["list", "actions", "directions"] + case .actions where keybindsOnly: + ["list", "actions", "keybinds"] + case .actions: + ["list", "actions"] + } + + return CLIRequest(routeComponents: routeComponents) + } +} diff --git a/LoopCLI/ListSubject.swift b/LoopCLI/ListSubject.swift new file mode 100644 index 00000000..b36e90f2 --- /dev/null +++ b/LoopCLI/ListSubject.swift @@ -0,0 +1,14 @@ +// +// ListSubject.swift +// LoopCLI +// +// Created by Kai Azim on 2026-03-29. +// + +import ArgumentParser + +enum ListSubject: String, CaseIterable, ExpressibleByArgument { + case windows + case screens + case actions +} diff --git a/LoopCLI/LoopCLIApplication.swift b/LoopCLI/LoopCLIApplication.swift new file mode 100644 index 00000000..f08bfdc9 --- /dev/null +++ b/LoopCLI/LoopCLIApplication.swift @@ -0,0 +1,39 @@ +// +// LoopCLIApplication.swift +// LoopCLI +// +// Created by Kai Azim on 2026-03-29. +// + +import Foundation + +final class LoopCLIApplication { + static let shared = LoopCLIApplication() + + static let executableName = URL( + fileURLWithPath: CommandLine.arguments.first ?? "loop-cli" + ).lastPathComponent + + private let socketClient: LoopSocketClient + private let errorFormatter: CLIErrorFormatter + private let outputFormatter: CLIOutputFormatter + + init( + socketClient: LoopSocketClient = LoopSocketClient(), + errorFormatter: CLIErrorFormatter = CLIErrorFormatter(executableName: LoopCLIApplication.executableName), + outputFormatter: CLIOutputFormatter = CLIOutputFormatter() + ) { + self.socketClient = socketClient + self.errorFormatter = errorFormatter + self.outputFormatter = outputFormatter + } + + func execute(_ request: CLIRequest, outputConfiguration: CLIOutputConfiguration) throws { + let response = try socketClient.send(request) + guard response.isSuccess else { + throw errorFormatter.error(from: response) + } + + print(outputFormatter.format(response, configuration: outputConfiguration)) + } +} diff --git a/LoopCLI/LoopCLICommand.swift b/LoopCLI/LoopCLICommand.swift new file mode 100644 index 00000000..348706a6 --- /dev/null +++ b/LoopCLI/LoopCLICommand.swift @@ -0,0 +1,30 @@ +// +// LoopCLICommand.swift +// LoopCLI +// +// Created by Kai Azim on 2026-03-29. +// + +import ArgumentParser + +struct LoopCLICommand: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: LoopCLIApplication.executableName, + abstract: "Command-line interface for Loop window manager.", + discussion: """ + Successful commands print human-readable text by default. Use --json to print raw JSON. + Failures print plain-text errors to stderr. + + Examples: + \(LoopCLIApplication.executableName) list windows + \(LoopCLIApplication.executableName) list windows --json + \(LoopCLIApplication.executableName) list actions --directions + \(LoopCLIApplication.executableName) list actions --ids + \(LoopCLIApplication.executableName) exec --direction right + \(LoopCLIApplication.executableName) exec --direction right --json + \(LoopCLIApplication.executableName) exec --keybind "My Layout" + \(LoopCLIApplication.executableName) exec --id 123e4567-e89b-12d3-a456-426614174000 + """, + subcommands: [ListCommand.self, ExecCommand.self] + ) +} diff --git a/LoopCLI/LoopSocketClient.swift b/LoopCLI/LoopSocketClient.swift new file mode 100644 index 00000000..f5e23efd --- /dev/null +++ b/LoopCLI/LoopSocketClient.swift @@ -0,0 +1,90 @@ +// +// LoopSocketClient.swift +// LoopCLI +// +// Created by Kai Azim on 2026-03-29. +// + +import Foundation + +final class LoopSocketClient { + private struct SocketRuntimeError: LocalizedError { + let message: String + + var errorDescription: String? { + message + } + } + + private let socketPath: String + + init(socketPath: String = "/tmp/loop-\(getuid()).socket") { + self.socketPath = socketPath + } + + func send(_ request: CLIRequest) throws -> CLIResponse { + let fileDescriptor = socket(AF_UNIX, SOCK_STREAM, 0) + guard fileDescriptor >= 0 else { + throw SocketRuntimeError(message: "Failed to create socket") + } + defer { close(fileDescriptor) } + + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + + let pathBytes = socketPath.utf8CString + withUnsafeMutablePointer(to: &address.sun_path) { pointer in + pointer.withMemoryRebound(to: CChar.self, capacity: pathBytes.count) { destination in + pathBytes.withUnsafeBufferPointer { source in + _ = memcpy(destination, source.baseAddress!, source.count) + } + } + } + + let connectResult = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { socketAddress in + connect(fileDescriptor, socketAddress, socklen_t(MemoryLayout.size)) + } + } + + guard connectResult == 0 else { + throw SocketRuntimeError( + message: "Loop is not running (could not connect to \(socketPath))" + ) + } + + var timeout = timeval(tv_sec: 5, tv_usec: 0) + setsockopt(fileDescriptor, SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout.size)) + setsockopt(fileDescriptor, SOL_SOCKET, SO_SNDTIMEO, &timeout, socklen_t(MemoryLayout.size)) + + let serializedRequest = request.serializedRequest + "\n" + let bytesSent = serializedRequest.utf8.withContiguousStorageIfAvailable { buffer in + Darwin.write(fileDescriptor, buffer.baseAddress!, buffer.count) + } ?? -1 + + guard bytesSent > 0 else { + throw SocketRuntimeError(message: "Failed to send request") + } + + var responseData = Data() + var buffer = [UInt8](repeating: 0, count: 4096) + + while true { + let bytesRead = read(fileDescriptor, &buffer, buffer.count) + if bytesRead <= 0 { + break + } + + responseData.append(contentsOf: buffer[.. CFBundleIdentifier - com.MrKai77.Loop.UpdaterHelper + com.MrKai77.Loop.PrivilegedHelper CFBundleExecutable $(EXECUTABLE_NAME) CFBundleName - LoopUpdaterHelper + LoopPrivilegedHelper CFBundlePackageType BNDL diff --git a/LoopUpdaterHelper/PrivilegedInstaller.swift b/LoopPrivilegedHelper/PrivilegedHelper.swift similarity index 71% rename from LoopUpdaterHelper/PrivilegedInstaller.swift rename to LoopPrivilegedHelper/PrivilegedHelper.swift index 5a6c1c50..03aca629 100644 --- a/LoopUpdaterHelper/PrivilegedInstaller.swift +++ b/LoopPrivilegedHelper/PrivilegedHelper.swift @@ -1,16 +1,17 @@ // -// PrivilegedInstaller.swift +// PrivilegedHelper.swift // Loop // // Created by Kai Azim on 2026-03-01. // +import Darwin import Foundation import Scribe import Security @Loggable -final class PrivilegedInstaller: NSObject, PrivilegedInstallerProtocol { +final class PrivilegedHelper: NSObject, PrivilegedHelperProtocol { private struct AtomicSwapPaths { let currentURL: URL let stagedURL: URL @@ -24,15 +25,27 @@ final class PrivilegedInstaller: NSObject, PrivilegedInstallerProtocol { let backupBundleURL: URL } + private enum ExistingFilesystemEntry { + case missing + case symbolicLink + case other + } + + private enum CommandLineToolDestinationState { + case missing + case loopManaged + case occupied(reason: String) + } + private static let maxRollbackIDLength = 128 private static let allowedRollbackIDScalars = CharacterSet.alphanumerics .union(CharacterSet(charactersIn: "._-")) - private let context: PrivilegedInstallerService.TrustedClientContext + private let context: PrivilegedHelperService.TrustedClientContext private let fileManager: FileManager init( - context: PrivilegedInstallerService.TrustedClientContext, + context: PrivilegedHelperService.TrustedClientContext, fileManager: FileManager = .default ) { self.context = context @@ -66,7 +79,24 @@ final class PrivilegedInstaller: NSObject, PrivilegedInstallerProtocol { } } - /// Executes a privileged atomic swap using rollback-token-derived paths in user Application Support. + func installCommandLineTool(withReply reply: @escaping (NSError?) -> ()) { + do { + try executeInstallCommandLineTool(reinstall: false) + reply(nil) + } catch { + reply(error as NSError) + } + } + + func reinstallCommandLineTool(withReply reply: @escaping (NSError?) -> ()) { + do { + try executeInstallCommandLineTool(reinstall: true) + reply(nil) + } catch { + reply(error as NSError) + } + } + private func executeAtomicSwap(rollbackID: String) throws { let operation = "atomic swap" @@ -93,7 +123,6 @@ final class PrivilegedInstaller: NSObject, PrivilegedInstallerProtocol { } } - /// Restores the current app directly from rollback-token-derived backup path in user Application Support. private func executeRestoreFromBackup(rollbackID: String) throws { let operation = "restore" @@ -124,7 +153,6 @@ final class PrivilegedInstaller: NSObject, PrivilegedInstallerProtocol { } } - /// Removes the authenticated client's current app bundle path. private func executeRemoveCurrentBundle() throws { let currentBundleURL = LoopSupportPaths.canonical(context.clientBundleURL) @@ -137,7 +165,146 @@ final class PrivilegedInstaller: NSObject, PrivilegedInstallerProtocol { log.success("Removed current app bundle at \(currentBundleURL.path)") } - /// Derives and validates atomic swap paths from trusted connection context and rollback token. + private func executeInstallCommandLineTool(reinstall: Bool) throws { + let sourceURL = try validatedCommandLineToolSourceURL() + let destinationURL = PrivilegedHelperConstants.commandLineToolSymlinkURL + + try ensureCommandLineToolInstallDirectoryExists() + + switch try commandLineToolDestinationState(at: destinationURL) { + case .missing: + guard !reinstall else { + throw PrivilegedHelperError.commandLineToolInstallFailed( + reason: "No existing Loop CLI symlink was found at \(destinationURL.path)." + ) + } + case .loopManaged: + guard reinstall else { + throw PrivilegedHelperError.commandLineToolInstallFailed( + reason: "Loop CLI is already installed at \(destinationURL.path)." + ) + } + + try fileManager.removeItem(at: destinationURL) + case let .occupied(reason): + throw PrivilegedHelperError.commandLineToolInstallFailed(reason: reason) + } + + do { + try fileManager.createSymbolicLink(at: destinationURL, withDestinationURL: sourceURL) + log.success("Installed Loop CLI symlink at \(destinationURL.path) -> \(sourceURL.path)") + } catch { + throw PrivilegedHelperError.commandLineToolInstallFailed( + reason: "Failed to create symlink at \(destinationURL.path): \(error.localizedDescription)" + ) + } + } + + private func validatedCommandLineToolSourceURL() throws -> URL { + let sourceURL = LoopSupportPaths.canonical( + context.clientBundleURL + .appendingPathComponent("Contents/MacOS", isDirectory: true) + .appendingPathComponent(PrivilegedHelperConstants.commandLineToolExecutableName, isDirectory: false) + ) + + guard fileManager.fileExists(atPath: sourceURL.path) else { + throw PrivilegedHelperError.commandLineToolInstallFailed( + reason: "Bundled CLI was not found at \(sourceURL.path)." + ) + } + + guard fileManager.isExecutableFile(atPath: sourceURL.path) else { + throw PrivilegedHelperError.commandLineToolInstallFailed( + reason: "Bundled CLI is not executable at \(sourceURL.path)." + ) + } + + return sourceURL + } + + private func ensureCommandLineToolInstallDirectoryExists() throws { + let installDirectoryURL = PrivilegedHelperConstants.commandLineToolInstallDirectoryURL + var isDirectory = ObjCBool(false) + + if fileManager.fileExists(atPath: installDirectoryURL.path, isDirectory: &isDirectory) { + guard isDirectory.boolValue else { + throw PrivilegedHelperError.commandLineToolInstallFailed( + reason: "\(installDirectoryURL.path) exists but is not a directory." + ) + } + return + } + + do { + try fileManager.createDirectory(at: installDirectoryURL, withIntermediateDirectories: true) + } catch { + throw PrivilegedHelperError.commandLineToolInstallFailed( + reason: "Could not create \(installDirectoryURL.path): \(error.localizedDescription)" + ) + } + } + + private func commandLineToolDestinationState(at destinationURL: URL) throws -> CommandLineToolDestinationState { + switch try filesystemEntry(at: destinationURL) { + case .missing: + return .missing + case .other: + return .occupied(reason: "\(destinationURL.path) is already in use.") + case .symbolicLink: + let rawDestination = try symbolicLinkDestination(at: destinationURL) + guard isLoopManagedCommandLineToolTarget(rawDestination) else { + return .occupied(reason: "\(destinationURL.path) is already in use by another symlink.") + } + return .loopManaged + } + } + + private func filesystemEntry(at url: URL) throws -> ExistingFilesystemEntry { + var statBuffer = stat() + let result = url.withUnsafeFileSystemRepresentation { path in + guard let path else { return -1 } + return Int(lstat(path, &statBuffer)) + } + + if result == 0 { + let fileType = statBuffer.st_mode & S_IFMT + return fileType == S_IFLNK ? .symbolicLink : .other + } + + if errno == ENOENT { + return .missing + } + + let errorCode = errno + throw PrivilegedHelperError.commandLineToolInstallFailed( + reason: "Could not inspect \(url.path): \(String(cString: strerror(errorCode))) (\(errorCode))" + ) + } + + private func symbolicLinkDestination(at url: URL) throws -> String { + do { + return try fileManager.destinationOfSymbolicLink(atPath: url.path) + } catch { + throw PrivilegedHelperError.commandLineToolInstallFailed( + reason: "Could not inspect symbolic link at \(url.path): \(error.localizedDescription)" + ) + } + } + + private func isLoopManagedCommandLineToolTarget(_ targetPath: String) -> Bool { + let standardizedTargetPath: String + if targetPath.hasPrefix("/") { + standardizedTargetPath = URL(fileURLWithPath: targetPath).standardizedFileURL.path + } else { + let baseURL = PrivilegedHelperConstants.commandLineToolInstallDirectoryURL + standardizedTargetPath = URL(fileURLWithPath: targetPath, relativeTo: baseURL) + .standardizedFileURL + .path + } + + return standardizedTargetPath.hasSuffix(PrivilegedHelperConstants.loopManagedCommandLineToolSuffix) + } + private func deriveAndValidateAtomicSwapPaths( for rollbackID: String, operation: String @@ -194,7 +361,6 @@ final class PrivilegedInstaller: NSObject, PrivilegedInstallerProtocol { ) } - /// Derives and validates restore paths from trusted connection context and rollback token. private func deriveAndValidateRestorePaths( for rollbackID: String, operation: String @@ -236,7 +402,6 @@ final class PrivilegedInstaller: NSObject, PrivilegedInstallerProtocol { ) } - /// Validates bundle code signature using the same static validation path as non-privileged install flow. private func validateBundleForInstall( at bundleURL: URL, operation: String, @@ -276,7 +441,6 @@ final class PrivilegedInstaller: NSObject, PrivilegedInstallerProtocol { } } - /// Validates rollback token format to prevent traversal and unexpected path materialization. private func validateRollbackID(_ rollbackID: String, operation: String) throws { guard !rollbackID.isEmpty else { throw pathValidationFailure( @@ -315,7 +479,6 @@ final class PrivilegedInstaller: NSObject, PrivilegedInstallerProtocol { } } - /// Ensures a candidate path remains within the expected root after canonicalization. private func ensurePathInside( _ candidate: URL, root: URL, @@ -334,7 +497,6 @@ final class PrivilegedInstaller: NSObject, PrivilegedInstallerProtocol { } } - /// Converts Security framework status codes to readable log/error strings. private func securityErrorMessage(for status: OSStatus) -> String { if let message = SecCopyErrorMessageString(status, nil) as String? { return "\(message) (OSStatus \(status))" @@ -342,13 +504,12 @@ final class PrivilegedInstaller: NSObject, PrivilegedInstallerProtocol { return "OSStatus \(status)" } - /// Logs and constructs a privileged path validation failure with operation context. private func pathValidationFailure( operation: String, rollbackID: String, path: String, reason: String - ) -> PrivilegedInstallerError { + ) -> PrivilegedHelperError { log.warn( """ Rejected privileged \(operation) path for pid \(context.clientPID), uid \(context.clientUID), rollbackID \(rollbackID). \ @@ -360,13 +521,12 @@ final class PrivilegedInstaller: NSObject, PrivilegedInstallerProtocol { return .pathValidationFailed(operation: operation, path: path, reason: reason) } - /// Logs and constructs a privileged bundle validation failure with operation context. private func bundleValidationFailure( operation: String, rollbackID: String, path: String, reason: String - ) -> PrivilegedInstallerError { + ) -> PrivilegedHelperError { log.warn( """ Rejected privileged \(operation) bundle for pid \(context.clientPID), uid \(context.clientUID), rollbackID \(rollbackID). \ @@ -378,14 +538,12 @@ final class PrivilegedInstaller: NSObject, PrivilegedInstallerProtocol { return .bundleValidationFailed(path: path, reason: reason) } - /// Returns true when a canonicalized URL is equal to or contained within a canonicalized root. private func isPath(_ url: URL, inside root: URL) -> Bool { let canonicalURLPath = LoopSupportPaths.canonical(url).path let canonicalRootPath = LoopSupportPaths.canonical(root).path return canonicalURLPath == canonicalRootPath || canonicalURLPath.hasPrefix("\(canonicalRootPath)/") } - /// Moves current app to backup and installs the staged app atomically with rollback on failure. private func performAtomicSwap( currentURL: URL, stagedURL: URL, @@ -445,7 +603,6 @@ final class PrivilegedInstaller: NSObject, PrivilegedInstallerProtocol { log.success("Privileged atomic swap completed") } - /// Restores the app from backup and reapplies root ownership. private func performRestoreFromBackup(currentURL: URL, backupBundleURL: URL) throws { log.info("Starting privileged restore from backup") log.info("Current app: \(currentURL.path)") @@ -461,14 +618,12 @@ final class PrivilegedInstaller: NSObject, PrivilegedInstallerProtocol { log.success("Privileged restore completed") } - /// Applies root ownership recursively to a directory tree. private func applyRootOwnershipRecursively(at url: URL) throws { log.info("Applying root ownership recursively at \(url.path)") try applyOwnershipRecursively(at: url, uid: 0, gid: 0) log.success("Applied root ownership recursively at \(url.path)") } - /// Applies a target uid/gid recursively to the root URL and its descendants. private func applyOwnershipRecursively(at rootURL: URL, uid: uid_t, gid: gid_t) throws { var itemCount = 0 try applyOwnership(to: rootURL, uid: uid, gid: gid) @@ -490,7 +645,6 @@ final class PrivilegedInstaller: NSObject, PrivilegedInstallerProtocol { log.success("Applied ownership to \(itemCount) items under \(rootURL.path)") } - /// Applies ownership to a single filesystem entry using `lchown`. private func applyOwnership(to itemURL: URL, uid: uid_t, gid: gid_t) throws { let result: Int32 = itemURL.withUnsafeFileSystemRepresentation { path in guard let path else { @@ -501,7 +655,7 @@ final class PrivilegedInstaller: NSObject, PrivilegedInstallerProtocol { guard result == 0 else { let errorCode = errno - throw PrivilegedInstallerError.ownershipChangeFailed(url: itemURL, code: errorCode) + throw PrivilegedHelperError.ownershipChangeFailed(url: itemURL, code: errorCode) } log.success("Applied ownership to \(itemURL.path) (uid: \(uid), gid: \(gid))") diff --git a/LoopUpdaterHelper/PrivilegedInstallerError.swift b/LoopPrivilegedHelper/PrivilegedHelperError.swift similarity index 78% rename from LoopUpdaterHelper/PrivilegedInstallerError.swift rename to LoopPrivilegedHelper/PrivilegedHelperError.swift index 31bbde8c..5f812843 100644 --- a/LoopUpdaterHelper/PrivilegedInstallerError.swift +++ b/LoopPrivilegedHelper/PrivilegedHelperError.swift @@ -1,5 +1,5 @@ // -// PrivilegedInstallerError.swift +// PrivilegedHelperError.swift // Loop // // Created by Kai Azim on 2026-02-23. @@ -7,11 +7,12 @@ import Foundation -enum PrivilegedInstallerError: LocalizedError { +enum PrivilegedHelperError: LocalizedError { case ownershipLookupFailed(url: URL) case ownershipChangeFailed(url: URL, code: Int32) case pathValidationFailed(operation: String, path: String, reason: String) case bundleValidationFailed(path: String, reason: String) + case commandLineToolInstallFailed(reason: String) var errorDescription: String? { switch self { @@ -24,6 +25,8 @@ enum PrivilegedInstallerError: LocalizedError { return "Rejected privileged \(operation) path \(path): \(reason)" case let .bundleValidationFailed(path, reason): return "Rejected privileged bundle at \(path): \(reason)" + case let .commandLineToolInstallFailed(reason): + return "Could not install Loop command-line tool: \(reason)" } } } diff --git a/LoopUpdaterHelper/PrivilegedInstallerService.swift b/LoopPrivilegedHelper/PrivilegedHelperService.swift similarity index 87% rename from LoopUpdaterHelper/PrivilegedInstallerService.swift rename to LoopPrivilegedHelper/PrivilegedHelperService.swift index 68dba963..071d9715 100644 --- a/LoopUpdaterHelper/PrivilegedInstallerService.swift +++ b/LoopPrivilegedHelper/PrivilegedHelperService.swift @@ -1,5 +1,5 @@ // -// PrivilegedInstallerService.swift +// PrivilegedHelperService.swift // Loop // // Created by Kai Azim on 2026-02-23. @@ -12,7 +12,7 @@ import Scribe import Security @Loggable -final class PrivilegedInstallerService: NSObject, NSXPCListenerDelegate { +final class PrivilegedHelperService: NSObject, NSXPCListenerDelegate { struct TrustedClientContext { let clientPID: pid_t let clientUID: uid_t @@ -34,9 +34,9 @@ final class PrivilegedInstallerService: NSObject, NSXPCListenerDelegate { } func run() { - log.info("Starting privileged installer listener") + log.info("Starting privileged helper listener") listener.resume() - log.success("Privileged installer listener is running") + log.success("Privileged helper listener is running") RunLoop.current.run() } @@ -60,15 +60,14 @@ final class PrivilegedInstallerService: NSObject, NSXPCListenerDelegate { newConnection.interruptionHandler = { [weak self] in self?.releaseActiveConnection(for: pid, reason: "interruption") } - newConnection.exportedInterface = NSXPCInterface(with: PrivilegedInstallerProtocol.self) - newConnection.exportedObject = PrivilegedInstaller(context: context) + newConnection.exportedInterface = NSXPCInterface(with: PrivilegedHelperProtocol.self) + newConnection.exportedObject = PrivilegedHelper(context: context) newConnection.resume() log.success("Accepted XPC connection (pid: \(pid), uid: \(context.clientUID))") return true } - /// Builds per-connection trusted context from authenticated process identity and uid-derived paths. private func trustedClientContext(for connection: NSXPCConnection) -> TrustedClientContext? { let pid = connection.processIdentifier guard pid > 0 else { @@ -77,7 +76,7 @@ final class PrivilegedInstallerService: NSObject, NSXPCListenerDelegate { } guard let app = NSRunningApplication(processIdentifier: pid), - app.bundleIdentifier == PrivilegedInstallerConstants.appBundleIdentifier else { + app.bundleIdentifier == PrivilegedHelperConstants.appBundleIdentifier else { log.warn("Rejecting client pid \(pid) due to bundle identifier mismatch") return nil } @@ -128,7 +127,7 @@ final class PrivilegedInstallerService: NSObject, NSXPCListenerDelegate { var requirement: SecRequirement? let requirementStatus = SecRequirementCreateWithString( - PrivilegedInstallerConstants.authorizedClientRequirement as CFString, + PrivilegedHelperConstants.authorizedClientRequirement as CFString, SecCSFlags(), &requirement ) @@ -148,7 +147,6 @@ final class PrivilegedInstallerService: NSObject, NSXPCListenerDelegate { return true } - /// Resolves a user's home directory and primary group from the system account database. private func userAccountInfo(for uid: uid_t) -> (homeDirectory: URL, primaryGroupID: gid_t)? { guard let passwdEntry = getpwuid(uid) else { return nil @@ -165,7 +163,6 @@ final class PrivilegedInstallerService: NSObject, NSXPCListenerDelegate { ) } - /// Reserves a single active connection slot to prevent overlapping privileged sessions. private func reserveActiveConnection(for pid: pid_t) -> Bool { connectionStateLock.lock() defer { connectionStateLock.unlock() } @@ -178,7 +175,6 @@ final class PrivilegedInstallerService: NSObject, NSXPCListenerDelegate { return true } - /// Releases the active connection slot when that connection ends. private func releaseActiveConnection(for pid: pid_t, reason: String) { connectionStateLock.lock() defer { connectionStateLock.unlock() } diff --git a/LoopUpdaterHelper/main.swift b/LoopPrivilegedHelper/main.swift similarity index 52% rename from LoopUpdaterHelper/main.swift rename to LoopPrivilegedHelper/main.swift index 195820bf..5fded360 100644 --- a/LoopUpdaterHelper/main.swift +++ b/LoopPrivilegedHelper/main.swift @@ -7,5 +7,5 @@ import Foundation -let service = PrivilegedInstallerService(serviceName: PrivilegedInstallerConstants.serviceName) +let service = PrivilegedHelperService(serviceName: PrivilegedHelperConstants.serviceName) service.run() diff --git a/README.md b/README.md index 75b56e0a..3f73f41a 100644 --- a/README.md +++ b/README.md @@ -114,16 +114,15 @@ To set Caps Lock as your trigger key, you have two options: #### c. Shell/AppleScript -Loop can be controlled via shell commands or AppleScript using its URL scheme: +Loop can be controlled from the shell or AppleScript using its URL scheme: ```bash # Shell examples open "loop://direction/right" # Move window to right half -open "loop://action/maximize" # Maximize window -open "loop://screen/next" # Move to next screen +open "loop://direction/maximize" # Maximize window +open "loop://direction/next_screen" # Move to next screen -# AppleScript examples -osascript -e 'tell application "Loop" to activate' +# AppleScript example osascript -e 'open location "loop://direction/left"' ``` @@ -134,17 +133,41 @@ You can also create custom scripts to chain multiple actions: # Example: Move window right and then maximize open "loop://direction/right" sleep 0.5 -open "loop://action/maximize" +open "loop://direction/maximize" ``` -For a complete list of available commands: +Read-style URL commands open a Loop output window with selectable JSON: ```bash -open "loop://list/all" # List all commands -open "loop://list/actions" # List window actions -open "loop://list/keybinds" # List custom keybinds +open "loop://list/windows" # List visible windows +open "loop://list/screens" # List connected screens +open "loop://list/actions" # List all executable actions +open "loop://list/actions/directions" # List built-in direction actions +open "loop://list/actions/keybinds" # List keybind-backed actions ``` +You can also execute an action directly by UUID when you already have one from `list/actions`: + +```bash +open "loop://id/123e4567-e89b-12d3-a456-426614174000" +``` + +For machine-readable shell output, install the CLI from Loop's Advanced tab. This creates `/usr/local/bin/loop`, which points at the bundled `loop-cli` binary: + +```bash +loop list windows +loop list screens +loop list actions --directions +loop list actions --ids +loop exec --direction right +loop exec --keybind "My Layout" +loop exec --id 123e4567-e89b-12d3-a456-426614174000 +loop list windows --json +loop exec --direction right --json +``` + +Successful `loop` commands print human-readable structured text by default. Pass `--json` to print the raw JSON response. Successful JSON now uses a shared envelope of `{ "success": true, "result": { ... } }`, and failures use `{ "success": false, "error": { ... } }`. Runtime failures print plain-text errors to `stderr`, and local usage errors are handled by the CLI's built-in help and validation output. + ### Keyboard Shortcuts diff --git a/Shared/LoopAutomationJSON.swift b/Shared/LoopAutomationJSON.swift new file mode 100644 index 00000000..75b55e5d --- /dev/null +++ b/Shared/LoopAutomationJSON.swift @@ -0,0 +1,39 @@ +// +// LoopAutomationJSON.swift +// Loop +// +// Created by Kai Azim on 2026-03-30. +// + +import Foundation + +enum LoopAutomationJSON { + static func makeEncoder() -> JSONEncoder { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + return encoder + } + + static func makeDecoder() -> JSONDecoder { + JSONDecoder() + } + + static func encodeString(_ response: LoopAutomationResponse) throws -> String { + let data = try makeEncoder().encode(response) + guard let string = String(data: data, encoding: .utf8) else { + throw EncodingError.invalidValue( + response, + EncodingError.Context( + codingPath: [], + debugDescription: "Failed to encode Loop automation response as UTF-8" + ) + ) + } + + return string + } + + static func decodeResponse(from string: String) throws -> LoopAutomationResponse { + try makeDecoder().decode(LoopAutomationResponse.self, from: Data(string.utf8)) + } +} diff --git a/Shared/LoopAutomationModels.swift b/Shared/LoopAutomationModels.swift new file mode 100644 index 00000000..bc94dce3 --- /dev/null +++ b/Shared/LoopAutomationModels.swift @@ -0,0 +1,228 @@ +// +// LoopAutomationModels.swift +// Loop +// +// Created by Kai Azim on 2026-03-30. +// + +import CoreGraphics +import Foundation + +enum LoopActionKind: String, Codable { + case direction + case keybind +} + +enum LoopActionListFilter: String, Codable { + case all + case directionsOnly + case keybindsOnly +} + +enum LoopAutomationResultKind: String, Codable { + case windowList + case screenList + case actionList + case execution +} + +struct LoopRect: Codable { + let x: CGFloat + let y: CGFloat + let width: CGFloat + let height: CGFloat + + init(x: CGFloat, y: CGFloat, width: CGFloat, height: CGFloat) { + self.x = x + self.y = y + self.width = width + self.height = height + } + + init(_ rect: CGRect) { + self.init( + x: rect.origin.x, + y: rect.origin.y, + width: rect.width, + height: rect.height + ) + } +} + +struct LoopWindowSummary: Codable { + let id: UInt32 + let bundleID: String + let appName: String + let title: String + let frame: LoopRect +} + +struct LoopExecutionTargetWindow: Codable { + let id: UInt32 + let bundleID: String + let appName: String + let title: String +} + +struct LoopScreenSummary: Codable { + let id: UInt32 + let name: String + let frame: LoopRect + let isMain: Bool +} + +struct LoopActionDescriptor: Codable { + let id: UUID? + let kind: LoopActionKind + let title: String + let name: String + let route: String + let idRoute: String? + + var idString: String? { + id?.uuidString.lowercased() + } +} + +struct LoopActionCategory: Codable { + let name: String + let actions: [LoopActionDescriptor] +} + +struct LoopWindowListResult: Codable { + let windows: [LoopWindowSummary] +} + +struct LoopScreenListResult: Codable { + let screens: [LoopScreenSummary] +} + +struct LoopActionListResult: Codable { + let filter: LoopActionListFilter + let directionCategories: [LoopActionCategory] + let keybindActions: [LoopActionDescriptor] +} + +struct LoopExecutionResult: Codable { + let action: LoopActionDescriptor + let targetWindow: LoopExecutionTargetWindow? +} + +enum LoopAutomationResult: Codable { + case windowList(LoopWindowListResult) + case screenList(LoopScreenListResult) + case actionList(LoopActionListResult) + case execution(LoopExecutionResult) + + private enum CodingKeys: String, CodingKey { + case kind + case windows + case screens + case filter + case directionCategories + case keybindActions + case action + case targetWindow + } + + var kind: LoopAutomationResultKind { + switch self { + case .windowList: + .windowList + case .screenList: + .screenList + case .actionList: + .actionList + case .execution: + .execution + } + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let kind = try container.decode(LoopAutomationResultKind.self, forKey: .kind) + + switch kind { + case .windowList: + self = try .windowList( + LoopWindowListResult( + windows: container.decode([LoopWindowSummary].self, forKey: .windows) + ) + ) + case .screenList: + self = try .screenList( + LoopScreenListResult( + screens: container.decode([LoopScreenSummary].self, forKey: .screens) + ) + ) + case .actionList: + self = try .actionList( + LoopActionListResult( + filter: container.decode(LoopActionListFilter.self, forKey: .filter), + directionCategories: container.decode([LoopActionCategory].self, forKey: .directionCategories), + keybindActions: container.decode([LoopActionDescriptor].self, forKey: .keybindActions) + ) + ) + case .execution: + self = try .execution( + LoopExecutionResult( + action: container.decode(LoopActionDescriptor.self, forKey: .action), + targetWindow: container.decodeIfPresent(LoopExecutionTargetWindow.self, forKey: .targetWindow) + ) + ) + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(kind, forKey: .kind) + + switch self { + case let .windowList(result): + try container.encode(result.windows, forKey: .windows) + case let .screenList(result): + try container.encode(result.screens, forKey: .screens) + case let .actionList(result): + try container.encode(result.filter, forKey: .filter) + try container.encode(result.directionCategories, forKey: .directionCategories) + try container.encode(result.keybindActions, forKey: .keybindActions) + case let .execution(result): + try container.encode(result.action, forKey: .action) + try container.encodeIfPresent(result.targetWindow, forKey: .targetWindow) + } + } +} + +struct LoopAutomationError: Codable { + let message: String + let replacementRoute: String? + let availableRoutes: [String]? + + init( + message: String, + replacementRoute: String? = nil, + availableRoutes: [String]? = nil + ) { + self.message = message + self.replacementRoute = replacementRoute + self.availableRoutes = availableRoutes + } +} + +struct LoopAutomationResponse: Codable { + let success: Bool + let result: LoopAutomationResult? + let error: LoopAutomationError? + + init(result: LoopAutomationResult) { + self.success = true + self.result = result + self.error = nil + } + + init(error: LoopAutomationError) { + self.success = false + self.result = nil + self.error = error + } +} diff --git a/Shared/PrivilegedHelperProtocol.swift b/Shared/PrivilegedHelperProtocol.swift new file mode 100644 index 00000000..1d07c6d0 --- /dev/null +++ b/Shared/PrivilegedHelperProtocol.swift @@ -0,0 +1,58 @@ +// +// PrivilegedHelperProtocol.swift +// Loop +// +// Created by Kai Azim on 2026-02-23. +// + +import Foundation + +@objc protocol PrivilegedHelperProtocol { + /// Performs a privileged swap using a validated rollback token-derived path set. + func atomicSwap( + rollbackID: String, + withReply reply: @escaping (NSError?) -> () + ) + + /// Restores the current app from the rollback snapshot identified by the rollback token. + func restoreFromBackup( + rollbackID: String, + withReply reply: @escaping (NSError?) -> () + ) + + /// Removes the authenticated client's current app bundle. + func removeCurrentBundle( + withReply reply: @escaping (NSError?) -> () + ) + + /// Installs the Loop CLI symlink at the fixed privileged destination. + func installCommandLineTool( + withReply reply: @escaping (NSError?) -> () + ) + + /// Reinstalls the Loop CLI symlink when the existing destination is already Loop-managed. + func reinstallCommandLineTool( + withReply reply: @escaping (NSError?) -> () + ) +} + +enum PrivilegedHelperConstants { + static let helperExecutableName = "LoopPrivilegedHelper" + static let serviceName = "com.MrKai77.Loop.PrivilegedHelperJob" + static let appBundleIdentifier = "com.MrKai77.Loop" + static let authorizedClientRequirement = "identifier \"com.MrKai77.Loop\" and anchor apple generic and certificate leaf[subject.OU] = \"5F967GYF84\"" + + static let commandLineToolExecutableName = "loop-cli" + static let commandLineToolSymlinkName = "loop" + static let commandLineToolInstallDirectory = "/usr/local/bin" + + static var commandLineToolInstallDirectoryURL: URL { + URL(fileURLWithPath: commandLineToolInstallDirectory, isDirectory: true) + } + + static var commandLineToolSymlinkURL: URL { + commandLineToolInstallDirectoryURL.appendingPathComponent(commandLineToolSymlinkName, isDirectory: false) + } + + static let loopManagedCommandLineToolSuffix = "/Loop.app/Contents/MacOS/\(commandLineToolExecutableName)" +} diff --git a/Shared/PrivilegedInstallerProtocol.swift b/Shared/PrivilegedInstallerProtocol.swift deleted file mode 100644 index 928c52ba..00000000 --- a/Shared/PrivilegedInstallerProtocol.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// PrivilegedInstallerProtocol.swift -// Loop -// -// Created by Kai Azim on 2026-02-23. -// - -import Foundation - -@objc protocol PrivilegedInstallerProtocol { - /// Performs a privileged swap using a validated rollback token-derived path set. - func atomicSwap( - rollbackID: String, - withReply reply: @escaping (NSError?) -> () - ) - - /// Restores the current app from the rollback snapshot identified by the rollback token. - func restoreFromBackup( - rollbackID: String, - withReply reply: @escaping (NSError?) -> () - ) - - /// Removes the authenticated client's current app bundle. - func removeCurrentBundle( - withReply reply: @escaping (NSError?) -> () - ) -} - -enum PrivilegedInstallerConstants { - static let helperExecutableName = "LoopUpdaterHelper" - static let serviceName = "com.MrKai77.Loop.UpdaterJob" - static let appBundleIdentifier = "com.MrKai77.Loop" - static let authorizedClientRequirement = "identifier \"com.MrKai77.Loop\" and anchor apple generic and certificate leaf[subject.OU] = \"5F967GYF84\"" -}