Always-on live monitoring, page blocker, universal builds

- LiveMonitor: menu-bar–resident; reads the focused field via the Accessibility
  API, runs the offline grammar engine, and shows live suggestions + a "Fix all".
- Menu-bar icon now reflects status (issue count / paused); added Pause/Resume.
- Page Blocker: exclude apps + websites (Safari domain detection) where GrammaGem
  stays out of the way; hotkeys + monitoring suppressed there.
- build.sh now produces a universal (arm64 + x86_64) bundle; sign-notarize.sh is a
  turnkey codesign→notarize→staple flow reading creds from env/keychain (no secrets).
- Clean entitlements plist (AMFI-compatible).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BigMarc 2026-06-16 15:17:08 +02:00
parent 197df3298f
commit bb096bed7b
13 changed files with 534 additions and 63 deletions

View File

@ -1,15 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<!--
Entitlements for codesigning the notarized release.
IMPORTANT: a system-wide tool that reads/replaces text in OTHER apps (via the
Accessibility API + synthetic keystrokes) is INCOMPATIBLE with the App Sandbox,
so the sandbox is intentionally OFF. Notarization still requires the Hardened
Runtime (enabled at signing time with `--options runtime`, not via this file).
The network client entitlement is listed for completeness (model download +
license API). No server-side anything; no sensitive entitlements beyond this.
-->
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>

View File

