Skip to content

Commit fcca4cf

Browse files
committed
fix(read): avoid search focus for chat_id opens
chat_id lookups already come from the chat list registry, so prefer the visible chat-list row before falling back to KakaoTalk global search. Normalize KakaoTalk windows to a readable minimum size before scanning/opening so chat_id reads do not depend on a tiny or unrelated focused search window, while preserving existing larger windows and restoring the previous window position after automatic resizing. Constraint: KakaoTalk search fields can remain focused in unrelated small windows and fail AX focus verification. Constraint: Automatic resizing must avoid shrinking user-sized windows or moving them to a surprising screen position. Rejected: Only document FOCUS_FAIL recovery | leaves chat_id reads dependent on the same failing search path. Rejected: Always use global search for chat_id | wastes the stronger identity from the chat list registry and keeps the focus failure mode. Rejected: Clamp every chat window to a fixed maximum | would shrink user-sized windows and create avoidable disruption. Confidence: medium Scope-risk: moderate Tested: swift build; .build/debug/kmsg read --help; git diff --check Not-tested: live read --chat-id completion; local debug binary hung in auth/window bootstrap before returning output. Python tests unavailable because pytest is not installed.
1 parent bf301b9 commit fcca4cf

5 files changed

Lines changed: 213 additions & 18 deletions

File tree

Sources/kmsg/Accessibility/UIElement.swift

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,27 @@ public final class UIElement: @unchecked Sendable {
150150
return CGRect(origin: pos, size: size)
151151
}
152152

153+
public func setPosition(_ point: CGPoint) throws {
154+
var point = point
155+
guard let value = AXValueCreate(.cgPoint, &point) else {
156+
throw AccessibilityError.typeMismatch
157+
}
158+
try setAttribute(kAXPositionAttribute, value: value)
159+
}
160+
161+
public func setSize(_ size: CGSize) throws {
162+
var size = size
163+
guard let value = AXValueCreate(.cgSize, &size) else {
164+
throw AccessibilityError.typeMismatch
165+
}
166+
try setAttribute(kAXSizeAttribute, value: value)
167+
}
168+
169+
public func setFrame(_ frame: CGRect) throws {
170+
try setPosition(frame.origin)
171+
try setSize(frame.size)
172+
}
173+
153174
// MARK: - Hierarchy
154175

