Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
83f6d0e
feat: add ScreenContext data model and AccessibilityPermission helper
chigichan24 Apr 6, 2026
3a479a2
feat: add ScreenReader actor for AXUIElement API
chigichan24 Apr 6, 2026
2e1e281
test: add ScreenContext unit tests
chigichan24 Apr 6, 2026
339d58f
feat: integrate screen context into LLM system prompt
chigichan24 Apr 6, 2026
7a45389
feat: add DangerousReadIndicatorWindow and controller
chigichan24 Apr 6, 2026
eb927ed
feat: add dangerous read mode settings and localization
chigichan24 Apr 6, 2026
63cdb0f
feat: wire dangerous read mode into HatokoInputController
chigichan24 Apr 6, 2026
9356b4c
fix: consolidate UserDefaults keys and defaults into single source of…
chigichan24 Apr 6, 2026
34f8c49
fix: guard against stale capture results after session stop
chigichan24 Apr 6, 2026
48dd495
fix: make dangerousReadController static to match RateLimiter lifecycle
chigichan24 Apr 6, 2026
6b6c46a
fix: unify isActive and screenContext access into activeScreenContext()
chigichan24 Apr 6, 2026
25445e7
fix: snapshot session duration at start to prevent mid-session drift
chigichan24 Apr 6, 2026
566d5d5
fix: extract UserDefaults fallback logic into shared static methods
chigichan24 Apr 6, 2026
440de25
fix: add length limit for windowTitle in ScreenContext
chigichan24 Apr 6, 2026
84d29c0
fix: remove stale llmSuggestion write in sendChatMessage
chigichan24 Apr 6, 2026
a49de19
refactor: change dangerous read mode shortcut from Ctrl+Shift+Space t…
chigichan24 Apr 6, 2026
96441f5
feat: add visibleText field to ScreenContext
chigichan24 Apr 6, 2026
2570334
feat: add AX tree traversal to ScreenReader
chigichan24 Apr 6, 2026
abb181e
feat: add pac-man scanning animation to indicator
chigichan24 Apr 6, 2026
5bf2312
fix: encapsulate scanFrame and document Sendable rationale
chigichan24 Apr 6, 2026
039822e
fix: add maxCollectedFragments limit to AX tree traversal
chigichan24 Apr 6, 2026
be4c2d1
fix: add AXValue type check in copyPositionAttribute
chigichan24 Apr 6, 2026
2567f1b
test: add missing visibleText and windowTitle truncation tests
chigichan24 Apr 6, 2026
859709f
fix: use defer for scanning flag and unify countdown with remainingSe…
chigichan24 Apr 6, 2026
e66049f
fix: move scanning flag into loop body and guard against double start
chigichan24 Apr 6, 2026
5a573c9
fix: apply truncateShort to appName for consistency
chigichan24 Apr 6, 2026
046512c
fix: replace empty do block with defer for scanning flag cleanup
chigichan24 Apr 6, 2026
6e4c5ab
fix: remove unused captureScreenContext method
chigichan24 Apr 6, 2026
0cd0c2f
test: add truncatesLongAppName test for ScreenContext
chigichan24 Apr 6, 2026
07698e5
fix: use explicit setScanning calls and preserve context on nil capture
chigichan24 Apr 6, 2026
62e5be8
fix: rename deduplicateFragments to deduplicateAdjacentFragments
chigichan24 Apr 6, 2026
64d83f1
fix: use [weak self] in Task closures to prevent retain cycle
chigichan24 Apr 6, 2026
6483231
fix: refresh accessibility permission status in settings
chigichan24 Apr 6, 2026
e1ac48f
feat: show pac-man animation continuously while mode is active
chigichan24 Apr 7, 2026
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
14 changes: 14 additions & 0 deletions Hatoko/Accessibility/AccessibilityPermission.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import ApplicationServices

enum AccessibilityPermission {

static var isTrusted: Bool {
AXIsProcessTrusted()
}

static func requestTrust() {
// Use the raw string to avoid concurrency-safety warnings with the global CFString constant
let options = ["AXTrustedCheckOptionPrompt": true] as CFDictionary
AXIsProcessTrustedWithOptions(options)
}
}
161 changes: 161 additions & 0 deletions Hatoko/Accessibility/DangerousReadModeController.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import Cocoa