@ -25,6 +25,8 @@ final class AppState: ObservableObject {
let usage: UsageStats let usage: UsageStats
let customModes: CustomModesStore let customModes: CustomModesStore
let snippets: SnippetStore let snippets: SnippetStore
let exclusions: Exclusions
let liveMonitor: LiveMonitor
private let capture: TextCapture private let capture: TextCapture
let coordinator: TextReplacementCoordinator let coordinator: TextReplacementCoordinator
@ -32,6 +34,7 @@ final class AppState: ObservableObject {
@Published var lastStatus: String = "Ready" @Published var lastStatus: String = "Ready"
@Published var askText: String = "" @Published var askText: String = ""
@Published var appAwareEnabled: Bool = false @Published var appAwareEnabled: Bool = false
@Published var isPaused: Bool = false
private var onboardingWindow: NSWindow? private var onboardingWindow: NSWindow?
private var askWindow: NSWindow? private var askWindow: NSWindow?
@ -54,6 +57,9 @@ final class AppState: ObservableObject {
usage = UsageStats() usage = UsageStats()
customModes = CustomModesStore() customModes = CustomModesStore()
snippets = SnippetStore() snippets = SnippetStore()
let excl = Exclusions()
exclusions = excl
liveMonitor = LiveMonitor(grammar: harper, detector: detector, exclusions: excl, capture: capture)
coordinator = TextReplacementCoordinator( coordinator = TextReplacementCoordinator(
capture: capture, grammar: harper, ai: ai, gate: gate, detector: detector) capture: capture, grammar: harper, ai: ai, gate: gate, detector: detector)
} }
@ -80,6 +86,12 @@ final class AppState: ObservableObject {
.sink { [weak self] _ in self?.reregisterHotkeys() } .sink { [weak self] _ in self?.reregisterHotkeys() }
.store(in: &cancellables) .store(in: &cancellables)
// Always-on live monitoring (menu-bar resident), driven by the preference.
liveMonitor.setEnabled(preferences.liveUnderlines)
preferences.$liveUnderlines.dropFirst()
.sink { [weak self] on in self?.liveMonitor.setEnabled(on) }
.store(in: &cancellables)
permissions.refresh() permissions.refresh()
appAwareEnabled = gate.appAwareEnabled appAwareEnabled = gate.appAwareEnabled
syncIgnoreList() syncIgnoreList()
@ -113,14 +125,42 @@ final class AppState: ObservableObject {
} }
func runFix() async { func runFix() async {
if let reason = blockReason() { lastStatus = reason; return }
let outcome = await coordinator.handleFix() let outcome = await coordinator.handleFix()
if case .replaced(let text) = outcome { usage.recordCorrection(words: wordCount(text)) } if case .replaced(let text) = outcome { usage.recordCorrection(words: wordCount(text)) }
present(outcome) present(outcome)
} }
/// Fix every issue in the focused field (the live monitor's "Fix all").
func fixFocusedField() async {
if let reason = blockReason() { lastStatus = reason; return }
let outcome = await coordinator.handleFixFocusedField()
if case .replaced(let text) = outcome { usage.recordCorrection(words: wordCount(text)) }
present(outcome)
liveMonitor.refreshSoon()
}
/// Pause/resume all of GrammaGem (hotkeys + live monitoring).
func togglePause() {
isPaused.toggle()
liveMonitor.setPaused(isPaused)
lastStatus = isPaused ? "Paused" : "Ready"
}
/// A reason GrammaGem should stay out of the way right now, or nil.
private func blockReason() -> String? {
if isPaused { return "GrammaGem is paused." }
let front = detector.frontmost()
if exclusions.isBlocked(bundleID: front?.bundleID, domain: detector.frontmostDomain()) {
return "GrammaGem is off for \(front?.name ?? "this app")."
}
return nil
}
func runAsk() async { func runAsk() async {
let instruction = askText.trimmingCharacters(in: .whitespacesAndNewlines) let instruction = askText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !instruction.isEmpty else { return } guard !instruction.isEmpty else { return }
if let reason = blockReason() { lastStatus = reason; askWindow?.close(); return }
let outcome = await coordinator.handleAsk(instruction) let outcome = await coordinator.handleAsk(instruction)
if case .replaced(let text) = outcome { usage.recordAIAction(words: wordCount(text)) } if case .replaced(let text) = outcome { usage.recordAIAction(words: wordCount(text)) }
present(outcome) present(outcome)
@ -133,6 +173,7 @@ final class AppState: ObservableObject {
/// Apply the Mode suggested by the frontmost app (App-Aware, paid). /// Apply the Mode suggested by the frontmost app (App-Aware, paid).
func runAppAwareRewrite() async { func runAppAwareRewrite() async {
if let reason = blockReason() { lastStatus = reason; return }
guard gate.appAwareEnabled else { guard gate.appAwareEnabled else {
present(.blockedByEntitlement("App-aware switching is part of the lifetime upgrade.")) present(.blockedByEntitlement("App-aware switching is part of the lifetime upgrade."))
return return
@ -201,6 +242,8 @@ final class AppState: ObservableObject {
.environmentObject(usage) .environmentObject(usage)
.environmentObject(customModes) .environmentObject(customModes)
.environmentObject(snippets) .environmentObject(snippets)
.environmentObject(exclusions)
.environmentObject(liveMonitor)
let host = NSHostingController(rootView: root) let host = NSHostingController(rootView: root)
let win = NSWindow(contentViewController: host) let win = NSWindow(contentViewController: host)
win.title = "GrammaGem" win.title = "GrammaGem"

View File

@ -10,9 +10,11 @@ struct GrammaGemApp: App {
@StateObject private var app = AppState.shared @StateObject private var app = AppState.shared
var body: some Scene { var body: some Scene {
MenuBarExtra("GrammaGem", systemImage: "pencil.and.scribble") { MenuBarExtra {
MenuBarContent() MenuBarContent()
.environmentObject(app) .environmentObject(app)
} label: {
MenuBarLabel()
} }
.menuBarExtraStyle(.window) .menuBarExtraStyle(.window)

View File

@ -0,0 +1,115 @@
import SwiftUI
import AppKit
struct ExclusionsView: View {
@EnvironmentObject private var exclusions: Exclusions
@State private var newDomain = ""
var body: some View {
DetailScaffold(
title: "Page Blocker",
subtitle: "Keep GrammaGem out of the way in sensitive apps and sites."
) {
Card {
Label("How it works", systemImage: "hand.raised")
.font(.headline)
Text("When the frontmost app or website is on these lists, GrammaGem stops monitoring and the hotkeys do nothing — useful for password managers, banking, and private work.")
.font(.callout).foregroundStyle(.secondary)
}
// Blocked apps
Card {
HStack {
Text("Blocked apps").font(.headline)
Spacer()
addAppMenu
}
if exclusions.blockedApps.isEmpty {
Text("No apps blocked.").font(.callout).foregroundStyle(.secondary)
} else {
ForEach(exclusions.blockedApps, id: \.self) { bundle in
HStack {
Image(systemName: "app.dashed").foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 1) {
Text(exclusions.appName(for: bundle))
Text(bundle).font(.caption2.monospaced()).foregroundStyle(.secondary)
}
Spacer()
Button(role: .destructive) { exclusions.removeApp(bundle) } label: {
Image(systemName: "trash")
}.buttonStyle(.borderless)
}
if bundle != exclusions.blockedApps.last { Divider() }
}
}
Divider()
HStack(spacing: 8) {
Text("Suggestions:").font(.caption).foregroundStyle(.secondary)
ForEach(Exclusions.suggestedApps, id: \.bundleID) { item in
if !exclusions.blockedApps.contains(item.bundleID) {
Button(item.name) { exclusions.addApp(item.bundleID) }
.buttonStyle(.bordered).controlSize(.small)
}
}
}
}
// Blocked domains
Card {
Text("Blocked websites").font(.headline)
if exclusions.blockedDomains.isEmpty {
Text("No websites blocked.").font(.callout).foregroundStyle(.secondary)
} else {
ForEach(exclusions.blockedDomains, id: \.self) { domain in
HStack {
Image(systemName: "globe").foregroundStyle(.secondary)
Text(domain)
Spacer()
Button(role: .destructive) { exclusions.removeDomain(domain) } label: {
Image(systemName: "trash")
}.buttonStyle(.borderless)
}
if domain != exclusions.blockedDomains.last { Divider() }
}
}
HStack {
TextField("Add a domain (e.g. mybank.com)", text: $newDomain)
.textFieldStyle(.roundedBorder)
.onSubmit(addDomain)
Button("Block", action: addDomain)
.disabled(newDomain.trimmingCharacters(in: .whitespaces).isEmpty)
}
Text("Website blocking works best in Safari; other browsers are matched when possible.")
.font(.caption).foregroundStyle(.secondary)
}
}
}
private var addAppMenu: some View {
Menu {
ForEach(runningApps(), id: \.bundleID) { item in
Button(item.name) { exclusions.addApp(item.bundleID) }
}
} label: {
Label("Add app", systemImage: "plus")
}
.menuStyle(.borderlessButton)
.fixedSize()
}
private func addDomain() {
exclusions.addDomain(newDomain)
newDomain = ""
}
private func runningApps() -> [(name: String, bundleID: String)] {
NSWorkspace.shared.runningApplications
.filter { $0.activationPolicy == .regular && $0.bundleIdentifier != nil }
.compactMap { app -> (String, String)? in
guard let id = app.bundleIdentifier else { return nil }
return (app.localizedName ?? id, id)
}
.sorted { $0.0.localizedCaseInsensitiveCompare($1.0) == .orderedAscending }
.map { (name: $0.0, bundleID: $0.1) }
}
}

View File

@ -10,7 +10,7 @@ enum GG {
enum MainSection: String, CaseIterable, Identifiable { enum MainSection: String, CaseIterable, Identifiable {
case dashboard, devices, license case dashboard, devices, license
case modes, dictionary, snippets case modes, dictionary, snippets
case shortcuts, model, privacy, general case shortcuts, model, privacy, exclusions, general
case about case about
var id: String { rawValue } var id: String { rawValue }
@ -26,6 +26,7 @@ enum MainSection: String, CaseIterable, Identifiable {
case .shortcuts: return "Shortcuts" case .shortcuts: return "Shortcuts"
case .model: return "AI Model" case .model: return "AI Model"
case .privacy: return "Privacy" case .privacy: return "Privacy"
case .exclusions: return "Page Blocker"
case .general: return "General" case .general: return "General"
case .about: return "About" case .about: return "About"
} }
@ -42,6 +43,7 @@ enum MainSection: String, CaseIterable, Identifiable {
case .shortcuts: return "command" case .shortcuts: return "command"
case .model: return "cpu" case .model: return "cpu"
case .privacy: return "lock.shield" case .privacy: return "lock.shield"
case .exclusions: return "hand.raised"
case .general: return "gearshape" case .general: return "gearshape"
case .about: return "info.circle" case .about: return "info.circle"
} }
@ -52,7 +54,7 @@ enum MainSection: String, CaseIterable, Identifiable {
("Overview", [.dashboard]), ("Overview", [.dashboard]),
("Account", [.devices, .license]), ("Account", [.devices, .license]),
("Writing", [.modes, .dictionary, .snippets]), ("Writing", [.modes, .dictionary, .snippets]),
("App", [.shortcuts, .model, .privacy, .general]), ("App", [.shortcuts, .model, .privacy, .exclusions, .general]),
("", [.about]), ("", [.about]),
] ]
} }
@ -98,6 +100,7 @@ struct MainWindowView: View {
case .shortcuts: ShortcutsView() case .shortcuts: ShortcutsView()
case .model: ModelView() case .model: ModelView()
case .privacy: PrivacyView() case .privacy: PrivacyView()
case .exclusions: ExclusionsView()
case .general: GeneralView() case .general: GeneralView()
case .about: AboutView() case .about: AboutView()
} }

View File

@ -1,20 +1,49 @@
import SwiftUI import SwiftUI
/// The menu-bar icon label — reflects live status (issue count / paused).
struct MenuBarLabel: View {
@ObservedObject private var app: AppState
@ObservedObject private var monitor: LiveMonitor
init() {
_app = ObservedObject(wrappedValue: AppState.shared)
_monitor = ObservedObject(wrappedValue: AppState.shared.liveMonitor)
}
var body: some View {
HStack(spacing: 3) {
Image(systemName: app.isPaused ? "pause.circle" : "pencil.and.scribble")
if !app.isPaused, monitor.issueCount > 0 {
Text("\(monitor.issueCount)")
}
}
}
}
/// The dropdown shown from the menu-bar icon. /// The dropdown shown from the menu-bar icon.
struct MenuBarContent: View { struct MenuBarContent: View {
@EnvironmentObject private var app: AppState @EnvironmentObject private var app: AppState
@ObservedObject private var monitor: LiveMonitor
@Environment(\.openSettings) private var openSettings @Environment(\.openSettings) private var openSettings
init() {
_monitor = ObservedObject(wrappedValue: AppState.shared.liveMonitor)
}
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
header header
Divider() Divider()
actionRow(title: "Fix selection", shortcut: "⌘;") { liveStatus
Divider()
actionRow(title: "Fix selection", shortcut: app.preferences.fixHotkey.display) {
Task { await app.runFix() } Task { await app.runFix() }
} }
actionRow(title: "Ask GrammaGem…", shortcut: "⌘'") { actionRow(title: "Ask GrammaGem…", shortcut: app.preferences.askHotkey.display) {
app.showAsk() app.showAsk()
} }
if app.gate.appAwareEnabled { if app.gate.appAwareEnabled {
@ -22,15 +51,15 @@ struct MenuBarContent: View {
Task { await app.runAppAwareRewrite() } Task { await app.runAppAwareRewrite() }
} }
} }
actionRow(title: app.isPaused ? "Resume GrammaGem" : "Pause GrammaGem", shortcut: nil) {
app.togglePause()
}
Divider() Divider()
actionRow(title: "Open GrammaGem…", shortcut: nil) { actionRow(title: "Open GrammaGem…", shortcut: nil) { app.showMainWindow() }
app.showMainWindow() actionRow(title: "Manage devices…", shortcut: nil) { app.showMainWindow(select: .devices) }
} actionRow(title: "Page blocker…", shortcut: nil) { app.showMainWindow(select: .exclusions) }
actionRow(title: "Manage devices…", shortcut: nil) {
app.showMainWindow(select: .devices)
}
Divider() Divider()
@ -46,31 +75,24 @@ struct MenuBarContent: View {
.buttonStyle(.borderless) .buttonStyle(.borderless)
Text(app.lastStatus) Text(app.lastStatus)
.font(.caption) .font(.caption).foregroundStyle(.secondary).lineLimit(2)
.foregroundStyle(.secondary)
.lineLimit(2)
} }
.padding(14) .padding(14)
.frame(width: 280) .frame(width: 290)
} }
private var header: some View { private var header: some View {
HStack(spacing: 8) { HStack(spacing: 8) {
Image(systemName: "pencil.and.scribble") Image(systemName: "pencil.and.scribble").foregroundStyle(.tint)
.foregroundStyle(.tint)
VStack(alignment: .leading, spacing: 1) { VStack(alignment: .leading, spacing: 1) {
Text("GrammaGem").font(.headline) Text("GrammaGem").font(.headline)
Text(app.license.tier.displayName + (app.license.isLicensed ? " · licensed" : " · free")) Text(app.license.tier.displayName + (app.license.isLicensed ? " · licensed" : " · free"))
.font(.caption) .font(.caption).foregroundStyle(.secondary)
.foregroundStyle(.secondary)
} }
Spacer() Spacer()
if !app.permissions.accessibilityTrusted { if !app.permissions.accessibilityTrusted {
Button { Button { app.showOnboarding() } label: {
app.showOnboarding() Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.orange)
} label: {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
} }
.buttonStyle(.borderless) .buttonStyle(.borderless)
.help("Accessibility permission needed") .help("Accessibility permission needed")
@ -78,19 +100,47 @@ struct MenuBarContent: View {
} }
} }
@ViewBuilder
private var liveStatus: some View {
if app.isPaused {
Label("Paused — not monitoring", systemImage: "pause.circle")
.font(.callout).foregroundStyle(.secondary)
} else if monitor.issueCount > 0 {
VStack(alignment: .leading, spacing: 6) {
Label("\(monitor.issueCount) suggestion\(monitor.issueCount == 1 ? "" : "s")"
+ (monitor.activeAppName.isEmpty ? "" : " in \(monitor.activeAppName)"),
systemImage: "text.badge.checkmark")
.font(.callout).foregroundStyle(.primary)
ForEach(monitor.suggestions.prefix(3)) { s in
Text("• \(s.message): “\(s.original)” → “\(s.replacement)”")
.font(.caption).foregroundStyle(.secondary).lineLimit(1)
}
Button { Task { await app.fixFocusedField() } } label: {
Label("Fix all", systemImage: "checkmark.circle.fill")
}
.buttonStyle(.borderedProminent).controlSize(.small)
}
} else if monitor.running {
Label(monitor.activeAppName.isEmpty ? "Monitoring — looks clean"
: "Watching \(monitor.activeAppName) — looks clean",
systemImage: "checkmark.circle")
.font(.callout).foregroundStyle(.secondary)
} else {
Label("Live monitoring is off", systemImage: "eye.slash")
.font(.callout).foregroundStyle(.secondary)
}
}
@ViewBuilder @ViewBuilder
private var usageRow: some View { private var usageRow: some View {
if app.gate.entitlements.unlimitedAIActions { if app.gate.entitlements.unlimitedAIActions {
Label("Unlimited AI actions", systemImage: "infinity") Label("Unlimited AI actions", systemImage: "infinity")
.font(.caption) .font(.caption).foregroundStyle(.secondary)
.foregroundStyle(.secondary)
} else { } else {
let used = app.gate.aiActionsUsedToday let used = app.gate.aiActionsUsedToday
let cap = app.gate.entitlements.dailyAIActionCap let cap = app.gate.entitlements.dailyAIActionCap
Label("\(max(0, cap - used)) of \(cap) free AI actions left today", Label("\(max(0, cap - used)) of \(cap) free AI actions left today", systemImage: "bolt")
systemImage: "bolt") .font(.caption).foregroundStyle(.secondary)
.font(.caption)
.foregroundStyle(.secondary)
} }
} }

View File

@ -1,5 +1,6 @@
import Foundation import Foundation
import AppKit import AppKit
import ApplicationServices
/// Reports the frontmost application so App-Aware mode switching can pick the /// Reports the frontmost application so App-Aware mode switching can pick the
/// right Writing Mode. Pure local introspection — no network, no logging of content. /// right Writing Mode. Pure local introspection — no network, no logging of content.
@ -23,4 +24,28 @@ final class AppDetector {
guard let front = frontmost() else { return nil } guard let front = frontmost() else { return nil }
return ModeRegistry.mode(forBundleID: front.bundleID) return ModeRegistry.mode(forBundleID: front.bundleID)
} }
/// Best-effort hostname of the frontmost browser tab (used by the page blocker).
/// Reads the window's AX document URL — works in Safari and some others with no
/// extra permission beyond Accessibility. Returns nil when it can't tell.
func frontmostDomain() -> String? {
guard AXIsProcessTrusted(),
let app = NSWorkspace.shared.frontmostApplication else { return nil }
let axApp = AXUIElementCreateApplication(app.processIdentifier)
var winRef: CFTypeRef?
guard AXUIElementCopyAttributeValue(
axApp, kAXFocusedWindowAttribute as CFString, &winRef) == .success,
let winRef
else { return nil }
let window = winRef as! AXUIElement
var docRef: CFTypeRef?
if AXUIElementCopyAttributeValue(
window, kAXDocumentAttribute as CFString, &docRef) == .success,
let urlString = docRef as? String,
let host = URL(string: urlString)?.host {
return host
}
return nil
}
} }

View File

@ -0,0 +1,84 @@
import Foundation
import AppKit
/// The "page blocker": apps and domains where GrammaGem should stay completely
/// out of the way — password managers, banking sites, anything sensitive.
/// When the frontmost context is excluded, live monitoring and the hotkeys are
/// suppressed.
@MainActor
final class Exclusions: ObservableObject {
@Published private(set) var blockedApps: [String] // bundle identifiers
@Published private(set) var blockedDomains: [String] // hostnames, e.g. "chase.com"
private let appsKey = "GrammaGem.blockedApps"
private let domainsKey = "GrammaGem.blockedDomains"
private let d = UserDefaults.standard
/// Common sensitive apps offered as one-tap suggestions in the UI.
static let suggestedApps: [(name: String, bundleID: String)] = [
("1Password", "com.1password.1password"),
("Bitwarden", "com.bitwarden.desktop"),
("Keychain Access", "com.apple.keychainaccess"),
("Terminal", "com.apple.Terminal"),
]
init() {
blockedApps = d.stringArray(forKey: appsKey) ?? []
blockedDomains = d.stringArray(forKey: domainsKey) ?? []
}
/// Whether GrammaGem should be suppressed for this frontmost context.
func isBlocked(bundleID: String?, domain: String?) -> Bool {
if let bundleID, blockedApps.contains(bundleID) { return true }
if let host = domain?.lowercased(), !host.isEmpty {
return blockedDomains.contains { dom in
let d = dom.lowercased()
return host == d || host.hasSuffix("." + d)
}
}
return false
}
func addApp(_ bundleID: String) {
let b = bundleID.trimmingCharacters(in: .whitespaces)
guard !b.isEmpty, !blockedApps.contains(b) else { return }
blockedApps.append(b)
d.set(blockedApps, forKey: appsKey)
}
func removeApp(_ bundleID: String) {
blockedApps.removeAll { $0 == bundleID }
d.set(blockedApps, forKey: appsKey)
}
func addDomain(_ domain: String) {
let host = normalizedDomain(domain)
guard !host.isEmpty, !blockedDomains.contains(host) else { return }
blockedDomains.append(host)
d.set(blockedDomains, forKey: domainsKey)
}
func removeDomain(_ domain: String) {
blockedDomains.removeAll { $0 == domain }
d.set(blockedDomains, forKey: domainsKey)
}
/// Friendly app name for a bundle id, if installed.
func appName(for bundleID: String) -> String {
if let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID),
let bundle = Bundle(url: url),
let name = (bundle.infoDictionary?["CFBundleName"] as? String) {
return name
}
return bundleID
}
private func normalizedDomain(_ raw: String) -> String {
var s = raw.trimmingCharacters(in: .whitespaces).lowercased()
if let url = URL(string: s), let host = url.host { return host }
s = s.replacingOccurrences(of: "https://", with: "")
.replacingOccurrences(of: "http://", with: "")
if let slash = s.firstIndex(of: "/") { s = String(s[..<slash]) }
return s
}
}

View File

@ -0,0 +1,98 @@
import Foundation
import AppKit
import ApplicationServices
/// The always-on, menu-bar–resident monitor. While enabled, it periodically
/// reads the focused text field (Accessibility API), runs the offline grammar
/// engine, and publishes live suggestions — so the menu bar reflects issues as
/// you type. It stays out of excluded apps/domains and never sends text anywhere.
@MainActor
final class LiveMonitor: ObservableObject {
@Published private(set) var suggestions: [Suggestion] = []
@Published private(set) var activeAppName: String = ""
@Published private(set) var running = false
var issueCount: Int { suggestions.count }
private let grammar: GrammarEngine
private let detector: AppDetector
private let exclusions: Exclusions
private let capture: TextCapture
private var timer: Timer?
private var enabled = false
private var paused = false
private var lastHash = 0
private let interval: TimeInterval = 1.3
init(grammar: GrammarEngine, detector: AppDetector, exclusions: Exclusions, capture: TextCapture) {
self.grammar = grammar
self.detector = detector
self.exclusions = exclusions
self.capture = capture
}
/// Turn live monitoring on/off (driven by the "live underlines" preference).
func setEnabled(_ on: Bool) {
enabled = on
reconcile()
}
/// Global pause (also stops the hotkeys, handled in AppState).
func setPaused(_ p: Bool) {
paused = p
reconcile()
}
/// Re-run immediately (e.g. after a "fix all").
func refreshSoon() {
lastHash = 0
tick()
}
private func reconcile() {
let shouldRun = enabled && !paused
if shouldRun && timer == nil {
running = true
tick()
let t = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
Task { @MainActor in self?.tick() }
}
t.tolerance = 0.4
timer = t
} else if !shouldRun {
running = false
timer?.invalidate()
timer = nil
clear()
}
}
private func tick() {
guard enabled, !paused, AXIsProcessTrusted() else { clear(); return }
let front = detector.frontmost()
activeAppName = front?.name ?? ""
// Respect the page blocker.
if exclusions.isBlocked(bundleID: front?.bundleID, domain: detector.frontmostDomain()) {
clear()
return
}
guard let field = capture.focusedFieldText() else { clear(); return }
let text = field.text
guard text.count >= 3 else { clear(); return }
let h = text.hashValue
if h == lastHash { return } // unchanged since last check
lastHash = h
suggestions = grammar.check(text)
}
private func clear() {
if !suggestions.isEmpty { suggestions = [] }
lastHash = 0
}
}

View File

@ -94,6 +94,32 @@ final class TextCapture {
return Capture(text: copied, method: .clipboard, focusedElement: nil) return Capture(text: copied, method: .clipboard, focusedElement: nil)
} }
// MARK: - Focused field (whole-value read/write, for live monitoring + "fix all")
/// The full text value of the focused element (not just the selection).
func focusedFieldText() -> (text: String, element: AXUIElement)? {
guard AXIsProcessTrusted() else { return nil }
let systemWide = AXUIElementCreateSystemWide()
var focusedRef: CFTypeRef?
guard AXUIElementCopyAttributeValue(
systemWide, kAXFocusedUIElementAttribute as CFString, &focusedRef) == .success,
let focusedRef
else { return nil }
let focused = focusedRef as! AXUIElement
var valueRef: CFTypeRef?
guard AXUIElementCopyAttributeValue(
focused, kAXValueAttribute as CFString, &valueRef) == .success,
let text = valueRef as? String
else { return nil }
return (text, focused)
}
@discardableResult
func setFocusedField(_ text: String, element: AXUIElement) -> Bool {
AXUIElementSetAttributeValue(element, kAXValueAttribute as CFString, text as CFTypeRef) == .success
}
// MARK: - Replace // MARK: - Replace
@discardableResult @discardableResult

View File

@ -82,6 +82,24 @@ final class TextReplacementCoordinator {
} }
} }
/// Grammar-fix the entire focused text field (used by the live monitor's
/// "Fix all" — Layer 1, always free, no selection required).
func handleFixFocusedField() async -> ProcessOutcome {
let field = await Task.detached(priority: .userInitiated) { [capture] in
capture.focusedFieldText()
}.value
guard let field else { return .noSelection }
let corrected = grammar.correct(field.text)
guard corrected != field.text else { return .replaced(corrected) }
let element = field.element
let ok = await Task.detached(priority: .userInitiated) { [capture] in
capture.setFocusedField(corrected, element: element)
}.value
return ok ? .replaced(corrected) : .failed("Couldn't update the text field.")
}
/// Milestone-1 proof: capture → UPPERCASE → replace, end-to-end, no engine. /// Milestone-1 proof: capture → UPPERCASE → replace, end-to-end, no engine.
func handleDebugUppercase() async -> ProcessOutcome { func handleDebugUppercase() async -> ProcessOutcome {
let cap: TextCapture.Capture let cap: TextCapture.Capture

View File

@ -1,5 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Build a runnable GrammaGem.app bundle from the SwiftPM executable. # Build a runnable, UNIVERSAL (Apple Silicon + Intel) GrammaGem.app bundle.
# Usage: ./scripts/build.sh (run from the mac/ directory) # Usage: ./scripts/build.sh (run from the mac/ directory)
set -euo pipefail set -euo pipefail
@ -8,19 +8,20 @@ cd "$ROOT"
CONFIG="release" CONFIG="release"
APP="GrammaGem.app" APP="GrammaGem.app"
BUILD_DIR=".build/${CONFIG}"
DIST="dist" DIST="dist"
echo "==> Compiling (${CONFIG})" echo "==> Compiling universal (arm64 + x86_64, ${CONFIG})"
swift build -c "${CONFIG}" swift build -c "${CONFIG}" --arch arm64 --arch x86_64
BIN="$(swift build -c "${CONFIG}" --arch arm64 --arch x86_64 --show-bin-path)"
echo "==> Assembling ${APP}" echo "==> Assembling ${APP}"
rm -rf "${DIST}/${APP}" rm -rf "${DIST}/${APP}"
mkdir -p "${DIST}/${APP}/Contents/MacOS" "${DIST}/${APP}/Contents/Resources" mkdir -p "${DIST}/${APP}/Contents/MacOS" "${DIST}/${APP}/Contents/Resources"
cp "${BUILD_DIR}/GrammaGem" "${DIST}/${APP}/Contents/MacOS/GrammaGem" cp "${BIN}/GrammaGem" "${DIST}/${APP}/Contents/MacOS/GrammaGem"
cp "AppSupport/Info.plist" "${DIST}/${APP}/Contents/Info.plist" cp "AppSupport/Info.plist" "${DIST}/${APP}/Contents/Info.plist"
cp "AppSupport/AppIcon.icns" "${DIST}/${APP}/Contents/Resources/AppIcon.icns" cp "AppSupport/AppIcon.icns" "${DIST}/${APP}/Contents/Resources/AppIcon.icns"
echo "==> Built ${DIST}/${APP}" echo "==> Built ${DIST}/${APP}"
lipo -info "${DIST}/${APP}/Contents/MacOS/GrammaGem"
echo " Run with: open \"${DIST}/${APP}\" (grant Accessibility on first launch)" echo " Run with: open \"${DIST}/${APP}\" (grant Accessibility on first launch)"

View File

@ -1,33 +1,50 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Codesign (Hardened Runtime) + notarize + staple the GrammaGem.app bundle. # Build (universal), codesign (Hardened Runtime + Developer ID), notarize, and
# Requires the Apple Developer Program ($99/yr) — a FIXED cost, not per-user. # staple GrammaGem.app. Requires the Apple Developer Program ($99/yr) — a FIXED
# cost, not per-user.
# #
# Prereqs (set as env vars or fill in): # Credentials are read from the environment or a stored keychain profile — never
# DEV_ID e.g. "Developer ID Application: Your Name (TEAMID)" # hard-coded here. Provide ONE of:
# KEYCHAIN_PROFILE a stored notarytool profile (xcrun notarytool store-credentials) # • NOTARY_PROFILE (a profile saved via: xcrun notarytool store-credentials)
# • APPLE_ID + TEAM_ID + NOTARY_PASSWORD (Apple ID + Team ID + app-specific pw)
#
# DEV_ID defaults to this project's identity; override if signing as someone else.
set -euo pipefail set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)" ROOT="$(cd "$(dirname "$0")/.." && pwd)"
APP="$ROOT/dist/GrammaGem.app" cd "$ROOT"
ENTITLEMENTS="$ROOT/AppSupport/GrammaGem.entitlements"
: "${DEV_ID:?Set DEV_ID to your Developer ID Application identity}" DEV_ID="${DEV_ID:-Developer ID Application: Marc Schultheiss (D5KT5B9Z9M)}"
: "${KEYCHAIN_PROFILE:?Set KEYCHAIN_PROFILE to your stored notarytool profile}" APP="dist/GrammaGem.app"
ZIP="dist/GrammaGem.zip"
ENTITLEMENTS="AppSupport/GrammaGem.entitlements"
echo "▸ Codesigning with Hardened Runtime…" echo "==> Building universal bundle"
codesign --force --deep --options runtime \ ./scripts/build.sh >/dev/null
echo "==> Codesigning (Hardened Runtime)"
codesign --force --timestamp --options runtime \
--entitlements "$ENTITLEMENTS" \ --entitlements "$ENTITLEMENTS" \
--sign "$DEV_ID" \ --sign "$DEV_ID" \
"$APP" "$APP"
codesign --verify --strict --verbose=2 "$APP"
echo "▸ Zipping for notarization…" echo "==> Zipping"
ZIP="$ROOT/dist/GrammaGem.zip"
ditto -c -k --keepParent "$APP" "$ZIP" ditto -c -k --keepParent "$APP" "$ZIP"
echo "▸ Submitting to Apple notary service…" echo "==> Notarizing"
xcrun notarytool submit "$ZIP" --keychain-profile "$KEYCHAIN_PROFILE" --wait if [[ -n "${NOTARY_PROFILE:-}" ]]; then
xcrun notarytool submit "$ZIP" --keychain-profile "$NOTARY_PROFILE" --wait
elif [[ -n "${APPLE_ID:-}" && -n "${TEAM_ID:-}" && -n "${NOTARY_PASSWORD:-}" ]]; then
xcrun notarytool submit "$ZIP" \
--apple-id "$APPLE_ID" --team-id "$TEAM_ID" --password "$NOTARY_PASSWORD" --wait
else
echo "ERROR: set NOTARY_PROFILE, or APPLE_ID + TEAM_ID + NOTARY_PASSWORD." >&2
exit 1
fi
echo "▸ Stapling ticket…" echo "==> Stapling + verifying"
xcrun stapler staple "$APP" xcrun stapler staple "$APP"
spctl -a -vvv -t install "$APP"
echo "✓ Signed, notarized, and stapled: $APP" echo "✓ Signed, notarized, stapled (universal): $APP"