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
28 changes: 28 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,34 @@ jobs:
- name: Test resource monitor
run: cargo test --locked --manifest-path native/resource-monitor/Cargo.toml

discord_sidecar:
name: Discord sidecar
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v6
with:
sparse-checkout: |
native/discord-sidecar

- name: Setup Go
uses: actions/setup-go@v6
with:
go-version-file: native/discord-sidecar/go.mod
cache-dependency-path: native/discord-sidecar/go.sum

- name: Check formatting
working-directory: native/discord-sidecar
run: test -z "$(gofmt -l .)"

- name: Test and vet
working-directory: native/discord-sidecar
run: |
go test -mod=readonly ./...
go vet -mod=readonly ./...
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -mod=readonly -trimpath -o /tmp/ditto-discord-sidecar .

# The static analysis below needs a macOS runner, which bills ~6.7x a Linux
# minute, so gate it on the native sources it actually lints instead of paying
# for it on every push. Detection is API-only (no checkout) and fails open: if
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ squashfs-root/
.gstack/
.plans/
dist-electron/
apps/desktop/.native/
.electron-runtime/
.showcase/
apps/mobile/.showcase/
Expand Down
351 changes: 351 additions & 0 deletions apps/desktop/native/discord-accessibility/main.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,351 @@
import AppKit
import ApplicationServices
import Darwin
import Foundation

private let discordBundleIDs: Set<String> = [
"com.hnc.Discord",
"com.hnc.DiscordPTB",
"com.hnc.DiscordCanary",
]

private struct HelperCommand: Decodable {
let command: String
let prompt: Bool?
let actionId: String?
let origin: String?
let mode: String?
let deepLink: String?
let expectedTitle: String?
let text: String?
let timeoutMs: Int?
}

private struct StatusResponse: Encodable {
let available: Bool
let permission: String
let detail: String
}

private struct ReplyResponse: Encodable {
let actionId: String
let origin: String
let mode: String
let outcome: String
let permission: String
let startedAt: String
let completedAt: String
let detail: String
let sent: Bool
let draftPrepared: Bool
let duplicate: Bool
}

private let isoFormatter = ISO8601DateFormatter()

private func emit<T: Encodable>(_ value: T) -> Never {
do {
let data = try JSONEncoder().encode(value)
FileHandle.standardOutput.write(data)
exit(EXIT_SUCCESS)
} catch {
FileHandle.standardError.write(Data("Unable to encode helper result.\n".utf8))
exit(EXIT_FAILURE)
}
}

private func trusted(prompt: Bool) -> Bool {
if prompt {
let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] as CFDictionary
return AXIsProcessTrustedWithOptions(options)
}
return AXIsProcessTrusted()
}

private func copyAttribute(_ element: AXUIElement, _ attribute: CFString) -> CFTypeRef? {
var value: CFTypeRef?
guard AXUIElementCopyAttributeValue(element, attribute, &value) == .success else { return nil }
return value
}

private func stringAttribute(_ element: AXUIElement, _ attribute: CFString) -> String? {
copyAttribute(element, attribute) as? String
}

private func boolAttribute(_ element: AXUIElement, _ attribute: CFString) -> Bool {
(copyAttribute(element, attribute) as? Bool) ?? false
}

private func childElements(_ element: AXUIElement) -> [AXUIElement] {
(copyAttribute(element, kAXChildrenAttribute as CFString) as? [AXUIElement]) ?? []
}

private func normalizeTitle(_ value: String) -> String {
var normalized = value.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current)
normalized = normalized.replacingOccurrences(of: "message to ", with: "")
normalized = normalized.replacingOccurrences(of: "message ", with: "")
normalized = normalized.replacingOccurrences(of: "@", with: "")
normalized = normalized.replacingOccurrences(of: "#", with: "")
normalized = normalized.unicodeScalars.map { scalar in
CharacterSet.alphanumerics.contains(scalar) ? Character(String(scalar)) : " "
}.reduce(into: "") { $0.append($1) }
return normalized.split(whereSeparator: { $0.isWhitespace }).joined(separator: " ")
}

private struct ComposerMatch {
let element: AXUIElement
let descriptor: String
}

