@@ -15,9 +15,22 @@
|
|||||||
A550A6622F3B72EA00136F2B /* oAI.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = oAI.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
A550A6622F3B72EA00136F2B /* oAI.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = oAI.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
/* End PBXFileReference section */
|
/* 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 */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
A550A6642F3B72EA00136F2B /* oAI */ = {
|
A550A6642F3B72EA00136F2B /* oAI */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
exceptions = (
|
||||||
|
911C4D0E69E11B84C61453DC /* Exceptions for "oAI" folder in "oAI" target */,
|
||||||
|
);
|
||||||
path = oAI;
|
path = oAI;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@@ -270,6 +283,7 @@
|
|||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
ENABLE_USER_SELECTED_FILES = readonly;
|
ENABLE_USER_SELECTED_FILES = readonly;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_FILE = "oAI/Info.plist";
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
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_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.";
|
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 = "@executable_path/Frameworks";
|
||||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
||||||
MARKETING_VERSION = 2.4.1;
|
MARKETING_VERSION = 2.4.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI;
|
PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
@@ -319,6 +333,7 @@
|
|||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
ENABLE_USER_SELECTED_FILES = readonly;
|
ENABLE_USER_SELECTED_FILES = readonly;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_FILE = "oAI/Info.plist";
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
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_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.";
|
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 = "@executable_path/Frameworks";
|
||||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
||||||
MARKETING_VERSION = 2.4.1;
|
MARKETING_VERSION = 2.4.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI;
|
PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
|
|||||||
@@ -20,7 +20,11 @@
|
|||||||
|
|
||||||
<nav class="toc">
|
<nav class="toc">
|
||||||
<h2>Contents</h2>
|
<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="#getting-started">Getting Started</a></li>
|
||||||
<li><a href="#providers">AI Providers & API Keys</a></li>
|
<li><a href="#providers">AI Providers & API Keys</a></li>
|
||||||
<li><a href="#models">Selecting Models</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>
|
<dt><kbd>⌘,</kbd></dt>
|
||||||
<dd>Open Settings</dd>
|
<dd>Open Settings</dd>
|
||||||
|
|
||||||
<dt><kbd>⌘/</kbd></dt>
|
|
||||||
<dd>Show in-app Help</dd>
|
|
||||||
|
|
||||||
<dt><kbd>⌘?</kbd></dt>
|
<dt><kbd>⌘?</kbd></dt>
|
||||||
<dd>Open this Help (macOS Help)</dd>
|
<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>
|
<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>
|
</footer>
|
||||||
</div>
|
</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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -101,6 +101,32 @@ nav.toc h2 {
|
|||||||
margin-bottom: 16px;
|
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 {
|
nav.toc ul {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,15 @@ struct BackupManifest: Codable {
|
|||||||
let credentials: [String: String]?
|
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
|
// MARK: - BackupService
|
||||||
|
|
||||||
@Observable
|
@Observable
|
||||||
@@ -51,6 +60,8 @@ final class BackupService {
|
|||||||
/// URL of the last backup file
|
/// URL of the last backup file
|
||||||
var lastBackupURL: URL?
|
var lastBackupURL: URL?
|
||||||
|
|
||||||
|
private var autoBackupTimer: Timer?
|
||||||
|
|
||||||
// Keys excluded from backup — encrypted_ prefix + internal migration flags
|
// Keys excluded from backup — encrypted_ prefix + internal migration flags
|
||||||
private static let excludedKeys: Set<String> = [
|
private static let excludedKeys: Set<String> = [
|
||||||
"encrypted_openrouterAPIKey",
|
"encrypted_openrouterAPIKey",
|
||||||
@@ -71,6 +82,7 @@ final class BackupService {
|
|||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
checkForExistingBackup()
|
checkForExistingBackup()
|
||||||
|
startAutoBackupTimer()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - iCloud Path Resolution
|
// MARK: - iCloud Path Resolution
|
||||||
@@ -176,6 +188,88 @@ final class BackupService {
|
|||||||
log.info("Restored \(manifest.settings.count) settings from backup (v\(manifest.version))")
|
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
|
// MARK: - Helpers
|
||||||
|
|
||||||
private func appVersion() -> String {
|
private func appVersion() -> String {
|
||||||
|
|||||||
@@ -63,7 +63,8 @@ class ContactsService {
|
|||||||
store.requestAccess(for: .contacts) { granted, error in
|
store.requestAccess(for: .contacts) { granted, error in
|
||||||
let after = CNContactStore.authorizationStatus(for: .contacts)
|
let after = CNContactStore.authorizationStatus(for: .contacts)
|
||||||
if let error {
|
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 {
|
} else {
|
||||||
Log.mcp.info("ContactsService.requestAccess: granted=\(granted); status after = \(Self.describe(after)) (raw=\(after.rawValue))")
|
Log.mcp.info("ContactsService.requestAccess: granted=\(granted); status after = \(Self.describe(after)) (raw=\(after.rawValue))")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,16 +25,11 @@ import Foundation
|
|||||||
import os
|
import os
|
||||||
import Security
|
import Security
|
||||||
|
|
||||||
/// Kill switch for the Personal Data tools (Calendar/Reminders/Contacts/Location & Maps).
|
/// Kill switch for the entire Personal Data tools section (Calendar/Reminders/Contacts/
|
||||||
/// Flip `isHiddenPendingAppleFix` back to `false` once Apple fixes the macOS 27 beta TCC bug
|
/// Location & Maps). Hides the UI and forces every `*Enabled` getter to return `false`
|
||||||
/// (filed with Apple). Each flag hides the relevant UI and forces the `*Enabled` getter to
|
/// regardless of the persisted DB value — no code deleted, just inert.
|
||||||
/// return `false` regardless of the persisted DB value — no code deleted, just inert.
|
|
||||||
enum PersonalDataTools {
|
enum PersonalDataTools {
|
||||||
/// Hides the entire Personal Data section. Flip to `false` once all four services work.
|
|
||||||
static let isHiddenPendingAppleFix = false
|
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
|
@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) {
|
func toggleFavoriteModel(_ id: String) {
|
||||||
var favs = favoriteModelIds
|
var favs = favoriteModelIds
|
||||||
if favs.contains(id) { favs.remove(id) } else { favs.insert(id) }
|
if favs.contains(id) { favs.remove(id) } else { favs.insert(id) }
|
||||||
favoriteModelIds = favs
|
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
|
// MARK: - Anytype MCP Settings
|
||||||
@@ -695,7 +713,7 @@ class SettingsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var contactsEnabled: Bool {
|
var contactsEnabled: Bool {
|
||||||
get { !PersonalDataTools.isHiddenPendingAppleFix && !PersonalDataTools.isContactsHiddenPendingAppleFix && cache["contactsEnabled"] == "true" }
|
get { !PersonalDataTools.isHiddenPendingAppleFix && cache["contactsEnabled"] == "true" }
|
||||||
set {
|
set {
|
||||||
cache["contactsEnabled"] = String(newValue)
|
cache["contactsEnabled"] = String(newValue)
|
||||||
DatabaseService.shared.setSetting(key: "contactsEnabled", value: String(newValue))
|
DatabaseService.shared.setSetting(key: "contactsEnabled", value: String(newValue))
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ You are a helpful AI assistant. Follow these core principles:
|
|||||||
- **Be Direct**: Provide concise, relevant answers. No unnecessary preambles.
|
- **Be Direct**: Provide concise, relevant answers. No unnecessary preambles.
|
||||||
- **Show Your Work**: If you use capabilities (tools, web search, etc.), demonstrate what you did.
|
- **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.
|
- **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
|
## FORMATTING
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ import Darwin // uname, sysctlbyname
|
|||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
@Environment(ChatViewModel.self) var chatViewModel
|
@Environment(ChatViewModel.self) var chatViewModel
|
||||||
private var updateService = UpdateCheckService.shared
|
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
|
@State private var showIntelWarning = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -46,6 +47,9 @@ struct ContentView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
.frame(minWidth: 860, minHeight: 560)
|
.frame(minWidth: 860, minHeight: 560)
|
||||||
|
.onChange(of: columnVisibility) { _, newValue in
|
||||||
|
SettingsService.shared.sidebarVisible = newValue != .detailOnly
|
||||||
|
}
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
NSApplication.shared.windows.forEach { $0.tabbingMode = .disallowed }
|
NSApplication.shared.windows.forEach { $0.tabbingMode = .disallowed }
|
||||||
|
|||||||
@@ -213,7 +213,6 @@ private let keyboardShortcuts: [(key: String, description: String)] = [
|
|||||||
("\u{2318}L", "Conversations"),
|
("\u{2318}L", "Conversations"),
|
||||||
("\u{21E7}\u{2318}S", "Statistics"),
|
("\u{21E7}\u{2318}S", "Statistics"),
|
||||||
("\u{2318},", "Settings"),
|
("\u{2318},", "Settings"),
|
||||||
("\u{2318}/", "Help"),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
// MARK: - HelpView
|
// MARK: - HelpView
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ You are a helpful AI assistant. Follow these core principles:
|
|||||||
- **Be Direct**: Provide concise, relevant answers. No unnecessary preambles.
|
- **Be Direct**: Provide concise, relevant answers. No unnecessary preambles.
|
||||||
- **Show Your Work**: If you use capabilities (tools, web search, etc.), demonstrate what you did.
|
- **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.
|
- **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
|
## FORMATTING
|
||||||
|
|
||||||
@@ -828,10 +829,6 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
|||||||
externalMCPSection
|
externalMCPSection
|
||||||
|
|
||||||
// MARK: Personal Data
|
// 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 {
|
if !PersonalDataTools.isHiddenPendingAppleFix {
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
@@ -842,18 +839,8 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
|||||||
.foregroundStyle(.teal)
|
.foregroundStyle(.teal)
|
||||||
Text("Personal Data")
|
Text("Personal Data")
|
||||||
.font(.system(size: 18, weight: .semibold))
|
.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))
|
.font(.system(size: 14))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.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 }
|
requestAccess: { remindersAccessState = await EventKitService.shared.requestReminderAccess() ? .granted : EventKitService.shared.reminderAccessState }
|
||||||
)
|
)
|
||||||
rowDivider()
|
rowDivider()
|
||||||
if !PersonalDataTools.isContactsHiddenPendingAppleFix {
|
personalDataRow(
|
||||||
personalDataRow(
|
title: "Contacts",
|
||||||
title: "Contacts",
|
isEnabled: $settingsService.contactsEnabled,
|
||||||
isEnabled: $settingsService.contactsEnabled,
|
state: contactsAccessState,
|
||||||
state: contactsAccessState,
|
systemSettingsAnchor: "Privacy_Contacts",
|
||||||
systemSettingsAnchor: "Privacy_Contacts",
|
requestAccess: { contactsAccessState = await ContactsService.shared.requestAccess() ? .granted : ContactsService.shared.accessState }
|
||||||
requestAccess: { contactsAccessState = await ContactsService.shared.requestAccess() ? .granted : ContactsService.shared.accessState }
|
)
|
||||||
)
|
rowDivider()
|
||||||
rowDivider()
|
|
||||||
}
|
|
||||||
personalDataRow(
|
personalDataRow(
|
||||||
title: "Location & Maps",
|
title: "Location & Maps",
|
||||||
isEnabled: $settingsService.locationMapsEnabled,
|
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
|
// Actions
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
sectionHeader("Actions")
|
sectionHeader("Actions")
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.personal-information.reminders</key>
|
<key>com.apple.security.personal-information.reminders</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.personal-information.contacts</key>
|
<key>com.apple.security.personal-information.addressbook</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.personal-information.location</key>
|
<key>com.apple.security.personal-information.location</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
|||||||
+19
-8
@@ -43,6 +43,11 @@ struct oAIApp: App {
|
|||||||
await GitSyncService.shared.syncOnStartup()
|
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
|
// Check for updates in the background
|
||||||
UpdateCheckService.shared.checkForUpdates()
|
UpdateCheckService.shared.checkForUpdates()
|
||||||
}
|
}
|
||||||
@@ -56,6 +61,11 @@ struct oAIApp: App {
|
|||||||
AboutView()
|
AboutView()
|
||||||
}
|
}
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
|
||||||
|
Task {
|
||||||
|
await BackupService.shared.syncFavoritesOnLaunch()
|
||||||
|
}
|
||||||
|
}
|
||||||
.onReceive(NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)) { _ in
|
.onReceive(NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)) { _ in
|
||||||
Task { @MainActor in ExternalMCPManager.shared.stopAll() }
|
Task { @MainActor in ExternalMCPManager.shared.stopAll() }
|
||||||
Task {
|
Task {
|
||||||
@@ -116,8 +126,11 @@ struct oAIApp: App {
|
|||||||
.disabled(chatViewModel.messages.filter { $0.role != .system }.isEmpty)
|
.disabled(chatViewModel.messages.filter { $0.role != .system }.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── View menu ─────────────────────────────────────────────────
|
// ── Chat menu ─────────────────────────────────────────────────
|
||||||
CommandMenu("View") {
|
// 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 }
|
Button("Select Model") { chatViewModel.showModelSelector = true }
|
||||||
.keyboardShortcut("m", modifiers: .command)
|
.keyboardShortcut("m", modifiers: .command)
|
||||||
|
|
||||||
@@ -132,9 +145,6 @@ struct oAIApp: App {
|
|||||||
Button("Command History") { chatViewModel.showHistory = true }
|
Button("Command History") { chatViewModel.showHistory = true }
|
||||||
.keyboardShortcut("h", modifiers: [.command, .shift])
|
.keyboardShortcut("h", modifiers: [.command, .shift])
|
||||||
|
|
||||||
Button("In-App Help") { chatViewModel.showHelp = true }
|
|
||||||
.keyboardShortcut("/", modifiers: .command)
|
|
||||||
|
|
||||||
Button("Credits") { chatViewModel.showCredits = true }
|
Button("Credits") { chatViewModel.showCredits = true }
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
@@ -161,11 +171,12 @@ struct oAIApp: App {
|
|||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
private func openHelp() {
|
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) {
|
if let helpBookURL = Bundle.main.url(forResource: "oAI.help", withExtension: nil) {
|
||||||
NSWorkspace.shared.open(helpBookURL.appendingPathComponent("Contents/Resources/en.lproj/index.html"))
|
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
|
#endif
|
||||||
|
|||||||
Reference in New Issue
Block a user