diff --git a/AppSupport/GrammaGem.entitlements b/AppSupport/GrammaGem.entitlements index ae4ce82..b1088de 100644 --- a/AppSupport/GrammaGem.entitlements +++ b/AppSupport/GrammaGem.entitlements @@ -1,15 +1,4 @@ - diff --git a/Sources/GrammaGem/App/AppState.swift b/Sources/GrammaGem/App/AppState.swift index 0c131c5..d03779f 100644 --- a/Sources/GrammaGem/App/AppState.swift +++ b/Sources/GrammaGem/App/AppState.swift @@ -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" diff --git a/Sources/GrammaGem/App/GrammaGemApp.swift b/Sources/GrammaGem/App/GrammaGemApp.swift index 70af3f5..b2d538f 100644 --- a/Sources/GrammaGem/App/GrammaGemApp.swift +++ b/Sources/GrammaGem/App/GrammaGemApp.swift @@ -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) diff --git a/Sources/GrammaGem/App/MainWindow/ExclusionsView.swift b/Sources/GrammaGem/App/MainWindow/ExclusionsView.swift new file mode 100644 index 0000000..74e4667 --- /dev/null +++ b/Sources/GrammaGem/App/MainWindow/ExclusionsView.swift @@ -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) } + } +} diff --git a/Sources/GrammaGem/App/MainWindow/MainWindowView.swift b/Sources/GrammaGem/App/MainWindow/MainWindowView.swift index 78937cb..bc81ab6 100644 --- a/Sources/GrammaGem/App/MainWindow/MainWindowView.swift +++ b/Sources/GrammaGem/App/MainWindow/MainWindowView.swift @@ -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() } diff --git a/Sources/GrammaGem/App/MenuBarContent.swift b/Sources/GrammaGem/App/MenuBarContent.swift index 62ca8ce..641c1bd 100644 --- a/Sources/GrammaGem/App/MenuBarContent.swift +++ b/Sources/GrammaGem/App/MenuBarContent.swift @@ -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) } } diff --git a/Sources/GrammaGem/System/AppDetector.swift b/Sources/GrammaGem/System/AppDetector.swift index f0bfddf..15df3d1 100644 --- a/Sources/GrammaGem/System/AppDetector.swift +++ b/Sources/GrammaGem/System/AppDetector.swift @@ -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 + } } diff --git a/Sources/GrammaGem/System/Exclusions.swift b/Sources/GrammaGem/System/Exclusions.swift new file mode 100644 index 0000000..a0c91b1 --- /dev/null +++ b/Sources/GrammaGem/System/Exclusions.swift @@ -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[..= 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 + } +} diff --git a/Sources/GrammaGem/System/TextCapture.swift b/Sources/GrammaGem/System/TextCapture.swift index eeb0cec..862d308 100644 --- a/Sources/GrammaGem/System/TextCapture.swift +++ b/Sources/GrammaGem/System/TextCapture.swift @@ -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 diff --git a/Sources/GrammaGem/System/TextReplacementCoordinator.swift b/Sources/GrammaGem/System/TextReplacementCoordinator.swift index e1f17b6..3746686 100644 --- a/Sources/GrammaGem/System/TextReplacementCoordinator.swift +++ b/Sources/GrammaGem/System/TextReplacementCoordinator.swift @@ -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 diff --git a/scripts/build.sh b/scripts/build.sh index 2672e5f..86c8fc3 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -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)" diff --git a/scripts/sign-notarize.sh b/scripts/sign-notarize.sh index 58421d7..be65729 100755 --- a/scripts/sign-notarize.sh +++ b/scripts/sign-notarize.sh @@ -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"