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"?>
|
<?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>
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
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 {
|
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()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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)
|
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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)"
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user