Skip to content
Open
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
25 changes: 8 additions & 17 deletions BoringNotchXPCHelper/BoringNotchXPCHelper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,10 @@ class BoringNotchXPCHelper: NSObject, BoringNotchXPCHelperProtocol {
}

@objc func replyToNotification(_ token: String, text: String, with reply: @escaping (Bool) -> Void) {
DispatchQueue.main.async { reply(Self.watcher.reply(token: token, text: text)) }
// The watcher's reply path does bounded waiting on the banner's AX
// hierarchy; it runs on the watcher's reply queue, never on main —
// the helper's main queue drives the banner poll and hold refresh.
Self.watcher.replyOnQueue(token: token, text: text, completion: reply)
}

/// Sends an iMessage directly through the Messages scripting
Expand All @@ -153,10 +156,6 @@ class BoringNotchXPCHelper: NSObject, BoringNotchXPCHelperProtocol {
DispatchQueue.main.async { reply(Self.watcher.debugDump()) }
}

@objc func dismissNotification(_ token: String, with reply: @escaping (Bool) -> Void) {
DispatchQueue.main.async { reply(Self.watcher.dismiss(token: token)) }
}

@objc func holdNotification(_ token: String) {
DispatchQueue.main.async { Self.watcher.hold(token: token) }
}
Expand All @@ -165,6 +164,10 @@ class BoringNotchXPCHelper: NSObject, BoringNotchXPCHelperProtocol {
DispatchQueue.main.async { Self.watcher.release(token: token) }
}

@objc func setNotchOpen(_ open: Bool) {
DispatchQueue.main.async { Self.watcher.notchOpen = open }
}

private class KeyboardBrightnessClient {
private static let keyboardID: UInt64 = 1
private var clientInstance: NSObject?
Expand All @@ -187,8 +190,6 @@ class BoringNotchXPCHelper: NSObject, BoringNotchXPCHelperProtocol {
}
}

var isAvailable: Bool { clientInstance != nil }

