Skip to content

Commit 1c89f3c

Browse files
committed
Added App Intents
1 parent 8048cba commit 1c89f3c

2 files changed

Lines changed: 364 additions & 0 deletions

File tree

StikJIT/Utilities/Intents.swift

Lines changed: 356 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,356 @@
1+
import AppIntents
2+
import Foundation
3+
4+
// MARK: - Installed App Entity
5+
6+
struct InstalledAppEntity: AppEntity {
7+
static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Installed App")
8+
static var defaultQuery = InstalledAppQuery()
9+
10+
var id: String
11+
var displayName: String
12+
13+
var displayRepresentation: DisplayRepresentation {
14+
DisplayRepresentation(title: "\(displayName)", subtitle: "\(id)")
15+
}
16+
}
17+
18+
struct InstalledAppQuery: EntityStringQuery {
19+
func entities(for identifiers: [String]) async throws -> [InstalledAppEntity] {
20+
let allApps = (try? JITEnableContext.shared.getAppList()) ?? [:]
21+
return identifiers.compactMap { bundleID in
22+
guard let name = allApps[bundleID] else { return nil }
23+
return InstalledAppEntity(id: bundleID, displayName: name)
24+
}
25+
}
26+
27+
func entities(matching string: String) async throws -> [InstalledAppEntity] {
28+
let all = try await suggestedEntities()
29+
guard !string.isEmpty else { return all }
30+
return all.filter {
31+
$0.displayName.localizedCaseInsensitiveContains(string) ||
32+
$0.id.localizedCaseInsensitiveContains(string)
33+
}
34+
}
35+
36+
func suggestedEntities() async throws -> [InstalledAppEntity] {
37+
await ensureHeartbeat()
38+
let allApps = (try? JITEnableContext.shared.getAppList()) ?? [:]
39+
return allApps.map { InstalledAppEntity(id: $0.key, displayName: $0.value) }
40+
.sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending }
41+
}
42+
}
43+
44+
// MARK: - Running Process Entity
45+
46+
struct RunningProcessEntity: AppEntity {
47+
static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Running Process")
48+
static var defaultQuery = RunningProcessQuery()
49+
50+
var id: String
51+
var pid: Int
52+
var displayName: String
53+
var bundleID: String?
54+
55+
var displayRepresentation: DisplayRepresentation {
56+
let subtitle: String
57+
if let bundleID, !bundleID.isEmpty {
58+
subtitle = "\(bundleID) — PID \(pid)"
59+
} else {
60+
subtitle = "PID \(pid)"
61+
}
62+
return DisplayRepresentation(title: "\(displayName)", subtitle: "\(subtitle)")
63+
}
64+
}
65+
66+
struct RunningProcessQuery: EntityStringQuery {
67+
func entities(for identifiers: [String]) async throws -> [RunningProcessEntity] {
68+
let all = try fetchProcessEntities()
69+
let idSet = Set(identifiers)
70+
return all.filter { idSet.contains($0.id) }
71+
}
72+
73+
func entities(matching string: String) async throws -> [RunningProcessEntity] {
74+
let all = try await suggestedEntities()
75+
guard !string.isEmpty else { return all }
76+
return all.filter {
77+
$0.displayName.localizedCaseInsensitiveContains(string) ||
78+
($0.bundleID?.localizedCaseInsensitiveContains(string) ?? false) ||
79+
"\($0.pid)".contains(string)
80+
}
81+
}
82+
83+
func suggestedEntities() async throws -> [RunningProcessEntity] {
84+
await ensureHeartbeat()
85+
return try fetchProcessEntities()
86+
}
87+
88+
private func fetchProcessEntities() throws -> [RunningProcessEntity] {
89+
var err: NSError?
90+
let entries = FetchDeviceProcessList(&err) ?? []
91+
if let err { throw err }
92+
93+
return entries.compactMap { item -> RunningProcessEntity? in
94+
guard let dict = item as? NSDictionary,
95+
let pidNumber = dict["pid"] as? NSNumber else { return nil }
96+
let pid = pidNumber.intValue
97+
let name = dict["name"] as? String
98+
let bundleID = dict["bundleID"] as? String
99+
let path = dict["path"] as? String ?? ""
100+
101+
let displayName: String
102+
if let name, !name.isEmpty {
103+
displayName = name
104+
} else if let bundleID, !bundleID.isEmpty {
105+
displayName = bundleID
106+
} else if let last = path.replacingOccurrences(of: "file://", with: "").split(separator: "/").last {
107+
displayName = String(last)
108+
} else {
109+
displayName = "Process \(pid)"
110+
}
111+
112+
return RunningProcessEntity(id: "\(pid)", pid: pid, displayName: displayName, bundleID: bundleID)
113+
}
114+
.sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending }
115+
}
116+
}
117+
118+
// MARK: - Enable JIT Intent
119+
120+
struct EnableJITIntent: AppIntent, ForegroundContinuableIntent {
121+
static var title: LocalizedStringResource = "Enable JIT"
122+
static var description = IntentDescription(
123+
"Enables JIT compilation for an app by selecting it or providing a process ID.",
124+
categoryName: "Debugging"
125+
)
126+
static var openAppWhenRun: Bool = true
127+
128+
@Parameter(title: "App", description: "Select an installed app to enable JIT for")
129+
var app: InstalledAppEntity?
130+
131+
@Parameter(title: "Process ID", description: "The process ID (PID) of a running app (optional, overrides app selection)")
132+
var pid: Int?
133+
134+
func perform() async throws -> some IntentResult & ReturnsValue<String> {
135+
let bundleID = app?.id
136+
137+
guard bundleID != nil || pid != nil else {
138+
return .result(value: "Select an app or provide a PID.")
139+
}
140+
141+
await ensureHeartbeat()
142+
143+
var scriptData: Data? = nil
144+
var scriptName: String? = nil
145+
if let bundleID {
146+
if let preferred = IntentScriptResolver.preferredScript(for: bundleID) {
147+
scriptData = preferred.data
148+
scriptName = preferred.name
149+
}
150+
}
151+
152+
var callback: DebugAppCallback? = nil
153+
if ProcessInfo.processInfo.hasTXM, let sd = scriptData {
154+
let name = scriptName ?? bundleID ?? "Script"
155+
callback = { pid, debugProxyHandle, remoteServerHandle, semaphore in
156+
let model = RunJSViewModel(
157+
pid: Int(pid),
158+
debugProxy: debugProxyHandle,
159+
remoteServer: remoteServerHandle,
160+
semaphore: semaphore
161+
)
162+
DispatchQueue.main.async {
163+
NotificationCenter.default.post(
164+
name: .intentJSScriptReady,
165+
object: nil,
166+
userInfo: ["model": model, "scriptData": sd, "scriptName": name]
167+
)
168+
}
169+
DispatchQueue.global(qos: .background).async {
170+
do { try model.runScript(data: sd, name: name) }
171+
catch { LogManager.shared.addErrorLog("Script error: \(error.localizedDescription)") }
172+
}
173+
}
174+
}
175+
176+
let logger: LogFunc = { message in
177+
if let message { LogManager.shared.addInfoLog(message) }
178+
}
179+
180+
let success: Bool
181+
let target: String
182+
if let pid {
183+
target = "PID \(pid)"
184+
success = JITEnableContext.shared.debugApp(withPID: Int32(pid), logger: logger, jsCallback: callback)
185+
} else if let bundleID {
186+
target = app?.displayName ?? bundleID
187+
success = JITEnableContext.shared.debugApp(withBundleID: bundleID, logger: logger, jsCallback: callback)
188+
} else {
189+
return .result(value: "No target specified.")
190+
}
191+
192+
if success {
193+
LogManager.shared.addInfoLog("JIT enabled for \(target) via Shortcut")
194+
return .result(value: "Successfully enabled JIT for \(target).")
195+
} else {
196+
LogManager.shared.addErrorLog("Failed to enable JIT for \(target) via Shortcut")
197+
return .result(value: "Failed to enable JIT for \(target).")
198+
}
199+
}
200+
}
201+
202+
// MARK: - Kill Process Intent
203+
204+
struct KillProcessIntent: AppIntent {
205+
static var title: LocalizedStringResource = "Kill Process"
206+
static var description = IntentDescription(
207+
"Terminates a running process on the device.",
208+
categoryName: "Debugging"
209+
)
210+
static var openAppWhenRun: Bool = false
211+
212+
@Parameter(title: "Process", description: "Select a running process to kill")
213+
var process: RunningProcessEntity?
214+
215+
@Parameter(title: "Process ID", description: "The PID to kill (optional, overrides process selection)")
216+
var pid: Int?
217+
218+
func perform() async throws -> some IntentResult & ReturnsValue<String> {
219+
let targetPID: Int
220+
let targetName: String
221+
222+
if let pid {
223+
targetPID = pid
224+
targetName = "PID \(pid)"
225+
} else if let process {
226+
targetPID = process.pid
227+
targetName = process.displayName
228+
} else {
229+
return .result(value: "Select a process or provide a PID.")
230+
}
231+
232+
await ensureHeartbeat()
233+
234+
var err: NSError?
235+
let success = KillDeviceProcess(Int32(targetPID), &err)
236+
237+
if success {
238+
LogManager.shared.addInfoLog("Killed \(targetName) via Shortcut")
239+
return .result(value: "Successfully killed \(targetName).")
240+
} else {
241+
let reason = err?.localizedDescription ?? "Unknown error"
242+
LogManager.shared.addErrorLog("Failed to kill \(targetName) via Shortcut: \(reason)")
243+
return .result(value: "Failed to kill \(targetName): \(reason)")
244+
}
245+
}
246+
}
247+
248+
// MARK: - Shortcuts Provider
249+
250+
struct StikDebugShortcuts: AppShortcutsProvider {
251+
static var appShortcuts: [AppShortcut] {
252+
AppShortcut(
253+
intent: EnableJITIntent(),
254+
phrases: [
255+
"Enable JIT for \(\.$app) in \(.applicationName)",
256+
"Enable JIT in \(.applicationName)",
257+
"Debug \(\.$app) with \(.applicationName)",
258+
"Debug app with \(.applicationName)"
259+
],
260+
shortTitle: "Enable JIT",
261+
systemImageName: "bolt.fill"
262+
)
263+
AppShortcut(
264+
intent: KillProcessIntent(),
265+
phrases: [
266+
"Kill \(\.$process) in \(.applicationName)",
267+
"Kill process in \(.applicationName)",
268+
"Stop \(\.$process) with \(.applicationName)"
269+
],
270+
shortTitle: "Kill Process",
271+
systemImageName: "xmark.circle.fill"
272+
)
273+
}
274+
}
275+
276+
// MARK: - Shared Heartbeat Helper
277+
278+
func ensureHeartbeat() async {
279+
await MainActor.run {
280+
pubHeartBeat = false
281+
startHeartbeatInBackground(showErrorUI: false)
282+
}
283+
try? await Task.sleep(nanoseconds: 1_000_000_000)
284+
}
285+
286+
// MARK: - Script Resolution (mirrors HomeView logic)
287+
288+
enum IntentScriptResolver {
289+
static func preferredScript(for bundleID: String) -> (data: Data, name: String)? {
290+
if let assigned = assignedScript(for: bundleID) {
291+
return assigned
292+
}
293+
return autoScript(for: bundleID)
294+
}
295+
296+
private static func assignedScript(for bundleID: String) -> (data: Data, name: String)? {
297+
guard let mapping = UserDefaults.standard.dictionary(forKey: "BundleScriptMap") as? [String: String],
298+
let scriptName = mapping[bundleID] else { return nil }
299+
let scriptsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
300+
.appendingPathComponent("scripts")
301+
let scriptURL = scriptsDir.appendingPathComponent(scriptName)
302+
guard FileManager.default.fileExists(atPath: scriptURL.path),
303+
let data = try? Data(contentsOf: scriptURL) else { return nil }
304+
return (data, scriptName)
305+
}
306+
307+
private static func autoScript(for bundleID: String) -> (data: Data, name: String)? {
308+
guard ProcessInfo.processInfo.hasTXM else { return nil }
309+
guard #available(iOS 26, *) else { return nil }
310+
let appName = (try? JITEnableContext.shared.getAppList()[bundleID]) ?? storedFavoriteName(for: bundleID)
311+
guard let appName,
312+
let resource = autoScriptResource(for: appName) else {
313+
return nil
314+
}
315+
let scriptsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
316+
.appendingPathComponent("scripts")
317+
let documentsURL = scriptsDir.appendingPathComponent(resource.fileName)
318+
if let data = try? Data(contentsOf: documentsURL) {
319+
return (data, resource.fileName)
320+
}
321+
guard let bundleURL = Bundle.main.url(forResource: resource.resource, withExtension: "js"),
322+
let data = try? Data(contentsOf: bundleURL) else {
323+
return nil
324+
}
325+
return (data, resource.fileName)
326+
}
327+
328+
private static func storedFavoriteName(for bundleID: String) -> String? {
329+
let defaults = UserDefaults(suiteName: "group.com.stik.sj")
330+
let names = defaults?.dictionary(forKey: "favoriteAppNames") as? [String: String]
331+
return names?[bundleID]
332+
}
333+
334+
private static func autoScriptResource(for appName: String) -> (resource: String, fileName: String)? {
335+
switch appName {
336+
case "maciOS":
337+
return ("maciOS", "maciOS.js")
338+
case "Amethyst", "MeloNX":
339+
return ("Amethyst-MeloNX", "Amethyst-MeloNX.js")
340+
case "Geode":
341+
return ("Geode", "Geode.js")
342+
case "Manic EMU":
343+
return ("manic", "manic.js")
344+
case "UTM", "DolphiniOS", "Flycast":
345+
return ("UTM-Dolphin", "UTM-Dolphin.js")
346+
default:
347+
return nil
348+
}
349+
}
350+
}
351+
352+
// MARK: - Notification for JS Script UI
353+
354+
extension Notification.Name {
355+
static let intentJSScriptReady = Notification.Name("intentJSScriptReady")
356+
}

StikJIT/Views/HomeView.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,14 @@ struct HomeView: View {
176176
startHeartbeatInBackground()
177177
MountingProgress.shared.checkforMounted()
178178
}
179+
.onReceive(NotificationCenter.default.publisher(for: .intentJSScriptReady)) { notification in
180+
guard let model = notification.userInfo?["model"] as? RunJSViewModel else { return }
181+
jsModel = model
182+
if let name = notification.userInfo?["scriptName"] as? String {
183+
selectedScript = name
184+
}
185+
scriptViewShow = true
186+
}
179187
.onReceive(timer) { _ in
180188
checkPairingFileExists()
181189
if mounting.mountingThread == nil && !mounting.coolisMounted {

0 commit comments

Comments
 (0)