Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0af644456e |
-29
@@ -1,29 +0,0 @@
|
|||||||
# 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.
|
|
||||||
|
|
||||||
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.
|
|
||||||
@@ -15,22 +15,9 @@
|
|||||||
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>";
|
||||||
};
|
};
|
||||||
@@ -283,7 +270,6 @@
|
|||||||
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.";
|
||||||
@@ -303,7 +289,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.2;
|
MARKETING_VERSION = 2.4.1;
|
||||||
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;
|
||||||
@@ -333,7 +319,6 @@
|
|||||||
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.";
|
||||||
@@ -353,7 +338,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.2;
|
MARKETING_VERSION = 2.4.1;
|
||||||
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,11 +20,7 @@
|
|||||||
|
|
||||||
<nav class="toc">
|
<nav class="toc">
|
||||||
<h2>Contents</h2>
|
<h2>Contents</h2>
|
||||||
<div class="toc-search">
|
<ul>
|
||||||
<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>
|
||||||
@@ -1530,6 +1526,9 @@ 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>
|
||||||
|
|
||||||
@@ -1816,37 +1815,5 @@ 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,32 +101,6 @@ 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,15 +34,6 @@ 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
|
||||||
@@ -60,8 +51,6 @@ 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",
|
||||||
@@ -82,7 +71,6 @@ final class BackupService {
|
|||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
checkForExistingBackup()
|
checkForExistingBackup()
|
||||||
startAutoBackupTimer()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - iCloud Path Resolution
|
// MARK: - iCloud Path Resolution
|
||||||
@@ -188,88 +176,6 @@ 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,8 +63,7 @@ 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 {
|
||||||
let nsError = error as NSError
|
Log.mcp.error("ContactsService.requestAccess: error=\(error.localizedDescription); granted=\(granted); status after = \(Self.describe(after)) (raw=\(after.rawValue))")
|
||||||
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,11 +25,16 @@ import Foundation
|
|||||||
import os
|
import os
|
||||||
import Security
|
import Security
|
||||||
|
|
||||||
/// Kill switch for the entire Personal Data tools section (Calendar/Reminders/Contacts/
|
/// Kill switch for the Personal Data tools (Calendar/Reminders/Contacts/Location & Maps).
|
||||||
/// Location & Maps). Hides the UI and forces every `*Enabled` getter to return `false`
|
/// Flip `isHiddenPendingAppleFix` back to `false` once Apple fixes the macOS 27 beta TCC bug
|
||||||
/// regardless of the persisted DB value — no code deleted, just inert.
|
/// (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.
|
||||||
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
|
||||||
@@ -515,33 +520,10 @@ 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
|
||||||
@@ -713,7 +695,7 @@ class SettingsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var contactsEnabled: Bool {
|
var contactsEnabled: Bool {
|
||||||
get { !PersonalDataTools.isHiddenPendingAppleFix && cache["contactsEnabled"] == "true" }
|
get { !PersonalDataTools.isHiddenPendingAppleFix && !PersonalDataTools.isContactsHiddenPendingAppleFix && 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,7 +90,6 @@ 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,8 +29,7 @@ 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 =
|
@State private var columnVisibility: NavigationSplitViewVisibility = .all
|
||||||
SettingsService.shared.sidebarVisible ? .all : .detailOnly
|
|
||||||
@State private var showIntelWarning = false
|
@State private var showIntelWarning = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -47,9 +46,6 @@ 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,6 +213,7 @@ 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,7 +125,6 @@ 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
|
||||||
|
|
||||||
@@ -829,6 +828,10 @@ 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()
|
||||||
|
|
||||||
@@ -839,8 +842,18 @@ 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, 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.")
|
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.")
|
||||||
.font(.system(size: 14))
|
.font(.system(size: 14))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
@@ -875,14 +888,16 @@ 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()
|
||||||
personalDataRow(
|
if !PersonalDataTools.isContactsHiddenPendingAppleFix {
|
||||||
title: "Contacts",
|
personalDataRow(
|
||||||
isEnabled: $settingsService.contactsEnabled,
|
title: "Contacts",
|
||||||
state: contactsAccessState,
|
isEnabled: $settingsService.contactsEnabled,
|
||||||
systemSettingsAnchor: "Privacy_Contacts",
|
state: contactsAccessState,
|
||||||
requestAccess: { contactsAccessState = await ContactsService.shared.requestAccess() ? .granted : ContactsService.shared.accessState }
|
systemSettingsAnchor: "Privacy_Contacts",
|
||||||
)
|
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,
|
||||||
@@ -2580,37 +2595,6 @@ 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.addressbook</key>
|
<key>com.apple.security.personal-information.contacts</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.personal-information.location</key>
|
<key>com.apple.security.personal-information.location</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
|||||||
+8
-19
@@ -43,11 +43,6 @@ 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()
|
||||||
}
|
}
|
||||||
@@ -61,11 +56,6 @@ 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 {
|
||||||
@@ -126,11 +116,8 @@ struct oAIApp: App {
|
|||||||
.disabled(chatViewModel.messages.filter { $0.role != .system }.isEmpty)
|
.disabled(chatViewModel.messages.filter { $0.role != .system }.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Chat menu ─────────────────────────────────────────────────
|
// ── View menu ─────────────────────────────────────────────────
|
||||||
// Named "Chat" (not "View") to avoid colliding with the "View" menu
|
CommandMenu("View") {
|
||||||
// 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)
|
||||||
|
|
||||||
@@ -145,6 +132,9 @@ 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()
|
||||||
@@ -171,12 +161,11 @@ 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