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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions App/Controllers/ServerController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ enum ServiceRegistry {
MapsService.shared,
MessageService.shared,
RemindersService.shared,
ShortcutsService.shared,
UtilitiesService.shared,
]
#if WEATHERKIT_AVAILABLE
Expand All @@ -73,6 +74,7 @@ enum ServiceRegistry {
mapsEnabled: Binding<Bool>,
messagesEnabled: Binding<Bool>,
remindersEnabled: Binding<Bool>,
shortcutsEnabled: Binding<Bool>,
utilitiesEnabled: Binding<Bool>,
weatherEnabled: Binding<Bool>
) -> [ServiceConfig] {
Expand Down Expand Up @@ -126,6 +128,13 @@ enum ServiceRegistry {
service: RemindersService.shared,
binding: remindersEnabled
),
ServiceConfig(
name: "Shortcuts",
iconName: "square.2.layers.3d",
color: .indigo,
service: ShortcutsService.shared,
binding: shortcutsEnabled
),
]
#if WEATHERKIT_AVAILABLE
configs.append(
Expand Down Expand Up @@ -163,6 +172,7 @@ final class ServerController: ObservableObject {
@AppStorage("mapsEnabled") private var mapsEnabled = true // Default enabled
@AppStorage("messagesEnabled") private var messagesEnabled = false
@AppStorage("remindersEnabled") private var remindersEnabled = false
@AppStorage("shortcutsEnabled") private var shortcutsEnabled = false
@AppStorage("utilitiesEnabled") private var utilitiesEnabled = true // Default enabled
@AppStorage("weatherEnabled") private var weatherEnabled = false

Expand All @@ -179,6 +189,7 @@ final class ServerController: ObservableObject {
mapsEnabled: $mapsEnabled,
messagesEnabled: $messagesEnabled,
remindersEnabled: $remindersEnabled,
shortcutsEnabled: $shortcutsEnabled,
utilitiesEnabled: $utilitiesEnabled,
weatherEnabled: $weatherEnabled
)
Expand Down
236 changes: 236 additions & 0 deletions App/Services/Shortcuts.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
import Foundation
import JSONSchema
import OSLog

private let log = Logger.service("shortcuts")

final class ShortcutsService: Service {
static let shared = ShortcutsService()

private let shortcutsPath = "/usr/bin/shortcuts"
private let executionTimeout: Duration = .seconds(300)

var tools: [Tool] {
Tool(
name: "shortcuts_list",
description: "List all available shortcuts on this Mac",
inputSchema: .object(
properties: [:],
additionalProperties: false
),
annotations: .init(
title: "List Shortcuts",
readOnlyHint: true,
openWorldHint: false
)
) { _ in
try await self.listShortcuts()
}

Tool(
name: "shortcuts_run",
description: "Run a shortcut by name, optionally with text input",
inputSchema: .object(
properties: [
"name": .string(
description: "The name of the shortcut to run"
),
"input": .string(
description: "Optional text input to pass to the shortcut"
),
],
required: ["name"],
additionalProperties: false
),
annotations: .init(
title: "Run Shortcut",
destructiveHint: true,
openWorldHint: true
)
) { arguments in
guard case let .string(name) = arguments["name"] else {
throw NSError(
domain: "ShortcutsError",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Shortcut name is required"]
)
}

let input = arguments["input"]?.stringValue

return try await self.runShortcut(name: name, input: input)
}
}

// MARK: - Private Implementation

private func runProcess(_ process: Process) async throws {
try process.run()
await withCheckedContinuation { continuation in
process.terminationHandler = { _ in
continuation.resume()
}
}
}

private func listShortcuts() async throws -> Value {
let process = Process()
process.executableURL = URL(fileURLWithPath: shortcutsPath)
process.arguments = ["list"]

let outputPipe = Pipe()
let errorPipe = Pipe()
process.standardOutput = outputPipe
process.standardError = errorPipe

let outputHandle = outputPipe.fileHandleForReading
let errorHandle = errorPipe.fileHandleForReading
defer {
outputHandle.closeFile()
errorHandle.closeFile()
}

do {
try await runProcess(process)
} catch {
log.error("Failed to run shortcuts command: \(error.localizedDescription)")
throw NSError(
domain: "ShortcutsError",
code: 2,
userInfo: [
NSLocalizedDescriptionKey: "Failed to run shortcuts command: \(error.localizedDescription)"
]
)
}

let outputData = (try? outputHandle.readToEnd()) ?? Data()
let errorData = (try? errorHandle.readToEnd()) ?? Data()

guard process.terminationStatus == 0 else {
let errorMessage = String(data: errorData, encoding: .utf8) ?? "Unknown error"
log.error("shortcuts list failed: \(errorMessage)")
throw NSError(
domain: "ShortcutsError",
code: 3,
userInfo: [NSLocalizedDescriptionKey: "shortcuts list failed: \(errorMessage)"]
)
}

guard let output = String(data: outputData, encoding: .utf8) else {
return .array([])
}

let shortcuts =
output
.split(separator: "\n")
.map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }

log.info("Found \(shortcuts.count) shortcuts")

return .array(shortcuts.map { .string($0) })
}

private func runShortcut(name: String, input: String?) async throws -> Value {
log.info("Running shortcut: \(name, privacy: .public)")

let tempDir = FileManager.default.temporaryDirectory
let outputFileURL = tempDir.appendingPathComponent("shortcut_output_\(UUID().uuidString).txt")

var arguments = ["run", name, "--output-path", outputFileURL.path]
var inputFileURL: URL?

if let input = input {
let inputURL = tempDir.appendingPathComponent("shortcut_input_\(UUID().uuidString).txt")
try input.write(to: inputURL, atomically: true, encoding: .utf8)
arguments.append(contentsOf: ["--input-path", inputURL.path])
inputFileURL = inputURL
}

defer {
if let inputURL = inputFileURL {
try? FileManager.default.removeItem(at: inputURL)
}
try? FileManager.default.removeItem(at: outputFileURL)
}

let process = Process()
process.executableURL = URL(fileURLWithPath: shortcutsPath)
process.arguments = arguments

let errorPipe = Pipe()
process.standardError = errorPipe
let errorHandle = errorPipe.fileHandleForReading
defer { errorHandle.closeFile() }

var errorData = Data()
do {
try await withThrowingTaskGroup(of: Void.self) { group in
group.addTask {
try await self.runProcess(process)
}

group.addTask {
try await Task.sleep(for: self.executionTimeout)
process.terminate()
throw NSError(
domain: "ShortcutsError",
code: 6,
userInfo: [
NSLocalizedDescriptionKey:
"Shortcut '\(name)' timed out after 5 minutes"
]
)
}

_ = try await group.next()
group.cancelAll()
}
} catch {
if process.isRunning {
process.terminate()
}
errorData = (try? errorHandle.readToEnd()) ?? Data()
if !errorData.isEmpty {
let stderrMessage = String(data: errorData, encoding: .utf8) ?? "Unknown error"
log.error("Shortcut '\(name, privacy: .public)' stderr: \(stderrMessage, privacy: .public)")
}
log.error("Failed to run shortcut '\(name, privacy: .public)': \(error.localizedDescription)")
throw error
}

if errorData.isEmpty {
errorData = (try? errorHandle.readToEnd()) ?? Data()
}

guard process.terminationStatus == 0 else {
let errorMessage = String(data: errorData, encoding: .utf8) ?? "Unknown error"
log.error("Shortcut '\(name, privacy: .public)' failed: \(errorMessage)")
throw NSError(
domain: "ShortcutsError",
code: 5,
userInfo: [NSLocalizedDescriptionKey: "Shortcut '\(name)' failed: \(errorMessage)"]
)
}

var output: String?
if FileManager.default.fileExists(atPath: outputFileURL.path) {
output = try? String(contentsOf: outputFileURL, encoding: .utf8)
}

log.info("Shortcut '\(name, privacy: .public)' completed successfully")

if let output = output, !output.isEmpty {
return .object([
"success": .bool(true),
"shortcut": .string(name),
"output": .string(output),
])
}

return .object([
"success": .bool(true),
"shortcut": .string(name),
])
}
}