Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@
xcuserdata/
Clamshell.xcodeproj/
DerivedData/
docs/plans/
13 changes: 13 additions & 0 deletions App/ClamshellApp/ClamshellApp.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import SwiftUI

@main
struct ClamshellApp: App {
@NSApplicationDelegateAdaptor(ControlActionApplicationDelegate.self)
private var applicationDelegate

var body: some Scene {
Settings {
EmptyView()
}
}
}
62 changes: 62 additions & 0 deletions App/ClamshellApp/Control/ApplicationInstancePolicy.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import Foundation

struct ApplicationInstancePolicy: Sendable {
struct Instance: Equatable, Sendable {
let processIdentifier: Int32
let bundleURL: URL?
let launchDate: Date?
}

enum Resolution: Equatable, Sendable {
case continueAndTerminate([Int32])
case terminateCurrent
}

private static let installedApplicationURL =
URL(fileURLWithPath: "/Applications/Clamshell.app").standardizedFileURL

func resolve(current: Instance, running: [Instance]) -> Resolution {
var instancesByProcessIdentifier: [Int32: Instance] = [:]
for instance in running {
instancesByProcessIdentifier[instance.processIdentifier] = instance
}
instancesByProcessIdentifier[current.processIdentifier] = current
let instances = Array(instancesByProcessIdentifier.values)

guard var selected = instances.first else {
return .continueAndTerminate([])
}
for candidate in instances.dropFirst() where isPreferred(candidate, to: selected) {
selected = candidate
}
guard selected.processIdentifier == current.processIdentifier else {
return .terminateCurrent
}

let otherProcessIdentifiers = instances
.lazy
.map(\.processIdentifier)
.filter { $0 != current.processIdentifier }
.sorted()
return .continueAndTerminate(otherProcessIdentifiers)
}

private func isPreferred(_ candidate: Instance, to selected: Instance) -> Bool {
let candidateIsInstalled = isInstalled(candidate)
let selectedIsInstalled = isInstalled(selected)
if candidateIsInstalled != selectedIsInstalled {
return candidateIsInstalled
}

let candidateLaunchDate = candidate.launchDate ?? .distantPast
let selectedLaunchDate = selected.launchDate ?? .distantPast
if candidateLaunchDate != selectedLaunchDate {
return candidateLaunchDate > selectedLaunchDate
}
return candidate.processIdentifier > selected.processIdentifier
}

private func isInstalled(_ instance: Instance) -> Bool {
instance.bundleURL?.standardizedFileURL == Self.installedApplicationURL
}
}
8 changes: 8 additions & 0 deletions App/ClamshellApp/Control/ApplicationLaunchPolicy.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import AppKit

@MainActor
enum ApplicationLaunchPolicy {
static func shouldShowSetup(userInfo: [AnyHashable: Any]?) -> Bool {
userInfo?[NSApplication.launchIsDefaultUserInfoKey] as? Bool ?? true
}
}
5 changes: 5 additions & 0 deletions App/ClamshellApp/Control/ApplicationRuntimePolicy.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
enum ApplicationRuntimePolicy {
static func shouldStartApplication(environment: [String: String]) -> Bool {
environment["XCTestConfigurationFilePath"] == nil
}
}
77 changes: 77 additions & 0 deletions App/ClamshellApp/Control/ControlActionApplicationDelegate.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import AppKit
import Foundation

@MainActor
final class ControlActionApplicationDelegate: NSObject, NSApplicationDelegate {
private let handler = ControlActionHandler.live
private let instanceCoordinator = RunningApplicationInstanceCoordinator()
private var ownsApplicationInstance = false
private lazy var setupWindowController = SetupWindowController(
model: SetupComposition.makeModel()
)

func applicationWillFinishLaunching(_ notification: Notification) {
guard
ApplicationRuntimePolicy.shouldStartApplication(
environment: ProcessInfo.processInfo.environment
),
instanceCoordinator.claimCurrentInstance()
else {
return
}
ownsApplicationInstance = true

NSAppleEventManager.shared().setEventHandler(
self,
andSelector: #selector(handleGetURL(_:withReplyEvent:)),
forEventClass: AEEventClass(kInternetEventClass),
andEventID: AEEventID(kAEGetURL)
)
}

func applicationDidFinishLaunching(_ notification: Notification) {
if ownsApplicationInstance,
ApplicationLaunchPolicy.shouldShowSetup(userInfo: notification.userInfo)
{
setupWindowController.show()
}
}

func applicationShouldHandleReopen(
_ sender: NSApplication,
hasVisibleWindows flag: Bool
) -> Bool {
if ownsApplicationInstance, !flag {
setupWindowController.show()
}
return false
}

func applicationWillTerminate(_ notification: Notification) {
guard ownsApplicationInstance else {
return
}
NSAppleEventManager.shared().removeEventHandler(
forEventClass: AEEventClass(kInternetEventClass),
andEventID: AEEventID(kAEGetURL)
)
}

@objc
private func handleGetURL(
_ event: NSAppleEventDescriptor,
withReplyEvent replyEvent: NSAppleEventDescriptor
) {
guard
let value = event.paramDescriptor(forKeyword: keyDirectObject)?.stringValue,
let url = URL(string: value)
else {
return
}

let handler = handler
Task.detached {
_ = handler.handle(url)
}
}
}
51 changes: 51 additions & 0 deletions App/ClamshellApp/Control/ControlActionHandler.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import ClamshellControlModel
import ClamshellControlProtocol
import Foundation
import OSLog
import WidgetKit

