Merge pull request '2.4.2' (#8) from 2.4.2 into main

Reviewed-on: #8
This commit was merged in pull request #8.
This commit is contained in:
2026-07-22 15:26:48 +02:00
12 changed files with 270 additions and 52 deletions
+17 -2
View File
@@ -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 = "<group>";
};
@@ -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.";
@@ -289,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;
@@ -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.";
@@ -338,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;
@@ -20,7 +20,11 @@
<nav class="toc">
<h2>Contents</h2>
<ul>
<div class="toc-search">
<input type="search" id="tocSearch" placeholder="Search help topics…" aria-label="Search help topics">
</div>
<p id="tocNoResults" class="toc-no-results" hidden>No topics match your search.</p>
<ul id="tocList">
<li><a href="#getting-started">Getting Started</a></li>
<li><a href="#providers">AI Providers &amp; API Keys</a></li>
<li><a href="#models">Selecting Models</a></li>
@@ -1526,9 +1530,6 @@ Whenever the user asks you to translate something, translate it to Norwegian Bok
<dt><kbd>⌘,</kbd></dt>
<dd>Open Settings</dd>
<dt><kbd>⌘/</kbd></dt>
<dd>Show in-app Help</dd>
<dt><kbd>⌘?</kbd></dt>
<dd>Open this Help (macOS Help)</dd>
@@ -1815,5 +1816,37 @@ Whenever the user asks you to translate something, translate it to Norwegian Bok
<p>© 2026 oAI - Rune Olsen. For support or feedback, visit <a href="https://gitlab.pm/rune/oai-swift">gitlab.pm</a> or <a href="mailto:support@fubar.pm?subject=oAI Support&body=What can I help you with?">Contact Us</a>.</p>
</footer>
</div>
<script>
(function () {
var searchInput = document.getElementById('tocSearch');
var tocList = document.getElementById('tocList');
var noResults = document.getElementById('tocNoResults');
if (!searchInput || !tocList) return;
var entries = Array.prototype.map.call(tocList.querySelectorAll('li'), function (li) {
var link = li.querySelector('a');
var id = link ? link.getAttribute('href').slice(1) : null;
var section = id ? document.getElementById(id) : null;
return {
li: li,
text: (section ? section.textContent : li.textContent).toLowerCase()
};
});
searchInput.addEventListener('input', function () {
var query = searchInput.value.trim().toLowerCase();
var visibleCount = 0;
entries.forEach(function (entry) {
var matches = query === '' || entry.text.indexOf(query) !== -1;
entry.li.hidden = !matches;
if (matches) visibleCount++;
});
noResults.hidden = visibleCount > 0;
});
})();
</script>
</body>
</html>
@@ -101,6 +101,32 @@ nav.toc h2 {
margin-bottom: 16px;
}
.toc-search {
margin-bottom: 16px;
}
.toc-search input[type="search"] {
width: 100%;
font-family: inherit;
font-size: 15px;
padding: 10px 14px;
color: var(--text-primary);
background: var(--background);
border: 1px solid var(--border);
border-radius: 8px;
outline: none;
}
.toc-search input[type="search"]:focus {
border-color: var(--primary-color);
}
.toc-no-results {
font-size: 14px;
color: var(--text-secondary);
margin-bottom: 0;
}
nav.toc ul {
list-style: none;
}
+94
View File
@@ -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<String> = [
"encrypted_openrouterAPIKey",
@@ -71,6 +82,7 @@ final class BackupService {
private init() {
checkForExistingBackup()
startAutoBackupTimer()
}
// MARK: - iCloud Path Resolution
@@ -176,6 +188,88 @@ 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
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 and identical sets: 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 {
+2 -1
View File
@@ -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))")
}
+27 -9
View File
@@ -25,16 +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. 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
}
@Observable
@@ -520,10 +515,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
@@ -695,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))
+1
View File
@@ -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
+5 -1
View File
@@ -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 }
-1
View File
@@ -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{2318}/", "Help"),
]
// MARK: - HelpView
+41 -25
View File
@@ -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
@@ -828,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 (still broken in beta 2
// under hardened runtime while Calendar/Reminders/Location are fixed). Flip each flag
// to false once Apple ships a fix.
if !PersonalDataTools.isHiddenPendingAppleFix {
Divider()
@@ -842,18 +839,8 @@ 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, 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)
@@ -888,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,
@@ -2595,6 +2580,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")
+1 -1
View File
@@ -12,7 +12,7 @@
<true/>
<key>com.apple.security.personal-information.reminders</key>
<true/>
<key>com.apple.security.personal-information.contacts</key>
<key>com.apple.security.personal-information.addressbook</key>
<true/>
<key>com.apple.security.personal-information.location</key>
<true/>
+19 -8
View File
@@ -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 {
@@ -116,8 +126,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 +145,6 @@ struct oAIApp: App {
Button("Command History") { chatViewModel.showHistory = true }
.keyboardShortcut("h", modifiers: [.command, .shift])
Button("In-App Help") { chatViewModel.showHelp = true }
.keyboardShortcut("/", modifiers: .command)
Button("Credits") { chatViewModel.showCredits = true }
Divider()
@@ -161,11 +171,12 @@ struct oAIApp: App {
#if os(macOS)
private func openHelp() {
// 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"))
} else {
// Fallback to Apple Help if help book not found
NSHelpManager.shared.openHelpAnchor("", inBook: Bundle.main.object(forInfoDictionaryKey: "CFBundleHelpBookName") as? String)
}
}
#endif