2.4.3 #9
@@ -11,8 +11,19 @@
|
||||
A550A8342F3C5C9300136F2B /* GRDB in Frameworks */ = {isa = PBXBuildFile; productRef = A550A6812F3B730000136F2B /* GRDB */; };
|
||||
/* 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 */
|
||||
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 */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
@@ -34,6 +45,11 @@
|
||||
path = oAI;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A586FF5230122589002CFF95 /* oAITests */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = oAITests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -46,6 +62,13 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
A586FF4E30122589002CFF95 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
@@ -53,6 +76,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A550A6642F3B72EA00136F2B /* oAI */,
|
||||
A586FF5230122589002CFF95 /* oAITests */,
|
||||
A550A6632F3B72EA00136F2B /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
@@ -61,6 +85,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A550A6622F3B72EA00136F2B /* oAI.app */,
|
||||
A586FF5130122589002CFF95 /* oAITests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
@@ -92,6 +117,29 @@
|
||||
productReference = A550A6622F3B72EA00136F2B /* oAI.app */;
|
||||
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 */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
@@ -99,12 +147,16 @@
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 2620;
|
||||
LastUpgradeCheck = 2620;
|
||||
LastSwiftUpdateCheck = 2700;
|
||||
LastUpgradeCheck = 2700;
|
||||
TargetAttributes = {
|
||||
A550A6612F3B72EA00136F2B = {
|
||||
CreatedOnToolsVersion = 26.2;
|
||||
};
|
||||
A586FF5030122589002CFF95 = {
|
||||
CreatedOnToolsVersion = 27.0;
|
||||
TestTargetID = A550A6612F3B72EA00136F2B;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = A550A65D2F3B72EA00136F2B /* Build configuration list for PBXProject "oAI" */;
|
||||
@@ -131,6 +183,7 @@
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
A550A6612F3B72EA00136F2B /* oAI */,
|
||||
A586FF5030122589002CFF95 /* oAITests */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
@@ -143,6 +196,13 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
A586FF4F30122589002CFF95 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
@@ -153,14 +213,30 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
A586FF4D30122589002CFF95 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
A586FF5630122589002CFF95 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = A550A6612F3B72EA00136F2B /* oAI */;
|
||||
targetProxy = A586FF5530122589002CFF95 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
A550A66B2F3B72EC00136F2B /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
@@ -190,7 +266,9 @@
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = 6RJQ2QZYPG;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
@@ -212,6 +290,7 @@
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
@@ -222,6 +301,7 @@
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
@@ -251,7 +331,9 @@
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = 6RJQ2QZYPG;
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
@@ -266,6 +348,7 @@
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
};
|
||||
name = Release;
|
||||
@@ -278,12 +361,12 @@
|
||||
CODE_SIGN_ENTITLEMENTS = oAI/oAI.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = 6RJQ2QZYPG;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
ENABLE_APP_SANDBOX = NO;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SELECTED_FILES = readonly;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = "oAI/Info.plist";
|
||||
INFOPLIST_FILE = oAI/Info.plist;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "oAI can read and create calendar events when you ask it to, if you enable Calendar access in Settings.";
|
||||
INFOPLIST_KEY_NSContactsUsageDescription = "oAI can search your contacts when you ask it to, if you enable Contacts access in Settings.";
|
||||
@@ -328,12 +411,12 @@
|
||||
CODE_SIGN_ENTITLEMENTS = oAI/oAI.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = 6RJQ2QZYPG;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
ENABLE_APP_SANDBOX = NO;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SELECTED_FILES = readonly;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = "oAI/Info.plist";
|
||||
INFOPLIST_FILE = oAI/Info.plist;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "oAI can read and create calendar events when you ask it to, if you enable Calendar access in Settings.";
|
||||
INFOPLIST_KEY_NSContactsUsageDescription = "oAI can search your contacts when you ask it to, if you enable Contacts access in Settings.";
|
||||
@@ -370,6 +453,50 @@
|
||||
};
|
||||
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 */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
@@ -391,6 +518,15 @@
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
A586FF5930122589002CFF95 /* Build configuration list for PBXNativeTarget "oAITests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
A586FF5730122589002CFF95 /* Debug */,
|
||||
A586FF5830122589002CFF95 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCRemoteSwiftPackageReference section */
|
||||
|
||||
@@ -186,6 +186,21 @@ class AnthropicProvider: AIProvider {
|
||||
("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.
|
||||
/// Falls back to knownModels if the request fails (no key, offline, etc.).
|
||||
func listModels() async throws -> [ModelInfo] {
|
||||
@@ -223,11 +238,7 @@ class AnthropicProvider: AIProvider {
|
||||
// Exact match first
|
||||
if let known = enrichment[id] { return known }
|
||||
// Fuzzy fallback: find the longest prefix that matches
|
||||
let fallback = Self.pricingFallback
|
||||
.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)
|
||||
let pricing = Self.resolveFallbackPricing(for: id)
|
||||
return ModelInfo(
|
||||
id: id,
|
||||
name: displayName,
|
||||
|
||||
@@ -206,37 +206,68 @@ final class BackupService {
|
||||
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).
|
||||
/// 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 remote: FavoritesPayload? = {
|
||||
guard let data = try? Data(contentsOf: fileURL) else { return nil }
|
||||
return try? JSONDecoder().decode(FavoritesPayload.self, from: data)
|
||||
}()
|
||||
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 {
|
||||
|
||||
switch Self.decideFavoritesSync(localIds: localIds, localUpdatedAt: settings.favoriteModelsUpdatedAt, remote: remote, now: Date()) {
|
||||
case .pushLocal:
|
||||
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())
|
||||
case .applyRemote(let ids, let updatedAt):
|
||||
settings.favoriteModelIds = ids
|
||||
settings.favoriteModelsUpdatedAt = updatedAt
|
||||
log.info("Applied \(ids.count) favorite model(s) from iCloud (remote was newer)")
|
||||
case .mergeAndPush(let ids, let updatedAt):
|
||||
settings.favoriteModelIds = ids
|
||||
settings.favoriteModelsUpdatedAt = updatedAt
|
||||
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
|
||||
@@ -252,17 +283,27 @@ final class BackupService {
|
||||
}
|
||||
}
|
||||
|
||||
func checkAndPerformAutoBackupIfDue() async {
|
||||
let frequency = SettingsService.shared.autoBackupFrequency
|
||||
/// Whether an automatic backup should run now, given the chosen frequency and the last
|
||||
/// 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
|
||||
switch frequency {
|
||||
case "daily": interval = Self.dailyInterval
|
||||
case "weekly": interval = Self.weeklyInterval
|
||||
default: return // "manual" — user triggers backups by hand
|
||||
case "daily": interval = dailyInterval
|
||||
case "weekly": interval = weeklyInterval
|
||||
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()
|
||||
if let last = lastBackupDate, Date().timeIntervalSince(last) < interval {
|
||||
guard Self.isBackupDue(frequency: frequency, lastBackupDate: lastBackupDate, now: Date()) else {
|
||||
return // not due yet
|
||||
}
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ class GitSyncService {
|
||||
func testConnection() async throws -> String {
|
||||
let url = try buildAuthenticatedURL()
|
||||
_ = try await runGit(["ls-remote", url])
|
||||
return "Connected to \(extractProvider())"
|
||||
return "Connected to \(Self.extractProvider(from: settings.syncRepoURL))"
|
||||
}
|
||||
|
||||
/// Clone repository to local path
|
||||
@@ -703,8 +703,7 @@ class GitSyncService {
|
||||
return name.components(separatedBy: invalid).joined(separator: "-")
|
||||
}
|
||||
|
||||
private func extractProvider() -> String {
|
||||
let url = settings.syncRepoURL
|
||||
static func extractProvider(from url: String) -> String {
|
||||
if url.contains("github.com") {
|
||||
return "GitHub"
|
||||
} else if url.contains("gitlab.com") {
|
||||
|
||||
@@ -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
|
||||
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 }
|
||||
guard chatMessages.count >= settings.syncAutoSaveMinMessages else {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if already auto-saved this conversation
|
||||
// (prevent duplicate saves)
|
||||
if let lastSavedId = settings.syncLastAutoSaveConversationId {
|
||||
// Create a hash of current conversation to detect if it's the same one
|
||||
let currentHash = chatMessages.map { $0.content }.joined()
|
||||
if lastSavedId == currentHash {
|
||||
return false // Already saved this exact conversation
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
let currentHash = chatMessages.map { $0.content }.joined()
|
||||
return Self.shouldAutoSave(
|
||||
syncEnabled: settings.syncEnabled,
|
||||
syncAutoSave: settings.syncAutoSave,
|
||||
syncConfigured: settings.syncConfigured,
|
||||
isCloned: GitSyncService.shared.syncStatus.isCloned,
|
||||
chatMessageCount: chatMessages.count,
|
||||
minMessages: settings.syncAutoSaveMinMessages,
|
||||
lastSavedConversationId: settings.syncLastAutoSaveConversationId,
|
||||
currentConversationHash: currentHash
|
||||
)
|
||||
}
|
||||
|
||||
/// Auto-save the current conversation with background summarization
|
||||
|
||||
@@ -177,4 +177,31 @@ struct AnthropicProviderTests {
|
||||
#expect(items?["type"] as? String == "string")
|
||||
#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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
#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"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,4 +91,18 @@ struct GitSyncServiceTests {
|
||||
let shortKey = "sk-" + String(repeating: "a", count: 31)
|
||||
#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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user