private func matchingComposers(in root: AXUIElement, expectedTitle: String) -> [ComposerMatch] {
let expected = normalizeTitle(expectedTitle)
var queue: [(AXUIElement, Int)] = [(root, 0)]
var visited = 0
var matches: [ComposerMatch] = []

while !queue.isEmpty && visited < 3_000 {
let (element, depth) = queue.removeFirst()
visited += 1
let role = stringAttribute(element, kAXRoleAttribute as CFString) ?? ""
if role == (kAXTextAreaRole as String) || role == (kAXTextFieldRole as String) {
let descriptors = [
stringAttribute(element, kAXPlaceholderValueAttribute as CFString),
stringAttribute(element, kAXDescriptionAttribute as CFString),
stringAttribute(element, kAXHelpAttribute as CFString),
stringAttribute(element, kAXTitleAttribute as CFString),
].compactMap { $0 }
if let descriptor = descriptors.first(where: { normalizeTitle($0) == expected }) {
matches.append(ComposerMatch(element: element, descriptor: descriptor))
}
}
if depth < 14 {
queue.append(contentsOf: childElements(element).map { ($0, depth + 1) })
}
}
return matches
}

private func discordApplication(deadline: Date) -> NSRunningApplication? {
while Date() < deadline {
if let app = NSWorkspace.shared.runningApplications.first(where: {
guard let bundleIdentifier = $0.bundleIdentifier else { return false }
return discordBundleIDs.contains(bundleIdentifier)
}) {
return app
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
return nil
}

private func verifiedComposer(app: NSRunningApplication, expectedTitle: String, deadline: Date) -> ComposerMatch? {
let appElement = AXUIElementCreateApplication(app.processIdentifier)
while Date() < deadline {
let windows = (copyAttribute(appElement, kAXWindowsAttribute as CFString) as? [AXUIElement]) ?? []
let matches = windows.flatMap { matchingComposers(in: $0, expectedTitle: expectedTitle) }
if matches.count == 1 {
return matches[0]
}
RunLoop.current.run(until: Date().addingTimeInterval(0.08))
}
return nil
}

private func supportsConfirm(_ element: AXUIElement) -> Bool {
var names: CFArray?
guard AXUIElementCopyActionNames(element, &names) == .success,
let actions = names as? [String]
else { return false }
return actions.contains(kAXConfirmAction as String)
}

private func performSend(_ composer: AXUIElement, application: AXUIElement) -> Bool {
if supportsConfirm(composer) {
return AXUIElementPerformAction(composer, kAXConfirmAction as CFString) == .success
}
guard AXUIElementSetAttributeValue(
composer,
kAXFocusedAttribute as CFString,
true as CFTypeRef
) == .success,
boolAttribute(composer, kAXFocusedAttribute as CFString)
else { return false }
let returnKeyCode: CGKeyCode = 36
typealias PostKeyboardEvent = @convention(c) (
AXUIElement,
UInt16,
CGKeyCode,
UInt8
) -> AXError
guard let symbol = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "AXUIElementPostKeyboardEvent")
else { return false }
let postKeyboardEvent = unsafeBitCast(symbol, to: PostKeyboardEvent.self)
guard postKeyboardEvent(application, 0, returnKeyCode, 1) == .success else {
return false
}
return postKeyboardEvent(application, 0, returnKeyCode, 0) == .success
}

private func composerValue(_ element: AXUIElement) -> String {
stringAttribute(element, kAXValueAttribute as CFString) ?? ""
}

private func restore(_ application: NSRunningApplication?) {
guard let application, !application.isTerminated else { return }
application.activate(options: [.activateIgnoringOtherApps])
}

private func execute(_ command: HelperCommand) -> ReplyResponse {
let startedAt = isoFormatter.string(from: Date())
let actionId = command.actionId ?? "invalid"
let origin = command.origin ?? "local_desktop"
let mode = command.mode ?? "prepare"
func result(
_ outcome: String,
_ detail: String,
sent: Bool = false,
draftPrepared: Bool = false,
permission: String = "granted"
) -> ReplyResponse {
ReplyResponse(
actionId: actionId,
origin: origin,
mode: mode,
outcome: outcome,
permission: permission,
startedAt: startedAt,
completedAt: isoFormatter.string(from: Date()),
detail: detail,
sent: sent,
draftPrepared: draftPrepared,
duplicate: false
)
}

guard trusted(prompt: false) else {
return result(
"permission_required",
"Allow Ditto's Discord helper in System Settings > Privacy & Security > Accessibility.",
permission: "not_granted"
)
}
guard let deepLink = command.deepLink,
let url = URL(string: deepLink),
url.scheme == "discord",
url.host == "-",
url.user == nil,
url.password == nil,
url.port == nil,
url.query == nil,
url.fragment == nil,
let expectedTitle = command.expectedTitle,
!normalizeTitle(expectedTitle).isEmpty,
let text = command.text,
!text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
text.count <= 4_000
else {
return result("target_not_verified", "The Discord target or draft did not pass validation.")
}
let path = url.pathComponents.filter { $0 != "/" }
let snowflake = try? NSRegularExpression(pattern: "^[0-9]{15,24}$")
func isSnowflake(_ value: String) -> Bool {
guard let snowflake else { return false }
return snowflake.firstMatch(in: value, range: NSRange(value.startIndex..., in: value)) != nil
}
guard path.count == 3,
path[0] == "channels",
(path[1] == "@me" || isSnowflake(path[1])),
isSnowflake(path[2])
else {
return result("target_not_verified", "The Discord deep link was rejected.")
}

let previousApplication = NSWorkspace.shared.frontmostApplication
defer { restore(previousApplication) }
guard NSWorkspace.shared.open(url) else {
return result("discord_unavailable", "Discord could not open the requested conversation.")
}
let timeout = min(max(command.timeoutMs ?? 10_000, 1_000), 15_000)
let deadline = Date().addingTimeInterval(Double(timeout) / 1_000)
guard let discord = discordApplication(deadline: deadline) else {
return result("discord_unavailable", "Discord did not become available before the action timed out.")
}
guard let match = verifiedComposer(app: discord, expectedTitle: expectedTitle, deadline: deadline) else {
return result(
"target_not_verified",
"Discord opened, but Ditto could not verify the exact conversation composer. Nothing was typed."
)
}
let discordElement = AXUIElementCreateApplication(discord.processIdentifier)

guard AXUIElementSetAttributeValue(
match.element,
kAXValueAttribute as CFString,
text as CFTypeRef
) == .success,
composerValue(match.element) == text
else {
return result("composer_not_found", "The verified Discord composer did not accept the draft.")
}

if mode == "prepare" {
return result(
"draft_prepared",
"Draft prepared in the verified Discord conversation. Open Discord and press Enter to send.",
draftPrepared: true
)
}
guard mode == "send" else {
return result(
"draft_prepared",
"The verified Discord draft was left ready instead of being sent.",
draftPrepared: true
)
}
guard performSend(match.element, application: discordElement) else {
return result(
"draft_prepared",
"Discord rejected the Accessibility send action. The verified draft was left ready instead.",
draftPrepared: true
)
}
let confirmationDeadline = min(deadline, Date().addingTimeInterval(1.5))
while Date() < confirmationDeadline {
if composerValue(match.element).isEmpty {
return result(
"sent",
"Discord cleared the verified composer after its Accessibility confirm action.",
sent: true
)
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
return result(
"send_not_confirmed",
"Discord did not clear the composer, so Ditto cannot confirm that the message was sent.",
draftPrepared: true
)
}

let inputData = FileHandle.standardInput.readDataToEndOfFile()
guard let command = try? JSONDecoder().decode(HelperCommand.self, from: inputData) else {
FileHandle.standardError.write(Data("Invalid helper command.\n".utf8))
exit(EXIT_FAILURE)
}

switch command.command {
case "status":
let isTrusted = trusted(prompt: command.prompt ?? false)
emit(StatusResponse(
available: true,
permission: isTrusted ? "granted" : "not_granted",
detail: isTrusted
? "Ditto can prepare and send explicit Discord replies."
: "Allow Ditto's Discord helper in System Settings > Privacy & Security > Accessibility."
))
case "execute":
emit(execute(command))
default:
FileHandle.standardError.write(Data("Unsupported helper command.\n".utf8))
exit(EXIT_FAILURE)
}
Loading
Loading