2.4.3 #9

Merged
rune merged 14 commits from 2.4.3 into main 2026-07-27 08:13:22 +02:00
9 changed files with 464 additions and 72 deletions
Showing only changes of commit 02bf73fec6 - Show all commits
+142 -6
View File
@@ -11,8 +11,19 @@
A550A8342F3C5C9300136F2B /* GRDB in Frameworks */ = {isa = PBXBuildFile; productRef = A550A6812F3B730000136F2B /* GRDB */; }; A550A8342F3C5C9300136F2B /* GRDB in Frameworks */ = {isa = PBXBuildFile; productRef = A550A6812F3B730000136F2B /* GRDB */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
A586FF5530122589002CFF95 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = A550A65A2F3B72EA00136F2B /* Project object */;
proxyType = 1;
remoteGlobalIDString = A550A6612F3B72EA00136F2B;
remoteInfo = oAI;
};
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
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; };
A586FF5130122589002CFF95 /* oAITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = oAITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -34,6 +45,11 @@
path = oAI; path = oAI;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
A586FF5230122589002CFF95 /* oAITests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = oAITests;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */ /* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@@ -46,6 +62,13 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
A586FF4E30122589002CFF95 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */ /* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */ /* Begin PBXGroup section */
@@ -53,6 +76,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
A550A6642F3B72EA00136F2B /* oAI */, A550A6642F3B72EA00136F2B /* oAI */,
A586FF5230122589002CFF95 /* oAITests */,
A550A6632F3B72EA00136F2B /* Products */, A550A6632F3B72EA00136F2B /* Products */,
); );
sourceTree = "<group>"; sourceTree = "<group>";
@@ -61,6 +85,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
A550A6622F3B72EA00136F2B /* oAI.app */, A550A6622F3B72EA00136F2B /* oAI.app */,
A586FF5130122589002CFF95 /* oAITests.xctest */,
); );
name = Products; name = Products;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -92,6 +117,29 @@
productReference = A550A6622F3B72EA00136F2B /* oAI.app */; productReference = A550A6622F3B72EA00136F2B /* oAI.app */;
productType = "com.apple.product-type.application"; productType = "com.apple.product-type.application";
}; };
A586FF5030122589002CFF95 /* oAITests */ = {
isa = PBXNativeTarget;
buildConfigurationList = A586FF5930122589002CFF95 /* Build configuration list for PBXNativeTarget "oAITests" */;
buildPhases = (
A586FF4D30122589002CFF95 /* Sources */,
A586FF4E30122589002CFF95 /* Frameworks */,
A586FF4F30122589002CFF95 /* Resources */,
);
buildRules = (
);
dependencies = (
A586FF5630122589002CFF95 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
A586FF5230122589002CFF95 /* oAITests */,
);
name = oAITests;
packageProductDependencies = (
);
productName = oAITests;
productReference = A586FF5130122589002CFF95 /* oAITests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
/* End PBXNativeTarget section */ /* End PBXNativeTarget section */
/* Begin PBXProject section */ /* Begin PBXProject section */
@@ -99,12 +147,16 @@
isa = PBXProject; isa = PBXProject;
attributes = { attributes = {
BuildIndependentTargetsInParallel = 1; BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 2620; LastSwiftUpdateCheck = 2700;
LastUpgradeCheck = 2620; LastUpgradeCheck = 2700;
TargetAttributes = { TargetAttributes = {
A550A6612F3B72EA00136F2B = { A550A6612F3B72EA00136F2B = {
CreatedOnToolsVersion = 26.2; CreatedOnToolsVersion = 26.2;
}; };
A586FF5030122589002CFF95 = {
CreatedOnToolsVersion = 27.0;
TestTargetID = A550A6612F3B72EA00136F2B;
};
}; };
}; };
buildConfigurationList = A550A65D2F3B72EA00136F2B /* Build configuration list for PBXProject "oAI" */; buildConfigurationList = A550A65D2F3B72EA00136F2B /* Build configuration list for PBXProject "oAI" */;
@@ -131,6 +183,7 @@
projectRoot = ""; projectRoot = "";
targets = ( targets = (
A550A6612F3B72EA00136F2B /* oAI */, A550A6612F3B72EA00136F2B /* oAI */,
A586FF5030122589002CFF95 /* oAITests */,
); );
}; };
/* End PBXProject section */ /* End PBXProject section */
@@ -143,6 +196,13 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
A586FF4F30122589002CFF95 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */ /* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */
@@ -153,14 +213,30 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
A586FF4D30122589002CFF95 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */ /* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
A586FF5630122589002CFF95 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = A550A6612F3B72EA00136F2B /* oAI */;
targetProxy = A586FF5530122589002CFF95 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */ /* Begin XCBuildConfiguration section */
A550A66B2F3B72EC00136F2B /* Debug */ = { A550A66B2F3B72EC00136F2B /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO; ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
@@ -190,7 +266,9 @@
CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO; COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = dwarf; DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = 6RJQ2QZYPG;
ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES; ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -212,6 +290,7 @@
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
}; };
@@ -222,6 +301,7 @@
buildSettings = { buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO; ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
@@ -251,7 +331,9 @@
CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO; COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = 6RJQ2QZYPG;
ENABLE_NS_ASSERTIONS = NO; ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -266,6 +348,7 @@
LOCALIZATION_PREFERS_STRING_CATALOGS = YES; LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_COMPILATION_MODE = wholemodule; SWIFT_COMPILATION_MODE = wholemodule;
}; };
name = Release; name = Release;
@@ -278,12 +361,12 @@
CODE_SIGN_ENTITLEMENTS = oAI/oAI.entitlements; CODE_SIGN_ENTITLEMENTS = oAI/oAI.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 6RJQ2QZYPG; DEAD_CODE_STRIPPING = YES;
ENABLE_APP_SANDBOX = NO; ENABLE_APP_SANDBOX = NO;
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_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.";
@@ -328,12 +411,12 @@
CODE_SIGN_ENTITLEMENTS = oAI/oAI.entitlements; CODE_SIGN_ENTITLEMENTS = oAI/oAI.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 6RJQ2QZYPG; DEAD_CODE_STRIPPING = YES;
ENABLE_APP_SANDBOX = NO; ENABLE_APP_SANDBOX = NO;
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_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.";
@@ -370,6 +453,50 @@
}; };
name = Release; name = Release;
}; };
A586FF5730122589002CFF95 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEAD_CODE_STRIPPING = YES;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 27.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAITests;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = macosx;
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/oAI.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/oAI";
};
name = Debug;
};
A586FF5830122589002CFF95 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEAD_CODE_STRIPPING = YES;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 27.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAITests;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = macosx;
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/oAI.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/oAI";
};
name = Release;
};
/* End XCBuildConfiguration section */ /* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */ /* Begin XCConfigurationList section */
@@ -391,6 +518,15 @@
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
A586FF5930122589002CFF95 /* Build configuration list for PBXNativeTarget "oAITests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
A586FF5730122589002CFF95 /* Debug */,
A586FF5830122589002CFF95 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */ /* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */ /* Begin XCRemoteSwiftPackageReference section */
+16 -5
View File
@@ -186,6 +186,21 @@ class AnthropicProvider: AIProvider {
("claude-haiku", 0.80, 4.0), ("claude-haiku", 0.80, 4.0),
] ]
/// Fuzzy pricing lookup for a model ID not found in `knownModels`: finds the
/// longest matching prefix in `fallback` (longest match wins when several
/// prefixes match, e.g. a future "claude-sonnet-mini" against both
/// "claude-sonnet" and a shorter unrelated prefix). Unmatched IDs price at zero.
static func resolveFallbackPricing(
for modelId: String,
fallback: [(prefix: String, prompt: Double, completion: Double)] = pricingFallback
) -> ModelInfo.Pricing {
let match = fallback
.filter { modelId.hasPrefix($0.prefix) }
.max(by: { $0.prefix.count < $1.prefix.count })
return match.map { ModelInfo.Pricing(prompt: $0.prompt, completion: $0.completion) }
?? ModelInfo.Pricing(prompt: 0, completion: 0)
}
/// Fetch live model list from GET /v1/models, enriched with local pricing/context metadata. /// Fetch live model list from GET /v1/models, enriched with local pricing/context metadata.
/// Falls back to knownModels if the request fails (no key, offline, etc.). /// Falls back to knownModels if the request fails (no key, offline, etc.).
func listModels() async throws -> [ModelInfo] { func listModels() async throws -> [ModelInfo] {
@@ -223,11 +238,7 @@ class AnthropicProvider: AIProvider {
// Exact match first // Exact match first
if let known = enrichment[id] { return known } if let known = enrichment[id] { return known }
// Fuzzy fallback: find the longest prefix that matches // Fuzzy fallback: find the longest prefix that matches
let fallback = Self.pricingFallback let pricing = Self.resolveFallbackPricing(for: id)
.filter { id.hasPrefix($0.prefix) }
.max(by: { $0.prefix.count < $1.prefix.count })
let pricing = fallback.map { ModelInfo.Pricing(prompt: $0.prompt, completion: $0.completion) }
?? ModelInfo.Pricing(prompt: 0, completion: 0)
return ModelInfo( return ModelInfo(
id: id, id: id,
name: displayName, name: displayName,
+68 -27
View File
@@ -206,37 +206,68 @@ final class BackupService {
log.debug("Pushed \(payload.ids.count) favorite model(s) to iCloud") log.debug("Pushed \(payload.ids.count) favorite model(s) to iCloud")
} }
/// Outcome of reconciling local favorites against the iCloud copy. Pure result type --
/// see `decideFavoritesSync` for the actual decision logic.
enum FavoritesSyncDecision: Equatable {
case applyRemote(ids: Set<String>, updatedAt: String)
case pushLocal
case mergeAndPush(ids: Set<String>, updatedAt: String)
case noop
}
/// Last-write-wins reconciliation, pulled out of `syncFavoritesOnLaunch` so the branching
/// logic (5 cases: remote absent / remote newer / local newer / tied-but-different /
/// tied-and-identical) can be tested without touching iCloud Drive or SettingsService.
static func decideFavoritesSync(
localIds: Set<String>,
localUpdatedAt: String,
remote: FavoritesPayload?,
now: Date
) -> FavoritesSyncDecision {
guard let remote else {
return .pushLocal
}
if remote.updatedAt > localUpdatedAt {
return .applyRemote(ids: Set(remote.ids), updatedAt: remote.updatedAt)
} else if localUpdatedAt > remote.updatedAt {
return .pushLocal
} 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.
let merged = localIds.union(remote.ids)
return .mergeAndPush(ids: merged, updatedAt: ISO8601DateFormatter().string(from: now))
}
return .noop
}
/// Reconcile local favorites with the iCloud copy using last-write-wins (by timestamp). /// 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. /// Call on launch (and optionally on app-become-active) to pick up changes from other machines.
func syncFavoritesOnLaunch() async { func syncFavoritesOnLaunch() async {
let settings = SettingsService.shared let settings = SettingsService.shared
let fileURL = favoritesFileURL() let fileURL = favoritesFileURL()
guard let data = try? Data(contentsOf: fileURL), let remote: FavoritesPayload? = {
let remote = try? JSONDecoder().decode(FavoritesPayload.self, from: data) else { guard let data = try? Data(contentsOf: fileURL) else { return nil }
// No remote copy yet push local state (may be empty, that's fine). return try? JSONDecoder().decode(FavoritesPayload.self, from: data)
await pushFavorites() }()
return
}
let localUpdatedAt = settings.favoriteModelsUpdatedAt
let localIds = settings.favoriteModelIds let localIds = settings.favoriteModelIds
if remote.updatedAt > localUpdatedAt {
settings.favoriteModelIds = Set(remote.ids) switch Self.decideFavoritesSync(localIds: localIds, localUpdatedAt: settings.favoriteModelsUpdatedAt, remote: remote, now: Date()) {
settings.favoriteModelsUpdatedAt = remote.updatedAt case .pushLocal:
log.info("Applied \(remote.ids.count) favorite model(s) from iCloud (remote was newer)")
} else if localUpdatedAt > remote.updatedAt {
await pushFavorites() await pushFavorites()
} else if Set(remote.ids) != localIds { case .applyRemote(let ids, let updatedAt):
// Tied timestamps (both empty is the common case: two machines that already had settings.favoriteModelIds = ids
// favorites before this sync feature existed, neither ever bumped the timestamp). settings.favoriteModelsUpdatedAt = updatedAt
// Union rather than silently dropping one side's favorites. log.info("Applied \(ids.count) favorite model(s) from iCloud (remote was newer)")
settings.favoriteModelIds = localIds.union(remote.ids) case .mergeAndPush(let ids, let updatedAt):
settings.favoriteModelsUpdatedAt = ISO8601DateFormatter().string(from: Date()) settings.favoriteModelIds = ids
settings.favoriteModelsUpdatedAt = updatedAt
await pushFavorites() await pushFavorites()
log.info("Merged favorites from iCloud (tied timestamps) — union of \(localIds.count) local + \(remote.ids.count) remote") log.info("Merged favorites from iCloud (tied timestamps) — union of \(localIds.count) local + \(remote?.ids.count ?? 0) remote")
case .noop:
break
} }
// Equal timestamps and identical sets: already in sync, nothing to do.
} }
// MARK: - Automatic Backup // MARK: - Automatic Backup
@@ -252,17 +283,27 @@ final class BackupService {
} }
} }
func checkAndPerformAutoBackupIfDue() async { /// Whether an automatic backup should run now, given the chosen frequency and the last
let frequency = SettingsService.shared.autoBackupFrequency /// known backup time. Pulled out of `checkAndPerformAutoBackupIfDue` for testability --
/// "manual" is never due, a missing last-backup-date is always due, otherwise it's due
/// once the elapsed time reaches the frequency's interval.
static func isBackupDue(frequency: String, lastBackupDate: Date?, now: Date) -> Bool {
let interval: TimeInterval let interval: TimeInterval
switch frequency { switch frequency {
case "daily": interval = Self.dailyInterval case "daily": interval = dailyInterval
case "weekly": interval = Self.weeklyInterval case "weekly": interval = weeklyInterval
default: return // "manual" user triggers backups by hand default: return false // "manual" user triggers backups by hand
} }
guard let last = lastBackupDate else { return true }
return now.timeIntervalSince(last) >= interval
}
func checkAndPerformAutoBackupIfDue() async {
let frequency = SettingsService.shared.autoBackupFrequency
guard frequency == "daily" || frequency == "weekly" else { return }
checkForExistingBackup() checkForExistingBackup()
if let last = lastBackupDate, Date().timeIntervalSince(last) < interval { guard Self.isBackupDue(frequency: frequency, lastBackupDate: lastBackupDate, now: Date()) else {
return // not due yet return // not due yet
} }
+2 -3
View File
@@ -45,7 +45,7 @@ class GitSyncService {
func testConnection() async throws -> String { func testConnection() async throws -> String {
let url = try buildAuthenticatedURL() let url = try buildAuthenticatedURL()
_ = try await runGit(["ls-remote", url]) _ = try await runGit(["ls-remote", url])
return "Connected to \(extractProvider())" return "Connected to \(Self.extractProvider(from: settings.syncRepoURL))"
} }
/// Clone repository to local path /// Clone repository to local path
@@ -703,8 +703,7 @@ class GitSyncService {
return name.components(separatedBy: invalid).joined(separator: "-") return name.components(separatedBy: invalid).joined(separator: "-")
} }
private func extractProvider() -> String { static func extractProvider(from url: String) -> String {
let url = settings.syncRepoURL
if url.contains("github.com") { if url.contains("github.com") {
return "GitHub" return "GitHub"
} else if url.contains("gitlab.com") { } else if url.contains("gitlab.com") {
+34 -31
View File
@@ -1888,40 +1888,43 @@ Don't narrate future actions ("Let me...") - just use the tools.
} }
} }
/// Pure auto-save eligibility check, pulled out of `shouldAutoSave()` so the criteria
/// (enabled, configured, cloned, min message count, not-already-saved) can be tested
/// without a live ChatViewModel/GitSyncService/SettingsService.
nonisolated static func shouldAutoSave(
syncEnabled: Bool,
syncAutoSave: Bool,
syncConfigured: Bool,
isCloned: Bool,
chatMessageCount: Int,
minMessages: Int,
lastSavedConversationId: String?,
currentConversationHash: String
) -> Bool {
guard syncEnabled && syncAutoSave else { return false }
guard syncConfigured else { return false }
guard isCloned else { return false }
guard chatMessageCount >= minMessages else { return false }
if let lastSavedId = lastSavedConversationId, lastSavedId == currentConversationHash {
return false // Already saved this exact conversation
}
return true
}
/// Check if conversation should be auto-saved based on criteria /// Check if conversation should be auto-saved based on criteria
func shouldAutoSave() -> Bool { func shouldAutoSave() -> Bool {
// Check if auto-save is enabled
guard settings.syncEnabled && settings.syncAutoSave else {
return false
}
// Check if sync is configured
guard settings.syncConfigured else {
return false
}
// Check if repository is cloned
guard GitSyncService.shared.syncStatus.isCloned else {
return false
}
// Check minimum message count
let chatMessages = messages.filter { $0.role == .user || $0.role == .assistant } let chatMessages = messages.filter { $0.role == .user || $0.role == .assistant }
guard chatMessages.count >= settings.syncAutoSaveMinMessages else { let currentHash = chatMessages.map { $0.content }.joined()
return false return Self.shouldAutoSave(
} syncEnabled: settings.syncEnabled,
syncAutoSave: settings.syncAutoSave,
// Check if already auto-saved this conversation syncConfigured: settings.syncConfigured,
// (prevent duplicate saves) isCloned: GitSyncService.shared.syncStatus.isCloned,
if let lastSavedId = settings.syncLastAutoSaveConversationId { chatMessageCount: chatMessages.count,
// Create a hash of current conversation to detect if it's the same one minMessages: settings.syncAutoSaveMinMessages,
let currentHash = chatMessages.map { $0.content }.joined() lastSavedConversationId: settings.syncLastAutoSaveConversationId,
if lastSavedId == currentHash { currentConversationHash: currentHash
return false // Already saved this exact conversation )
}
}
return true
} }
/// Auto-save the current conversation with background summarization /// Auto-save the current conversation with background summarization
+27
View File
@@ -177,4 +177,31 @@ struct AnthropicProviderTests {
#expect(items?["type"] as? String == "string") #expect(items?["type"] as? String == "string")
#expect(dict["required"] == nil) #expect(dict["required"] == nil)
} }
// MARK: - resolveFallbackPricing
@Test("Exact-length single prefix match returns that tier's pricing")
func fallbackPricingSingleMatch() {
let pricing = AnthropicProvider.resolveFallbackPricing(for: "claude-haiku-4-5-20251001")
#expect(pricing.prompt == 0.80)
#expect(pricing.completion == 4.0)
}
@Test("Longest matching prefix wins when multiple prefixes could match")
func fallbackPricingLongestPrefixWins() {
let fallback: [(prefix: String, prompt: Double, completion: Double)] = [
("claude", 1.0, 2.0),
("claude-sonnet", 3.0, 15.0),
]
let pricing = AnthropicProvider.resolveFallbackPricing(for: "claude-sonnet-5", fallback: fallback)
#expect(pricing.prompt == 3.0)
#expect(pricing.completion == 15.0)
}
@Test("An unmatched model ID prices at zero rather than guessing")
func fallbackPricingNoMatch() {
let pricing = AnthropicProvider.resolveFallbackPricing(for: "some-future-unknown-model")
#expect(pricing.prompt == 0)
#expect(pricing.completion == 0)
}
} }
+105
View File
@@ -0,0 +1,105 @@
//
// BackupServiceTests.swift
// oAITests
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
import Testing
import Foundation
@testable import oAI
@Suite("BackupService.decideFavoritesSync")
struct BackupServiceFavoritesSyncTests {
private let now = Date(timeIntervalSince1970: 1_700_000_000)
@Test("No remote copy yet -- push local state")
func noRemoteCopy() {
let decision = BackupService.decideFavoritesSync(localIds: ["a", "b"], localUpdatedAt: "", remote: nil, now: now)
#expect(decision == .pushLocal)
}
@Test("Remote is newer -- apply it locally")
func remoteNewer() {
let remote = FavoritesPayload(updatedAt: "2026-02-01T00:00:00Z", ids: ["x", "y"])
let decision = BackupService.decideFavoritesSync(
localIds: ["a"],
localUpdatedAt: "2026-01-01T00:00:00Z",
remote: remote,
now: now
)
#expect(decision == .applyRemote(ids: ["x", "y"], updatedAt: "2026-02-01T00:00:00Z"))
}
@Test("Local is newer -- push local state, don't touch remote")
func localNewer() {
let remote = FavoritesPayload(updatedAt: "2026-01-01T00:00:00Z", ids: ["x"])
let decision = BackupService.decideFavoritesSync(
localIds: ["a"],
localUpdatedAt: "2026-02-01T00:00:00Z",
remote: remote,
now: now
)
#expect(decision == .pushLocal)
}
@Test("Tied timestamps with different sets -- merge via union and push")
func tiedTimestampsDifferentSets() {
let remote = FavoritesPayload(updatedAt: "", ids: ["b", "c"])
let decision = BackupService.decideFavoritesSync(localIds: ["a", "b"], localUpdatedAt: "", remote: remote, now: now)
guard case .mergeAndPush(let ids, let updatedAt) = decision else {
Issue.record("Expected .mergeAndPush")
return
}
#expect(ids == ["a", "b", "c"])
#expect(!updatedAt.isEmpty) // stamped with `now`, not left blank
}
@Test("Tied timestamps with identical sets -- no-op, already in sync")
func tiedTimestampsIdenticalSets() {
let remote = FavoritesPayload(updatedAt: "2026-01-01T00:00:00Z", ids: ["a", "b"])
let decision = BackupService.decideFavoritesSync(
localIds: ["a", "b"],
localUpdatedAt: "2026-01-01T00:00:00Z",
remote: remote,
now: now
)
#expect(decision == .noop)
}
}
@Suite("BackupService.isBackupDue")
struct BackupServiceAutoBackupTests {
private let now = Date(timeIntervalSince1970: 1_700_000_000)
private let oneHour: TimeInterval = 3600
@Test("Manual frequency is never due")
func manualNeverDue() {
#expect(!BackupService.isBackupDue(frequency: "manual", lastBackupDate: nil, now: now))
#expect(!BackupService.isBackupDue(frequency: "manual", lastBackupDate: now.addingTimeInterval(-1_000_000), now: now))
}
@Test("No prior backup at all is always due, regardless of frequency")
func noPriorBackupIsAlwaysDue() {
#expect(BackupService.isBackupDue(frequency: "daily", lastBackupDate: nil, now: now))
#expect(BackupService.isBackupDue(frequency: "weekly", lastBackupDate: nil, now: now))
}
@Test("Daily: not due before 24 hours have passed, due at or after")
func dailyThreshold() {
let justUnder = now.addingTimeInterval(-(24 * oneHour - 1))
let exactly = now.addingTimeInterval(-(24 * oneHour))
#expect(!BackupService.isBackupDue(frequency: "daily", lastBackupDate: justUnder, now: now))
#expect(BackupService.isBackupDue(frequency: "daily", lastBackupDate: exactly, now: now))
}
@Test("Weekly: not due before 7 days have passed, due at or after")
func weeklyThreshold() {
let justUnder = now.addingTimeInterval(-(7 * 24 * oneHour - 1))
let exactly = now.addingTimeInterval(-(7 * 24 * oneHour))
#expect(!BackupService.isBackupDue(frequency: "weekly", lastBackupDate: justUnder, now: now))
#expect(BackupService.isBackupDue(frequency: "weekly", lastBackupDate: exactly, now: now))
}
}
@@ -103,4 +103,60 @@ struct ChatViewModelPureLogicTests {
let pricing = ModelInfo.Pricing(prompt: 3.0, completion: 15.0) let pricing = ModelInfo.Pricing(prompt: 3.0, completion: 15.0)
#expect(ChatViewModel.calculateCost(usage: usage, pricing: pricing) == 0.0) #expect(ChatViewModel.calculateCost(usage: usage, pricing: pricing) == 0.0)
} }
// MARK: - shouldAutoSave
private func autoSaveEligible(
syncEnabled: Bool = true,
syncAutoSave: Bool = true,
syncConfigured: Bool = true,
isCloned: Bool = true,
chatMessageCount: Int = 10,
minMessages: Int = 4,
lastSavedConversationId: String? = nil,
currentConversationHash: String = "hash-a"
) -> Bool {
ChatViewModel.shouldAutoSave(
syncEnabled: syncEnabled,
syncAutoSave: syncAutoSave,
syncConfigured: syncConfigured,
isCloned: isCloned,
chatMessageCount: chatMessageCount,
minMessages: minMessages,
lastSavedConversationId: lastSavedConversationId,
currentConversationHash: currentConversationHash
)
}
@Test("Eligible when every criterion is satisfied")
func autoSaveEligibleWhenAllCriteriaMet() {
#expect(autoSaveEligible())
}
@Test("Not eligible when sync or auto-save is disabled")
func autoSaveIneligibleWhenDisabled() {
#expect(!autoSaveEligible(syncEnabled: false))
#expect(!autoSaveEligible(syncAutoSave: false))
}
@Test("Not eligible when sync isn't configured or the repo isn't cloned")
func autoSaveIneligibleWhenNotConfiguredOrCloned() {
#expect(!autoSaveEligible(syncConfigured: false))
#expect(!autoSaveEligible(isCloned: false))
}
@Test("Not eligible below the minimum message count")
func autoSaveIneligibleBelowMinimumMessages() {
#expect(!autoSaveEligible(chatMessageCount: 2, minMessages: 4))
}
@Test("Not eligible if this exact conversation was already auto-saved")
func autoSaveIneligibleWhenAlreadySaved() {
#expect(!autoSaveEligible(lastSavedConversationId: "hash-a", currentConversationHash: "hash-a"))
}
@Test("Eligible when the last saved hash differs from the current conversation")
func autoSaveEligibleWhenConversationChanged() {
#expect(autoSaveEligible(lastSavedConversationId: "hash-a", currentConversationHash: "hash-b"))
}
} }
+14
View File
@@ -91,4 +91,18 @@ struct GitSyncServiceTests {
let shortKey = "sk-" + String(repeating: "a", count: 31) let shortKey = "sk-" + String(repeating: "a", count: 31)
#expect(!service.detectSecretsInText(shortKey).contains("OpenAI Key")) #expect(!service.detectSecretsInText(shortKey).contains("OpenAI Key"))
} }
// MARK: - extractProvider
@Test("Recognizes github.com, gitlab.com, and gitea URLs by name")
func extractProviderRecognizesKnownHosts() {
#expect(GitSyncService.extractProvider(from: "https://github.com/user/repo.git") == "GitHub")
#expect(GitSyncService.extractProvider(from: "https://gitlab.com/user/repo.git") == "GitLab")
#expect(GitSyncService.extractProvider(from: "https://my-gitea-instance.example.com/user/repo.git") == "Gitea")
}
@Test("Falls back to a generic label for an unrecognized host")
func extractProviderFallsBackForUnknownHost() {
#expect(GitSyncService.extractProvider(from: "https://gitlab.pm/rune/oai-swift.git") == "Git repository")
}
} }