func currentBrightness() -> Float? {
guard let clientInstance,
let fn: BrightnessGetter = methodIMP(on: clientInstance, selector: getSelector, as: BrightnessGetter.self)
Expand Down Expand Up @@ -217,10 +218,6 @@ class BoringNotchXPCHelper: NSObject, BoringNotchXPCHelperProtocol {

private static let keyboardClient = KeyboardBrightnessClient()

@objc func isKeyboardBrightnessAvailable(with reply: @escaping (Bool) -> Void) {
reply(Self.keyboardClient.isAvailable)
}

@objc func currentKeyboardBrightness(with reply: @escaping (NSNumber?) -> Void) {
reply(Self.keyboardClient.currentBrightness().map { NSNumber(value: $0) })
}
Expand Down Expand Up @@ -252,12 +249,6 @@ class BoringNotchXPCHelper: NSObject, BoringNotchXPCHelperProtocol {
return mainDisplayID
}

@objc func isScreenBrightnessAvailable(with reply: @escaping (Bool) -> Void) {
let displayID = brightnessDisplayID()
var b: Float = 0
reply(displayServicesGetBrightness(displayID: displayID, out: &b) || ioServiceFor(displayID: displayID) != nil)
}

@objc func currentScreenBrightness(with reply: @escaping (NSNumber?) -> Void) {
let displayID = brightnessDisplayID()
var b: Float = 0
Expand Down
105 changes: 87 additions & 18 deletions BoringNotchXPCHelper/MessagesSender.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,30 +25,84 @@ import Foundation
import AppKit

enum MessagesSender {
/// Matches the notification's sender against a Messages chat (then a
/// participant) and sends. Returns false if the chat can't be resolved
/// or automation permission was denied, so the caller can fall back to
/// the clipboard hand-off.
/// Resolves the notification's sender to a Messages conversation and
/// sends the reply INTO that conversation, so the account, service
/// (iMessage/SMS/RCS), handle, and thread of the originating chat are
/// preserved. Returns false when no conversation resolves
/// unambiguously (or automation permission was denied) — a
/// wrong-person/wrong-thread send is worse than no send, and the
/// caller falls back to the clipboard hand-off.
static func send(_ text: String, toChatNamed name: String) -> Bool {
// Participants only — deliberately not chats. Verified against a
// real Messages library: `name of chat` returns `missing value` for
// every chat, so matching on it can never succeed. Participants do
// carry the display name the notification shows ("Harsh Vardhan
// Goswami"), which is the only thing a notification gives us.
// Chat-first, matching on the chat's PARTICIPANTS — never on the
// chat's own name: verified against a real Messages library,
// `name of chat` returns `missing value` for every 1:1 chat, so
// matching on it can never succeed. Participants do carry the
// display name the notification shows ("Harsh Vardhan Goswami"),
// which is the only thing a notification gives us. AppleScript's
// `is equal to` on strings ignores case by default, which is what
// we want here.
//
// The first match wins. Duplicate participant entries for the same
// person are normal (one per handle/service — e:me@…, +9198…), and
// they all reach the same human, so picking the first is fine.
// Resolution order:
// 1. A chat with exactly ONE participant matching — the 1:1
// thread is the originating conversation; sending into the
// chat keeps its account/service/handle/thread.
// 2. The first GROUP chat containing a matching participant —
// still a real conversation, so routing is preserved.
// 3. A bare participant, ONLY if exactly one participant in the
// whole library matches. A participant has no thread context,
// and duplicates for the same person across handles/services
// (e:me@…, +9198…) are normal — 2+ matches is ambiguous and
// must not be guessed.
let script = """
tell application "Messages"
set targetName to "\(escape(name))"
set replyText to "\(escape(text))"

set groupMatch to missing value
repeat with c in chats
try
set matched to false
repeat with p in participants of c
try
if (name of p as string) is equal to targetName then
set matched to true
exit repeat
end if
end try
end repeat
if matched then
if (count of participants of c) is 1 then
send replyText to c
return "ok-chat-1v1"
else if groupMatch is missing value then
set groupMatch to c
end if
end if
end try
end repeat
if groupMatch is not missing value then
send replyText to groupMatch
return "ok-chat-group"
end if

set matchCount to 0
set soleMatch to missing value
repeat with p in participants
try
if (name of p as string) is equal to "\(escape(name))" then
send "\(escape(text))" to p
return "ok"
if (name of p as string) is equal to targetName then
set matchCount to matchCount + 1
set soleMatch to p
end if
end try
end repeat
if matchCount is 1 then
send replyText to soleMatch
return "ok-participant"
else if matchCount is 0 then
return "notfound"
else
return "ambiguous"
end if
end tell
return "notfound"
"""
Expand All @@ -66,11 +120,26 @@ enum MessagesSender {
return false
}

let ok = output.stringValue == "ok"
if !ok {
// Any "ok-*" status is a successful send; the suffix says which
// resolution path delivered it so routing decisions are
// diagnosable from the log. "notfound" and "ambiguous" both map
// to false — never send when unsure.
let status = output.stringValue ?? ""
switch status {
case "ok-chat-1v1":
NSLog("[boringNotch] Messages: sent into 1:1 chat with \(name.debugDescription) (account/service/handle/thread preserved)")
case "ok-chat-group":
NSLog("[boringNotch] Messages: no 1:1 chat for \(name.debugDescription); sent into first matching group chat")
case "ok-participant":
NSLog("[boringNotch] Messages: no chat matched \(name.debugDescription); sent to the single matching participant")
case "notfound":
NSLog("[boringNotch] Messages: no chat or participant named \(name.debugDescription)")
case "ambiguous":
NSLog("[boringNotch] Messages: \(name.debugDescription) matches multiple participants across handles/services — refusing to guess")
default:
NSLog("[boringNotch] Messages: unexpected status \(status.debugDescription) for \(name.debugDescription)")
}
return ok
return status.hasPrefix("ok")
}

/// Message text is arbitrary user input going into an AppleScript
Expand Down
Loading
Loading