155176
public var parent: UIElement? {

Sources/kmsg/Commands/ReadCommand.swift

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,20 @@ struct ReadCommand: ParsableCommand {
1919
static let configuration = CommandConfiguration(
2020
commandName: "read",
2121
abstract: "Read messages from a chat",
22-
discussion: "When author is \"(me)\", the message was sent by you."
22+
discussion: """
23+
Use either:
24+
kmsg read <chat>
25+
kmsg read --chat-id <chat-id>
26+
27+
When author is "(me)", the message was sent by you.
28+
"""
2329
)
2430

31+
@Option(name: .long, help: "Read using a chat_id from 'kmsg chats'")
32+
var chatID: String?
33+
2534
@Argument(help: "Name of the chat to read from (partial match supported)")
26-
var chat: String
35+
var chat: String?
2736

2837
@Option(name: .shortAndLong, help: "Maximum number of messages to show")
2938
var limit: Int = 20
@@ -49,6 +58,19 @@ struct ReadCommand: ParsableCommand {
4958
@Flag(name: .long, help: "Output in JSON format")
5059
var json: Bool = false
5160

61+
func validate() throws {
62+
if let chatID, !chatID.isEmpty {
63+
guard chat == nil else {
64+
throw ValidationError("Chat name cannot be provided together with --chat-id.")
65+
}
66+
return
67+
}
68+
69+
guard let chat, !chat.isEmpty else {
70+
throw ValidationError("Chat name is required unless --chat-id is provided.")
71+
}
72+
}
73+
5274
func run() throws {
5375
guard AccessibilityPermission.ensureGranted() else {
5476
AccessibilityPermission.printInstructions()
@@ -65,10 +87,18 @@ struct ReadCommand: ParsableCommand {
6587
let transcriptReader = KakaoTalkTranscriptReader(kakao: kakao, runner: runner)
6688

6789
let resolution: ChatWindowResolution
90+
let requestedChat: String
6891
do {
69-
resolution = try chatWindowResolver.resolve(query: chat)
92+
if let chatID {
93+
requestedChat = chatID
94+
resolution = try chatWindowResolver.resolve(chatID: chatID)
95+
} else {
96+
let chat = chat ?? ""
97+
requestedChat = chat
98+
resolution = try chatWindowResolver.resolve(query: chat)
99+
}
70100
} catch {
71-
print("No chat window found for '\(chat)'")
101+
print("No chat window found for '\(requestedChat)'")
72102
print("Reason: \(error)")
73103
print("\nAvailable windows:")
74104
for (index, window) in kakao.windows.enumerated() {
@@ -80,6 +110,11 @@ struct ReadCommand: ParsableCommand {
80110
let window = resolution.window
81111
if resolution.openedViaSearch {
82112
runner.log("read: opening chat via search")
113+
} else if resolution.method == .openedViaChatList {
114+
runner.log("read: opening chat via chat list")
115+
}
116+
117+
if resolution.openedTransiently {
83118
if keepWindow {
84119
runner.log("read: keep-window enabled; auto-opened window will be kept")
85120
} else {
@@ -90,16 +125,16 @@ struct ReadCommand: ParsableCommand {
90125
}
91126

92127
defer {
93-
if resolution.openedViaSearch && !keepWindow {
128+
if resolution.openedTransiently && !keepWindow {
94129
let resolvedTitle = window.title ?? ""
95-
if !resolvedTitle.isEmpty && !resolvedTitle.localizedCaseInsensitiveContains(chat) {
130+
if chatID == nil && !resolvedTitle.isEmpty && !resolvedTitle.localizedCaseInsensitiveContains(requestedChat) {
96131
runner.log("read: skipped auto-close because resolved title '\(resolvedTitle)' did not match query")
97132
} else if chatWindowResolver.closeWindow(window) {
98133
runner.log("read: auto-opened chat window closed")
99134
} else {
100135
runner.log("read: failed to close auto-opened chat window")
101136
}
102-
} else if resolution.openedViaSearch && keepWindow {
137+
} else if resolution.openedTransiently && keepWindow {
103138
runner.log("read: auto-opened chat window kept by --keep-window")
104139
}
105140
}
@@ -108,7 +143,7 @@ struct ReadCommand: ParsableCommand {
108143
do {
109144
snapshot = try transcriptReader.readSnapshot(
110145
from: window,
111-
fallbackChatTitle: chat,
146+
fallbackChatTitle: window.title ?? requestedChat,
112147
limit: limit
113148
)
114149
} catch TranscriptReadError.transcriptContextUnavailable {

Sources/kmsg/Commands/SendCommand.swift

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -129,22 +129,18 @@ struct SendCommand: ParsableCommand {
129129
runner.log("window strategy: focusedWindow -> mainWindow -> windows.first")
130130
let resolution: ChatWindowResolution
131131
if let chatID {
132-
guard let record = ChatIdentityRegistryStore.shared.record(for: chatID) else {
133-
throw KakaoTalkError.elementNotFound("Unknown chat_id '\(chatID)'. Run 'kmsg chats' first to refresh the local registry.")
134-
}
135132
print("Looking for chat with \(targetDescription)...")
136-
print("Resolved \(targetDescription) to '\(record.displayName)'.")
137-
resolution = try chatWindowResolver.resolve(query: record.displayName)
138-
if resolution.openedViaSearch {
139-
print("No existing chat window. Opening via search...")
133+
resolution = try chatWindowResolver.resolve(chatID: chatID)
134+
if resolution.openedTransiently {
135+
print("No existing chat window. Opening via chat list or search...")
140136
} else {
141137
print("Found existing chat window.")
142138
}
143139
} else {
144140
let recipient = recipient ?? ""
145141
print("Looking for chat with \(targetDescription)...")
146142
resolution = try chatWindowResolver.resolve(query: recipient)
147-
if resolution.openedViaSearch {
143+
if resolution.openedTransiently {
148144
print("No existing chat window. Opening via search...")
149145
} else {
150146
print("Found existing chat window.")

Sources/kmsg/Commands/WatchCommand.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ struct WatchCommand: ParsableCommand {
100100

101101
var currentWindow = resolution.window
102102
var currentChatTitle = currentWindow.title ?? chat
103-
var autoOpenedWindow: UIElement? = resolution.openedViaSearch ? currentWindow : nil
103+
var autoOpenedWindow: UIElement? = resolution.openedTransiently ? currentWindow : nil
104104
var cachedContext: MessageTranscriptContext?
105105

106106
defer {
@@ -224,7 +224,7 @@ struct WatchCommand: ParsableCommand {
224224
currentWindow = resolution.window
225225
currentChatTitle = currentWindow.title ?? chat
226226
cachedContext = nil
227-
if resolution.openedViaSearch {
227+
if resolution.openedTransiently {
228228
autoOpenedWindow = currentWindow
229229
}
230230
return try stabilizeBaseline(

Sources/kmsg/KakaoTalk/ChatWindowResolver.swift

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import Foundation
33

44
enum ChatWindowResolutionMethod {
55
case existingWindow
6+
case openedViaChatList
67
case openedViaSearch
78
}
89

@@ -13,6 +14,10 @@ struct ChatWindowResolution {
1314
var openedViaSearch: Bool {
1415
method == .openedViaSearch
1516
}
17+
18+
var openedTransiently: Bool {
19+
method != .existingWindow
20+
}
1621
}
1722

1823
private enum ChatWindowFailureCode: String {
@@ -43,6 +48,9 @@ private struct SearchCandidate {
4348
}
4449

4550
struct ChatWindowResolver {
51+
private static let minimumReadableWindowSize = CGSize(width: 760, height: 900)
52+
private static let maximumAutomaticWindowSize = CGSize(width: 1200, height: 1000)
53+
4654
private let kakao: KakaoTalkApp
4755
private let runner: AXActionRunner
4856
private let useCache: Bool
@@ -64,11 +72,42 @@ struct ChatWindowResolver {
6472
let usableWindow = try requireUsableWindow()
6573

6674
if let existingWindow = findMatchingChatWindow(in: kakao.windows, query: query) {
75+
standardizeReadableWindow(existingWindow, label: "existing chat window")
6776
return ChatWindowResolution(window: existingWindow, method: .existingWindow)
6877
}
6978

7079
let searchWindow = selectSearchWindow(fallback: usableWindow)
80+
standardizeReadableWindow(searchWindow, label: "search root window")
7181
let chatWindow = try openChatViaSearch(query: query, in: searchWindow, fallbackWindow: usableWindow)
82+
standardizeReadableWindow(chatWindow, label: "opened chat window")
83+
return ChatWindowResolution(window: chatWindow, method: .openedViaSearch)
84+
}
85+
86+
func resolve(chatID: String) throws -> ChatWindowResolution {
87+
guard let record = ChatIdentityRegistryStore.shared.record(for: chatID) else {
88+
throw KakaoTalkError.elementNotFound("Unknown chat_id '\(chatID)'. Run 'kmsg chats' first to refresh the local registry.")
89+
}
90+
91+
let usableWindow = try requireUsableWindow()
92+
let query = record.displayName
93+
94+
if let existingWindow = findMatchingChatWindow(in: kakao.windows, query: query) {
95+
standardizeReadableWindow(existingWindow, label: "existing chat window")
96+
return ChatWindowResolution(window: existingWindow, method: .existingWindow)
97+
}
98+
99+
if let chatListWindow = kakao.chatListWindow,
100+
let chatWindow = openChatListRow(chatID: chatID, query: query, in: chatListWindow, fallbackWindow: usableWindow)
101+
{
102+
standardizeReadableWindow(chatWindow, label: "opened chat window")
103+
return ChatWindowResolution(window: chatWindow, method: .openedViaChatList)
104+
}
105+
106+
runner.log("chat_id: falling back to search for '\(query)'")
107+
let searchWindow = selectSearchWindow(fallback: usableWindow)
108+
standardizeReadableWindow(searchWindow, label: "search root window")
109+
let chatWindow = try openChatViaSearch(query: query, in: searchWindow, fallbackWindow: usableWindow)
110+
standardizeReadableWindow(chatWindow, label: "opened chat window")
72111
return ChatWindowResolution(window: chatWindow, method: .openedViaSearch)
73112
}
74113

@@ -229,6 +268,110 @@ struct ChatWindowResolver {
229268
throw KakaoTalkError.windowNotFound("[\(ChatWindowFailureCode.windowNotReady.rawValue)] Chat window for '\(query)' did not open")
230269
}
231270

271+
private func openChatListRow(chatID: String, query: String, in chatListWindow: UIElement, fallbackWindow: UIElement) -> UIElement? {
272+
runner.log("chat_id: scanning chat list rows")
273+
standardizeReadableWindow(chatListWindow, label: "chat list window")
274+
let scanner = ChatListScanner()
275+
let snapshots = scanner.scan(in: chatListWindow, limit: 200, trace: { message in
276+
runner.log(message)
277+
})
278+
guard !snapshots.isEmpty else {
279+
runner.log("chat_id: chat list scan returned no rows")
280+
return nil
281+
}
282+
283+
let registry = ChatIdentityRegistryStore.shared
284+
let assignedIDs = registry.assignChatIDs(for: snapshots.map(\.discovery))
285+
guard let matchIndex = assignedIDs.firstIndex(of: chatID) else {
286+
runner.log("chat_id: no visible chat row matched \(chatID)")
287+
return nil
288+
}
289+
290+
let row = snapshots[matchIndex].element
291+
runner.log("chat_id: matched row title='\(snapshots[matchIndex].discovery.title)'")
292+
kakao.activate()
293+
_ = tryRaiseWindow(chatListWindow)
294+
295+
if triggerChatListRowOpen(row) {
296+
if let window = waitForOpenedChatWindow(query: query, fallbackWindow: fallbackWindow) {
297+
return window
298+
}
299+
}
300+
301+
runner.log("chat_id: matched row did not open a chat window")
302+
return nil
303+
}
304+
305+
private func triggerChatListRowOpen(_ row: UIElement) -> Bool {
306+
if tryActivateSearchResult(row, label: "chat list row") {
307+
return true
308+
}
309+
310+
let selected = trySelectSearchResult(row, label: "chat list row")
311+
if !selected, let parent = row.parent, trySelectSearchResult(parent, label: "chat list row.parent") {
312+
runner.pressEnterKey()
313+
return true
314+
}
315+
316+
if selected {
317+
runner.pressEnterKey()
318+
return true
319+
}
320+
321+
return false
322+
}
323+
324+
private func standardizeReadableWindow(_ window: UIElement, label: String) {
325+
kakao.activate()
326+
_ = tryRaiseWindow(window)
327+
328+
guard let currentSize = window.size else {
329+
runner.log("\(label): size unavailable; skipping resize")
330+
return
331+
}
332+
let currentPosition = window.position
333+
334+
let targetSize = readableTargetSize(for: currentSize)
335+
guard targetSize != currentSize else {
336+
runner.log("\(label): size already readable \(Int(currentSize.width))x\(Int(currentSize.height))")
337+
return
338+
}
339+
340+
do {
341+
try window.setSize(targetSize)
342+
if let currentPosition {
343+
try? window.setPosition(currentPosition)
344+
}
345+
runner.log("\(label): resized to \(Int(targetSize.width))x\(Int(targetSize.height))")
346+
Thread.sleep(forTimeInterval: 0.08)
347+
} catch {
348+
runner.log("\(label): resize failed (\(error))")
349+
}
350+
}
351+
352+
private func readableTargetSize(for currentSize: CGSize) -> CGSize {
353+
CGSize(
354+
width: readableTargetDimension(
355+
current: currentSize.width,
356+
minimum: Self.minimumReadableWindowSize.width,
357+
automaticMaximum: Self.maximumAutomaticWindowSize.width
358+
),
359+
height: readableTargetDimension(
360+
current: currentSize.height,
361+
minimum: Self.minimumReadableWindowSize.height,
362+
automaticMaximum: Self.maximumAutomaticWindowSize.height
363+
)
364+
)
365+
}
366+
367+
private func readableTargetDimension(current: CGFloat, minimum: CGFloat, automaticMaximum: CGFloat) -> CGFloat {
368+
if current >= automaticMaximum {
369+
return current
370+
}
371+
372+
return max(current, minimum)
373+
}
374+
232375
private func resolveCachedElement(
233376
slot: AXPathSlot,
234377
root: UIElement,

0 commit comments

Comments
 (0)