Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ endif
@codesign --force --options runtime --sign "$(CODESIGN_IDENTITY)" --entitlements FreeFlow.entitlements "$(APP_BUNDLE)"
@echo "Built $(APP_BUNDLE)"

# AppContextService now depends on this source, so the test build needs it too.
$(TEST_RUNNER): Sources/AppContextSource.swift

test: $(TEST_RUNNER)
@$(TEST_RUNNER)

Expand All @@ -79,7 +82,7 @@ $(TEST_RUNNER): Sources/AppContextService.swift Sources/LLMAPITransport.swift So
-o "$(TEST_RUNNER)" \
-sdk $(shell xcrun --show-sdk-path) \
-target $(ARCH)-apple-macosx13.0 \
Sources/AppContextService.swift Sources/LLMAPITransport.swift Sources/ModelConfiguration.swift Tests/AppContextServiceTests.swift
$^

icon: $(ICON_ICNS)

Expand Down
195 changes: 195 additions & 0 deletions Sources/AppContextScopeStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import SwiftUI
import AppKit
import UniformTypeIdentifiers

/// Observable store for the per-app screenshot allowlist, persisted to `AppContextSource.appsKey`.
/// Uses `ObservableObject` (not `@Observable`) because the deployment target is macOS 13.
@MainActor
final class AppContextAllowlist: ObservableObject {
/// The allowlisted bundle identifiers (persisted).
@Published private(set) var bundleIDs: [String]

/// Loads the persisted allowlist (empty by default).
init() {
bundleIDs = UserDefaults.standard.array(forKey: AppContextSource.appsKey) as? [String] ?? []
}

/// Adds bundle ids, skipping blanks and duplicates; persists only if something changed.
func add(_ ids: [String]) {
let existing = Set(bundleIDs)
let fresh = ids.filter { !$0.isEmpty && !existing.contains($0) }
guard !fresh.isEmpty else { return }
bundleIDs.append(contentsOf: fresh)
persist()
}

/// Removes a bundle id and persists.
func remove(_ id: String) {
bundleIDs.removeAll { $0 == id }
persist()
}

/// Writes the allowlist back to `UserDefaults`.
private func persist() {
UserDefaults.standard.set(bundleIDs, forKey: AppContextSource.appsKey)
}

/// Opens the "Add app…" picker and adds the bundle ids of any chosen apps.
func presentPicker() {
// AppKit open panel rooted at /Applications, limited to .app bundles.
let panel = NSOpenPanel()
panel.directoryURL = URL(fileURLWithPath: "/Applications")
panel.allowedContentTypes = [.application]
panel.allowsMultipleSelection = true
panel.canChooseDirectories = false
panel.canChooseFiles = true
guard panel.runModal() == .OK else { return }
add(panel.urls.compactMap { Bundle(url: $0)?.bundleIdentifier })
}
}

/// Resolves and caches an app's display name + icon from its bundle id (for the chips).
@MainActor
enum AppContextAppInfo {
private static var cache: [String: (name: String, icon: NSImage?)] = [:]

/// Returns the app's display name (falls back to the bundle id) and icon. Resolved once per id.
static func info(forBundleID id: String) -> (name: String, icon: NSImage?) {
if let hit = cache[id] { return hit }
// Resolve the app's URL once, then its Finder name and icon (AppKit).
let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: id)
let name = url.map { FileManager.default.displayName(atPath: $0.path) } ?? id
let icon = url.map { NSWorkspace.shared.icon(forFile: $0.path) }
let result = (name, icon)
cache[id] = result
return result
}
}

/// The screenshot scope multiselector: two radio cards plus the per-app chips and "Add app…" picker.
/// Binds the scope to `AppContextSource.scopeKey` via `@AppStorage` and observes the allowlist store.
struct ScreenshotScopeSection: View {
/// Persisted scope (`"all"` | `"specific"`), defaulting to `all`.
@AppStorage(AppContextSource.scopeKey) private var scopeRaw: String = AppContextSource.Scope.all.rawValue
/// The allowlist store, owned by this view.
@StateObject private var allowlist = AppContextAllowlist()
/// Reflects the parent `.disabled()` (true only in Screenshot mode) — used to grey out chip icons.
@Environment(\.isEnabled) private var isEnabled

/// Current scope from the persisted value.
private var scope: AppContextSource.Scope { AppContextSource.Scope(rawValue: scopeRaw) ?? .all }

/// Header + the two scope options, plus the app chips when `specific`.
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Screenshot scope")
.font(.caption.weight(.semibold))
scopeOption(.all, title: "Send screenshot to all apps", subtitle: "Every app takes a screenshot.")
scopeOption(.specific, title: "Only specific apps", subtitle: "Other apps use App summary instead.")
if scope == .specific {
specificAppsEditor
}
}
}

