Skip to content

Commit f73a2ac

Browse files
committed
Add Shortcuts service
1 parent 0c097e3 commit f73a2ac

2 files changed

Lines changed: 229 additions & 0 deletions

File tree

App/Controllers/ServerController.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ enum ServiceRegistry {
5757
MapsService.shared,
5858
MessageService.shared,
5959
RemindersService.shared,
60+
ShortcutsService.shared,
6061
UtilitiesService.shared,
6162
]
6263
#if WEATHERKIT_AVAILABLE
@@ -73,6 +74,7 @@ enum ServiceRegistry {
7374
mapsEnabled: Binding<Bool>,
7475
messagesEnabled: Binding<Bool>,
7576
remindersEnabled: Binding<Bool>,
77+
shortcutsEnabled: Binding<Bool>,
7678
utilitiesEnabled: Binding<Bool>,
7779
weatherEnabled: Binding<Bool>
7880
) -> [ServiceConfig] {
@@ -126,6 +128,13 @@ enum ServiceRegistry {
126128
service: RemindersService.shared,
127129
binding: remindersEnabled
128130
),
131+
ServiceConfig(
132+
name: "Shortcuts",
133+
iconName: "bolt.fill",
134+
color: .yellow,
135+
service: ShortcutsService.shared,
136+
binding: shortcutsEnabled
137+
),
129138
]
130139
#if WEATHERKIT_AVAILABLE
131140
configs.append(
@@ -163,6 +172,7 @@ final class ServerController: ObservableObject {
163172
@AppStorage("mapsEnabled") private var mapsEnabled = true // Default enabled
164173
@AppStorage("messagesEnabled") private var messagesEnabled = false
165174
@AppStorage("remindersEnabled") private var remindersEnabled = false
175+
@AppStorage("shortcutsEnabled") private var shortcutsEnabled = false
166176
@AppStorage("utilitiesEnabled") private var utilitiesEnabled = true // Default enabled
167177
@AppStorage("weatherEnabled") private var weatherEnabled = false
168178

@@ -179,6 +189,7 @@ final class ServerController: ObservableObject {
179189
mapsEnabled: $mapsEnabled,
180190
messagesEnabled: $messagesEnabled,
181191
remindersEnabled: $remindersEnabled,
192+
shortcutsEnabled: $shortcutsEnabled,
182193
utilitiesEnabled: $utilitiesEnabled,
183194
weatherEnabled: $weatherEnabled
184195
)

App/Services/Shortcuts.swift

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
import Foundation
2+
import JSONSchema
3+
import OSLog
4+
5+
private let log = Logger.service("shortcuts")
6+
7+
final class ShortcutsService: Service {
8+
static let shared = ShortcutsService()
9+
10+
private let shortcutsPath = "/usr/bin/shortcuts"
11+
private let executionTimeout: Duration = .seconds(300)
12+
13+
var tools: [Tool] {
14+
Tool(
15+
name: "shortcuts_list",
16+
description: "List all available shortcuts on this Mac",
17+
inputSchema: .object(
18+
properties: [:],
19+
additionalProperties: false
20+
),
21+
annotations: .init(
22+
title: "List Shortcuts",
23+
readOnlyHint: true,
24+
openWorldHint: false
25+
)
26+
) { _ in
27+
try await self.listShortcuts()
28+
}
29+
30+
Tool(
31+
name: "shortcuts_run",
32+
description: "Run a shortcut by name, optionally with text input",
33+
inputSchema: .object(
34+
properties: [
35+
"name": .string(
36+
description: "The name of the shortcut to run"
37+
),
38+
"input": .string(
39+
description: "Optional text input to pass to the shortcut"
40+
),
41+
],
42+
required: ["name"],
43+
additionalProperties: false
44+
),
45+
annotations: .init(
46+
title: "Run Shortcut",
47+
destructiveHint: true,
48+
openWorldHint: true
49+
)
50+
) { arguments in
51+
guard case let .string(name) = arguments["name"] else {
52+
throw NSError(
53+
domain: "ShortcutsError",
54+
code: 1,
55+
userInfo: [NSLocalizedDescriptionKey: "Shortcut name is required"]
56+
)
57+
}
58+
59+
let input = arguments["input"]?.stringValue
60+
61+
return try await self.runShortcut(name: name, input: input)
62+
}
63+
}
64+
65+
// MARK: - Private Implementation
66+
67+
private func runProcess(_ process: Process) async throws {
68+
try process.run()
69+
await withCheckedContinuation { continuation in
70+
process.terminationHandler = { _ in
71+
continuation.resume()
72+
}
73+
}
74+
}
75+
76+
private func listShortcuts() async throws -> Value {
77+
let process = Process()
78+
process.executableURL = URL(fileURLWithPath: shortcutsPath)
79+
process.arguments = ["list"]
80+
81+
let outputPipe = Pipe()
82+
let errorPipe = Pipe()
83+
process.standardOutput = outputPipe
84+
process.standardError = errorPipe
85+
86+
do {
87+
try await runProcess(process)
88+
} catch {
89+
log.error("Failed to run shortcuts command: \(error.localizedDescription)")
90+
throw NSError(
91+
domain: "ShortcutsError",
92+
code: 2,
93+
userInfo: [
94+
NSLocalizedDescriptionKey: "Failed to run shortcuts command: \(error.localizedDescription)"
95+
]
96+
)
97+
}
98+
99+
let outputData = (try? outputPipe.fileHandleForReading.readToEnd()) ?? Data()
100+
let errorData = (try? errorPipe.fileHandleForReading.readToEnd()) ?? Data()
101+
102+
guard process.terminationStatus == 0 else {
103+
let errorMessage = String(data: errorData, encoding: .utf8) ?? "Unknown error"
104+
log.error("shortcuts list failed: \(errorMessage)")
105+
throw NSError(
106+
domain: "ShortcutsError",
107+
code: 3,
108+
userInfo: [NSLocalizedDescriptionKey: "shortcuts list failed: \(errorMessage)"]
109+
)
110+
}
111+
112+
guard let output = String(data: outputData, encoding: .utf8) else {
113+
return .array([])
114+
}
115+
116+
let shortcuts = output
117+
.split(separator: "\n")
118+
.map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) }
119+
.filter { !$0.isEmpty }
120+
121+
log.info("Found \(shortcuts.count) shortcuts")
122+
123+
return .array(shortcuts.map { .string($0) })
124+
}
125+
126+
private func runShortcut(name: String, input: String?) async throws -> Value {
127+
log.info("Running shortcut: \(name, privacy: .public)")
128+
129+
let tempDir = FileManager.default.temporaryDirectory
130+
let outputFileURL = tempDir.appendingPathComponent("shortcut_output_\(UUID().uuidString).txt")
131+
132+
var arguments = ["run", name, "--output-path", outputFileURL.path]
133+
var inputFileURL: URL?
134+
135+
if let input = input {
136+
let inputURL = tempDir.appendingPathComponent("shortcut_input_\(UUID().uuidString).txt")
137+
try input.write(to: inputURL, atomically: true, encoding: .utf8)
138+
arguments.append(contentsOf: ["--input-path", inputURL.path])
139+
inputFileURL = inputURL
140+
}
141+
142+
defer {
143+
if let inputURL = inputFileURL {
144+
try? FileManager.default.removeItem(at: inputURL)
145+
}
146+
try? FileManager.default.removeItem(at: outputFileURL)
147+
}
148+
149+
let process = Process()
150+
process.executableURL = URL(fileURLWithPath: shortcutsPath)
151+
process.arguments = arguments
152+
153+
let errorPipe = Pipe()
154+
process.standardError = errorPipe
155+
156+
do {
157+
try await withThrowingTaskGroup(of: Void.self) { group in
158+
group.addTask {
159+
try await self.runProcess(process)
160+
}
161+
162+
group.addTask {
163+
try await Task.sleep(for: self.executionTimeout)
164+
process.terminate()
165+
throw NSError(
166+
domain: "ShortcutsError",
167+
code: 6,
168+
userInfo: [
169+
NSLocalizedDescriptionKey:
170+
"Shortcut '\(name)' timed out after 5 minutes"
171+
]
172+
)
173+
}
174+
175+
_ = try await group.next()
176+
group.cancelAll()
177+
}
178+
} catch {
179+
if process.isRunning {
180+
process.terminate()
181+
}
182+
log.error("Failed to run shortcut '\(name, privacy: .public)': \(error.localizedDescription)")
183+
throw error
184+
}
185+
186+
let errorData = (try? errorPipe.fileHandleForReading.readToEnd()) ?? Data()
187+
188+
guard process.terminationStatus == 0 else {
189+
let errorMessage = String(data: errorData, encoding: .utf8) ?? "Unknown error"
190+
log.error("Shortcut '\(name, privacy: .public)' failed: \(errorMessage)")
191+
throw NSError(
192+
domain: "ShortcutsError",
193+
code: 5,
194+
userInfo: [NSLocalizedDescriptionKey: "Shortcut '\(name)' failed: \(errorMessage)"]
195+
)
196+
}
197+
198+
var output: String?
199+
if FileManager.default.fileExists(atPath: outputFileURL.path) {
200+
output = try? String(contentsOf: outputFileURL, encoding: .utf8)
201+
}
202+
203+
log.info("Shortcut '\(name, privacy: .public)' completed successfully")
204+
205+
if let output = output, !output.isEmpty {
206+
return .object([
207+
"success": .bool(true),
208+
"shortcut": .string(name),
209+
"output": .string(output),
210+
])
211+
}
212+
213+
return .object([
214+
"success": .bool(true),
215+
"shortcut": .string(name),
216+
])
217+
}
218+
}

0 commit comments

Comments
 (0)