struct ControlActionHandler: Sendable {
enum Outcome: Equatable, Sendable {
case applied
case failed
case ignored
}

static let live = Self(
model: .live,
reloadControls: { ControlCenter.shared.reloadAllControls() }
)

private static let logger = Logger(
subsystem: "uk.co.lmliam.clamshell",
category: "ControlAction"
)

private let model: ControlModel
private let reloadControls: @Sendable () -> Void

init(
model: ControlModel,
reloadControls: @escaping @Sendable () -> Void
) {
self.model = model
self.reloadControls = reloadControls
}

func handle(_ url: URL) -> Outcome {
guard let request = ControlActionRequest(url: url) else {
Self.logger.error("Ignored an invalid control action URL")
return .ignored
}

defer { reloadControls() }

do {
try model.setValue(request.isEnabled)
return .applied
} catch {
Self.logger.error("Could not apply the control action: \(error.localizedDescription)")
return .failed
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import AppKit

@MainActor
struct RunningApplicationInstanceCoordinator {
private let policy = ApplicationInstancePolicy()

func claimCurrentInstance() -> Bool {
let current = NSRunningApplication.current
guard let bundleIdentifier = current.bundleIdentifier else {
return true
}

let runningApplications = NSRunningApplication.runningApplications(
withBundleIdentifier: bundleIdentifier
)
let resolution = policy.resolve(
current: instance(for: current),
running: runningApplications.map(instance)
)

switch resolution {
case .terminateCurrent:
terminate(current)
return false
case let .continueAndTerminate(processIdentifiers):
let processIdentifierSet = Set(processIdentifiers)
for application in runningApplications
where processIdentifierSet.contains(application.processIdentifier) {
terminate(application)
}
return true
}
}

private func instance(for application: NSRunningApplication) -> ApplicationInstancePolicy.Instance
{
ApplicationInstancePolicy.Instance(
processIdentifier: application.processIdentifier,
bundleURL: application.bundleURL,
launchDate: application.launchDate
)
}

private func terminate(_ application: NSRunningApplication) {
if !application.terminate() {
application.forceTerminate()
}
}
}
41 changes: 41 additions & 0 deletions App/ClamshellApp/Info.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Clamshell</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>CFBundleURLName</key>
<string>uk.co.lmliam.clamshell.control-action</string>
<key>CFBundleURLSchemes</key>
<array>
<string>clamshellctl</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.utilities</string>
<key>LSUIElement</key>
<true/>
</dict>
</plist>
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
protocol CompanionDiagnosing: Sendable {
func currentState() throws -> SetupState
}
45 changes: 45 additions & 0 deletions App/ClamshellApp/Setup/Diagnostics/CompanionDiagnostics.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import ClamshellCore
import Foundation

struct CompanionDiagnostics: CompanionDiagnosing {
private let bundleURL: URL
private let payloadInspector: any CompanionPayloadInspecting
private let installation: any InstallationStatusReading

init(
bundleURL: URL,
payloadInspector: any CompanionPayloadInspecting = FoundationCompanionPayloadInspector(),
installation: any InstallationStatusReading
) {
self.bundleURL = bundleURL
self.payloadInspector = payloadInspector
self.installation = installation
}

func currentState() throws -> SetupState {
let commandAvailable = payloadInspector.isRegularFile(at: commandURL)
guard commandAvailable else {
return .missingBundlePayload(commandAvailable: false)
}
guard payloadInspector.isRegularFile(at: helperPayloadURL) else {
return .missingBundlePayload(commandAvailable: true)
}

return switch try installation.currentStatus() {
case .notInstalled:
.needsSetup
case .ready:
.ready
case .invalid:
.invalidHelper
}
}

private var commandURL: URL {
bundleURL.appendingPathComponent("Contents/MacOS/clamshellctl")
}

private var helperPayloadURL: URL {
bundleURL.appendingPathComponent("Contents/Resources/clamshellctl-helper")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import Foundation

protocol CompanionPayloadInspecting: Sendable {
func isRegularFile(at url: URL) -> Bool
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import Foundation

struct FoundationCompanionPayloadInspector: CompanionPayloadInspecting {
func isRegularFile(at url: URL) -> Bool {
var isDirectory = ObjCBool(false)
return FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory)
&& !isDirectory.boolValue
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import ClamshellCore

protocol InstallationStatusReading: Sendable {
func currentStatus() throws -> InstallationStatus
}

extension PrivilegedInstallation: InstallationStatusReading {
func currentStatus() throws -> InstallationStatus {
try status()
}
}
Loading