From b9c3c97dc0289781705b81541675885c0aef9bef Mon Sep 17 00:00:00 2001 From: Rune Olsen Date: Thu, 16 Jul 2026 09:05:18 +0200 Subject: [PATCH 01/14] Add SECURITY.md with vulnerability reporting policy Points reporters to the contact form at oai.pm instead of public issues. Co-Authored-By: Claude Sonnet 5 --- SECURITY.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 SECURITY.md diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..d54b0d3 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,25 @@ +# Security Policy + +## Reporting a Vulnerability + +If you discover a security vulnerability in oAI, please report it privately rather than opening a public GitHub issue. + +To report a security concern, use the contact form at **[https://oai.pm/#contact](https://oai.pm/#contact)**. + +Please include as much detail as possible: +- A description of the vulnerability and its potential impact +- Steps to reproduce the issue +- The oAI version and macOS version you're using +- Any relevant logs (`~/Library/Logs/oAI.log`), with sensitive data redacted + +## Scope + +oAI is a native macOS app that stores conversations, settings, and API keys locally (SQLite database and Keychain). Areas of particular interest for security reports include: +- API key handling and Keychain storage +- MCP file access permission checks +- Bash execution approval flow +- Any path that could lead to data exfiltration or unauthorized local file/system access + +## Response + +Reports submitted through the contact form will be reviewed and acknowledged as soon as possible. Please allow time for a fix to be developed and released before any public disclosure. -- 2.54.0 From d38b5442ed8e9ea886ce070ce83fda987b2a3028 Mon Sep 17 00:00:00 2001 From: Rune Olsen Date: Thu, 16 Jul 2026 09:07:37 +0200 Subject: [PATCH 02/14] Clarify that only the latest release is supported for security fixes Co-Authored-By: Claude Sonnet 5 --- SECURITY.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/SECURITY.md b/SECURITY.md index d54b0d3..4b469d4 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,5 +1,9 @@ # Security Policy +## Supported Versions + +Only the latest publicly released version of oAI is supported with security fixes. Please update to the latest version before reporting an issue, and confirm it still reproduces there. + ## Reporting a Vulnerability If you discover a security vulnerability in oAI, please report it privately rather than opening a public GitHub issue. -- 2.54.0 From 75e5b40f4554835c36394c64e3b9fc5260d6756a Mon Sep 17 00:00:00 2001 From: Rune Olsen Date: Sun, 19 Jul 2026 16:01:04 +0200 Subject: [PATCH 03/14] Persist sidebar visibility; add language-matching rule to default prompt - ContentView now reads/writes SettingsService.sidebarVisible so the NavigationSplitView sidebar's shown/hidden state survives relaunch, matching the existing window size/position persistence. - Default system prompt gains a rule: always reply in the user's language, even for requests (e.g. translation) targeting another one. --- oAI/ViewModels/ChatViewModel.swift | 1 + oAI/Views/Main/ContentView.swift | 6 +++++- oAI/Views/Screens/SettingsView.swift | 1 + 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/oAI/ViewModels/ChatViewModel.swift b/oAI/ViewModels/ChatViewModel.swift index ecc2dc8..7b3f88e 100644 --- a/oAI/ViewModels/ChatViewModel.swift +++ b/oAI/ViewModels/ChatViewModel.swift @@ -90,6 +90,7 @@ You are a helpful AI assistant. Follow these core principles: - **Be Direct**: Provide concise, relevant answers. No unnecessary preambles. - **Show Your Work**: If you use capabilities (tools, web search, etc.), demonstrate what you did. - **Complete Tasks Properly**: If you start something, finish it correctly. +- **Match the User's Language**: Always reply in the same language the user is writing in, even when the request itself involves another language (e.g. a translation or spelling request) — the target language applies to the content you produce, not to your own reply. ## FORMATTING diff --git a/oAI/Views/Main/ContentView.swift b/oAI/Views/Main/ContentView.swift index a823094..f2d740b 100644 --- a/oAI/Views/Main/ContentView.swift +++ b/oAI/Views/Main/ContentView.swift @@ -29,7 +29,8 @@ import Darwin // uname, sysctlbyname struct ContentView: View { @Environment(ChatViewModel.self) var chatViewModel private var updateService = UpdateCheckService.shared - @State private var columnVisibility: NavigationSplitViewVisibility = .all + @State private var columnVisibility: NavigationSplitViewVisibility = + SettingsService.shared.sidebarVisible ? .all : .detailOnly @State private var showIntelWarning = false var body: some View { @@ -46,6 +47,9 @@ struct ContentView: View { ) } .frame(minWidth: 860, minHeight: 560) + .onChange(of: columnVisibility) { _, newValue in + SettingsService.shared.sidebarVisible = newValue != .detailOnly + } #if os(macOS) .onAppear { NSApplication.shared.windows.forEach { $0.tabbingMode = .disallowed } diff --git a/oAI/Views/Screens/SettingsView.swift b/oAI/Views/Screens/SettingsView.swift index 809667b..5911fee 100644 --- a/oAI/Views/Screens/SettingsView.swift +++ b/oAI/Views/Screens/SettingsView.swift @@ -125,6 +125,7 @@ You are a helpful AI assistant. Follow these core principles: - **Be Direct**: Provide concise, relevant answers. No unnecessary preambles. - **Show Your Work**: If you use capabilities (tools, web search, etc.), demonstrate what you did. - **Complete Tasks Properly**: If you start something, finish it correctly. +- **Match the User's Language**: Always reply in the same language the user is writing in, even when the request itself involves another language (e.g. a translation or spelling request) — the target language applies to the content you produce, not to your own reply. ## FORMATTING -- 2.54.0 From 74c8be8f9443fc2b0b453a8180ed6539498ba6d8 Mon Sep 17 00:00:00 2001 From: Rune Olsen Date: Mon, 20 Jul 2026 07:24:20 +0200 Subject: [PATCH 04/14] Fix Cmd+? opening Help Book in the default browser instead of Help Viewer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit openHelp() always found the bundled oAI.help folder, so it always took the NSWorkspace.open(index.html) branch — handing the page to the default web browser. The NSHelpManager branch, which launches the native Help Viewer (with its built-in full-text search), was unreachable dead code. Now always routes through NSHelpManager. --- oAI/oAIApp.swift | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/oAI/oAIApp.swift b/oAI/oAIApp.swift index de68282..7339c34 100644 --- a/oAI/oAIApp.swift +++ b/oAI/oAIApp.swift @@ -161,12 +161,7 @@ struct oAIApp: App { #if os(macOS) private func openHelp() { - if let helpBookURL = Bundle.main.url(forResource: "oAI.help", withExtension: nil) { - NSWorkspace.shared.open(helpBookURL.appendingPathComponent("Contents/Resources/en.lproj/index.html")) - } else { - // Fallback to Apple Help if help book not found - NSHelpManager.shared.openHelpAnchor("", inBook: Bundle.main.object(forInfoDictionaryKey: "CFBundleHelpBookName") as? String) - } + NSHelpManager.shared.openHelpAnchor("", inBook: Bundle.main.object(forInfoDictionaryKey: "CFBundleHelpBookName") as? String) } #endif } -- 2.54.0 From 333e2884180a4163635f5bead6d06cb65bdbdcba Mon Sep 17 00:00:00 2001 From: Rune Olsen Date: Mon, 20 Jul 2026 07:33:51 +0200 Subject: [PATCH 05/14] Fix Help Book keys missing from the built Info.plist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GENERATE_INFOPLIST_FILE = YES with no INFOPLIST_FILE meant Xcode synthesized Info.plist purely from INFOPLIST_KEY_* build settings and silently ignored oAI/Info.plist on disk — so CFBundleHelpBookName/ CFBundleHelpBookFolder never made it into the built app. NSHelpManager therefore couldn't resolve the book and macOS showed the generic Help Center instead of oAI's help content (previous commit 74c8be8 fixed openHelp() to call NSHelpManager, but that call had nothing to find). Fix: point INFOPLIST_FILE at oAI/Info.plist so Xcode merges its extra keys into the generated Info.plist, and add a synchronized-group membership exception so Info.plist isn't also copied into Resources (would otherwise produce a duplicate-file build warning). --- oAI.xcodeproj/project.pbxproj | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/oAI.xcodeproj/project.pbxproj b/oAI.xcodeproj/project.pbxproj index 3e2321f..a412e96 100644 --- a/oAI.xcodeproj/project.pbxproj +++ b/oAI.xcodeproj/project.pbxproj @@ -15,9 +15,22 @@ A550A6622F3B72EA00136F2B /* oAI.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = oAI.app; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 911C4D0E69E11B84C61453DC /* Exceptions for "oAI" folder in "oAI" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = A550A6612F3B72EA00136F2B /* oAI */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + /* Begin PBXFileSystemSynchronizedRootGroup section */ A550A6642F3B72EA00136F2B /* oAI */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 911C4D0E69E11B84C61453DC /* Exceptions for "oAI" folder in "oAI" target */, + ); path = oAI; sourceTree = ""; }; @@ -270,6 +283,7 @@ ENABLE_PREVIEWS = YES; ENABLE_USER_SELECTED_FILES = readonly; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "oAI/Info.plist"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "oAI can read and create calendar events when you ask it to, if you enable Calendar access in Settings."; INFOPLIST_KEY_NSContactsUsageDescription = "oAI can search your contacts when you ask it to, if you enable Contacts access in Settings."; @@ -319,6 +333,7 @@ ENABLE_PREVIEWS = YES; ENABLE_USER_SELECTED_FILES = readonly; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "oAI/Info.plist"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "oAI can read and create calendar events when you ask it to, if you enable Calendar access in Settings."; INFOPLIST_KEY_NSContactsUsageDescription = "oAI can search your contacts when you ask it to, if you enable Contacts access in Settings."; -- 2.54.0 From 98979584fb753fbbd00b8b0f547e00ea0ab93d66 Mon Sep 17 00:00:00 2001 From: Rune Olsen Date: Mon, 20 Jul 2026 08:00:25 +0200 Subject: [PATCH 06/14] Fix dead Cmd+/ shortcut for In-App Help; rebind to Ctrl+Cmd+H MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cmd+/ was never reaching the "In-App Help" menu item — AppKit auto-reserves Cmd+/ to open/focus the app's own Help menu (same mechanism as Cmd+?), silently pre-empting any custom binding on that combo, exactly like the earlier Cmd+H (Hide Application) conflict. Verified live with computer-use before landing on Ctrl+Cmd+H: Option+Cmd+/ has unpredictable menu-glyph rendering and didn't fire; Option+Cmd+H is macOS's reserved "Hide Others" shortcut (visibly hid other app windows when tested). Ctrl+Cmd+H showed no OS-level effect and correctly opens the panel. HelpView's search bar was already fully implemented and working — this was purely a matter of the shortcut never reaching it. --- oAI/Resources/oAI.help/Contents/Resources/en.lproj/index.html | 2 +- oAI/Views/Screens/HelpView.swift | 2 +- oAI/oAIApp.swift | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/oAI/Resources/oAI.help/Contents/Resources/en.lproj/index.html b/oAI/Resources/oAI.help/Contents/Resources/en.lproj/index.html index 7592fe8..c02e094 100644 --- a/oAI/Resources/oAI.help/Contents/Resources/en.lproj/index.html +++ b/oAI/Resources/oAI.help/Contents/Resources/en.lproj/index.html @@ -1526,7 +1526,7 @@ Whenever the user asks you to translate something, translate it to Norwegian Bok
⌘,
Open Settings
-
⌘/
+
⌃⌘H
Show in-app Help
⌘?
diff --git a/oAI/Views/Screens/HelpView.swift b/oAI/Views/Screens/HelpView.swift index d290046..2471e16 100644 --- a/oAI/Views/Screens/HelpView.swift +++ b/oAI/Views/Screens/HelpView.swift @@ -213,7 +213,7 @@ private let keyboardShortcuts: [(key: String, description: String)] = [ ("\u{2318}L", "Conversations"), ("\u{21E7}\u{2318}S", "Statistics"), ("\u{2318},", "Settings"), - ("\u{2318}/", "Help"), + ("\u{2303}\u{2318}H", "Help"), ] // MARK: - HelpView diff --git a/oAI/oAIApp.swift b/oAI/oAIApp.swift index 7339c34..280ae0a 100644 --- a/oAI/oAIApp.swift +++ b/oAI/oAIApp.swift @@ -133,7 +133,7 @@ struct oAIApp: App { .keyboardShortcut("h", modifiers: [.command, .shift]) Button("In-App Help") { chatViewModel.showHelp = true } - .keyboardShortcut("/", modifiers: .command) + .keyboardShortcut("h", modifiers: [.command, .control]) Button("Credits") { chatViewModel.showCredits = true } -- 2.54.0 From 86027001c7e61449d8dae66b2d4c596084d70898 Mon Sep 17 00:00:00 2001 From: Rune Olsen Date: Mon, 20 Jul 2026 08:17:33 +0200 Subject: [PATCH 07/14] Roll back native Help Viewer integration; fix duplicate View menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per Rune: the NSHelpManager-based Cmd+? fix from the last few commits technically worked (registration was correct) but opened Apple's broken generic Tips landing page instead of oAI's own content on the macOS 27 beta this is built against — worse than the original browser-tab behavior. Revisit at macOS 27 RC1 (see CLAUDE.md). - openHelp() reverts to NSWorkspace.open() on index.html directly. - Removed the "In-App Help" Ctrl+Cmd+H menu item entirely — HelpView's panel (search already fully working) is reachable only via /help from the input field now, by design. - Renamed the custom CommandMenu("View") to CommandMenu("Chat") — it was colliding with the "View" menu macOS auto-adds for NavigationSplitView (Enter Full Screen, etc.), producing two identically-titled top-level menus in the menu bar. --- .../Contents/Resources/en.lproj/index.html | 3 --- oAI/Views/Screens/HelpView.swift | 1 - oAI/oAIApp.swift | 18 ++++++++++++------ 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/oAI/Resources/oAI.help/Contents/Resources/en.lproj/index.html b/oAI/Resources/oAI.help/Contents/Resources/en.lproj/index.html index c02e094..341ce65 100644 --- a/oAI/Resources/oAI.help/Contents/Resources/en.lproj/index.html +++ b/oAI/Resources/oAI.help/Contents/Resources/en.lproj/index.html @@ -1526,9 +1526,6 @@ Whenever the user asks you to translate something, translate it to Norwegian Bok
⌘,
Open Settings
-
⌃⌘H
-
Show in-app Help
-
⌘?
Open this Help (macOS Help)
diff --git a/oAI/Views/Screens/HelpView.swift b/oAI/Views/Screens/HelpView.swift index 2471e16..9063dd9 100644 --- a/oAI/Views/Screens/HelpView.swift +++ b/oAI/Views/Screens/HelpView.swift @@ -213,7 +213,6 @@ private let keyboardShortcuts: [(key: String, description: String)] = [ ("\u{2318}L", "Conversations"), ("\u{21E7}\u{2318}S", "Statistics"), ("\u{2318},", "Settings"), - ("\u{2303}\u{2318}H", "Help"), ] // MARK: - HelpView diff --git a/oAI/oAIApp.swift b/oAI/oAIApp.swift index 280ae0a..064b093 100644 --- a/oAI/oAIApp.swift +++ b/oAI/oAIApp.swift @@ -116,8 +116,11 @@ struct oAIApp: App { .disabled(chatViewModel.messages.filter { $0.role != .system }.isEmpty) } - // ── View menu ───────────────────────────────────────────────── - CommandMenu("View") { + // ── Chat menu ───────────────────────────────────────────────── + // Named "Chat" (not "View") to avoid colliding with the "View" menu + // macOS auto-adds for NavigationSplitView (Enter Full Screen, etc.) — + // two menus both titled "View" would otherwise appear in the menu bar. + CommandMenu("Chat") { Button("Select Model") { chatViewModel.showModelSelector = true } .keyboardShortcut("m", modifiers: .command) @@ -132,9 +135,6 @@ struct oAIApp: App { Button("Command History") { chatViewModel.showHistory = true } .keyboardShortcut("h", modifiers: [.command, .shift]) - Button("In-App Help") { chatViewModel.showHelp = true } - .keyboardShortcut("h", modifiers: [.command, .control]) - Button("Credits") { chatViewModel.showCredits = true } Divider() @@ -161,7 +161,13 @@ struct oAIApp: App { #if os(macOS) private func openHelp() { - NSHelpManager.shared.openHelpAnchor("", inBook: Bundle.main.object(forInfoDictionaryKey: "CFBundleHelpBookName") as? String) + // Opens the Help Book's index.html directly in the default browser rather than + // through NSHelpManager/Help Viewer — see CLAUDE.md's macOS 27 beta note for why + // (Apple's Tips.app replacement for Help Viewer can't resolve anchors on this beta). + // Revisit once macOS 27 reaches RC. + if let helpBookURL = Bundle.main.url(forResource: "oAI.help", withExtension: nil) { + NSWorkspace.shared.open(helpBookURL.appendingPathComponent("Contents/Resources/en.lproj/index.html")) + } } #endif } -- 2.54.0 From fc786d48f8ab33c37dcd16ec92d8cd0efacc3390 Mon Sep 17 00:00:00 2001 From: Rune Olsen Date: Tue, 21 Jul 2026 13:19:50 +0200 Subject: [PATCH 08/14] Sync starred models across machines; add automatic backup scheduling Favorites now push/pull through a small oai_favorites.json file in the same iCloud Drive folder used by Settings > Backup, reconciled by last-write-wins timestamp on launch and app-become-active. Also adds an Off/Daily/Weekly frequency picker so the full settings backup can run itself (checked at launch and hourly) instead of requiring a manual "Back Up Now" click every time. --- oAI/Services/BackupService.swift | 85 ++++++++++++++++++++++++++++ oAI/Services/SettingsService.swift | 23 ++++++++ oAI/Views/Screens/SettingsView.swift | 31 ++++++++++ oAI/oAIApp.swift | 10 ++++ 4 files changed, 149 insertions(+) diff --git a/oAI/Services/BackupService.swift b/oAI/Services/BackupService.swift index 36b52ff..522a862 100644 --- a/oAI/Services/BackupService.swift +++ b/oAI/Services/BackupService.swift @@ -34,6 +34,15 @@ struct BackupManifest: Codable { let credentials: [String: String]? } +// MARK: - FavoritesPayload + +/// Small standalone file (separate from the full settings backup) so starring a model +/// syncs near-instantly across machines instead of waiting for the next full backup. +struct FavoritesPayload: Codable { + let updatedAt: String + let ids: [String] +} + // MARK: - BackupService @Observable @@ -51,6 +60,8 @@ final class BackupService { /// URL of the last backup file var lastBackupURL: URL? + private var autoBackupTimer: Timer? + // Keys excluded from backup — encrypted_ prefix + internal migration flags private static let excludedKeys: Set = [ "encrypted_openrouterAPIKey", @@ -71,6 +82,7 @@ final class BackupService { private init() { checkForExistingBackup() + startAutoBackupTimer() } // MARK: - iCloud Path Resolution @@ -176,6 +188,79 @@ final class BackupService { log.info("Restored \(manifest.settings.count) settings from backup (v\(manifest.version))") } + // MARK: - Favorite Models Sync + + private func favoritesFileURL() -> URL { + resolveBackupDirectory().appendingPathComponent("oai_favorites.json") + } + + /// Write the current local favorites to iCloud Drive. Called whenever a favorite is toggled. + func pushFavorites() async { + let settings = SettingsService.shared + let payload = FavoritesPayload( + updatedAt: settings.favoriteModelsUpdatedAt, + ids: settings.favoriteModelIds.sorted() + ) + guard let data = try? JSONEncoder().encode(payload) else { return } + try? data.write(to: favoritesFileURL(), options: .atomic) + log.debug("Pushed \(payload.ids.count) favorite model(s) to iCloud") + } + + /// Reconcile local favorites with the iCloud copy using last-write-wins (by timestamp). + /// Call on launch (and optionally on app-become-active) to pick up changes from other machines. + func syncFavoritesOnLaunch() async { + let settings = SettingsService.shared + let fileURL = favoritesFileURL() + + guard let data = try? Data(contentsOf: fileURL), + let remote = try? JSONDecoder().decode(FavoritesPayload.self, from: data) else { + // No remote copy yet — push local state (may be empty, that's fine). + await pushFavorites() + return + } + + let localUpdatedAt = settings.favoriteModelsUpdatedAt + if remote.updatedAt > localUpdatedAt { + settings.favoriteModelIds = Set(remote.ids) + settings.favoriteModelsUpdatedAt = remote.updatedAt + log.info("Applied \(remote.ids.count) favorite model(s) from iCloud (remote was newer)") + } else if localUpdatedAt > remote.updatedAt { + await pushFavorites() + } + // Equal timestamps: already in sync, nothing to do. + } + + // MARK: - Automatic Backup + + private static let dailyInterval: TimeInterval = 24 * 3600 + private static let weeklyInterval: TimeInterval = 7 * 24 * 3600 + + /// Checked once at launch and hourly thereafter while the app is running. + private func startAutoBackupTimer() { + Task { await checkAndPerformAutoBackupIfDue() } + autoBackupTimer = Timer.scheduledTimer(withTimeInterval: 3600, repeats: true) { [weak self] _ in + Task { await self?.checkAndPerformAutoBackupIfDue() } + } + } + + func checkAndPerformAutoBackupIfDue() async { + let frequency = SettingsService.shared.autoBackupFrequency + let interval: TimeInterval + switch frequency { + case "daily": interval = Self.dailyInterval + case "weekly": interval = Self.weeklyInterval + default: return // "manual" — user triggers backups by hand + } + + checkForExistingBackup() + if let last = lastBackupDate, Date().timeIntervalSince(last) < interval { + return // not due yet + } + + log.info("Automatic backup (\(frequency, privacy: .public)) is due — backing up now") + _ = try? await exportSettings() + } + // MARK: - Helpers private func appVersion() -> String { diff --git a/oAI/Services/SettingsService.swift b/oAI/Services/SettingsService.swift index 8c3060b..eadcbe8 100644 --- a/oAI/Services/SettingsService.swift +++ b/oAI/Services/SettingsService.swift @@ -520,10 +520,33 @@ class SettingsService { } } + /// ISO8601 timestamp of the last local change to favoriteModelIds — used to + /// resolve last-write-wins conflicts when syncing favorites across machines. + var favoriteModelsUpdatedAt: String { + get { cache["favoriteModelsUpdatedAt"] ?? "" } + set { + cache["favoriteModelsUpdatedAt"] = newValue + DatabaseService.shared.setSetting(key: "favoriteModelsUpdatedAt", value: newValue) + } + } + func toggleFavoriteModel(_ id: String) { var favs = favoriteModelIds if favs.contains(id) { favs.remove(id) } else { favs.insert(id) } favoriteModelIds = favs + favoriteModelsUpdatedAt = ISO8601DateFormatter().string(from: Date()) + Task { await BackupService.shared.pushFavorites() } + } + + // MARK: - Automatic Backup + + /// "manual" (default), "daily", or "weekly" + var autoBackupFrequency: String { + get { cache["autoBackupFrequency"] ?? "manual" } + set { + cache["autoBackupFrequency"] = newValue + DatabaseService.shared.setSetting(key: "autoBackupFrequency", value: newValue) + } } // MARK: - Anytype MCP Settings diff --git a/oAI/Views/Screens/SettingsView.swift b/oAI/Views/Screens/SettingsView.swift index 5911fee..6852497 100644 --- a/oAI/Views/Screens/SettingsView.swift +++ b/oAI/Views/Screens/SettingsView.swift @@ -2596,6 +2596,37 @@ It's better to admit "I need more information" or "I cannot do that" than to fak } } + // Automatic Backup + VStack(alignment: .leading, spacing: 6) { + sectionHeader("Automatic Backup") + formSection { + row("Frequency") { + Picker("", selection: $settingsService.autoBackupFrequency) { + Text("Off").tag("manual") + Text("Daily").tag("daily") + Text("Weekly").tag("weekly") + } + .pickerStyle(.segmented) + .frame(width: 220) + } + } + Text("When enabled, oAI backs up automatically in the background (checked at launch and hourly while running) — no need to press \"Back Up Now\" yourself.") + .font(.system(size: 13)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, 4) + } + + // Favorites Sync + VStack(alignment: .leading, spacing: 6) { + sectionHeader("Favorite Models") + Text("Starred models sync automatically via the same iCloud Drive folder — star a model on one Mac and it appears starred on your others within a launch or two.") + .font(.system(size: 13)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, 4) + } + // Actions VStack(alignment: .leading, spacing: 6) { sectionHeader("Actions") diff --git a/oAI/oAIApp.swift b/oAI/oAIApp.swift index 064b093..132864d 100644 --- a/oAI/oAIApp.swift +++ b/oAI/oAIApp.swift @@ -43,6 +43,11 @@ struct oAIApp: App { await GitSyncService.shared.syncOnStartup() } + // Reconcile starred models with the iCloud copy (cross-machine favorites sync) + Task { + await BackupService.shared.syncFavoritesOnLaunch() + } + // Check for updates in the background UpdateCheckService.shared.checkForUpdates() } @@ -56,6 +61,11 @@ struct oAIApp: App { AboutView() } #if os(macOS) + .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in + Task { + await BackupService.shared.syncFavoritesOnLaunch() + } + } .onReceive(NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)) { _ in Task { @MainActor in ExternalMCPManager.shared.stopAll() } Task { -- 2.54.0 From 5db1b40552713fcca03b7bec2cde2a2ec29cee4c Mon Sep 17 00:00:00 2001 From: Rune Olsen Date: Tue, 21 Jul 2026 14:06:05 +0200 Subject: [PATCH 09/14] Union favorites on tied timestamps instead of dropping one side Two machines that already had favorites before this sync feature shipped will both have an empty favoriteModelsUpdatedAt, so the first sync between them ties. Previously that meant the second machine silently kept only its own set; now tied timestamps merge via union and re-push, so no pre-existing favorites are lost. --- oAI/Services/BackupService.swift | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/oAI/Services/BackupService.swift b/oAI/Services/BackupService.swift index 522a862..e65b89a 100644 --- a/oAI/Services/BackupService.swift +++ b/oAI/Services/BackupService.swift @@ -220,14 +220,23 @@ final class BackupService { } let localUpdatedAt = settings.favoriteModelsUpdatedAt + let localIds = settings.favoriteModelIds if remote.updatedAt > localUpdatedAt { settings.favoriteModelIds = Set(remote.ids) settings.favoriteModelsUpdatedAt = remote.updatedAt log.info("Applied \(remote.ids.count) favorite model(s) from iCloud (remote was newer)") } else if localUpdatedAt > remote.updatedAt { await pushFavorites() + } else if Set(remote.ids) != localIds { + // Tied timestamps (both empty is the common case: two machines that already had + // favorites before this sync feature existed, neither ever bumped the timestamp). + // Union rather than silently dropping one side's favorites. + settings.favoriteModelIds = localIds.union(remote.ids) + settings.favoriteModelsUpdatedAt = ISO8601DateFormatter().string(from: Date()) + await pushFavorites() + log.info("Merged favorites from iCloud (tied timestamps) — union of \(localIds.count) local + \(remote.ids.count) remote") } - // Equal timestamps: already in sync, nothing to do. + // Equal timestamps and identical sets: already in sync, nothing to do. } // MARK: - Automatic Backup -- 2.54.0 From 57f407ea507883a01fa07280f0f9a65ba680228a Mon Sep 17 00:00:00 2001 From: Rune Olsen Date: Tue, 21 Jul 2026 14:07:22 +0200 Subject: [PATCH 10/14] Bump version to 2.4.2 --- oAI.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/oAI.xcodeproj/project.pbxproj b/oAI.xcodeproj/project.pbxproj index a412e96..10b326d 100644 --- a/oAI.xcodeproj/project.pbxproj +++ b/oAI.xcodeproj/project.pbxproj @@ -303,7 +303,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 26.2; - MARKETING_VERSION = 2.4.1; + MARKETING_VERSION = 2.4.2; PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -353,7 +353,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 26.2; - MARKETING_VERSION = 2.4.1; + MARKETING_VERSION = 2.4.2; PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; -- 2.54.0 From 77cc646ee04515dd59e22c53f98bf06cfd776668 Mon Sep 17 00:00:00 2001 From: Rune Olsen Date: Tue, 21 Jul 2026 14:16:03 +0200 Subject: [PATCH 11/14] Re-enable Contacts row to retest on macOS 27 beta 4 isContactsHiddenPendingAppleFix was flipped true on beta 2 after CNContactStore.requestAccess returned instant "Access Denied" under hardened runtime, while Calendar/Reminders/Location worked fine. Rune just installed beta 4 and wants to retest. --- oAI/Services/SettingsService.swift | 7 ++++--- oAI/Views/Screens/SettingsView.swift | 8 ++++---- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/oAI/Services/SettingsService.swift b/oAI/Services/SettingsService.swift index eadcbe8..876ac0c 100644 --- a/oAI/Services/SettingsService.swift +++ b/oAI/Services/SettingsService.swift @@ -32,9 +32,10 @@ import Security enum PersonalDataTools { /// Hides the entire Personal Data section. Flip to `false` once all four services work. static let isHiddenPendingAppleFix = false - /// Hides only the Contacts row. Contacts TCC still broken under hardened runtime on - /// macOS 27 beta 2 while Calendar/Reminders/Location are fixed. Flip to `false` once fixed. - static let isContactsHiddenPendingAppleFix = true + /// Hides only the Contacts row. Was broken under hardened runtime on macOS 27 beta 2 + /// (Calendar/Reminders/Location were fine). Re-enabled 2026-07-21 to retest on beta 4 — + /// flip back to `true` if `CNContactStore.requestAccess` still fails under signed builds. + static let isContactsHiddenPendingAppleFix = false } @Observable diff --git a/oAI/Views/Screens/SettingsView.swift b/oAI/Views/Screens/SettingsView.swift index 6852497..e09b691 100644 --- a/oAI/Views/Screens/SettingsView.swift +++ b/oAI/Views/Screens/SettingsView.swift @@ -830,9 +830,9 @@ It's better to admit "I need more information" or "I cannot do that" than to fak // MARK: Personal Data // isHiddenPendingAppleFix hides the entire section (macOS 27 beta TCC bug). - // isContactsHiddenPendingAppleFix hides just the Contacts row (still broken in beta 2 - // under hardened runtime while Calendar/Reminders/Location are fixed). Flip each flag - // to false once Apple ships a fix. + // isContactsHiddenPendingAppleFix hides just the Contacts row — was broken in beta 2 + // under hardened runtime while Calendar/Reminders/Location were fixed. Re-enabled + // 2026-07-21 to retest on beta 4; flip back to true if it regresses. if !PersonalDataTools.isHiddenPendingAppleFix { Divider() @@ -854,7 +854,7 @@ It's better to admit "I need more information" or "I cannot do that" than to fak .help("Beta Feature. May change.") } } - Text("Let the AI access your Calendar, Reminders, and Location & Maps to answer questions about your schedule and surroundings. Each service is opt-in and uses standard macOS permission prompts. This functionality is in beta and may change.") + Text("Let the AI access your Calendar, Reminders, Contacts, and Location & Maps to answer questions about your schedule and surroundings. Each service is opt-in and uses standard macOS permission prompts. This functionality is in beta and may change.") .font(.system(size: 14)) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) -- 2.54.0 From abf25bd897aaddfe9069373f1985c6b392ca7fcc Mon Sep 17 00:00:00 2001 From: Rune Olsen Date: Tue, 21 Jul 2026 14:59:39 +0200 Subject: [PATCH 12/14] Fix Contacts TCC entitlement key: contacts -> addressbook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Confirmed root cause via tccd's own log output: the hardened-runtime hardened-runtime prompting policy checks for com.apple.security.personal-information.addressbook (the Contacts framework's TCC service is still internally named kTCCServiceAddressBook, a holdover from the old AddressBook framework), not com.apple.security.personal-information.contacts as the entitlements file had. With the correct key, tccd allows the prompt and access is now granted correctly on both macOS 27 beta and 26.5.1 stable — this was never an OS bug, notarization requirement, or beta-only issue. Also keeps the fuller CNError domain/code/userInfo logging added while diagnosing this, in case Contacts TCC issues resurface. --- oAI/Services/ContactsService.swift | 3 ++- oAI/oAI.entitlements | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/oAI/Services/ContactsService.swift b/oAI/Services/ContactsService.swift index 058ab42..1863c4e 100644 --- a/oAI/Services/ContactsService.swift +++ b/oAI/Services/ContactsService.swift @@ -63,7 +63,8 @@ class ContactsService { store.requestAccess(for: .contacts) { granted, error in let after = CNContactStore.authorizationStatus(for: .contacts) if let error { - Log.mcp.error("ContactsService.requestAccess: error=\(error.localizedDescription); granted=\(granted); status after = \(Self.describe(after)) (raw=\(after.rawValue))") + let nsError = error as NSError + Log.mcp.error("ContactsService.requestAccess: error=\(error.localizedDescription) domain=\(nsError.domain) code=\(nsError.code) userInfo=\(nsError.userInfo); granted=\(granted); status after = \(Self.describe(after)) (raw=\(after.rawValue))") } else { Log.mcp.info("ContactsService.requestAccess: granted=\(granted); status after = \(Self.describe(after)) (raw=\(after.rawValue))") } diff --git a/oAI/oAI.entitlements b/oAI/oAI.entitlements index 97a7c31..a86f180 100644 --- a/oAI/oAI.entitlements +++ b/oAI/oAI.entitlements @@ -12,7 +12,7 @@ com.apple.security.personal-information.reminders - com.apple.security.personal-information.contacts + com.apple.security.personal-information.addressbook com.apple.security.personal-information.location -- 2.54.0 From e9aceca4e707e7ff3f3f59f6136b703c1d6dc61e Mon Sep 17 00:00:00 2001 From: Rune Olsen Date: Tue, 21 Jul 2026 15:01:19 +0200 Subject: [PATCH 13/14] Remove now-dead isContactsHiddenPendingAppleFix kill switch Contacts was never actually broken by an Apple/OS bug -- it was a wrong entitlement key in oAI.entitlements, now fixed (abf25bd). No functional change here: the flag was already false in both places this session, so behavior is identical; this just removes the now-pointless beta-badge/conditional-row scaffolding built around it. --- oAI/Services/SettingsService.swift | 14 ++++-------- oAI/Views/Screens/SettingsView.swift | 32 +++++++--------------------- 2 files changed, 12 insertions(+), 34 deletions(-) diff --git a/oAI/Services/SettingsService.swift b/oAI/Services/SettingsService.swift index 876ac0c..3f8088e 100644 --- a/oAI/Services/SettingsService.swift +++ b/oAI/Services/SettingsService.swift @@ -25,17 +25,11 @@ import Foundation import os import Security -/// Kill switch for the Personal Data tools (Calendar/Reminders/Contacts/Location & Maps). -/// Flip `isHiddenPendingAppleFix` back to `false` once Apple fixes the macOS 27 beta TCC bug -/// (filed with Apple). Each flag hides the relevant UI and forces the `*Enabled` getter to -/// return `false` regardless of the persisted DB value — no code deleted, just inert. +/// Kill switch for the entire Personal Data tools section (Calendar/Reminders/Contacts/ +/// Location & Maps). Hides the UI and forces every `*Enabled` getter to return `false` +/// regardless of the persisted DB value — no code deleted, just inert. enum PersonalDataTools { - /// Hides the entire Personal Data section. Flip to `false` once all four services work. static let isHiddenPendingAppleFix = false - /// Hides only the Contacts row. Was broken under hardened runtime on macOS 27 beta 2 - /// (Calendar/Reminders/Location were fine). Re-enabled 2026-07-21 to retest on beta 4 — - /// flip back to `true` if `CNContactStore.requestAccess` still fails under signed builds. - static let isContactsHiddenPendingAppleFix = false } @Observable @@ -719,7 +713,7 @@ class SettingsService { } var contactsEnabled: Bool { - get { !PersonalDataTools.isHiddenPendingAppleFix && !PersonalDataTools.isContactsHiddenPendingAppleFix && cache["contactsEnabled"] == "true" } + get { !PersonalDataTools.isHiddenPendingAppleFix && cache["contactsEnabled"] == "true" } set { cache["contactsEnabled"] = String(newValue) DatabaseService.shared.setSetting(key: "contactsEnabled", value: String(newValue)) diff --git a/oAI/Views/Screens/SettingsView.swift b/oAI/Views/Screens/SettingsView.swift index e09b691..0f3c8e7 100644 --- a/oAI/Views/Screens/SettingsView.swift +++ b/oAI/Views/Screens/SettingsView.swift @@ -829,10 +829,6 @@ It's better to admit "I need more information" or "I cannot do that" than to fak externalMCPSection // MARK: Personal Data - // isHiddenPendingAppleFix hides the entire section (macOS 27 beta TCC bug). - // isContactsHiddenPendingAppleFix hides just the Contacts row — was broken in beta 2 - // under hardened runtime while Calendar/Reminders/Location were fixed. Re-enabled - // 2026-07-21 to retest on beta 4; flip back to true if it regresses. if !PersonalDataTools.isHiddenPendingAppleFix { Divider() @@ -843,16 +839,6 @@ It's better to admit "I need more information" or "I cannot do that" than to fak .foregroundStyle(.teal) Text("Personal Data") .font(.system(size: 18, weight: .semibold)) - if PersonalDataTools.isContactsHiddenPendingAppleFix { - Text("β") - .font(.system(size: 11, weight: .bold)) - .foregroundStyle(.orange) - .padding(.horizontal, 5) - .padding(.vertical, 2) - .background(Color.orange.opacity(0.15)) - .clipShape(RoundedRectangle(cornerRadius: 4)) - .help("Beta Feature. May change.") - } } Text("Let the AI access your Calendar, Reminders, Contacts, and Location & Maps to answer questions about your schedule and surroundings. Each service is opt-in and uses standard macOS permission prompts. This functionality is in beta and may change.") .font(.system(size: 14)) @@ -889,16 +875,14 @@ It's better to admit "I need more information" or "I cannot do that" than to fak requestAccess: { remindersAccessState = await EventKitService.shared.requestReminderAccess() ? .granted : EventKitService.shared.reminderAccessState } ) rowDivider() - if !PersonalDataTools.isContactsHiddenPendingAppleFix { - personalDataRow( - title: "Contacts", - isEnabled: $settingsService.contactsEnabled, - state: contactsAccessState, - systemSettingsAnchor: "Privacy_Contacts", - requestAccess: { contactsAccessState = await ContactsService.shared.requestAccess() ? .granted : ContactsService.shared.accessState } - ) - rowDivider() - } + personalDataRow( + title: "Contacts", + isEnabled: $settingsService.contactsEnabled, + state: contactsAccessState, + systemSettingsAnchor: "Privacy_Contacts", + requestAccess: { contactsAccessState = await ContactsService.shared.requestAccess() ? .granted : ContactsService.shared.accessState } + ) + rowDivider() personalDataRow( title: "Location & Maps", isEnabled: $settingsService.locationMapsEnabled, -- 2.54.0 From 854fd02fad7749b1b04fa130cd473a7ee5bdd399 Mon Sep 17 00:00:00 2001 From: Rune Olsen Date: Wed, 22 Jul 2026 15:21:30 +0200 Subject: [PATCH 14/14] Add live search to the Help Book's table of contents A search box above the Contents list filters TOC entries by each linked section's full text content (not just the link title), so e.g. searching "european" surfaces "Slash Commands" via its command history date-format note. Pure vanilla JS/CSS, no dependencies -- the page is a static file opened directly in the default browser. Tested interactively in Safari Technology Preview: filtering, no-results state, reset, and click-through navigation all verified working, in both the matched and empty-query cases. --- .../Contents/Resources/en.lproj/index.html | 38 ++++++++++++++++++- .../Contents/Resources/en.lproj/styles.css | 26 +++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/oAI/Resources/oAI.help/Contents/Resources/en.lproj/index.html b/oAI/Resources/oAI.help/Contents/Resources/en.lproj/index.html index 341ce65..1f9bca2 100644 --- a/oAI/Resources/oAI.help/Contents/Resources/en.lproj/index.html +++ b/oAI/Resources/oAI.help/Contents/Resources/en.lproj/index.html @@ -20,7 +20,11 @@