mirror of
https://github.com/BigMarc/grammargem.git
synced 2026-10-13 17:01:55 +02:00
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:
parent
197df3298f
commit
bb096bed7b
@ -1,15 +1,4 @@
|
||||
<?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">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
|
||||
@ -25,6 +25,8 @@ final class AppState: ObservableObject {
|
||||
let usage: UsageStats
|
||||
let customModes: CustomModesStore
|
||||
let snippets: SnippetStore
|
||||
let exclusions: Exclusions
|
||||
let liveMonitor: LiveMonitor
|
||||
private let capture: TextCapture
|
||||
let coordinator: TextReplacementCoordinator
|
||||
|
||||
@ -32,6 +34,7 @@ final class AppState: ObservableObject {
|
||||
@Published var lastStatus: String = "Ready"
|
||||
@Published var askText: String = ""
|
||||
@Published var appAwareEnabled: Bool = false
|
||||
@Published var isPaused: Bool = false
|
||||
|
||||
private var onboardingWindow: NSWindow?
|
||||
private var askWindow: NSWindow?
|
||||
@ -54,6 +57,9 @@ final class AppState: ObservableObject {
|
||||
usage = UsageStats()
|
||||
customModes = CustomModesStore()
|
||||
snippets = SnippetStore()
|
||||
let excl = Exclusions()
|
||||
exclusions = excl
|
||||
liveMonitor = LiveMonitor(grammar: harper, detector: detector, exclusions: excl, capture: capture)
|
||||
coordinator = TextReplacementCoordinator(
|
||||
capture: capture, grammar: harper, ai: ai, gate: gate, detector: detector)
|
||||
}
|
||||
@ -80,6 +86,12 @@ final class AppState: ObservableObject {
|
||||
.sink { [weak self] _ in self?.reregisterHotkeys() }
|
||||
.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()
|
||||
appAwareEnabled = gate.appAwareEnabled
|
||||
syncIgnoreList()
|
||||
@ -113,14 +125,42 @@ final class AppState: ObservableObject {
|
||||
}
|
||||
|
||||
func runFix() async {
|
||||
if let reason = blockReason() { lastStatus = reason; return }
|
||||
let outcome = await coordinator.handleFix()
|
||||
if case .replaced(let text) = outcome { usage.recordCorrection(words: wordCount(text)) }
|
||||
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 {
|
||||
let instruction = askText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !instruction.isEmpty else { return }
|
||||
if let reason = blockReason() { lastStatus = reason; askWindow?.close(); return }
|
||||
let outcome = await coordinator.handleAsk(instruction)
|
||||
if case .replaced(let text) = outcome { usage.recordAIAction(words: wordCount(text)) }
|
||||
present(outcome)
|
||||
@ -133,6 +173,7 @@ final class AppState: ObservableObject {
|
||||
|
||||
/// Apply the Mode suggested by the frontmost app (App-Aware, paid).
|
||||
func runAppAwareRewrite() async {
|
||||
if let reason = blockReason() { lastStatus = reason; return }
|
||||
guard gate.appAwareEnabled else {
|
||||
present(.blockedByEntitlement("App-aware switching is part of the lifetime upgrade."))
|
||||
return
|
||||
@ -201,6 +242,8 @@ final class AppState: ObservableObject {
|
||||
.environmentObject(usage)
|
||||
.environmentObject(customModes)
|
||||
.environmentObject(snippets)
|
||||
.environmentObject(exclusions)
|
||||
.environmentObject(liveMonitor)
|
||||
let host = NSHostingController(rootView: root)
|
||||
let win = NSWindow(contentViewController: host)
|
||||
win.title = "GrammaGem"
|
||||
|
||||
@ -10,9 +10,11 @@ struct GrammaGemApp: App {
|
||||
@StateObject private var app = AppState.shared
|
||||
|
||||
var body: some Scene {
|
||||
MenuBarExtra("GrammaGem", systemImage: "pencil.and.scribble") {
|
||||
MenuBarExtra {
|
||||
MenuBarContent()
|
||||
.environmentObject(app)
|
||||
} label: {
|
||||
MenuBarLabel()
|
||||
}
|
||||
.menuBarExtraStyle(.window)
|
||||
|
||||
|
||||
115
Sources/GrammaGem/App/MainWindow/ExclusionsView.swift
Normal file
115
Sources/GrammaGem/App/MainWindow/ExclusionsView.swift
Normal 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) }
|
||||
}
|
||||
}
|
||||
@ -10,7 +10,7 @@ enum GG {
|
||||
enum MainSection: String, CaseIterable, Identifiable {
|
||||
case dashboard, devices, license
|
||||
case modes, dictionary, snippets
|
||||
case shortcuts, model, privacy, general
|
||||
case shortcuts, model, privacy, exclusions, general
|
||||
case about
|
||||
|
||||
var id: String { rawValue }
|
||||
@ -26,6 +26,7 @@ enum MainSection: String, CaseIterable, Identifiable {
|
||||
case .shortcuts: return "Shortcuts"
|
||||
case .model: return "AI Model"
|
||||
case .privacy: return "Privacy"
|
||||
case .exclusions: return "Page Blocker"
|
||||
case .general: return "General"
|
||||
case .about: return "About"
|
||||
}
|
||||
@ -42,6 +43,7 @@ enum MainSection: String, CaseIterable, Identifiable {
|
||||
case .shortcuts: return "command"
|
||||
case .model: return "cpu"
|
||||
case .privacy: return "lock.shield"
|
||||
case .exclusions: return "hand.raised"
|
||||
case .general: return "gearshape"
|
||||
case .about: return "info.circle"
|
||||
}
|
||||
@ -52,7 +54,7 @@ enum MainSection: String, CaseIterable, Identifiable {
|
||||
("Overview", [.dashboard]),
|
||||
("Account", [.devices, .license]),
|
||||
("Writing", [.modes, .dictionary, .snippets]),
|
||||
("App", [.shortcuts, .model, .privacy, .general]),
|
||||
("App", [.shortcuts, .model, .privacy, .exclusions, .general]),
|
||||
("", [.about]),
|
||||
]
|
||||
}
|
||||
@ -98,6 +100,7 @@ struct MainWindowView: View {
|
||||
case .shortcuts: ShortcutsView()
|
||||
case .model: ModelView()
|
||||
case .privacy: PrivacyView()
|
||||
case .exclusions: ExclusionsView()
|
||||
case .general: GeneralView()
|
||||
case .about: AboutView()
|
||||
}
|
||||
|
||||
@ -1,20 +1,49 @@
|
||||
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.
|
||||
struct MenuBarContent: View {
|
||||
@EnvironmentObject private var app: AppState
|
||||
@ObservedObject private var monitor: LiveMonitor
|
||||
@Environment(\.openSettings) private var openSettings
|
||||
|
||||
init() {
|
||||
_monitor = ObservedObject(wrappedValue: AppState.shared.liveMonitor)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
header
|
||||
|
||||
Divider()
|
||||
|
||||
actionRow(title: "Fix selection", shortcut: "⌘;") {
|
||||
liveStatus
|
||||
|
||||
Divider()
|
||||
|
||||
actionRow(title: "Fix selection", shortcut: app.preferences.fixHotkey.display) {
|
||||
Task { await app.runFix() }
|
||||
}
|
||||
actionRow(title: "Ask GrammaGem…", shortcut: "⌘'") {
|
||||
actionRow(title: "Ask GrammaGem…", shortcut: app.preferences.askHotkey.display) {
|
||||
app.showAsk()
|
||||
}
|
||||
if app.gate.appAwareEnabled {
|
||||
@ -22,15 +51,15 @@ struct MenuBarContent: View {
|
||||
Task { await app.runAppAwareRewrite() }
|
||||
}
|
||||
}
|
||||
actionRow(title: app.isPaused ? "Resume GrammaGem" : "Pause GrammaGem", shortcut: nil) {
|
||||
app.togglePause()
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
actionRow(title: "Open GrammaGem…", shortcut: nil) {
|
||||
app.showMainWindow()
|
||||
}
|
||||
actionRow(title: "Manage devices…", shortcut: nil) {
|
||||
app.showMainWindow(select: .devices)
|
||||
}
|
||||
actionRow(title: "Open GrammaGem…", shortcut: nil) { app.showMainWindow() }
|
||||
actionRow(title: "Manage devices…", shortcut: nil) { app.showMainWindow(select: .devices) }
|
||||
actionRow(title: "Page blocker…", shortcut: nil) { app.showMainWindow(select: .exclusions) }
|
||||
|
||||
Divider()
|
||||
|
||||
@ -46,31 +75,24 @@ struct MenuBarContent: View {
|
||||
.buttonStyle(.borderless)
|
||||
|
||||
Text(app.lastStatus)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
.font(.caption).foregroundStyle(.secondary).lineLimit(2)
|
||||
}
|
||||
.padding(14)
|
||||
.frame(width: 280)
|
||||
.frame(width: 290)
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "pencil.and.scribble")
|
||||
.foregroundStyle(.tint)
|
||||
Image(systemName: "pencil.and.scribble").foregroundStyle(.tint)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text("GrammaGem").font(.headline)
|
||||
Text(app.license.tier.displayName + (app.license.isLicensed ? " · licensed" : " · free"))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if !app.permissions.accessibilityTrusted {
|
||||
Button {
|
||||
app.showOnboarding()
|
||||
} label: {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
Button { app.showOnboarding() } label: {
|
||||
Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.orange)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.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
|
||||
private var usageRow: some View {
|
||||
if app.gate.entitlements.unlimitedAIActions {
|
||||
Label("Unlimited AI actions", systemImage: "infinity")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
} else {
|
||||
let used = app.gate.aiActionsUsedToday
|
||||
let cap = app.gate.entitlements.dailyAIActionCap
|
||||
Label("\(max(0, cap - used)) of \(cap) free AI actions left today",
|
||||
systemImage: "bolt")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Label("\(max(0, cap - used)) of \(cap) free AI actions left today", systemImage: "bolt")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import Foundation
|
||||
import AppKit
|
||||
import ApplicationServices
|
||||
|
||||
/// Reports the frontmost application so App-Aware mode switching can pick the
|
||||
/// 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 }
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
84
Sources/GrammaGem/System/Exclusions.swift
Normal file
84
Sources/GrammaGem/System/Exclusions.swift
Normal 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
|
||||
}
|
||||
}
|
||||
98
Sources/GrammaGem/System/LiveMonitor.swift
Normal file
98
Sources/GrammaGem/System/LiveMonitor.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@ -94,6 +94,32 @@ final class TextCapture {
|
||||
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
|
||||
|
||||
@discardableResult
|
||||
|
||||
@ -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.
|
||||
func handleDebugUppercase() async -> ProcessOutcome {
|
||||
let cap: TextCapture.Capture
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
#!/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)
|
||||
set -euo pipefail
|
||||
|
||||
@ -8,19 +8,20 @@ cd "$ROOT"
|
||||
|
||||
CONFIG="release"
|
||||
APP="GrammaGem.app"
|
||||
BUILD_DIR=".build/${CONFIG}"
|
||||
DIST="dist"
|
||||
|
||||
echo "==> Compiling (${CONFIG})"
|
||||
swift build -c "${CONFIG}"
|
||||
echo "==> Compiling universal (arm64 + x86_64, ${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}"
|
||||
rm -rf "${DIST}/${APP}"
|
||||
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/AppIcon.icns" "${DIST}/${APP}/Contents/Resources/AppIcon.icns"
|
||||
|
||||
echo "==> Built ${DIST}/${APP}"
|
||||
lipo -info "${DIST}/${APP}/Contents/MacOS/GrammaGem"
|
||||
echo " Run with: open \"${DIST}/${APP}\" (grant Accessibility on first launch)"
|
||||
|
||||
@ -1,33 +1,50 @@
|
||||
#!/usr/bin/env bash
|
||||
# Codesign (Hardened Runtime) + notarize + staple the GrammaGem.app bundle.
|
||||
# Requires the Apple Developer Program ($99/yr) — a FIXED cost, not per-user.
|
||||
# Build (universal), codesign (Hardened Runtime + Developer ID), notarize, and
|
||||
# staple GrammaGem.app. Requires the Apple Developer Program ($99/yr) — a FIXED
|
||||
# cost, not per-user.
|
||||
#
|
||||
# Prereqs (set as env vars or fill in):
|
||||
# DEV_ID e.g. "Developer ID Application: Your Name (TEAMID)"
|
||||
# KEYCHAIN_PROFILE a stored notarytool profile (xcrun notarytool store-credentials)
|
||||
# Credentials are read from the environment or a stored keychain profile — never
|
||||
# hard-coded here. Provide ONE of:
|
||||
# • 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
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
APP="$ROOT/dist/GrammaGem.app"
|
||||
ENTITLEMENTS="$ROOT/AppSupport/GrammaGem.entitlements"
|
||||
cd "$ROOT"
|
||||
|
||||
: "${DEV_ID:?Set DEV_ID to your Developer ID Application identity}"
|
||||
: "${KEYCHAIN_PROFILE:?Set KEYCHAIN_PROFILE to your stored notarytool profile}"
|
||||
DEV_ID="${DEV_ID:-Developer ID Application: Marc Schultheiss (D5KT5B9Z9M)}"
|
||||
APP="dist/GrammaGem.app"
|
||||
ZIP="dist/GrammaGem.zip"
|
||||
ENTITLEMENTS="AppSupport/GrammaGem.entitlements"
|
||||
|
||||
echo "▸ Codesigning with Hardened Runtime…"
|
||||
codesign --force --deep --options runtime \
|
||||
echo "==> Building universal bundle"
|
||||
./scripts/build.sh >/dev/null
|
||||
|
||||
echo "==> Codesigning (Hardened Runtime)"
|
||||
codesign --force --timestamp --options runtime \
|
||||
--entitlements "$ENTITLEMENTS" \
|
||||
--sign "$DEV_ID" \
|
||||
"$APP"
|
||||
codesign --verify --strict --verbose=2 "$APP"
|
||||
|
||||
echo "▸ Zipping for notarization…"
|
||||
ZIP="$ROOT/dist/GrammaGem.zip"
|
||||
echo "==> Zipping"
|
||||
ditto -c -k --keepParent "$APP" "$ZIP"
|
||||
|
||||
echo "▸ Submitting to Apple notary service…"
|
||||
xcrun notarytool submit "$ZIP" --keychain-profile "$KEYCHAIN_PROFILE" --wait
|
||||
echo "==> Notarizing"
|
||||
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"
|
||||
spctl -a -vvv -t install "$APP"
|
||||
|
||||
echo "✓ Signed, notarized, and stapled: $APP"
|
||||
echo "✓ Signed, notarized, stapled (universal): $APP"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user