/// Manages the lifecycle of the Dangerous Read Mode session.
///
/// Marked `@preconcurrency @MainActor` because all state mutations and UI operations
/// require the main thread. `nonisolated` accessors use `MainActor.assumeIsolated`
/// because they are called from IMKInputController (always main thread) but cannot
/// be statically proven to be MainActor-isolated.
@preconcurrency @MainActor
final class DangerousReadModeController {

private var activeState = false
private var latestScreenContext: ScreenContext?
private var sessionStartTime: Date?
private var sessionDuration = 0
private var captureTask: Task<Void, Never>?
private var countdownTask: Task<Void, Never>?
private let indicatorWindow = DangerousReadIndicatorWindow()

static let enabledKey = "dangerous_read_enabled"
static let maxDurationKey = "dangerous_read_max_duration"
static let captureIntervalKey = "dangerous_read_capture_interval"
static let defaultMaxDuration = 300
static let defaultCaptureInterval = 3

nonisolated var isActive: Bool {
MainActor.assumeIsolated { activeState }
}

nonisolated func activeScreenContext() -> ScreenContext? {
MainActor.assumeIsolated {
guard activeState else { return nil }
return latestScreenContext
}
}

var isEnabledInSettings: Bool {
UserDefaults.standard.bool(forKey: Self.enabledKey)
}

static func storedMaxDuration() -> Int {
let stored = UserDefaults.standard.integer(forKey: maxDurationKey)
return stored > 0 ? stored : defaultMaxDuration
}

static func storedCaptureInterval() -> Int {
let stored = UserDefaults.standard.integer(forKey: captureIntervalKey)
return stored > 0 ? stored : defaultCaptureInterval
}

var maxSessionDuration: Int { Self.storedMaxDuration() }
var captureInterval: Int { Self.storedCaptureInterval() }

nonisolated func toggleSession() {
MainActor.assumeIsolated {
if activeState {
stopSession()
} else {
startSession()
}
}
}

func startSession() {
guard !activeState else { return }
guard isEnabledInSettings else {
NSSound.beep()
return
}

guard AccessibilityPermission.isTrusted else {
AccessibilityPermission.requestTrust()
return
}

guard showConsentAlert() else { return }

activeState = true
sessionStartTime = Date()
latestScreenContext = nil
sessionDuration = maxSessionDuration

let duration = sessionDuration
indicatorWindow.show(remainingSeconds: duration)
startCaptureLoop()
startCountdown()
}

func stopSession() {
activeState = false
captureTask?.cancel()
captureTask = nil
countdownTask?.cancel()
countdownTask = nil
sessionStartTime = nil
sessionDuration = 0
latestScreenContext = nil
indicatorWindow.hide()
}

var remainingSeconds: Int {
guard let start = sessionStartTime else { return 0 }
let elapsed = Int(Date().timeIntervalSince(start))
return max(0, sessionDuration - elapsed)
}

// MARK: - Private

private func startCaptureLoop() {
let interval = captureInterval
captureTask = Task { [weak self] in
defer { self?.indicatorWindow.setScanning(false) }
let reader = ScreenReader()
while !Task.isCancelled {
guard let self else { return }
self.indicatorWindow.setScanning(true)
let context = await reader.captureFullWindowContext()
self.indicatorWindow.setScanning(false)
guard !Task.isCancelled, self.activeState else { return }
if let context {
self.latestScreenContext = context
}
do {
try await Task.sleep(for: .seconds(interval))
} catch {
return
}
}
}
}

private func startCountdown() {
countdownTask = Task { [weak self] in
while !Task.isCancelled {
guard let self else { return }
let remaining = self.remainingSeconds
guard remaining > 0 else { break }
self.indicatorWindow.updateRemainingTime(remaining)
do {
try await Task.sleep(for: .seconds(1))
} catch {
return
}
}
guard !Task.isCancelled, let self else { return }
NSSound.beep()
self.stopSession()
}
}

private func showConsentAlert() -> Bool {
NSApp.activate()
let alert = NSAlert()
alert.messageText = L10n.DangerousRead.Consent.title
alert.informativeText = L10n.DangerousRead.Consent.message
alert.alertStyle = .critical
alert.addButton(withTitle: L10n.DangerousRead.Consent.start)
alert.addButton(withTitle: L10n.DangerousRead.Consent.cancel)
return alert.runModal() == .alertFirstButtonReturn
}
}
61 changes: 61 additions & 0 deletions Hatoko/Accessibility/ScreenContext.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import Foundation

struct ScreenContext: Sendable, Equatable {

let appName: String?
let windowTitle: String?
let focusedText: String?
let selectedText: String?
let visibleText: String?
let capturedAt: Date

init(
appName: String?,
windowTitle: String?,
focusedText: String?,
selectedText: String?,
visibleText: String? = nil,
capturedAt: Date = Date()
) {
self.appName = Self.truncateShort(appName)
self.windowTitle = Self.truncateShort(windowTitle)
self.focusedText = Self.truncate(focusedText)
self.selectedText = Self.truncate(selectedText)
self.visibleText = Self.truncate(visibleText)
self.capturedAt = capturedAt
}

func formatted() -> String {
var lines: [String] = []
if let appName {
lines.append("Application: \(appName)")
}
if let windowTitle {
lines.append("Window: \(windowTitle)")
}
if let selectedText {
lines.append("Selected text: \(selectedText)")
}
// Prefer visibleText (full window) over focusedText (single element)
if let visibleText {
lines.append("Visible text:\n\(visibleText)")
} else if let focusedText {
lines.append("Focused text: \(focusedText)")
}
guard !lines.isEmpty else { return "" }
let body = lines.joined(separator: "\n")
return "<screen_context>\n\(body)\n</screen_context>"
}

private static let maxShortFieldLength = 500

private static func truncate(_ text: String?) -> String? {
guard let text, !text.isEmpty else { return nil }
return String(text.prefix(PromptGuard.maxScreenContextLength))
}

private static func truncateShort(_ text: String?) -> String? {
guard let text, !text.isEmpty else { return nil }
return String(text.prefix(maxShortFieldLength))
}
}
Loading