fix: harden react command AppleScript execution

This commit is contained in:
Peter Steinberger 2026-02-16 03:59:50 +01:00
parent 7d51f301e0
commit 9321a77efa
2 changed files with 191 additions and 63 deletions

View File

@ -13,7 +13,6 @@ enum ReactCommand {
- Only reacts to the MOST RECENT incoming message in the conversation
- Requires Messages.app to be running
- Uses UI automation (System Events) which requires accessibility permissions
- The chat must be open in Messages.app for reliable operation
Reaction types:
love (), like (👍), dislike (👎), laugh (😂), emphasis (), question ()
@ -40,7 +39,11 @@ enum ReactCommand {
static func run(
values: ParsedValues,
runtime: RuntimeOptions
runtime: RuntimeOptions,
storeFactory: @escaping (String) throws -> MessageStore = { try MessageStore(path: $0) },
appleScriptRunner: @escaping (String, [String]) throws -> Void = { source, arguments in
try runAppleScript(source, arguments: arguments)
}
) async throws {
guard let chatID = values.optionInt64("chatID") else {
throw ParsedValuesError.missingOption("chat-id")
@ -51,17 +54,27 @@ enum ReactCommand {
guard let reactionType = ReactionType.parse(reactionString) else {
throw IMsgError.invalidReaction(reactionString)
}
if case let .custom(emoji) = reactionType, !isSingleEmoji(emoji) {
throw IMsgError.invalidReaction(reactionString)
}
// Get chat info for the GUID
let dbPath = values.option("db") ?? MessageStore.defaultPath
let store = try MessageStore(path: dbPath)
let store = try storeFactory(dbPath)
guard let chatInfo = try store.chatInfo(chatID: chatID) else {
throw IMsgError.chatNotFound(chatID: chatID)
}
let chatLookup = preferredChatLookup(chatInfo: chatInfo)
// Send the reaction via AppleScript + System Events
try sendReaction(reactionType: reactionType, chatGUID: chatInfo.guid)
try sendReaction(
reactionType: reactionType,
chatGUID: chatInfo.guid,
chatLookup: chatLookup,
appleScriptRunner: appleScriptRunner
)
if runtime.jsonOutput {
let result = ReactResult(
success: true,
@ -74,16 +87,14 @@ enum ReactCommand {
print("Sent \(reactionType.emoji) reaction to chat \(chatID)")
}
}
private static func sendReaction(reactionType: ReactionType, chatGUID: String) throws {
// Use AppleScript with System Events to:
// 1. Activate Messages app
// 2. Open the specific chat
// 3. Use Cmd+T to open tapback menu on most recent message
// 4. Press the appropriate number key (1-6) for standard reactions
// or type the emoji for custom reactions
let keyNumber: Int?
private static func sendReaction(
reactionType: ReactionType,
chatGUID: String,
chatLookup: String,
appleScriptRunner: @escaping (String, [String]) throws -> Void
) throws {
let keyNumber: Int
switch reactionType {
case .love: keyNumber = 1
case .like: keyNumber = 2
@ -91,70 +102,110 @@ enum ReactCommand {
case .laugh: keyNumber = 4
case .emphasis: keyNumber = 5
case .question: keyNumber = 6
case .custom: keyNumber = nil
case .custom:
let script = """
on run argv
set chatGUID to item 1 of argv
set chatLookup to item 2 of argv
set customEmoji to item 3 of argv
tell application "Messages"
activate
set targetChat to chat id chatGUID
end tell
delay 0.3
tell application "System Events"
tell process "Messages"
keystroke "f" using command down
delay 0.15
keystroke "a" using command down
keystroke chatLookup
delay 0.25
key code 36
delay 0.35
keystroke "t" using command down
delay 0.2
keystroke customEmoji
delay 0.1
key code 36
end tell
end tell
end run
"""
try appleScriptRunner(script, [chatGUID, chatLookup, reactionType.emoji])
return
}
let script: String
if let keyNumber = keyNumber {
// Standard tapback: Cmd+T then number key
script = """
let script = """
on run argv
set chatGUID to item 1 of argv
set chatLookup to item 2 of argv
set reactionKey to item 3 of argv
tell application "Messages"
activate
set targetChat to chat id "\(chatGUID)"
set targetChat to chat id chatGUID
end tell
delay 0.3
tell application "System Events"
tell process "Messages"
-- Open tapback menu with Cmd+T
keystroke "f" using command down
delay 0.15
keystroke "a" using command down
keystroke chatLookup
delay 0.25
key code 36
delay 0.35
keystroke "t" using command down
delay 0.2
-- Select reaction with number key
keystroke "\(keyNumber)"
keystroke reactionKey
end tell
end tell
"""
} else {
// Custom emoji reaction: Cmd+T, then type the emoji, then Enter
let emoji = reactionType.emoji
script = """
tell application "Messages"
activate
set targetChat to chat id "\(chatGUID)"
end tell
delay 0.3
tell application "System Events"
tell process "Messages"
-- Open tapback menu with Cmd+T
keystroke "t" using command down
delay 0.2
-- Type the emoji
keystroke "\(emoji)"
delay 0.1
-- Press Enter to confirm
keystroke return
end tell
end tell
"""
}
try runAppleScript(script)
end run
"""
try appleScriptRunner(script, [chatGUID, chatLookup, "\(keyNumber)"])
}
private static func runAppleScript(_ source: String) throws {
private static func preferredChatLookup(chatInfo: ChatInfo) -> String {
let preferred = chatInfo.name.trimmingCharacters(in: .whitespacesAndNewlines)
if !preferred.isEmpty {
return preferred
}
let identifier = chatInfo.identifier.trimmingCharacters(in: .whitespacesAndNewlines)
if !identifier.isEmpty {
return identifier
}
return chatInfo.guid
}
private static func isSingleEmoji(_ value: String) -> Bool {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.count == 1 else { return false }
guard let scalar = trimmed.unicodeScalars.first else { return false }
return scalar.properties.isEmoji || scalar.properties.isEmojiPresentation
}
private static func runAppleScript(_ source: String, arguments: [String]) throws {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
process.arguments = ["-e", source]
process.arguments = ["-l", "AppleScript", "-"] + arguments
let stdinPipe = Pipe()
let stderrPipe = Pipe()
process.standardInput = stdinPipe
process.standardError = stderrPipe
try process.run()
if let data = source.data(using: .utf8) {
stdinPipe.fileHandleForWriting.write(data)
}
stdinPipe.fileHandleForWriting.closeFile()
process.waitUntilExit()
if process.terminationStatus != 0 {
let data = stderrPipe.fileHandleForReading.readDataToEndOfFile()
let message = String(data: data, encoding: .utf8) ?? "Unknown AppleScript error"

View File

@ -195,6 +195,83 @@ func sendCommandResolvesChatID() async throws {
#expect(captured?.recipient.isEmpty == true)
}
@Test
func reactCommandRejectsMultiCharacterEmojiInput() async {
do {
let path = try CommandTestDatabase.makePath()
let values = ParsedValues(
positional: [],
options: ["db": [path], "chatID": ["1"], "reaction": ["🎉 party"]],
flags: []
)
let runtime = RuntimeOptions(parsedValues: values)
try await ReactCommand.run(values: values, runtime: runtime)
#expect(Bool(false))
} catch let error as IMsgError {
switch error {
case .invalidReaction(let value):
#expect(value == "🎉 party")
default:
#expect(Bool(false))
}
} catch {
#expect(Bool(false))
}
}
@Test
func reactCommandBuildsParameterizedAppleScriptForStandardTapback() async throws {
let path = try CommandTestDatabase.makePath()
let values = ParsedValues(
positional: [],
options: ["db": [path], "chatID": ["1"], "reaction": ["like"]],
flags: []
)
let runtime = RuntimeOptions(parsedValues: values)
var capturedScript = ""
var capturedArguments: [String] = []
try await ReactCommand.run(
values: values,
runtime: runtime,
appleScriptRunner: { source, arguments in
capturedScript = source
capturedArguments = arguments
}
)
#expect(capturedArguments == ["iMessage;+;chat123", "Test Chat", "2"])
#expect(capturedScript.contains("on run argv"))
#expect(capturedScript.contains("keystroke \"f\" using command down"))
#expect(capturedScript.contains("set targetChat to chat id chatGUID"))
#expect(capturedScript.contains("keystroke reactionKey"))
#expect(capturedScript.contains("chat123") == false)
}
@Test
func reactCommandBuildsParameterizedAppleScriptForCustomEmoji() async throws {
let path = try CommandTestDatabase.makePath()
let values = ParsedValues(
positional: [],
options: ["db": [path], "chatID": ["1"], "reaction": ["🎉"]],
flags: []
)
let runtime = RuntimeOptions(parsedValues: values)
var capturedScript = ""
var capturedArguments: [String] = []
try await ReactCommand.run(
values: values,
runtime: runtime,
appleScriptRunner: { source, arguments in
capturedScript = source
capturedArguments = arguments
}
)
#expect(capturedArguments == ["iMessage;+;chat123", "Test Chat", "🎉"])
#expect(capturedScript.contains("on run argv"))
#expect(capturedScript.contains("keystroke customEmoji"))
#expect(capturedScript.contains("key code 36"))
#expect(capturedScript.contains("chat123") == false)
}
@Test
func watchCommandRejectsInvalidDebounce() async {
let values = ParsedValues(