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"?>
<!--
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>

View File

@ -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"

View File

@ -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)

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 {
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()
}

View File

@ -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)
}
}

View File

@ -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
}
}

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)
}
// 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

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.
func handleDebugUppercase() async -> ProcessOutcome {
let cap: TextCapture.Capture

View File

@ -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)"

View File

@ -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"