chore: merge fix-ci into main
This commit is contained in:
parent
5c5030f3bb
commit
fd01c64840
20
.github/workflows/macos-ci.yml
vendored
20
.github/workflows/macos-ci.yml
vendored
@ -359,7 +359,15 @@ jobs:
|
||||
- name: Build Peekaboo app (Xcode)
|
||||
working-directory: Apps
|
||||
run: |
|
||||
/usr/bin/env -u DYLD_LIBRARY_PATH -u DYLD_FRAMEWORK_PATH -u DYLD_FALLBACK_FRAMEWORK_PATH \
|
||||
/usr/bin/env \
|
||||
-u DYLD_LIBRARY_PATH \
|
||||
-u DYLD_FRAMEWORK_PATH \
|
||||
-u DYLD_FALLBACK_FRAMEWORK_PATH \
|
||||
-u DYLD_ROOT_PATH \
|
||||
-u DYLD_INSERT_LIBRARIES \
|
||||
-u DYLD_IMAGE_SUFFIX \
|
||||
-u DYLD_VERSIONED_LIBRARY_PATH \
|
||||
-u DYLD_VERSIONED_FRAMEWORK_PATH \
|
||||
xcodebuild -workspace Peekaboo.xcworkspace \
|
||||
-scheme Peekaboo \
|
||||
-configuration Debug \
|
||||
@ -370,7 +378,15 @@ jobs:
|
||||
- name: Build Inspector app (Xcode)
|
||||
working-directory: Apps/PeekabooInspector
|
||||
run: |
|
||||
/usr/bin/env -u DYLD_LIBRARY_PATH -u DYLD_FRAMEWORK_PATH -u DYLD_FALLBACK_FRAMEWORK_PATH \
|
||||
/usr/bin/env \
|
||||
-u DYLD_LIBRARY_PATH \
|
||||
-u DYLD_FRAMEWORK_PATH \
|
||||
-u DYLD_FALLBACK_FRAMEWORK_PATH \
|
||||
-u DYLD_ROOT_PATH \
|
||||
-u DYLD_INSERT_LIBRARIES \
|
||||
-u DYLD_IMAGE_SUFFIX \
|
||||
-u DYLD_VERSIONED_LIBRARY_PATH \
|
||||
-u DYLD_VERSIONED_FRAMEWORK_PATH \
|
||||
xcodebuild -project Inspector.xcodeproj \
|
||||
-scheme Inspector \
|
||||
-configuration Debug \
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -182,7 +182,6 @@ docs/debug/*
|
||||
# Build artifacts and derived data
|
||||
.artifacts/
|
||||
.derived-data/
|
||||
.derivedData/
|
||||
|
||||
# Crush directory
|
||||
.crush/
|
||||
|
||||
@ -324,7 +324,8 @@ final class AgentChatUI {
|
||||
MarkdownComponent(
|
||||
text: text,
|
||||
padding: .init(horizontal: 1, vertical: 0),
|
||||
defaultTextStyle: .init(color: color))
|
||||
defaultTextStyle: .init(color: color)
|
||||
)
|
||||
}
|
||||
|
||||
private func removeLoader() {
|
||||
@ -556,11 +557,11 @@ final class AgentChatEventDelegate: AgentEventDelegate {
|
||||
|
||||
private func valuesEqual(_ lhs: Any, _ rhs: Any) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case let (l as String, r as String): return l == r
|
||||
case let (l as Int, r as Int): return l == r
|
||||
case let (l as Double, r as Double): return l == r
|
||||
case let (l as Bool, r as Bool): return l == r
|
||||
default: return false
|
||||
case let (l as String, r as String): l == r
|
||||
case let (l as Int, r as Int): l == r
|
||||
case let (l as Double, r as Double): l == r
|
||||
case let (l as Bool, r as Bool): l == r
|
||||
default: false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -326,7 +326,6 @@ extension AgentCommand {
|
||||
}
|
||||
|
||||
let runTask = Task { () throws -> AgentExecutionResult in
|
||||
|
||||
if let existingSessionId = startingSessionId {
|
||||
let outputDelegate = self.makeDisplayDelegate(for: batchedInput)
|
||||
let streamingDelegate = self.makeStreamingDelegate(using: outputDelegate)
|
||||
|
||||
@ -3,9 +3,9 @@ import Darwin
|
||||
import Dispatch
|
||||
import Foundation
|
||||
import Logging
|
||||
import PeekabooAgentRuntime
|
||||
import PeekabooCore
|
||||
import PeekabooFoundation
|
||||
import PeekabooAgentRuntime
|
||||
import Spinner
|
||||
import Tachikoma
|
||||
import TachikomaMCP
|
||||
|
||||
@ -141,7 +141,7 @@ extension AgentOutputDelegate {
|
||||
return // no change; avoid spamming the log
|
||||
}
|
||||
let diffSummary = self.diffSummary(for: name, newArgs: args)
|
||||
let (formatter, _ /* toolType */) = self.toolFormatter(for: name)
|
||||
let (formatter, _ /* toolType */ ) = self.toolFormatter(for: name)
|
||||
|
||||
switch self.outputMode {
|
||||
case .minimal:
|
||||
@ -498,12 +498,12 @@ extension AgentOutputDelegate {
|
||||
|
||||
private func valuesEqual(_ lhs: Any, _ rhs: Any) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case let (l as String, r as String): return l == r
|
||||
case let (l as Int, r as Int): return l == r
|
||||
case let (l as Double, r as Double): return l == r
|
||||
case let (l as Bool, r as Bool): return l == r
|
||||
case let (l as String, r as String): l == r
|
||||
case let (l as Int, r as Int): l == r
|
||||
case let (l as Double, r as Double): l == r
|
||||
case let (l as Bool, r as Bool): l == r
|
||||
default:
|
||||
return false
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@ -530,7 +530,8 @@ extension AgentOutputDelegate {
|
||||
default:
|
||||
if let data = try? JSONSerialization.data(withJSONObject: ["v": value], options: []),
|
||||
let text = String(data: data, encoding: .utf8) {
|
||||
return text.replacingOccurrences(of: "{\"v\":", with: "").trimmingCharacters(in: CharacterSet(charactersIn: "}"))
|
||||
return text.replacingOccurrences(of: "{\"v\":", with: "")
|
||||
.trimmingCharacters(in: CharacterSet(charactersIn: "}"))
|
||||
}
|
||||
return "…"
|
||||
}
|
||||
|
||||
@ -587,8 +587,7 @@ enum MenuServiceBridge {
|
||||
}
|
||||
|
||||
static func listMenuBarItems(menu: any MenuServiceProtocol, includeRaw: Bool = false) async throws
|
||||
-> [MenuBarItemInfo]
|
||||
{
|
||||
-> [MenuBarItemInfo] {
|
||||
try await Task { @MainActor in
|
||||
try await menu.listMenuBarItems(includeRaw: includeRaw)
|
||||
}.value
|
||||
|
||||
@ -321,7 +321,10 @@ extension TypeCommand: CommanderBindableCommand {
|
||||
if let delay: Int = try values.decodeOption("delay", as: Int.self) {
|
||||
self.delay = delay
|
||||
}
|
||||
if let wpm: Int = try values.decodeOption("wordsPerMinute", as: Int.self) ?? values.decodeOption("wpm", as: Int.self) {
|
||||
if let wpm: Int = try values.decodeOption("wordsPerMinute", as: Int.self) ?? values.decodeOption(
|
||||
"wpm",
|
||||
as: Int.self
|
||||
) {
|
||||
self.wordsPerMinute = wpm
|
||||
}
|
||||
if let profile = values.singleOption("profileOption") ?? values.singleOption("profile") {
|
||||
|
||||
@ -60,6 +60,7 @@ struct CleanCommand: OutputFormattable, RuntimeOptionsConfigurable {
|
||||
// to the parsed runtime options so flags like --json-output are visible.
|
||||
return self.runtimeOptions.makeConfiguration()
|
||||
}
|
||||
|
||||
var jsonOutput: Bool { self.configuration.jsonOutput }
|
||||
|
||||
@MainActor
|
||||
|
||||
@ -20,7 +20,8 @@ struct ClipboardCommand: OutputFormattable, RuntimeOptionsConfigurable {
|
||||
restore Restore a previously saved slot.
|
||||
load Shortcut for set with --file-path.
|
||||
""",
|
||||
showHelpOnEmptyInvocation: true)
|
||||
showHelpOnEmptyInvocation: true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -121,7 +122,8 @@ struct ClipboardCommand: OutputFormattable, RuntimeOptionsConfigurable {
|
||||
size: result.data.count,
|
||||
filePath: output,
|
||||
slot: nil,
|
||||
textPreview: result.textPreview)
|
||||
textPreview: result.textPreview
|
||||
)
|
||||
|
||||
self.output(payload) {
|
||||
if let text = String(data: result.data, encoding: .utf8) {
|
||||
@ -129,7 +131,9 @@ struct ClipboardCommand: OutputFormattable, RuntimeOptionsConfigurable {
|
||||
} else if let output {
|
||||
print("📋 Saved \(result.data.count) bytes (\(result.utiIdentifier)) to \(output)")
|
||||
} else {
|
||||
print("📋 Clipboard contains \(result.data.count) bytes of \(result.utiIdentifier); use --output to save.")
|
||||
print(
|
||||
"📋 Clipboard contains \(result.data.count) bytes of \(result.utiIdentifier); use --output to save."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -143,7 +147,8 @@ struct ClipboardCommand: OutputFormattable, RuntimeOptionsConfigurable {
|
||||
size: result.data.count,
|
||||
filePath: nil,
|
||||
slot: nil,
|
||||
textPreview: result.textPreview)
|
||||
textPreview: result.textPreview
|
||||
)
|
||||
|
||||
self.output(payload) {
|
||||
print("✅ Set clipboard (\(result.utiIdentifier), \(result.data.count) bytes)")
|
||||
@ -162,7 +167,8 @@ struct ClipboardCommand: OutputFormattable, RuntimeOptionsConfigurable {
|
||||
size: result.data.count,
|
||||
filePath: path,
|
||||
slot: nil,
|
||||
textPreview: result.textPreview)
|
||||
textPreview: result.textPreview
|
||||
)
|
||||
|
||||
self.output(payload) {
|
||||
print("✅ Loaded \(result.data.count) bytes (\(result.utiIdentifier)) from \(path) into clipboard")
|
||||
@ -171,7 +177,14 @@ struct ClipboardCommand: OutputFormattable, RuntimeOptionsConfigurable {
|
||||
|
||||
private func handleClear() {
|
||||
self.services.clipboard.clear()
|
||||
let payload = ClipboardCommandResult(action: "clear", uti: nil, size: nil, filePath: nil, slot: nil, textPreview: nil)
|
||||
let payload = ClipboardCommandResult(
|
||||
action: "clear",
|
||||
uti: nil,
|
||||
size: nil,
|
||||
filePath: nil,
|
||||
slot: nil,
|
||||
textPreview: nil
|
||||
)
|
||||
self.output(payload) {
|
||||
print("🧹 Cleared clipboard")
|
||||
}
|
||||
@ -180,7 +193,14 @@ struct ClipboardCommand: OutputFormattable, RuntimeOptionsConfigurable {
|
||||
private func handleSave() throws {
|
||||
let slotName = self.slot ?? "0"
|
||||
try self.services.clipboard.save(slot: slotName)
|
||||
let payload = ClipboardCommandResult(action: "save", uti: nil, size: nil, filePath: nil, slot: slotName, textPreview: nil)
|
||||
let payload = ClipboardCommandResult(
|
||||
action: "save",
|
||||
uti: nil,
|
||||
size: nil,
|
||||
filePath: nil,
|
||||
slot: slotName,
|
||||
textPreview: nil
|
||||
)
|
||||
self.output(payload) {
|
||||
print("💾 Saved clipboard to slot \"\(slotName)\"")
|
||||
}
|
||||
@ -195,7 +215,8 @@ struct ClipboardCommand: OutputFormattable, RuntimeOptionsConfigurable {
|
||||
size: result.data.count,
|
||||
filePath: nil,
|
||||
slot: slotName,
|
||||
textPreview: result.textPreview)
|
||||
textPreview: result.textPreview
|
||||
)
|
||||
self.output(payload) {
|
||||
print("♻️ Restored slot \"\(slotName)\" (\(result.utiIdentifier), \(result.data.count) bytes)")
|
||||
}
|
||||
@ -210,7 +231,8 @@ struct ClipboardCommand: OutputFormattable, RuntimeOptionsConfigurable {
|
||||
ClipboardRepresentation(utiIdentifier: UTType.plainText.identifier, data: Data(text.utf8)),
|
||||
],
|
||||
alsoText: self.alsoText,
|
||||
allowLarge: self.allowLarge)
|
||||
allowLarge: self.allowLarge
|
||||
)
|
||||
}
|
||||
|
||||
if let path = overridePath ?? self.filePath ?? self.imagePath {
|
||||
@ -220,7 +242,8 @@ struct ClipboardCommand: OutputFormattable, RuntimeOptionsConfigurable {
|
||||
return ClipboardWriteRequest(
|
||||
representations: [ClipboardRepresentation(utiIdentifier: uti.identifier, data: data)],
|
||||
alsoText: self.alsoText,
|
||||
allowLarge: self.allowLarge)
|
||||
allowLarge: self.allowLarge
|
||||
)
|
||||
}
|
||||
|
||||
if let b64 = self.dataBase64, let utiId = self.uti {
|
||||
@ -230,7 +253,8 @@ struct ClipboardCommand: OutputFormattable, RuntimeOptionsConfigurable {
|
||||
return ClipboardWriteRequest(
|
||||
representations: [ClipboardRepresentation(utiIdentifier: utiId, data: data)],
|
||||
alsoText: self.alsoText,
|
||||
allowLarge: self.allowLarge)
|
||||
allowLarge: self.allowLarge
|
||||
)
|
||||
}
|
||||
|
||||
throw ValidationError("Provide --text, --file-path/--image-path, or --data-base64 with --uti")
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import AXorcist
|
||||
import CoreGraphics
|
||||
import Commander
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
import PeekabooCore
|
||||
import PeekabooFoundation
|
||||
@ -95,7 +95,8 @@ struct MenuBarCommand: ParsableCommand, OutputFormattable {
|
||||
self.logger.debug("Listing menu bar items includeRawDebug=\(self.includeRawDebug)")
|
||||
let menuBarItems = try await MenuServiceBridge.listMenuBarItems(
|
||||
menu: self.services.menu,
|
||||
includeRaw: self.includeRawDebug)
|
||||
includeRaw: self.includeRawDebug
|
||||
)
|
||||
|
||||
if self.jsonOutput {
|
||||
let output = ListJSONOutput(
|
||||
@ -106,18 +107,18 @@ struct MenuBarCommand: ParsableCommand, OutputFormattable {
|
||||
raw_title: item.rawTitle,
|
||||
bundle_id: item.bundleIdentifier,
|
||||
owner_name: item.ownerName,
|
||||
identifier: item.identifier,
|
||||
ax_identifier: item.axIdentifier,
|
||||
ax_description: item.axDescription,
|
||||
raw_window_id: item.rawWindowID,
|
||||
raw_window_layer: item.rawWindowLayer,
|
||||
raw_owner_pid: item.rawOwnerPID,
|
||||
raw_source: item.rawSource,
|
||||
index: item.index,
|
||||
isVisible: item.isVisible,
|
||||
description: item.description
|
||||
)
|
||||
},
|
||||
identifier: item.identifier,
|
||||
ax_identifier: item.axIdentifier,
|
||||
ax_description: item.axDescription,
|
||||
raw_window_id: item.rawWindowID,
|
||||
raw_window_layer: item.rawWindowLayer,
|
||||
raw_owner_pid: item.rawOwnerPID,
|
||||
raw_source: item.rawSource,
|
||||
index: item.index,
|
||||
isVisible: item.isVisible,
|
||||
description: item.description
|
||||
)
|
||||
},
|
||||
executionTime: Date().timeIntervalSince(startTime)
|
||||
)
|
||||
outputSuccessCodable(data: output, logger: self.outputLogger)
|
||||
|
||||
@ -36,7 +36,7 @@ private enum VersionMetadata {
|
||||
gitCommit: "unknown",
|
||||
gitCommitDate: "unknown",
|
||||
gitBranch: "unknown",
|
||||
buildDate: iso8601Now()
|
||||
buildDate: self.iso8601Now()
|
||||
)
|
||||
}
|
||||
|
||||
@ -51,7 +51,7 @@ private enum VersionMetadata {
|
||||
let commit = info["PeekabooGitCommit"] as? String ?? "unknown"
|
||||
let commitDate = info["PeekabooGitCommitDate"] as? String ?? "unknown"
|
||||
let branch = info["PeekabooGitBranch"] as? String ?? "unknown"
|
||||
let buildDate = info["PeekabooBuildDate"] as? String ?? iso8601Now()
|
||||
let buildDate = info["PeekabooBuildDate"] as? String ?? self.iso8601Now()
|
||||
|
||||
return Values(
|
||||
current: display,
|
||||
@ -63,25 +63,25 @@ private enum VersionMetadata {
|
||||
}
|
||||
|
||||
private static func valuesFromWorkingCopy() -> Values? {
|
||||
let root = repositoryRoot()
|
||||
let root = self.repositoryRoot()
|
||||
guard FileManager.default.fileExists(atPath: root.path) else { return nil }
|
||||
|
||||
let versionString = workingCopyVersion(root: root) ?? "0.0.0"
|
||||
var commit = git(["rev-parse", "--short", "HEAD"], root: root) ?? "unknown"
|
||||
let diffStatus = git(["status", "--porcelain"], root: root) ?? ""
|
||||
let versionString = self.workingCopyVersion(root: root) ?? "0.0.0"
|
||||
var commit = self.git(["rev-parse", "--short", "HEAD"], root: root) ?? "unknown"
|
||||
let diffStatus = self.git(["status", "--porcelain"], root: root) ?? ""
|
||||
if !diffStatus.isEmpty {
|
||||
commit += "-dirty"
|
||||
}
|
||||
|
||||
let commitDate = git(["show", "-s", "--format=%ci", "HEAD"], root: root) ?? "unknown"
|
||||
let branch = git(["rev-parse", "--abbrev-ref", "HEAD"], root: root) ?? "unknown"
|
||||
let commitDate = self.git(["show", "-s", "--format=%ci", "HEAD"], root: root) ?? "unknown"
|
||||
let branch = self.git(["rev-parse", "--abbrev-ref", "HEAD"], root: root) ?? "unknown"
|
||||
|
||||
return Values(
|
||||
current: "Peekaboo \(versionString)",
|
||||
gitCommit: commit,
|
||||
gitCommitDate: commitDate,
|
||||
gitBranch: branch,
|
||||
buildDate: iso8601Now()
|
||||
buildDate: self.iso8601Now()
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
import PeekabooCore
|
||||
import Commander
|
||||
import Testing
|
||||
|
||||
@testable import PeekabooCLI
|
||||
|
||||
@ -189,7 +189,7 @@ struct MenuCommandIntegrationTests {
|
||||
|
||||
extension MenuCommandIntegrationTests {
|
||||
/// Trim any progress/preamble characters emitted by the test runner and decode from the first JSON token.
|
||||
fileprivate func decodeJSON<T: Decodable>(_ type: T.Type, from output: String) throws -> T {
|
||||
private func decodeJSON<T: Decodable>(_ type: T.Type, from output: String) throws -> T {
|
||||
guard let start = output.firstIndex(where: { $0 == "{" || $0 == "[" }) else {
|
||||
throw DecodingError.dataCorrupted(
|
||||
.init(codingPath: [], debugDescription: "No JSON object found in output: \(output)")
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import Foundation
|
||||
import Darwin
|
||||
import Foundation
|
||||
import PeekabooCore
|
||||
@testable import PeekabooCLI
|
||||
|
||||
|
||||
@ -798,7 +798,8 @@ final class StubClipboardService: ClipboardServiceProtocol {
|
||||
let result = ClipboardReadResult(
|
||||
utiIdentifier: primary.utiIdentifier,
|
||||
data: primary.data,
|
||||
textPreview: request.alsoText)
|
||||
textPreview: request.alsoText
|
||||
)
|
||||
self.current = result
|
||||
return result
|
||||
}
|
||||
|
||||
@ -79,7 +79,7 @@ struct TTYCommandRunner {
|
||||
}
|
||||
let waitDeadline = Date().addingTimeInterval(1.5)
|
||||
while proc.isRunning, Date() < waitDeadline {
|
||||
usleep(80_000)
|
||||
usleep(80000)
|
||||
}
|
||||
if proc.isRunning {
|
||||
if let pgid = processGroup {
|
||||
@ -128,7 +128,7 @@ struct TTYCommandRunner {
|
||||
if let byteDeadline = afterFirstByteDeadline, now >= byteDeadline { break }
|
||||
if afterFirstByteDeadline == nil, now >= primaryDeadline { break }
|
||||
|
||||
usleep(60_000)
|
||||
usleep(60000)
|
||||
}
|
||||
|
||||
guard let text = String(data: buffer, encoding: .utf8), !text.isEmpty else {
|
||||
@ -137,6 +137,7 @@ struct TTYCommandRunner {
|
||||
|
||||
return Result(text: text)
|
||||
}
|
||||
|
||||
// swiftlint:enable cyclomatic_complexity function_body_length
|
||||
|
||||
static func which(_ tool: String) -> String? {
|
||||
@ -166,7 +167,7 @@ struct TTYCommandRunner {
|
||||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||
guard let path = String(data: data, encoding: .utf8)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!path.isEmpty else { return nil }
|
||||
!path.isEmpty else { return nil }
|
||||
return path
|
||||
}
|
||||
|
||||
|
||||
@ -26,7 +26,8 @@ struct TTYCommandRunnerTests {
|
||||
let result = try runner.run(
|
||||
binary: scriptURL.path,
|
||||
send: "",
|
||||
options: .init(rows: 5, cols: 40, timeout: 0.8, extraArgs: []))
|
||||
options: .init(rows: 5, cols: 40, timeout: 0.8, extraArgs: [])
|
||||
)
|
||||
|
||||
guard let childPID = Self.extractChildPID(result.text) else {
|
||||
Issue.record("Did not capture child PID from PTY output. Output: \(result.text)")
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import Combine
|
||||
import OSLog
|
||||
import SwiftUI
|
||||
|
||||
|
||||
@ -412,7 +412,7 @@ private struct ScrollAccessibilityConfigurator: NSViewRepresentable {
|
||||
scrollView.contentView.setAccessibilityIdentifier("\(id)-clip")
|
||||
scrollView.documentView?.setAccessibilityIdentifier("\(id)-content")
|
||||
|
||||
NSAccessibility.post(element: scrollView, notification: .layoutChanged)
|
||||
NSAccessibility.post(notification: .layoutChanged, for: scrollView)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,10 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to Peekaboo will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
@ -65,7 +65,7 @@ extension PeekabooAgentService {
|
||||
|
||||
// If queue mode is "all" and we have queued messages, inject them
|
||||
// before the next turn so the model sees them together.
|
||||
if queueMode == .all && !queuedMessages.isEmpty {
|
||||
if queueMode == .all, !queuedMessages.isEmpty {
|
||||
state.messages.append(contentsOf: queuedMessages)
|
||||
queuedMessages.removeAll()
|
||||
}
|
||||
@ -332,7 +332,7 @@ extension PeekabooAgentService {
|
||||
"(?i)bearer\\s+[a-z0-9._-]{8,}",
|
||||
"(?i)api[_-]?key\\s*[:=]\\s*[a-z0-9._-]{6,}",
|
||||
"(?i)sess[a-z0-9]{12,}",
|
||||
"(?i)token\\s*[:=]\\s*[a-z0-9._-]{12,}"
|
||||
"(?i)token\\s*[:=]\\s*[a-z0-9._-]{12,}",
|
||||
]
|
||||
|
||||
var output = text
|
||||
|
||||
@ -2,6 +2,5 @@
|
||||
// either one at a time per turn, or all queued together before the next turn.
|
||||
public enum QueueMode: String, Sendable {
|
||||
case oneAtATime = "one-at-a-time"
|
||||
case all = "all"
|
||||
case all
|
||||
}
|
||||
|
||||
|
||||
@ -97,7 +97,9 @@ public struct ClickTool: MCPTool {
|
||||
return await UISessionManager.shared.getSession(id: sessionId)
|
||||
}
|
||||
|
||||
return await UISessionManager.shared.getMostRecentSession()
|
||||
// Get most recent session
|
||||
// For now, return nil - in a real implementation we'd track the most recent session
|
||||
return nil
|
||||
}
|
||||
|
||||
private func resolveClickTarget(for request: ClickRequest) async throws -> ClickResolution {
|
||||
|
||||
@ -36,7 +36,8 @@ public struct ClipboardTool: MCPTool {
|
||||
"dataBase64": SchemaBuilder.string(description: "Base64-encoded data to copy"),
|
||||
"uti": SchemaBuilder.string(description: "Uniform Type Identifier for dataBase64 or to force type"),
|
||||
"prefer": SchemaBuilder.string(description: "Preferred UTI when reading clipboard"),
|
||||
"outputPath": SchemaBuilder.string(description: "When reading, path to write binary data. Use '-' for stdout."),
|
||||
"outputPath": SchemaBuilder
|
||||
.string(description: "When reading, path to write binary data. Use '-' for stdout."),
|
||||
"slot": SchemaBuilder.string(description: "Save/restore slot name (default: \"0\")"),
|
||||
"alsoText": SchemaBuilder.string(description: "Optional plain text companion when setting binary data"),
|
||||
"allowLarge": SchemaBuilder.boolean(description: "Allow writes larger than the 10 MB guard"),
|
||||
@ -113,7 +114,7 @@ public struct ClipboardTool: MCPTool {
|
||||
@MainActor
|
||||
private func handleLoad(arguments: ToolArguments) throws -> ToolResponse {
|
||||
// Alias for set; validation occurs in makeWriteRequest.
|
||||
return try self.handleSet(arguments: arguments)
|
||||
try self.handleSet(arguments: arguments)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
|
||||
@ -205,7 +205,9 @@ public struct DragTool: MCPTool {
|
||||
return await UISessionManager.shared.getSession(id: sessionId)
|
||||
}
|
||||
|
||||
return await UISessionManager.shared.getMostRecentSession()
|
||||
// Get most recent session
|
||||
// For now, return nil - in a real implementation we'd track the most recent session
|
||||
return nil
|
||||
}
|
||||
|
||||
private func focusTargetAppIfNeeded(request: DragRequest) async throws {
|
||||
|
||||
@ -311,7 +311,9 @@ public struct MoveTool: MCPTool {
|
||||
return await UISessionManager.shared.getSession(id: sessionId)
|
||||
}
|
||||
|
||||
return await UISessionManager.shared.getMostRecentSession()
|
||||
// Get most recent session
|
||||
// For now, return nil - in a real implementation we'd track the most recent session
|
||||
return nil
|
||||
}
|
||||
|
||||
private func resolveMovementParameters(for request: MoveRequest, distance: CGFloat) -> MovementParameters {
|
||||
|
||||
@ -87,7 +87,9 @@ public struct ScrollTool: MCPTool {
|
||||
return await UISessionManager.shared.getSession(id: sessionId)
|
||||
}
|
||||
|
||||
return await UISessionManager.shared.getMostRecentSession()
|
||||
// Get most recent session
|
||||
// For now, return nil - in a real implementation we'd track the most recent session
|
||||
return nil
|
||||
}
|
||||
|
||||
private func parseRequest(arguments: ToolArguments) throws -> ScrollToolRequest {
|
||||
|
||||
@ -617,10 +617,6 @@ actor UISession {
|
||||
self.lastAccessedAt = Date()
|
||||
}
|
||||
|
||||
func markAccessed() {
|
||||
self.lastAccessedAt = Date()
|
||||
}
|
||||
|
||||
func getElement(byId id: String) -> UIElement? {
|
||||
self.uiElements.first { $0.id == id }
|
||||
}
|
||||
@ -647,29 +643,8 @@ actor UISessionManager {
|
||||
return session
|
||||
}
|
||||
|
||||
func getSession(id: String) async -> UISession? {
|
||||
guard let session = self.sessions[id] else { return nil }
|
||||
await session.markAccessed()
|
||||
return session
|
||||
}
|
||||
|
||||
func getMostRecentSession() async -> UISession? {
|
||||
var newest: (session: UISession, lastAccessed: Date)?
|
||||
|
||||
for session in self.sessions.values {
|
||||
let accessed = await session.lastAccessedAt
|
||||
if let current = newest {
|
||||
if accessed > current.lastAccessed {
|
||||
newest = (session, accessed)
|
||||
}
|
||||
} else {
|
||||
newest = (session, accessed)
|
||||
}
|
||||
}
|
||||
|
||||
guard let session = newest?.session else { return nil }
|
||||
await session.markAccessed()
|
||||
return session
|
||||
func getSession(id: String) -> UISession? {
|
||||
self.sessions[id]
|
||||
}
|
||||
|
||||
func removeSession(id: String) {
|
||||
|
||||
@ -81,7 +81,9 @@ public struct TypeTool: MCPTool {
|
||||
return await UISessionManager.shared.getSession(id: sessionId)
|
||||
}
|
||||
|
||||
return await UISessionManager.shared.getMostRecentSession()
|
||||
// Get most recent session
|
||||
// For now, return nil - in a real implementation we'd track the most recent session
|
||||
return nil
|
||||
}
|
||||
|
||||
private func parseRequest(arguments: ToolArguments) throws -> TypeRequest {
|
||||
|
||||
@ -104,7 +104,8 @@ public final class ConfigurationManager: @unchecked Sendable {
|
||||
public func migrateIfNeeded() throws {
|
||||
// Allow tests or automation to disable migration to isolate temporary config roots.
|
||||
if let disable = ProcessInfo.processInfo.environment["PEEKABOO_CONFIG_DISABLE_MIGRATION"],
|
||||
disable.lowercased() == "1" || disable.lowercased() == "true" {
|
||||
disable.lowercased() == "1" || disable.lowercased() == "true"
|
||||
{
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@ -91,20 +91,24 @@ public final class ClipboardService: ClipboardServiceProtocol {
|
||||
public func get(prefer uti: UTType?) throws -> ClipboardReadResult? {
|
||||
guard let types = self.pasteboard.types, !types.isEmpty else { return nil }
|
||||
|
||||
let targetType: NSPasteboard.PasteboardType
|
||||
if let uti, let preferred = types.first(where: { $0.rawValue == uti.identifier }) {
|
||||
targetType = preferred
|
||||
let targetType: NSPasteboard.PasteboardType = if let uti,
|
||||
let preferred = types
|
||||
.first(where: { $0.rawValue == uti.identifier })
|
||||
{
|
||||
preferred
|
||||
} else if let stringType = types.first(where: { $0 == .string || $0 == .init("public.utf8-plain-text") }) {
|
||||
targetType = stringType
|
||||
stringType
|
||||
} else {
|
||||
targetType = types[0]
|
||||
types[0]
|
||||
}
|
||||
|
||||
let data: Data?
|
||||
var textPreview: String?
|
||||
|
||||
if targetType == .string, let string = self.pasteboard.string(forType: .string) {
|
||||
let normalized = string.replacingOccurrences(of: "\r\n", with: "\n").replacingOccurrences(of: "\r", with: "\n")
|
||||
let normalized = string.replacingOccurrences(of: "\r\n", with: "\n").replacingOccurrences(
|
||||
of: "\r",
|
||||
with: "\n")
|
||||
data = normalized.data(using: .utf8)
|
||||
textPreview = Self.makePreview(normalized)
|
||||
} else {
|
||||
@ -147,15 +151,14 @@ public final class ClipboardService: ClipboardServiceProtocol {
|
||||
}
|
||||
|
||||
let primary = request.representations.first!
|
||||
let preview: String?
|
||||
if let text = request.alsoText {
|
||||
preview = Self.makePreview(text)
|
||||
let preview: String? = if let text = request.alsoText {
|
||||
Self.makePreview(text)
|
||||
} else if primary.utiIdentifier == UTType.plainText.identifier,
|
||||
let string = String(data: primary.data, encoding: .utf8)
|
||||
{
|
||||
preview = Self.makePreview(string)
|
||||
Self.makePreview(string)
|
||||
} else {
|
||||
preview = nil
|
||||
nil
|
||||
}
|
||||
|
||||
return ClipboardReadResult(
|
||||
|
||||
@ -8,12 +8,13 @@ import Foundation
|
||||
// Option bits (mirrored from Ice)
|
||||
private struct CGSWindowListOption: OptionSet {
|
||||
let rawValue: UInt32
|
||||
static let onScreen = CGSWindowListOption(rawValue: 1 << 0)
|
||||
static let onScreen = CGSWindowListOption(rawValue: 1 << 0)
|
||||
static let menuBarItems = CGSWindowListOption(rawValue: 1 << 1)
|
||||
static let activeSpace = CGSWindowListOption(rawValue: 1 << 2)
|
||||
static let activeSpace = CGSWindowListOption(rawValue: 1 << 2)
|
||||
}
|
||||
|
||||
// MARK: - Dynamic loading helpers
|
||||
|
||||
// MARK: - Dynamic loading helpers
|
||||
|
||||
private func loadCGSHandle() -> UnsafeMutableRawPointer? {
|
||||
@ -71,10 +72,14 @@ func cgsMenuBarWindowIDs(onScreen: Bool = false, activeSpace: Bool = false) -> [
|
||||
func cgsProcessMenuBarWindowIDs(onScreenOnly: Bool = true) -> [CGWindowID] {
|
||||
typealias CGSConnectionID = Int32
|
||||
typealias CGSMainConnectionFunc = @convention(c) () -> CGSConnectionID
|
||||
typealias CGSGetWindowCountFunc = @convention(c) (CGSConnectionID, CGSConnectionID, UnsafeMutablePointer<Int32>) -> Int32
|
||||
typealias CGSGetWindowCountFunc = @convention(c) (CGSConnectionID, CGSConnectionID, UnsafeMutablePointer<Int32>)
|
||||
-> Int32
|
||||
typealias CGSGetProcessMenuBarWindowListFunc = @convention(c) (
|
||||
CGSConnectionID, CGSConnectionID, Int32, UnsafeMutablePointer<CGWindowID>, UnsafeMutablePointer<Int32>) -> Int32
|
||||
typealias CGSGetOnScreenWindowCountFunc = @convention(c) (CGSConnectionID, CGSConnectionID, UnsafeMutablePointer<Int32>) -> Int32
|
||||
typealias CGSGetOnScreenWindowCountFunc = @convention(c) (
|
||||
CGSConnectionID,
|
||||
CGSConnectionID,
|
||||
UnsafeMutablePointer<Int32>) -> Int32
|
||||
typealias CGSGetOnScreenWindowListFunc = @convention(c) (
|
||||
CGSConnectionID, CGSConnectionID, Int32, UnsafeMutablePointer<CGWindowID>, UnsafeMutablePointer<Int32>) -> Int32
|
||||
typealias CGSCopySpacesForWindowsFunc = @convention(c) (CGSConnectionID, UInt32, CFArray) -> Unmanaged<CFArray>?
|
||||
@ -115,7 +120,7 @@ func cgsProcessMenuBarWindowIDs(onScreenOnly: Bool = true) -> [CGWindowID] {
|
||||
// Active space filter to mirror Ice.
|
||||
let activeSpace = getActiveSpace(mainConn())
|
||||
ids = ids.filter { windowID in
|
||||
guard let spaces = copySpaces(mainConn(), 1 << 0 /*includes current*/, [windowID] as CFArray)?
|
||||
guard let spaces = copySpaces(mainConn(), 1 << 0 /* includes current */, [windowID] as CFArray)?
|
||||
.takeRetainedValue() as? [UInt32]
|
||||
else { return true }
|
||||
return spaces.contains(activeSpace)
|
||||
@ -142,7 +147,7 @@ private func cgsIsWindowOnActiveSpace(_ windowID: CGWindowID) -> Bool {
|
||||
|
||||
let cid = mainConn()
|
||||
let activeSpace = getActiveSpace(cid)
|
||||
guard let spaces = copySpaces(cid, 1 << 0 /*includes current*/, [windowID] as CFArray)?
|
||||
guard let spaces = copySpaces(cid, 1 << 0 /* includes current */, [windowID] as CFArray)?
|
||||
.takeRetainedValue() as? [UInt32]
|
||||
else { return true }
|
||||
return spaces.contains(activeSpace)
|
||||
|
||||
@ -188,17 +188,13 @@ extension MenuService {
|
||||
private func getMenuBarItemsViaWindows() -> [MenuExtraInfo] {
|
||||
var items: [MenuExtraInfo] = []
|
||||
|
||||
// Preferred: call LSUIElement helper (AppKit context) to get WindowServer view like Ice.
|
||||
if let helperItems = self.getMenuBarItemsViaHelper(), !helperItems.isEmpty {
|
||||
self.logger.debug("MenuService helper returned \(helperItems.count) items")
|
||||
return helperItems
|
||||
}
|
||||
|
||||
// Preferred path: CGS menuBarItems window list (private API, mirrored from Ice).
|
||||
let cgsIDs = cgsMenuBarWindowIDs(onScreen: true, activeSpace: true)
|
||||
let legacyIDs = cgsProcessMenuBarWindowIDs(onScreenOnly: true)
|
||||
let combinedIDs = Array(Set(cgsIDs + legacyIDs))
|
||||
self.logger.debug("CGS menuBarItems returned \(cgsIDs.count) ids; processMenuBar returned \(legacyIDs.count); combined \(combinedIDs.count)")
|
||||
self.logger
|
||||
.debug(
|
||||
"CGS menuBarItems returned \(cgsIDs.count) ids; processMenuBar returned \(legacyIDs.count); combined \(combinedIDs.count)")
|
||||
if !combinedIDs.isEmpty {
|
||||
// Use CGWindow metadata per window ID to resolve owner/bundle.
|
||||
for id in combinedIDs {
|
||||
@ -228,47 +224,13 @@ extension MenuService {
|
||||
return items
|
||||
}
|
||||
|
||||
/// Invoke the LSUIElement helper (if built) to enumerate menu bar windows from a GUI context.
|
||||
private func getMenuBarItemsViaHelper() -> [MenuExtraInfo]? {
|
||||
let helperPath = "\(FileManager.default.currentDirectoryPath)/Helpers/MenuBarHelper/build/MenubarHelper.app/Contents/MacOS/menubar-helper"
|
||||
guard FileManager.default.isExecutableFile(atPath: helperPath) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let process = Process()
|
||||
process.launchPath = helperPath
|
||||
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
do {
|
||||
try process.run()
|
||||
} catch {
|
||||
self.logger.debug("Failed to run menubar helper: \(error.localizedDescription)")
|
||||
return nil
|
||||
}
|
||||
|
||||
process.waitUntilExit()
|
||||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let windows = json["windows"] as? [[String: Any]]
|
||||
else { return nil }
|
||||
|
||||
var items: [MenuExtraInfo] = []
|
||||
for windowInfo in windows {
|
||||
guard let windowID = windowInfo["CGSWindowID"] as? UInt32 else { continue }
|
||||
if let item = self.makeMenuExtra(from: CGWindowID(windowID), info: windowInfo) {
|
||||
items.append(item)
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
private func makeMenuExtra(from windowID: CGWindowID, info: [String: Any]? = nil) -> MenuExtraInfo? {
|
||||
let windowInfo: [String: Any]
|
||||
if let info {
|
||||
windowInfo = info
|
||||
} else if let refreshed = CGWindowListCopyWindowInfo([.optionIncludingWindow], windowID) as? [[String: Any]],
|
||||
let first = refreshed.first {
|
||||
let first = refreshed.first
|
||||
{
|
||||
windowInfo = first
|
||||
} else {
|
||||
return nil
|
||||
@ -370,7 +332,7 @@ extension MenuService {
|
||||
let identifier = extra.identifier()
|
||||
let hasIdentifier = identifier?.isEmpty == false
|
||||
let hasNonPlaceholderTitle = !isPlaceholderMenuTitle(baseTitle)
|
||||
if !hasIdentifier && !hasNonPlaceholderTitle {
|
||||
if !hasIdentifier, !hasNonPlaceholderTitle {
|
||||
continue
|
||||
}
|
||||
|
||||
@ -442,8 +404,7 @@ extension MenuService {
|
||||
.first(where: { !isPlaceholderMenuTitle($0) })
|
||||
{
|
||||
effectiveTitle = childDerived
|
||||
}
|
||||
else if let ident = sanitizedMenuText(extra.identifier()), !ident.isEmpty {
|
||||
} else if let ident = sanitizedMenuText(extra.identifier()), !ident.isEmpty {
|
||||
effectiveTitle = ident
|
||||
}
|
||||
}
|
||||
@ -472,7 +433,7 @@ extension MenuService {
|
||||
var results: [MenuExtraInfo] = []
|
||||
let commonMenuTitles: Set<String> = [
|
||||
"apple", "file", "edit", "view", "window", "help", "history", "bookmarks", "navigate", "tab", "tools",
|
||||
"cut", "copy", "paste", "format"
|
||||
"cut", "copy", "paste", "format",
|
||||
]
|
||||
|
||||
func collectElements(from element: Element, depth: Int = 0, limit: Int = 4) -> [Element] {
|
||||
@ -510,17 +471,18 @@ extension MenuService {
|
||||
// Fallbacks to app name when placeholder/short/common menu words.
|
||||
if isPlaceholderMenuTitle(effectiveTitle) ||
|
||||
effectiveTitle.count <= 2 ||
|
||||
commonMenuTitles.contains(effectiveTitle.lowercased()) {
|
||||
commonMenuTitles.contains(effectiveTitle.lowercased())
|
||||
{
|
||||
effectiveTitle = app.localizedName ?? effectiveTitle
|
||||
}
|
||||
|
||||
let position = extra.position() ?? .zero
|
||||
// Restrict to top-of-screen positions to avoid stray elements.
|
||||
if position != .zero && position.y > 100 { continue }
|
||||
if position != .zero, position.y > 100 { continue }
|
||||
|
||||
// Avoid duplicating children of a status item: require that this element itself is status-like.
|
||||
let childrenRoles = (extra.children() ?? []).compactMap { $0.role() }
|
||||
if !isStatusLike && childrenRoles.contains(where: { $0 == "AXMenuItem" }) {
|
||||
if !isStatusLike, childrenRoles.contains(where: { $0 == "AXMenuItem" }) {
|
||||
continue
|
||||
}
|
||||
|
||||
@ -547,9 +509,10 @@ extension MenuService {
|
||||
|
||||
/// Hit-test window extras to attach AX identifiers/titles when CGS gives only placeholders.
|
||||
private func enrichWindowExtrasWithAXHitTest(_ extras: [MenuExtraInfo]) -> [MenuExtraInfo] {
|
||||
return extras.map { extra in
|
||||
guard extra.identifier == nil || isPlaceholderMenuTitle(extra.title) || isPlaceholderMenuTitle(extra.rawTitle),
|
||||
extra.position != .zero
|
||||
extras.map { extra in
|
||||
guard extra
|
||||
.identifier == nil || isPlaceholderMenuTitle(extra.title) || isPlaceholderMenuTitle(extra.rawTitle),
|
||||
extra.position != .zero
|
||||
else { return extra }
|
||||
|
||||
guard let hit = Element.elementAtPoint(extra.position) else {
|
||||
@ -561,12 +524,12 @@ extension MenuService {
|
||||
let isStatusLike = role == "AXStatusItem" || subrole == "AXStatusItem" || subrole == "AXMenuExtra"
|
||||
if !isStatusLike { return extra }
|
||||
|
||||
let hitTitle = sanitizedMenuText(hit.identifier())
|
||||
?? sanitizedMenuText(hit.help())
|
||||
?? sanitizedMenuText(hit.title())
|
||||
?? hit.descriptionText()
|
||||
?? extra.title
|
||||
?? extra.rawTitle
|
||||
let hitTitle = sanitizedMenuText(hit.identifier())
|
||||
?? sanitizedMenuText(hit.help())
|
||||
?? sanitizedMenuText(hit.title())
|
||||
?? hit.descriptionText()
|
||||
?? extra.title
|
||||
?? extra.rawTitle
|
||||
let hitIdentifier = hit.identifier() ?? extra.identifier
|
||||
|
||||
return MenuExtraInfo(
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import Foundation
|
||||
import CoreGraphics
|
||||
import MCP
|
||||
import PeekabooFoundation
|
||||
import TachikomaMCP
|
||||
@ -194,7 +193,6 @@ private enum MCPToolTestHelpers {
|
||||
private final class MockAutomationService: UIAutomationServiceProtocol {
|
||||
private let accessibilityGranted: Bool
|
||||
var lastCadence: TypingCadence?
|
||||
var lastClickTarget: ClickTarget?
|
||||
|
||||
init(accessibilityGranted: Bool) {
|
||||
self.accessibilityGranted = accessibilityGranted
|
||||
@ -206,9 +204,7 @@ private final class MockAutomationService: UIAutomationServiceProtocol {
|
||||
throw PeekabooError.notImplemented("mock detectElements")
|
||||
}
|
||||
|
||||
func click(target: ClickTarget, clickType _: ClickType, sessionId _: String?) async throws {
|
||||
self.lastClickTarget = target
|
||||
}
|
||||
func click(target _: ClickTarget, clickType _: ClickType, sessionId _: String?) async throws {}
|
||||
|
||||
func type(text _: String, target _: String?, clearExisting _: Bool, typingDelay _: Int, sessionId _: String?) async
|
||||
throws {}
|
||||
@ -468,130 +464,6 @@ struct MCPToolErrorHandlingTests {
|
||||
Issue.record("Expected linear cadence, got \(cadence)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Type tool falls back to latest session when session is omitted")
|
||||
func typeToolUsesLatestSessionWhenMissingSessionId() async throws {
|
||||
let automation = await MainActor.run { MockAutomationService(accessibilityGranted: true) }
|
||||
|
||||
try await MCPToolTestHelpers.withContext(automation: automation) {
|
||||
// Older session that should NOT be picked
|
||||
let oldSession = await UISessionManager.shared.createSession()
|
||||
await oldSession.setUIElements([
|
||||
UIElement(
|
||||
id: "old",
|
||||
elementId: "old",
|
||||
role: "textField",
|
||||
title: "Old",
|
||||
label: "Old",
|
||||
value: nil,
|
||||
description: nil,
|
||||
help: nil,
|
||||
roleDescription: nil,
|
||||
identifier: "old-id",
|
||||
frame: CGRect(x: 0, y: 0, width: 10, height: 10),
|
||||
isActionable: true),
|
||||
])
|
||||
|
||||
// Ensure timestamps differ
|
||||
try await Task.sleep(nanoseconds: 1_000_000)
|
||||
|
||||
// Newest session that should be selected by fallback
|
||||
let newSession = await UISessionManager.shared.createSession()
|
||||
let targetElement = UIElement(
|
||||
id: "new",
|
||||
elementId: "new",
|
||||
role: "textField",
|
||||
title: "Target",
|
||||
label: "Target",
|
||||
value: nil,
|
||||
description: nil,
|
||||
help: nil,
|
||||
roleDescription: nil,
|
||||
identifier: "target-field",
|
||||
frame: CGRect(x: 50, y: 50, width: 20, height: 20),
|
||||
isActionable: true)
|
||||
await newSession.setUIElements([targetElement])
|
||||
|
||||
let tool = TypeTool()
|
||||
let response = try await tool.execute(arguments: ToolArguments(raw: [
|
||||
"on": targetElement.id,
|
||||
"text": "hi",
|
||||
]))
|
||||
|
||||
#expect(response.isError == false)
|
||||
|
||||
// Cleanup
|
||||
await UISessionManager.shared.removeSession(id: oldSession.id)
|
||||
await UISessionManager.shared.removeSession(id: newSession.id)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Click tool falls back to latest session when session is omitted")
|
||||
func clickToolUsesLatestSessionWhenMissingSessionId() async throws {
|
||||
let automation = await MainActor.run { MockAutomationService(accessibilityGranted: true) }
|
||||
|
||||
try await MCPToolTestHelpers.withContext(automation: automation) {
|
||||
// Older session should be ignored
|
||||
let oldSession = await UISessionManager.shared.createSession()
|
||||
await oldSession.setUIElements([
|
||||
UIElement(
|
||||
id: "old",
|
||||
elementId: "old",
|
||||
role: "button",
|
||||
title: "Old",
|
||||
label: "Old",
|
||||
value: nil,
|
||||
description: nil,
|
||||
help: nil,
|
||||
roleDescription: nil,
|
||||
identifier: "old-id",
|
||||
frame: CGRect(x: 0, y: 0, width: 10, height: 10),
|
||||
isActionable: true),
|
||||
])
|
||||
|
||||
try await Task.sleep(nanoseconds: 1_000_000)
|
||||
|
||||
// Newest session should be used
|
||||
let newSession = await UISessionManager.shared.createSession()
|
||||
let targetElement = UIElement(
|
||||
id: "new",
|
||||
elementId: "new",
|
||||
role: "button",
|
||||
title: "Target",
|
||||
label: "Target",
|
||||
value: nil,
|
||||
description: nil,
|
||||
help: nil,
|
||||
roleDescription: nil,
|
||||
identifier: "target-button",
|
||||
frame: CGRect(x: 100, y: 200, width: 40, height: 20),
|
||||
isActionable: true)
|
||||
await newSession.setUIElements([targetElement])
|
||||
|
||||
let tool = ClickTool()
|
||||
let response = try await tool.execute(arguments: ToolArguments(raw: [
|
||||
"on": targetElement.id,
|
||||
]))
|
||||
|
||||
#expect(response.isError == false)
|
||||
|
||||
let lastClick = await MainActor.run { automation.lastClickTarget }
|
||||
guard let clickTarget = lastClick else {
|
||||
Issue.record("Expected automation click to be invoked")
|
||||
return
|
||||
}
|
||||
switch clickTarget {
|
||||
case let .coordinates(point):
|
||||
#expect(Int(point.x) == 120)
|
||||
#expect(Int(point.y) == 210)
|
||||
default:
|
||||
Issue.record("Expected coordinate click target")
|
||||
}
|
||||
|
||||
await UISessionManager.shared.removeSession(id: oldSession.id)
|
||||
await UISessionManager.shared.removeSession(id: newSession.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("MCP Tool Integration Tests", .tags(.integration))
|
||||
|
||||
@ -1,24 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>menubar-helper</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>boo.peekaboo.menubarhelper</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>MenuBar Helper</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>LSUIElement</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@ -1,86 +0,0 @@
|
||||
import AppKit
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
|
||||
// LSUIElement helper that enumerates menu bar windows via private CGS APIs and prints JSON.
|
||||
// Running inside AppKit provides the GUI WindowServer connection needed to see third-party extras.
|
||||
|
||||
private struct CGSWindowListOption: OptionSet {
|
||||
let rawValue: UInt32
|
||||
static let onScreen = CGSWindowListOption(rawValue: 1 << 0)
|
||||
static let menuBarItems = CGSWindowListOption(rawValue: 1 << 1)
|
||||
static let activeSpace = CGSWindowListOption(rawValue: 1 << 2)
|
||||
}
|
||||
|
||||
private func loadSymbol<T>(_ name: String, handle: UnsafeMutableRawPointer?) -> T? {
|
||||
guard let sym = dlsym(handle, name) else { return nil }
|
||||
return unsafeBitCast(sym, to: T.self)
|
||||
}
|
||||
|
||||
private func loadCGSHandle() -> UnsafeMutableRawPointer? {
|
||||
let handles = [
|
||||
"/System/Library/PrivateFrameworks/SkyLight.framework/SkyLight",
|
||||
"/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics",
|
||||
]
|
||||
for path in handles {
|
||||
if let h = dlopen(path, RTLD_NOW) { return h }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func listMenuBarWindows() -> [[String: Any]] {
|
||||
guard let handle = loadCGSHandle(),
|
||||
let mainConnSym: @convention(c) () -> UInt32 = loadSymbol("CGSMainConnectionID", handle: handle),
|
||||
let copySym: @convention(c) (UInt32, Int32, UInt32) -> CFArray? =
|
||||
loadSymbol("CGSCopyWindowsWithOptions", handle: handle),
|
||||
let getCountSym: @convention(c) (UInt32, UInt32, UnsafeMutablePointer<Int32>) -> Int32 =
|
||||
loadSymbol("CGSGetWindowCount", handle: handle),
|
||||
let getMenuBarSym: @convention(c) (
|
||||
UInt32, UInt32, Int32, UnsafeMutablePointer<CGWindowID>, UnsafeMutablePointer<Int32>) -> Int32 =
|
||||
loadSymbol("CGSGetProcessMenuBarWindowList", handle: handle)
|
||||
else {
|
||||
return []
|
||||
}
|
||||
|
||||
let cid = mainConnSym()
|
||||
|
||||
// Process-level list (Ice primary path).
|
||||
var total: Int32 = 0
|
||||
_ = getCountSym(cid, 0, &total)
|
||||
var buf = [CGWindowID](repeating: 0, count: Int(max(total, 32)))
|
||||
var out: Int32 = 0
|
||||
_ = getMenuBarSym(cid, 0, total, &buf, &out)
|
||||
let procIDs = Array(buf.prefix(Int(out)))
|
||||
|
||||
// Copy-with-options (sometimes returns extras).
|
||||
let opts: CGSWindowListOption = [.menuBarItems, .onScreen, .activeSpace]
|
||||
let copyIDs = (copySym(cid, 0, opts.rawValue) as? [UInt32]) ?? []
|
||||
|
||||
let ids = Array(Set(procIDs + copyIDs))
|
||||
guard !ids.isEmpty else { return [] }
|
||||
|
||||
let windowInfo = CGWindowListCopyWindowInfo(.optionAll, kCGNullWindowID) as? [[String: Any]] ?? []
|
||||
let dictByID: [CGWindowID: [String: Any]] = Dictionary(uniqueKeysWithValues: windowInfo.compactMap { info in
|
||||
guard let id = info[kCGWindowNumber as String] as? CGWindowID else { return nil }
|
||||
return (id, info)
|
||||
})
|
||||
|
||||
return ids.compactMap { id in
|
||||
var info = dictByID[id] ?? [:]
|
||||
info["CGSWindowID"] = id
|
||||
return info
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize AppKit to get a GUI connection (LSUIElement).
|
||||
NSApplication.shared
|
||||
|
||||
let windows = listMenuBarWindows()
|
||||
let payload: [String: Any] = ["windows": windows]
|
||||
if let data = try? JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted]) {
|
||||
FileHandle.standardOutput.write(data)
|
||||
} else {
|
||||
fputs("{\"error\":\"serialization_failed\"}", stdout)
|
||||
}
|
||||
fflush(stdout)
|
||||
exit(0)
|
||||
@ -1,24 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>cgs-menu-probe-app</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>boo.peekaboo.cgs-menu-probe</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>cgs-menu-probe-app</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>LSUIElement</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
Binary file not shown.
@ -1,85 +0,0 @@
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
import AppKit
|
||||
import Darwin
|
||||
|
||||
// Dynamic loader mirroring Peekaboo CGS bridge
|
||||
private struct CGSWindowListOption: OptionSet {
|
||||
let rawValue: UInt32
|
||||
static let onScreen = CGSWindowListOption(rawValue: 1 << 0)
|
||||
static let menuBarItems = CGSWindowListOption(rawValue: 1 << 1)
|
||||
static let activeSpace = CGSWindowListOption(rawValue: 1 << 2)
|
||||
}
|
||||
|
||||
func loadHandle() -> UnsafeMutableRawPointer? {
|
||||
let candidates = [
|
||||
"/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics",
|
||||
"/System/Library/PrivateFrameworks/SkyLight.framework/SkyLight",
|
||||
"/System/Library/PrivateFrameworks/SkyLight.framework/Versions/A/SkyLight",
|
||||
]
|
||||
for path in candidates {
|
||||
if let h = dlopen(path, RTLD_NOW) { return h }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cgsMenuBarWindowIDs() -> [UInt32] {
|
||||
guard let handle = loadHandle(),
|
||||
let mainSym = dlsym(handle, "CGSMainConnectionID"),
|
||||
let copySym = dlsym(handle, "CGSCopyWindowsWithOptions") else { return [] }
|
||||
|
||||
typealias MainConn = @convention(c) () -> UInt32
|
||||
typealias CopyWins = @convention(c) (UInt32, Int32, UInt32) -> CFArray?
|
||||
let mainConnection = unsafeBitCast(mainSym, to: MainConn.self)
|
||||
let copyWindows = unsafeBitCast(copySym, to: CopyWins.self)
|
||||
|
||||
let cid = mainConnection()
|
||||
let opts: CGSWindowListOption = [.menuBarItems, .onScreen, .activeSpace]
|
||||
if let arr = copyWindows(cid, 0, opts.rawValue) as? [UInt32] {
|
||||
return arr
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
func cgsProcessMenuBarIDs() -> [UInt32] {
|
||||
guard let handle = loadHandle(),
|
||||
let mainSym = dlsym(handle, "CGSMainConnectionID"),
|
||||
let countSym = dlsym(handle, "CGSGetWindowCount"),
|
||||
let listSym = dlsym(handle, "CGSGetProcessMenuBarWindowList") else { return [] }
|
||||
|
||||
typealias MainConn = @convention(c) () -> UInt32
|
||||
typealias GetCount = @convention(c) (UInt32, UInt32, UnsafeMutablePointer<Int32>) -> Int32
|
||||
typealias GetList = @convention(c) (UInt32, UInt32, Int32, UnsafeMutablePointer<UInt32>, UnsafeMutablePointer<Int32>) -> Int32
|
||||
|
||||
let mainConnection = unsafeBitCast(mainSym, to: MainConn.self)
|
||||
let getCount = unsafeBitCast(countSym, to: GetCount.self)
|
||||
let getList = unsafeBitCast(listSym, to: GetList.self)
|
||||
|
||||
let cid = mainConnection()
|
||||
var total: Int32 = 0
|
||||
_ = getCount(cid, 0, &total)
|
||||
var buf = [UInt32](repeating: 0, count: Int(max(total, 32)))
|
||||
var out: Int32 = 0
|
||||
_ = getList(cid, 0, total, &buf, &out)
|
||||
return Array(buf.prefix(Int(out)))
|
||||
}
|
||||
|
||||
func cgLayer25Count() -> Int {
|
||||
let cgList = CGWindowListCopyWindowInfo([.optionAll, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] ?? []
|
||||
return cgList.filter { ($0[kCGWindowLayer as String] as? Int) == 25 }.count
|
||||
}
|
||||
|
||||
@main
|
||||
struct ProbeApp {
|
||||
static func main() {
|
||||
NSApplication.shared // ensure AppKit init for LSUIElement context
|
||||
let idsCopy = cgsMenuBarWindowIDs()
|
||||
let idsProc = cgsProcessMenuBarIDs()
|
||||
let layer25 = cgLayer25Count()
|
||||
print("CGSCopy menuBar=\(idsCopy.count) ids=\(idsCopy)")
|
||||
print("CGSGetProcessMenuBarWindowList=\(idsProc.count) ids=\(idsProc)")
|
||||
print("CGWindowList layer25=\(layer25)")
|
||||
fflush(stdout)
|
||||
exit(0)
|
||||
}
|
||||
}
|
||||
@ -9,7 +9,5 @@ let package = Package(
|
||||
// Targets are the basic building blocks of a package, defining a module or a test suite.
|
||||
// Targets can depend on other targets in this package and products from dependencies.
|
||||
.executableTarget(
|
||||
name: "cgs-menu-probe"
|
||||
),
|
||||
]
|
||||
)
|
||||
name: "cgs-menu-probe"),
|
||||
])
|
||||
|
||||
@ -6,13 +6,14 @@ import Foundation
|
||||
// Run as plain CLI; to test GUI privilege, re-run after wrapping in an LSUIElement app
|
||||
// or via an inspector helper. Outputs counts from both private APIs and CGWindowList.
|
||||
|
||||
struct CGSMenuProbe {
|
||||
enum CGSMenuProbe {
|
||||
typealias CGSConnectionID = UInt32
|
||||
typealias CGSMainConnectionFunc = @convention(c) () -> CGSConnectionID
|
||||
typealias CGSCopyWindowsFunc = @convention(c) (CGSConnectionID, Int32, UInt32) -> CFArray?
|
||||
typealias CGSGetProcessMenuBarWindowListFunc = @convention(c) (
|
||||
CGSConnectionID, CGSConnectionID, Int32, UnsafeMutablePointer<CGWindowID>, UnsafeMutablePointer<Int32>) -> Int32
|
||||
typealias CGSGetWindowCountFunc = @convention(c) (CGSConnectionID, CGSConnectionID, UnsafeMutablePointer<Int32>) -> Int32
|
||||
typealias CGSGetWindowCountFunc = @convention(c) (CGSConnectionID, CGSConnectionID, UnsafeMutablePointer<Int32>)
|
||||
-> Int32
|
||||
|
||||
private static func loadSymbol<T>(_ name: String, handle: UnsafeMutableRawPointer?) -> T? {
|
||||
guard let sym = dlsym(handle, name) else { return nil }
|
||||
@ -22,17 +23,21 @@ struct CGSMenuProbe {
|
||||
static func run() {
|
||||
let handles = [
|
||||
"/System/Library/PrivateFrameworks/SkyLight.framework/SkyLight",
|
||||
"/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics"
|
||||
"/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics",
|
||||
]
|
||||
var chosen: UnsafeMutableRawPointer?
|
||||
for h in handles { if let ptr = dlopen(h, RTLD_NOW) { chosen = ptr; break } }
|
||||
for h in handles {
|
||||
if let ptr = dlopen(h, RTLD_NOW) { chosen = ptr; break }
|
||||
}
|
||||
guard let handle = chosen else { print("could not load CGS symbols"); return }
|
||||
|
||||
guard
|
||||
let mainConn: CGSMainConnectionFunc = loadSymbol("CGSMainConnectionID", handle: handle),
|
||||
let copyWindows: CGSCopyWindowsFunc = loadSymbol("CGSCopyWindowsWithOptions", handle: handle),
|
||||
let getCount: CGSGetWindowCountFunc = loadSymbol("CGSGetWindowCount", handle: handle),
|
||||
let getMenuBarList: CGSGetProcessMenuBarWindowListFunc = loadSymbol("CGSGetProcessMenuBarWindowList", handle: handle)
|
||||
let getMenuBarList: CGSGetProcessMenuBarWindowListFunc = loadSymbol(
|
||||
"CGSGetProcessMenuBarWindowList",
|
||||
handle: handle)
|
||||
else { print("missing symbols"); return }
|
||||
|
||||
let cid = mainConn()
|
||||
@ -52,7 +57,9 @@ struct CGSMenuProbe {
|
||||
let ids3 = Array(buf.prefix(Int(out)))
|
||||
|
||||
// Public CGWindowList fallback
|
||||
let cgList = CGWindowListCopyWindowInfo([.optionAll, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] ?? []
|
||||
let cgList = CGWindowListCopyWindowInfo(
|
||||
[.optionAll, .excludeDesktopElements],
|
||||
kCGNullWindowID) as? [[String: Any]] ?? []
|
||||
let layer25 = cgList.filter { ($0[kCGWindowLayer as String] as? Int) == 25 }
|
||||
|
||||
print("CGSCopyWindows menuBar: count=\(ids1.count) ids=\(ids1)")
|
||||
|
||||
@ -273,7 +273,7 @@
|
||||
{
|
||||
"label": "Changelog",
|
||||
"placement": "summary",
|
||||
"command": "node -e \"const fs=require('fs');const md=fs.readFileSync('CHANGELOG.md','utf8').split(/\\\\r?\\\\n/);const start=md.findIndex((l)=>l.startsWith('## '));const end=md.findIndex((l,i)=>i>start&&l.startsWith('## '));const chunk=md.slice(start,end>0?end:md.length);const out=chunk.map((l)=>{if(/^#+\\\\s*/.test(l))return l.replace(/^#+\\\\s*/,'').toUpperCase();if(/^\\\\s*-\\\\s+/.test(l))return '• '+l.replace(/^\\\\s*-\\\\s+/,'');return l.trim();}).filter(Boolean);console.log(out.slice(0,50).join('\\\\n'));\"",
|
||||
"command": "node -e \"const fs=require('fs');const lines=fs.readFileSync('CHANGELOG.md','utf8').split(/\\\\r?\\\\n/);const start=lines.findIndex((l)=>l.startsWith('## '));if(start<0){process.exit(0);}const end=lines.findIndex((l,i)=>i>start&&l.startsWith('## '));const slice=lines.slice(start,end>0?end:lines.length);const headingLine=slice[0]||'## Changelog';const heading=headingLine.replace(/^##\\\\s*/,'').trim();const bullets=slice.filter((l)=>l.trim().startsWith('- ')).length;console.log('@count: '+heading+' · '+bullets);console.log(slice.slice(0,50).join('\\\\n'));\"",
|
||||
"refreshSeconds": 600,
|
||||
"timeoutSeconds": 5,
|
||||
"maxLines": 50,
|
||||
|
||||
@ -1,21 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
HELPER_DIR="$ROOT_DIR/Helpers/MenuBarHelper"
|
||||
BUILD_DIR="$HELPER_DIR/build"
|
||||
APP_DIR="$BUILD_DIR/MenubarHelper.app"
|
||||
|
||||
rm -rf "$BUILD_DIR"
|
||||
mkdir -p "$APP_DIR/Contents/MacOS"
|
||||
|
||||
# Build the helper binary; allow undefined private symbols (resolved at runtime via dlopen).
|
||||
swiftc -O -framework AppKit \
|
||||
-Xlinker -undefined -Xlinker dynamic_lookup \
|
||||
"$HELPER_DIR/main.swift" \
|
||||
-o "$APP_DIR/Contents/MacOS/menubar-helper"
|
||||
|
||||
# Copy Info.plist to make it LSUIElement.
|
||||
cp "$HELPER_DIR/Info.plist" "$APP_DIR/Contents/Info.plist"
|
||||
|
||||
echo "Built helper at $APP_DIR"
|
||||
Loading…
Reference in New Issue
Block a user