/// One selectable scope row in the app's radio-row style (checkmark + blue accent when selected).
private func scopeOption(_ option: AppContextSource.Scope, title: String, subtitle: String) -> some View {
let selected = scope == option
return Button {
scopeRaw = option.rawValue
} label: {
HStack(alignment: .top, spacing: 10) {
Image(systemName: selected ? "checkmark.circle.fill" : "circle")
.foregroundStyle(selected ? .blue : .secondary)
VStack(alignment: .leading, spacing: 2) {
Text(title)
.foregroundStyle(.primary)
Text(subtitle)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer(minLength: 0)
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(selected ? Color.blue.opacity(0.1) : Color(nsColor: .controlBackgroundColor))
.cornerRadius(8)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(selected ? Color.blue : Color.clear, lineWidth: 1.5)
)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
// Read the row as one element to VoiceOver, with its selected state.
.accessibilityElement(children: .ignore)
.accessibilityLabel("\(title). \(subtitle)")
.accessibilityAddTraits(selected ? [.isButton, .isSelected] : .isButton)
}

/// The app chips (or an empty-state line) plus the dashed "Add app…" button.
private var specificAppsEditor: some View {
VStack(alignment: .leading, spacing: 8) {
if allowlist.bundleIDs.isEmpty {
Text("No apps added yet.")
.font(.caption)
.foregroundStyle(.secondary)
}
FlowLayout(spacing: 6) {
ForEach(allowlist.bundleIDs, id: \.self) { id in
chip(for: id)
}
addButton
}
}
}

/// A removable app chip: icon + name + ✕.
private func chip(for id: String) -> some View {
let info = AppContextAppInfo.info(forBundleID: id)
return HStack(spacing: 6) {
if let icon = info.icon {
Image(nsImage: icon)
.resizable()
.frame(width: 16, height: 16)
// Desaturate when the screenshot block is disabled, so the icon dims like the text.
.grayscale(isEnabled ? 0 : 1)
}
Text(info.name)
.font(.caption)
Button {
allowlist.remove(id)
} label: {
Image(systemName: "xmark")
.font(.caption2)
}
.buttonStyle(.plain)
.foregroundStyle(.secondary)
.accessibilityLabel("Remove \(info.name)")
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(RoundedRectangle(cornerRadius: 6).fill(Color(nsColor: .controlBackgroundColor)))
.overlay(RoundedRectangle(cornerRadius: 6).stroke(Color.secondary.opacity(0.25), lineWidth: 1))
}

/// The dashed "Add app…" button that opens the picker.
private var addButton: some View {
Button {
allowlist.presentPicker()
} label: {
HStack(spacing: 4) {
Image(systemName: "plus")
Text("Add app…")
}
.font(.caption)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.overlay(
RoundedRectangle(cornerRadius: 6).stroke(Color.secondary.opacity(0.5), style: StrokeStyle(lineWidth: 1, dash: [4]))
)
}
.buttonStyle(.plain)
.accessibilityLabel("Add app")
}
}
35 changes: 35 additions & 0 deletions Sources/AppContextService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ Return only two sentences, no labels, no markdown, no extra commentary.
private let maxScreenshotDataURILength = 500_000
private let screenshotCompressionPrimary = 0.5
private let screenshotMaxDimension: CGFloat
/// Forces the vision path on this instance only — set by the "Test Vision Prompt" button on its own
/// throwaway service so the test works in any mode/scope. Real dictations use a separate instance.
var forceScreenshotForTest = false
private var contextRequestTimeoutSeconds: TimeInterval {
let override = UserDefaults.standard.double(forKey: "context_request_timeout_seconds")
return override > 0 ? override : 20
Expand Down Expand Up @@ -176,6 +179,11 @@ Return only two sentences, no labels, no markdown, no extra commentary.
screenshotDataURL: String?,
contextSystemPrompt: String
) async -> (activity: String, prompt: String)? {
// Skip vision/LLM inference unless this app uses Screenshot mode.
guard forceScreenshotForTest || AppContextSource.effectiveSource(forBundleID: bundleIdentifier) == .screenshot else {
return nil
}

let attempts: [(model: String, screenshotDataURL: String?)] =
if let screenshotDataURL {
[
Expand Down Expand Up @@ -318,6 +326,26 @@ Selected text: \(selectedText ?? "None")
windowTitle: String?,
screenshotAvailable: Bool
) -> String {
// Off → empty, App summary → metadata line, Screenshot → original error text below.
let source: AppContextSource.EffectiveSource =
forceScreenshotForTest ? .screenshot : AppContextSource.effectiveSource(forBundleID: bundleIdentifier)
switch source {
case .off:
return ""
case .metadata:
// One AX read: host / web-capable / address-bar focus (skips if the frontmost app changed).
let web = AppContextSource.webContext(expectedBundleID: bundleIdentifier)
return AppContextSource.metadataSummary(
appName: appName,
pageTitle: windowTitle,
webHost: web.host,
isWebApp: web.isWebApp,
addressBarFocused: web.addressBarFocused
)
case .screenshot:
break
}

let activeApp = appName ?? "the active application"
if screenshotAvailable {
return "Could not reliably infer a two-sentence summary for \(activeApp) from the screenshot and metadata."
Expand Down Expand Up @@ -423,6 +451,13 @@ Selected text: \(selectedText ?? "None")
appElement: AXUIElement,
focusedWindowTitle: String?
) -> (dataURL: String?, mimeType: String?, error: String?) {
// Derive the bundle id from the pid; skip capture unless this app uses Screenshot mode.
let bundleID = NSRunningApplication(processIdentifier: processIdentifier)?.bundleIdentifier
guard forceScreenshotForTest || AppContextSource.effectiveSource(forBundleID: bundleID) == .screenshot else {
// Intentional skip, not a failure — no error so it isn't logged/shown as one.
return (nil, nil, nil)
}

if !CGPreflightScreenCaptureAccess() {
return (
nil,
Expand Down
Loading