From 49a3dba408c07982c8b25eac904bc17e109a29b0 Mon Sep 17 00:00:00 2001 From: Davide Date: Tue, 4 Aug 2026 14:39:35 +0200 Subject: [PATCH 01/13] Suppress local stats --- app-apple/Passepartout/App/AppDelegate.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app-apple/Passepartout/App/AppDelegate.swift b/app-apple/Passepartout/App/AppDelegate.swift index b741c1262..89a84a096 100644 --- a/app-apple/Passepartout/App/AppDelegate.swift +++ b/app-apple/Passepartout/App/AppDelegate.swift @@ -26,7 +26,7 @@ final class AppDelegate: NSObject { func configure(with uiConfiguring: AppLibraryConfiguring?) { context.userPreferences.applyAppearance() uiConfiguring?.configure() - debugLocalStoreStats() +// debugLocalStoreStats() } } From 817777bb66f702b14fe6244efde32c1e053141a8 Mon Sep 17 00:00:00 2001 From: Davide Date: Sun, 2 Aug 2026 14:38:26 +0200 Subject: [PATCH 02/13] Delete unused FileProfileRepository --- .../AppConfiguration+Dependencies.swift | 6 - .../Strategy/FileProfileRepository.swift | 273 ------------------ 2 files changed, 279 deletions(-) delete mode 100644 app-apple/Sources/CommonLibraryCore/Strategy/FileProfileRepository.swift diff --git a/app-apple/Sources/CommonLibrary/Dependencies/AppConfiguration+Dependencies.swift b/app-apple/Sources/CommonLibrary/Dependencies/AppConfiguration+Dependencies.swift index fb697e8eb..40160fc09 100644 --- a/app-apple/Sources/CommonLibrary/Dependencies/AppConfiguration+Dependencies.swift +++ b/app-apple/Sources/CommonLibrary/Dependencies/AppConfiguration+Dependencies.swift @@ -56,12 +56,6 @@ extension ABI.AppConfiguration { ) } - public func newFileProfileRepository(path: String) throws -> ProfileRepository { - try FileProfileRepository( - directoryURL: URL(filePath: path, directoryHint: .isDirectory) - ) - } - public func newIAPManager( inAppHelper: InAppHelper, receiptReader: UserInAppReceiptReader, diff --git a/app-apple/Sources/CommonLibraryCore/Strategy/FileProfileRepository.swift b/app-apple/Sources/CommonLibraryCore/Strategy/FileProfileRepository.swift deleted file mode 100644 index ffa9730d2..000000000 --- a/app-apple/Sources/CommonLibraryCore/Strategy/FileProfileRepository.swift +++ /dev/null @@ -1,273 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Davide De Rosa -// -// SPDX-License-Identifier: GPL-3.0 - -import Partout - -public actor FileProfileRepository: ProfileRepository { - private struct IndexEntry: Codable, Sendable { - let id: String - let name: String - let lastUpdate: Date? - let fingerprint: String? - } - - private struct IndexFile: Codable, Sendable { - let version: Int - let profiles: [IndexEntry] - } - - private enum FileProfileRepositoryError: LocalizedError { - case missingProfileId(String) - case malformedIndex(Error) - case malformedProfile(URL, Error) - - var errorDescription: String? { - switch self { - case .missingProfileId(let id): - "Unable to locate stored profile \(id)" - case .malformedIndex(let error): - "Unable to decode profile index: \(error)" - case .malformedProfile(let url, let error): - "Unable to decode profile at \(url.lastPathComponent): \(error)" - } - } - } - - private nonisolated let profilesSubject: CurrentValueStream<[Profile]> - private let rootURL: URL - private let objectsURL: URL - private let tmpURL: URL - private let indexURL: URL - - public init(directoryURL: URL) throws { - profilesSubject = CurrentValueStream([]) - rootURL = directoryURL - objectsURL = directoryURL.appending(component: "objects", directoryHint: .isDirectory) - tmpURL = directoryURL.appending(component: "tmp", directoryHint: .isDirectory) - indexURL = directoryURL.appending(component: "index.json") - - try FileManager.default.createDirectory(at: rootURL, withIntermediateDirectories: true) - try FileManager.default.createDirectory(at: objectsURL, withIntermediateDirectories: true) - try FileManager.default.createDirectory(at: tmpURL, withIntermediateDirectories: true) - try Self.ensureIndex(indexURL: indexURL, objectsURL: objectsURL) - } - - public nonisolated var profilesPublisher: AsyncStream<[Profile]> { - profilesSubject.subscribe() - } - - public func fetchProfiles() async throws -> [Profile] { - let profiles = try loadProfiles() - profilesSubject.send(profiles) - return profiles - } - - public func saveProfile(_ profile: Profile) async throws { - let data = try ABI.encode(profile.asTaggedProfile) - try writeAtomically(data, to: objectURL(for: profile.id)) - try persistIndex(for: try loadProfilesById()) - try publishProfiles() - } - - public func removeProfiles(withIds profileIds: [Profile.ID]) async throws { - guard !profileIds.isEmpty else { - return - } - for profileId in profileIds { - let url = objectURL(for: profileId) - if FileManager.default.fileExists(atPath: url.filePath()) { - try FileManager.default.removeItem(at: url) - } - } - try persistIndex(for: try loadProfilesById()) - try publishProfiles() - } - - public func removeAllProfiles() async throws { - let existingFiles = try FileManager.default.contentsOfDirectory(at: objectsURL) - for fileURL in existingFiles where fileURL.pathExtension == "json" { - try? FileManager.default.removeItem(at: fileURL) - } - try persistIndex(for: [:]) - try publishProfiles() - } -} - -private extension FileProfileRepository { - static func ensureIndex(indexURL: URL, objectsURL: URL) throws { - guard FileManager.default.fileExists(atPath: indexURL.filePath()) else { - try persistIndex( - for: loadProfilesByIdFromObjects(objectsURL: objectsURL), - indexURL: indexURL, - tmpURL: objectsURL.deletingLastPathComponent().appending(component: "tmp", directoryHint: .isDirectory), - sortProfiles: sortProfiles - ) - return - } - do { - _ = try loadIndex(from: indexURL) - } catch { - pspLog(.profiles, .error, "Rebuilding malformed profile index: \(error)") - try persistIndex( - for: loadProfilesByIdFromObjects(objectsURL: objectsURL), - indexURL: indexURL, - tmpURL: objectsURL.deletingLastPathComponent().appending(component: "tmp", directoryHint: .isDirectory), - sortProfiles: sortProfiles - ) - } - } - - func ensureIndex() throws { - guard FileManager.default.fileExists(atPath: indexURL.filePath()) else { - try rebuildIndex() - return - } - do { - _ = try loadIndex() - } catch { - pspLog(.profiles, .error, "Rebuilding malformed profile index: \(error)") - try rebuildIndex() - } - } - - func publishProfiles() throws { - profilesSubject.send(try loadProfiles()) - } - - func rebuildIndex() throws { - try persistIndex(for: loadProfilesByIdFromObjects()) - } - - func loadProfiles() throws -> [Profile] { - let profilesById = try loadProfilesById() - return try orderedIds().compactMap { - guard let profile = profilesById[$0] else { - throw FileProfileRepositoryError.missingProfileId($0) - } - return profile - } - } - - func loadProfilesById() throws -> [String: Profile] { - let knownIds = Set(try orderedIds()) - let objects = try loadProfilesByIdFromObjects() - let unknownIds = Set(objects.keys).subtracting(knownIds) - guard !unknownIds.isEmpty else { - return objects - } - pspLog(.profiles, .error, "Profile index missing \(unknownIds.count) entries, rebuilding") - try persistIndex(for: objects) - return objects - } - - func loadProfilesByIdFromObjects() throws -> [String: Profile] { - try Self.loadProfilesByIdFromObjects(objectsURL: objectsURL) - } - - func orderedIds() throws -> [String] { - try loadIndex().profiles.map(\.id) - } - - private func loadIndex() throws -> IndexFile { - try Self.loadIndex(from: indexURL) - } - - func persistIndex(for profilesById: [String: Profile]) throws { - try Self.persistIndex( - for: profilesById, - indexURL: indexURL, - tmpURL: tmpURL, - sortProfiles: sortProfiles - ) - } - - func sortProfiles(lhs: Profile, rhs: Profile) -> Bool { - Self.sortProfiles(lhs: lhs, rhs: rhs) - } - - static func sortProfiles(lhs: Profile, rhs: Profile) -> Bool { - let leftName = lhs.name.lowercased() - let rightName = rhs.name.lowercased() - if leftName != rightName { - return leftName < rightName - } - let leftLastUpdate = lhs.attributes.lastUpdate ?? .distantPast - let rightLastUpdate = rhs.attributes.lastUpdate ?? .distantPast - if leftLastUpdate != rightLastUpdate { - return leftLastUpdate > rightLastUpdate - } - return lhs.id.uuidString < rhs.id.uuidString - } - - func objectURL(for id: Profile.ID) -> URL { - objectsURL.appending(component: "\(id.uuidString).json") - } - - func writeAtomically(_ data: Data, to destinationURL: URL) throws { - let tempURL = tmpURL.appending(component: "\(UUID().uuidString).tmp") - try data.write(to: tempURL, options: .atomic) - if FileManager.default.fileExists(atPath: destinationURL.filePath()) { - try FileManager.default.removeItem(at: destinationURL) - } - try FileManager.default.moveItem(at: tempURL, to: destinationURL) - } - - static func loadProfilesByIdFromObjects(objectsURL: URL) throws -> [String: Profile] { - let fileURLs = try FileManager.default.contentsOfDirectory(at: objectsURL) - var profilesById: [String: Profile] = [:] - for fileURL in fileURLs where fileURL.pathExtension == "json" { - let data = try Data(contentsOf: fileURL) - do { - let tagged = try ABI.decode(TaggedProfile.self, from: data) - let profile = try tagged.asProfile() - profilesById[profile.id.uuidString] = profile - } catch { - throw FileProfileRepositoryError.malformedProfile(fileURL, error) - } - } - return profilesById - } - - private static func loadIndex(from indexURL: URL) throws -> IndexFile { - do { - let data = try Data(contentsOf: indexURL) - return try ABI.decode(IndexFile.self, from: data) - } catch let error as FileProfileRepositoryError { - throw error - } catch { - throw FileProfileRepositoryError.malformedIndex(error) - } - } - - static func persistIndex( - for profilesById: [String: Profile], - indexURL: URL, - tmpURL: URL, - sortProfiles: (Profile, Profile) -> Bool - ) throws { - let profiles = profilesById.values.sorted(by: sortProfiles) - let index = IndexFile( - version: 1, - profiles: profiles.map { - IndexEntry( - id: $0.id.uuidString, - name: $0.name, - lastUpdate: $0.attributes.lastUpdate, - fingerprint: $0.attributes.fingerprint?.uuidString - ) - } - ) - let data = try ABI.encode(index) - try writeAtomically(data, to: indexURL, tmpURL: tmpURL) - } - - static func writeAtomically(_ data: Data, to destinationURL: URL, tmpURL: URL) throws { - let tempURL = tmpURL.appending(component: "\(UUID().uuidString).tmp") - try data.write(to: tempURL, options: .atomic) - if FileManager.default.fileExists(atPath: destinationURL.filePath()) { - try FileManager.default.removeItem(at: destinationURL) - } - try FileManager.default.moveItem(at: tempURL, to: destinationURL) - } -} From 2e8e6522d98b3ec30522afc924f7a3f4a6b79cc8 Mon Sep 17 00:00:00 2001 From: Davide Date: Tue, 4 Aug 2026 16:20:13 +0200 Subject: [PATCH 03/13] Add keychain-based repository --- .../Strategy/KeychainProfileRepository.swift | 107 ++++++++++++++++++ partout | 2 +- 2 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 app-apple/Sources/CommonLibraryCore/Strategy/KeychainProfileRepository.swift diff --git a/app-apple/Sources/CommonLibraryCore/Strategy/KeychainProfileRepository.swift b/app-apple/Sources/CommonLibraryCore/Strategy/KeychainProfileRepository.swift new file mode 100644 index 000000000..5182d040c --- /dev/null +++ b/app-apple/Sources/CommonLibraryCore/Strategy/KeychainProfileRepository.swift @@ -0,0 +1,107 @@ +// SPDX-FileCopyrightText: 2026 Davide De Rosa +// +// SPDX-License-Identifier: GPL-3.0 + +import Partout + +public final class KeychainProfileRepository: ProfileRepository { + private let keychain: Keychain + private let coder: ProfileCoder + private let label: @Sendable (Profile) -> String + private let profilesSubject: CurrentValueStream<[Profile]> + private let eventsSubject: PassthroughStream + + public var profilesPublisher: AsyncStream<[Profile]> { + profilesSubject.subscribe() + } + + public var eventsPublisher: AsyncStream { + eventsSubject.subscribe() + } + + public init( + keychain: Keychain, + coder: ProfileCoder, + label: @escaping @Sendable (Profile) -> String + ) { + self.keychain = keychain + self.coder = coder + self.label = label + profilesSubject = CurrentValueStream([]) + eventsSubject = PassthroughStream() + } + + // FIXME: ###, Failures here might break AppContext.onLaunch() irreparably with .couldNotLaunch, should double check + public func fetchProfiles() async throws -> [Profile] { + let profiles = try keychain + .allPasswordReferences() + .compactMap { + do { + return try keychain.password(forReference: $0) + } catch { + pspLog(.core, .error, "Unable to fetch keychain item from reference: \(error)") + return nil + } + } + .compactMap { + do { + return try coder.profile(fromString: $0) + } catch { + pspLog(.core, .error, "Unable to decode profile: \(error)") + return nil + } + } + profilesSubject.send(profiles) + eventsSubject.send(.snapshot(profiles)) + return profiles + } + + public func saveProfile(_ profile: Profile) async throws { + let string = try coder.string(fromProfile: profile) + let fingerprint = profile.attributes.fingerprint?.uuidString + try keychain.set( + password: string, + for: profile.id.uuidString, + metadata: [ + .label(label(profile)), + .comment(fingerprint ?? "") + ] + ) + // Update existing or add new profile + var allProfiles = profilesSubject.value + if let index = allProfiles.firstIndex(where: { $0.id == profile.id }) { + allProfiles[index] = profile + } else { + allProfiles.append(profile) + } + profilesSubject.send(allProfiles) + eventsSubject.send(.changes([ + .upsert(profile) + ])) + } + + public func removeProfiles(withIds profileIds: [Profile.ID]) async throws { + var removedIds: Set = [] + profileIds.forEach { + do { + try keychain.removePassword(for: $0.uuidString) + removedIds.insert($0) + } catch { + pspLog(.core, .error, "Unable to remove profile \($0) from keychain: \(error)") + } + } + var allProfiles = profilesSubject.value + // Only skip profiles that were actually removed + allProfiles.removeAll { + removedIds.contains($0.id) + } + profilesSubject.send(allProfiles) + eventsSubject.send(.changes(profileIds.map { + .remove($0) + })) + } + + public func removeAllProfiles() async throws { + assertionFailure("Refusing to bulk remove sensitive data") + } +} diff --git a/partout b/partout index 9d3c67004..871106011 160000 --- a/partout +++ b/partout @@ -1 +1 @@ -Subproject commit 9d3c670045730e15a85ad1764628c4a2a62aed67 +Subproject commit 8711060111903689f7f57d3220f7fe8f32bbcce5 From 74dd03b7048f95538167f3924ef6fe7d79c05bb9 Mon Sep 17 00:00:00 2001 From: Davide Date: Tue, 4 Aug 2026 16:20:40 +0200 Subject: [PATCH 04/13] Use new strategy in App Store --- .../App/Context/AppContext+Production.swift | 30 ++++++++++- .../Tunnel/TunnelContext+Production.swift | 2 +- .../AppLibrary/Observables/AppContext.swift | 9 ++++ .../AppConfiguration+Dependencies.swift | 50 +++++++++++++++---- .../Business/ProfileManager.swift | 4 ++ 5 files changed, 83 insertions(+), 12 deletions(-) diff --git a/app-apple/Passepartout/App/Context/AppContext+Production.swift b/app-apple/Passepartout/App/Context/AppContext+Production.swift index 8556952e0..d4f41f7cb 100644 --- a/app-apple/Passepartout/App/Context/AppContext+Production.swift +++ b/app-apple/Passepartout/App/Context/AppContext+Production.swift @@ -167,8 +167,34 @@ extension AppContext { ) let backupProfileRepository: ProfileRepository? = nil #else - let tunnelStrategy = appConfiguration.newNETunnelStrategy(ctx, coder: registry) - let mainProfileRepository = NEProfileRepository(repository: tunnelStrategy) + let mainProfileRepository: ProfileRepository + let tunnelStrategy: TunnelObservableStrategy + if distributionTarget.supportsAppGroups { + let keychain = AppleKeychain( + ctx, + group: appConfiguration.bundle.bundleString(for: .keychainGroupId) + ) + let keychainRepository = KeychainProfileRepository( + keychain: keychain, + coder: registry, + label: appConfiguration.newKeychainTitle() + ) + let newStrategy = appConfiguration.newNETunnelStrategy( + ctx, + coder: registry, + source: keychainRepository.eventsPublisher + ) + mainProfileRepository = keychainRepository + tunnelStrategy = newStrategy + } else { + let legacyStrategy = appConfiguration.legacyNETunnelStrategy( + ctx, + coder: registry + ) + let neRepository = NEProfileRepository(repository: legacyStrategy) + mainProfileRepository = neRepository + tunnelStrategy = legacyStrategy + } let backupProfileRepository = appConfiguration.newBackupProfileRepositoryV2( encoder: appEncoder, model: cdRemoteModel, diff --git a/app-apple/Passepartout/Tunnel/TunnelContext+Production.swift b/app-apple/Passepartout/Tunnel/TunnelContext+Production.swift index 620ebc1cb..c1cda654a 100644 --- a/app-apple/Passepartout/Tunnel/TunnelContext+Production.swift +++ b/app-apple/Passepartout/Tunnel/TunnelContext+Production.swift @@ -66,7 +66,7 @@ extension TunnelContext { // Decode profile from NE provider do { - let decoder = appConfiguration.newNEProtocolCoder(.global, coder: registry) + let decoder = appConfiguration.newNEProtocolCoder(.global, coder: registry, legacy: false) originalProfile = try Profile(withNEProvider: neProvider, decoder: decoder) let resolvedProfile = try registry.resolvedProfile(originalProfile) let processor = appConfiguration.newTunnelProcessor() diff --git a/app-apple/Sources/AppLibrary/Observables/AppContext.swift b/app-apple/Sources/AppLibrary/Observables/AppContext.swift index a89cbbcb8..4c205878f 100644 --- a/app-apple/Sources/AppLibrary/Observables/AppContext.swift +++ b/app-apple/Sources/AppLibrary/Observables/AppContext.swift @@ -313,6 +313,13 @@ private extension AppContext { await iapManager.reloadReceipt() didLoadReceiptDate = Date() } + + // Re-fetch local profiles to sync with external changes + do { + try await profileManager.refreshLocalProfiles() + } catch { + pspLog(.core, .error, "Unable to refresh local profiles: \(error)") + } } await pendingTask?.value pendingTask = nil @@ -381,10 +388,12 @@ private extension AppContext { do { try await onLaunch() } catch { + pspLog(.core, .fault, "Unable to launch: \(error)") launchTask = nil // Redo the launch task. throw ABI.AppError.couldNotLaunch(reason: error) } } + // FIXME: ###, Shouldn't this be returned by launchTask? didLaunch = true } diff --git a/app-apple/Sources/CommonLibrary/Dependencies/AppConfiguration+Dependencies.swift b/app-apple/Sources/CommonLibrary/Dependencies/AppConfiguration+Dependencies.swift index 40160fc09..02b7c82d1 100644 --- a/app-apple/Sources/CommonLibrary/Dependencies/AppConfiguration+Dependencies.swift +++ b/app-apple/Sources/CommonLibrary/Dependencies/AppConfiguration+Dependencies.swift @@ -374,6 +374,12 @@ extension ABI.AppConfiguration { StoreKitReceiptReader(modeBlock: modeBlock) } + public func newKeychainTitle() -> @Sendable (Profile) -> String { + { + String(format: constants.tunnel.profileTitleFormat, $0.name) + } + } + public func newLogFormatter() -> LogFormatter? { FoundationLogFormatter( dateFormat: constants.log.formatter.timestamp, @@ -381,31 +387,57 @@ extension ABI.AppConfiguration { ) } - public func newNEProtocolCoder(_ ctx: PartoutLoggerContext, coder: ProfileCoder) -> NEProtocolCoder { + public func newNEProtocolCoder( + _ ctx: PartoutLoggerContext, + coder: ProfileCoder, + legacy: Bool + ) -> NEProtocolCoder { let tunnelIdentifier = bundle.bundleString(for: .tunnelId) if bundle.distributionTarget.supportsAppGroups { return KeychainNEProtocolCoder( ctx, tunnelBundleIdentifier: tunnelIdentifier, coder: coder, - keychain: AppleKeychain(ctx, group: bundle.bundleString(for: .keychainGroupId)) + keychain: AppleKeychain(ctx, group: bundle.bundleString(for: .keychainGroupId)), + legacyOptions: legacy ? .init(title: newKeychainTitle()) : nil ) } else { return ProviderNEProtocolCoder( ctx, tunnelBundleIdentifier: tunnelIdentifier, - coder: coder + coder: coder, + uid: Int(getuid()) ) } } - public func newNETunnelStrategy(_ ctx: PartoutLoggerContext, coder: ProfileCoder) -> NETunnelStrategy { - NETunnelStrategy( + public func legacyNETunnelStrategy( + _ ctx: PartoutLoggerContext, + coder: ProfileCoder + ) -> LegacyNETunnelStrategy { + let bundleIdentifier = bundle.bundleString(for: .tunnelId) + let protocolCoder = newNEProtocolCoder(ctx, coder: coder, legacy: true) + return LegacyNETunnelStrategy( + ctx, + bundleIdentifier: bundleIdentifier, + coder: protocolCoder + ) + } + + public func newNETunnelStrategy( + _ ctx: PartoutLoggerContext, + coder: ProfileCoder, + source: AsyncStream + ) -> NETunnelStrategy { + let bundleIdentifier = bundle.bundleString(for: .tunnelId) + let protocolCoder = newNEProtocolCoder(ctx, coder: coder, legacy: false) + return NETunnelStrategy( ctx, - bundleIdentifier: bundle.bundleString(for: .tunnelId), - coder: newNEProtocolCoder(ctx, coder: coder), - title: { - String(format: constants.tunnel.profileTitleFormat, $0.name) + bundleIdentifier: bundleIdentifier, + source: source, + coder: protocolCoder, + fingerprint: { + ($0.attributes.fingerprint ?? UUID())?.uuidString } ) } diff --git a/app-apple/Sources/CommonLibraryCore/Business/ProfileManager.swift b/app-apple/Sources/CommonLibraryCore/Business/ProfileManager.swift index 887ecdd59..779be03f9 100644 --- a/app-apple/Sources/CommonLibraryCore/Business/ProfileManager.swift +++ b/app-apple/Sources/CommonLibraryCore/Business/ProfileManager.swift @@ -102,6 +102,10 @@ extension ProfileManager { self.isRemoteImportingEnabled = isRemoteImportingEnabled } + public func refreshLocalProfiles() async throws { + try await repository.fetchProfiles() + } + public func save(_ originalProfile: Profile, isLocal: Bool = false, remotelyShared: Bool? = nil) async throws { let profile: Profile if isLocal { From c02ca4ea998ca00a4d999f0e587285cf10494cb1 Mon Sep 17 00:00:00 2001 From: Davide Date: Fri, 31 Jul 2026 00:29:52 +0200 Subject: [PATCH 05/13] Fix models path --- scripts/gen-models.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/gen-models.sh b/scripts/gen-models.sh index be729872a..aec7a1628 100755 --- a/scripts/gen-models.sh +++ b/scripts/gen-models.sh @@ -27,7 +27,7 @@ generate_partout_models() { generate_swift_models() { infile=scripts/openapi.yaml - models_dir=`realpath app-shared/Sources/CommonLibraryCore/Domain` + models_dir=`realpath app-apple/Sources/CommonLibraryCore/Domain` models_tmp=$models_dir/tmp models_out=$models_tmp/Sources/OpenAPIClient/Models models_gen=$models_dir/Codegen From dc2c8d13538cfa8e38f5c5a37bf5674552222ebb Mon Sep 17 00:00:00 2001 From: Davide Date: Tue, 4 Aug 2026 16:32:34 +0200 Subject: [PATCH 06/13] Delete outdated tests --- .../Strategy/FileProfileRepositoryTests.swift | 77 ------------------- 1 file changed, 77 deletions(-) delete mode 100644 app-apple/Tests/CommonLibraryTests/Strategy/FileProfileRepositoryTests.swift diff --git a/app-apple/Tests/CommonLibraryTests/Strategy/FileProfileRepositoryTests.swift b/app-apple/Tests/CommonLibraryTests/Strategy/FileProfileRepositoryTests.swift deleted file mode 100644 index 1e2b7918c..000000000 --- a/app-apple/Tests/CommonLibraryTests/Strategy/FileProfileRepositoryTests.swift +++ /dev/null @@ -1,77 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Davide De Rosa -// -// SPDX-License-Identifier: GPL-3.0 - -@testable import CommonLibraryCore -import Foundation -import Partout -import Testing - -struct FileProfileRepositoryTests { - @Test - func givenRepository_whenSaveAndReload_thenPersistsProfiles() async throws { - let directoryURL = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString, isDirectory: true) - defer { - try? FileManager.default.removeItem(at: directoryURL) - } - - let firstProfile = newProfile("alpha") - let secondProfile = newProfile("beta") - let repository = try FileProfileRepository(directoryURL: directoryURL) - - let publishedProfiles = repository.profilesPublisher - let exp = Expectation() - Task { - var isFirstEvent = true - for await profiles in publishedProfiles { - if isFirstEvent { - isFirstEvent = false - continue - } - if profiles.map(\.id) == [firstProfile.id, secondProfile.id] { - await exp.fulfill() - return - } - } - } - - try await repository.saveProfile(secondProfile) - try await repository.saveProfile(firstProfile) - try await exp.fulfillment(timeout: CommonLibraryTests.timeout) - - let reloadedRepository = try FileProfileRepository(directoryURL: directoryURL) - let profiles = try await reloadedRepository.fetchProfiles() - - #expect(profiles.count == 2) - #expect(profiles.map(\.id) == [firstProfile.id, secondProfile.id]) - #expect( - FileManager.default.fileExists( - atPath: directoryURL.appendingPathComponent("index.json").path - ) - ) - #expect( - FileManager.default.fileExists( - atPath: directoryURL - .appendingPathComponent("objects", isDirectory: true) - .appendingPathComponent("\(firstProfile.id.uuidString).json") - .path - ) - ) - } -} - -private extension FileProfileRepositoryTests { - func newProfile(_ name: String = "", id: UniqueID? = nil, fingerprint: UniqueID? = nil) -> Profile { - do { - var builder = Profile.Builder(id: id ?? UniqueID()) - builder.name = name - if let fingerprint { - builder.attributes.fingerprint = fingerprint - } - return try builder.build() - } catch { - fatalError(error.localizedDescription) - } - } -} From 25b8999eb64eecc86e201bec77090f7832c37b90 Mon Sep 17 00:00:00 2001 From: Davide Date: Tue, 4 Aug 2026 16:36:05 +0200 Subject: [PATCH 07/13] Fix warnings --- .../Strategy/KeychainProfileRepository.swift | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/app-apple/Sources/CommonLibraryCore/Strategy/KeychainProfileRepository.swift b/app-apple/Sources/CommonLibraryCore/Strategy/KeychainProfileRepository.swift index 5182d040c..4e81ed0ff 100644 --- a/app-apple/Sources/CommonLibraryCore/Strategy/KeychainProfileRepository.swift +++ b/app-apple/Sources/CommonLibraryCore/Strategy/KeychainProfileRepository.swift @@ -83,12 +83,8 @@ public final class KeychainProfileRepository: ProfileRepository { public func removeProfiles(withIds profileIds: [Profile.ID]) async throws { var removedIds: Set = [] profileIds.forEach { - do { - try keychain.removePassword(for: $0.uuidString) - removedIds.insert($0) - } catch { - pspLog(.core, .error, "Unable to remove profile \($0) from keychain: \(error)") - } + keychain.removePassword(for: $0.uuidString) + removedIds.insert($0) } var allProfiles = profilesSubject.value // Only skip profiles that were actually removed From b34629e3efd2c5d153d6d17da59e8915f3b24890 Mon Sep 17 00:00:00 2001 From: Davide Date: Tue, 4 Aug 2026 16:47:21 +0200 Subject: [PATCH 08/13] Make discardable --- .../Sources/CommonLibraryCore/Business/ProfileManager.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app-apple/Sources/CommonLibraryCore/Business/ProfileManager.swift b/app-apple/Sources/CommonLibraryCore/Business/ProfileManager.swift index 779be03f9..af82621ef 100644 --- a/app-apple/Sources/CommonLibraryCore/Business/ProfileManager.swift +++ b/app-apple/Sources/CommonLibraryCore/Business/ProfileManager.swift @@ -102,7 +102,8 @@ extension ProfileManager { self.isRemoteImportingEnabled = isRemoteImportingEnabled } - public func refreshLocalProfiles() async throws { + @discardableResult + public func refreshLocalProfiles() async throws -> [Profile] { try await repository.fetchProfiles() } From 0520db86c33464b6db7cfb4b0593ef011efc63f9 Mon Sep 17 00:00:00 2001 From: Davide Date: Tue, 4 Aug 2026 17:12:33 +0200 Subject: [PATCH 09/13] Only emit confirmed removals --- .../Strategy/KeychainProfileRepository.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app-apple/Sources/CommonLibraryCore/Strategy/KeychainProfileRepository.swift b/app-apple/Sources/CommonLibraryCore/Strategy/KeychainProfileRepository.swift index 4e81ed0ff..0a846876c 100644 --- a/app-apple/Sources/CommonLibraryCore/Strategy/KeychainProfileRepository.swift +++ b/app-apple/Sources/CommonLibraryCore/Strategy/KeychainProfileRepository.swift @@ -83,7 +83,7 @@ public final class KeychainProfileRepository: ProfileRepository { public func removeProfiles(withIds profileIds: [Profile.ID]) async throws { var removedIds: Set = [] profileIds.forEach { - keychain.removePassword(for: $0.uuidString) + guard keychain.removePassword(for: $0.uuidString) else { return } removedIds.insert($0) } var allProfiles = profilesSubject.value @@ -92,7 +92,7 @@ public final class KeychainProfileRepository: ProfileRepository { removedIds.contains($0.id) } profilesSubject.send(allProfiles) - eventsSubject.send(.changes(profileIds.map { + eventsSubject.send(.changes(removedIds.map { .remove($0) })) } From 76c97cabcfd588b02c256701469056243dcb51cf Mon Sep 17 00:00:00 2001 From: Davide Date: Tue, 4 Aug 2026 17:13:32 +0200 Subject: [PATCH 10/13] Fall back to profile ID as fingerprint --- .../Dependencies/AppConfiguration+Dependencies.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app-apple/Sources/CommonLibrary/Dependencies/AppConfiguration+Dependencies.swift b/app-apple/Sources/CommonLibrary/Dependencies/AppConfiguration+Dependencies.swift index 02b7c82d1..c8d54089a 100644 --- a/app-apple/Sources/CommonLibrary/Dependencies/AppConfiguration+Dependencies.swift +++ b/app-apple/Sources/CommonLibrary/Dependencies/AppConfiguration+Dependencies.swift @@ -437,7 +437,7 @@ extension ABI.AppConfiguration { source: source, coder: protocolCoder, fingerprint: { - ($0.attributes.fingerprint ?? UUID())?.uuidString + ($0.attributes.fingerprint ?? $0.id)?.uuidString } ) } From 0e2f41c2a17bcf172fc50dc4e36953ba20dff3fd Mon Sep 17 00:00:00 2001 From: Davide Date: Tue, 4 Aug 2026 17:20:04 +0200 Subject: [PATCH 11/13] Test keychain repo --- .../KeychainProfileRepositoryTests.swift | 245 ++++++++++++++++++ 1 file changed, 245 insertions(+) create mode 100644 app-apple/Tests/CommonLibraryTests/Strategy/KeychainProfileRepositoryTests.swift diff --git a/app-apple/Tests/CommonLibraryTests/Strategy/KeychainProfileRepositoryTests.swift b/app-apple/Tests/CommonLibraryTests/Strategy/KeychainProfileRepositoryTests.swift new file mode 100644 index 000000000..ffd865812 --- /dev/null +++ b/app-apple/Tests/CommonLibraryTests/Strategy/KeychainProfileRepositoryTests.swift @@ -0,0 +1,245 @@ +// SPDX-FileCopyrightText: 2026 Davide De Rosa +// +// SPDX-License-Identifier: GPL-3.0 + +@testable import CommonLibraryCore +import Foundation +import Partout +import Testing + +struct KeychainProfileRepositoryTests { + @Test + func fetchPublishesSnapshotAndSkipsMalformedItems() async throws { + let first = try makeProfile(name: "first") + let second = try makeProfile(name: "second") + let coder = makeCoder() + let keychain = MockProfileKeychain(passwords: [ + first.id.uuidString: try coder.string(fromProfile: first), + second.id.uuidString: try coder.string(fromProfile: second), + "malformed": "not-a-profile" + ]) + let sut = makeRepository(keychain: keychain, coder: coder) + var profilesIterator = sut.profilesPublisher.makeAsyncIterator() + var eventsIterator = sut.eventsPublisher.makeAsyncIterator() + + #expect(await profilesIterator.next()?.isEmpty == true) + + let fetched = try await sut.fetchProfiles() + let published = try #require(await profilesIterator.next()) + let event = try #require(await eventsIterator.next()) + + #expect(Set(fetched.map(\.id)) == [first.id, second.id]) + #expect(Set(published.map(\.id)) == [first.id, second.id]) + guard case .snapshot(let snapshot) = event else { + Issue.record("Expected a snapshot event") + return + } + #expect(Set(snapshot.map(\.id)) == [first.id, second.id]) + } + + @Test + func savePersistsMetadataAndPublishesUpsert() async throws { + let fingerprint = UniqueID() + let profile = try makeProfile(name: "saved", fingerprint: fingerprint) + let coder = makeCoder() + let keychain = MockProfileKeychain() + let sut = makeRepository(keychain: keychain, coder: coder) + var profilesIterator = sut.profilesPublisher.makeAsyncIterator() + var eventsIterator = sut.eventsPublisher.makeAsyncIterator() + + #expect(await profilesIterator.next()?.isEmpty == true) + + try await sut.saveProfile(profile) + let published = try #require(await profilesIterator.next()) + let event = try #require(await eventsIterator.next()) + let setCall = try #require(keychain.setCalls.first) + + #expect(published == [profile]) + #expect(setCall.username == profile.id.uuidString) + #expect(setCall.label == "VPN: \(profile.name)") + #expect(setCall.comment == fingerprint.uuidString) + #expect(try coder.profile(fromString: setCall.password) == profile) + guard case .changes(let changes) = event, + changes.count == 1, + case .upsert(let upserted) = changes[0] else { + Issue.record("Expected one upsert event") + return + } + #expect(upserted == profile) + } + + @Test + func removePublishesOnlyConfirmedRemovals() async throws { + let removed = try makeProfile(name: "removed") + let retained = try makeProfile(name: "retained") + let coder = makeCoder() + let keychain = MockProfileKeychain( + passwords: [ + removed.id.uuidString: try coder.string(fromProfile: removed), + retained.id.uuidString: try coder.string(fromProfile: retained) + ], + failedRemovals: [retained.id.uuidString] + ) + let sut = makeRepository(keychain: keychain, coder: coder) + var profilesIterator = sut.profilesPublisher.makeAsyncIterator() + var eventsIterator = sut.eventsPublisher.makeAsyncIterator() + + #expect(await profilesIterator.next()?.isEmpty == true) + _ = try await sut.fetchProfiles() + _ = await profilesIterator.next() + _ = await eventsIterator.next() + + try await sut.removeProfiles(withIds: [removed.id, retained.id]) + let published = try #require(await profilesIterator.next()) + let event = try #require(await eventsIterator.next()) + + #expect(published == [retained]) + #expect(!keychain.contains(username: removed.id.uuidString)) + #expect(keychain.contains(username: retained.id.uuidString)) + guard case .changes(let changes) = event, + changes.count == 1, + case .remove(let removedId) = changes[0] else { + Issue.record("Expected one confirmed removal event") + return + } + #expect(removedId == removed.id) + } +} + +private extension KeychainProfileRepositoryTests { + func makeCoder() -> CodingRegistry { + CodingRegistry(registry: Registry(withKnown: true)) + } + + func makeRepository( + keychain: Keychain, + coder: ProfileCoder + ) -> KeychainProfileRepository { + KeychainProfileRepository( + keychain: keychain, + coder: coder, + label: { "VPN: \($0.name)" } + ) + } + + func makeProfile( + name: String, + fingerprint: UniqueID = UniqueID() + ) throws -> Profile { + var builder = Profile.Builder(name: name) + builder.attributes.fingerprint = fingerprint + return try builder.build() + } +} + +private final class MockProfileKeychain: Keychain, @unchecked Sendable { + struct SetCall: Sendable { + let password: String + let username: String + let label: String? + let comment: String? + } + + private let lock = NSLock() + private var passwords: [String: String] + private let failedRemovals: Set + private var mutableSetCalls: [SetCall] = [] + + init( + passwords: [String: String] = [:], + failedRemovals: Set = [] + ) { + self.passwords = passwords + self.failedRemovals = failedRemovals + } + + var setCalls: [SetCall] { + lock.withLock { mutableSetCalls } + } + + func contains(username: String) -> Bool { + lock.withLock { passwords[username] != nil } + } + + func set( + password: String, + for username: String, + metadata: [KeychainMetadata]? + ) throws -> Data { + var label: String? + var comment: String? + metadata?.forEach { + switch $0 { + case .label(let value): + label = value + case .comment(let value): + comment = value + } + } + lock.withLock { + passwords[username] = password + mutableSetCalls.append(SetCall( + password: password, + username: username, + label: label, + comment: comment + )) + } + return reference(for: username) + } + + func removePassword(for username: String) -> Bool { + lock.withLock { + guard !failedRemovals.contains(username) else { return false } + return passwords.removeValue(forKey: username) != nil + } + } + + func removePassword(forReference reference: Data) -> Bool { + guard let username = username(from: reference) else { return false } + return removePassword(for: username) + } + + func password(for username: String) throws -> String { + try lock.withLock { + guard let password = passwords[username] else { + throw PartoutError(.keychainItemNotFound) + } + return password + } + } + + func passwordReference(for username: String) throws -> Data { + try lock.withLock { + guard passwords[username] != nil else { + throw PartoutError(.keychainItemNotFound) + } + } + return reference(for: username) + } + + func allPasswordReferences() throws -> [Data] { + lock.withLock { + passwords.keys.map(reference(for:)) + } + } + + func password(forReference reference: Data) throws -> String { + guard let username = username(from: reference) else { + throw PartoutError(.decoding) + } + return try password(for: username) + } + + private func reference(for username: String) -> Data { + Data("reference:\(username)".utf8) + } + + private func username(from reference: Data) -> String? { + guard let value = String(data: reference, encoding: .utf8), + value.hasPrefix("reference:") else { + return nil + } + return String(value.dropFirst("reference:".count)) + } +} From 51f9559e36434f0b8770a76870ee91fa74d7a253 Mon Sep 17 00:00:00 2001 From: Davide Date: Tue, 4 Aug 2026 17:27:16 +0200 Subject: [PATCH 12/13] [ci skip] Mark FIXME --- app-apple/Sources/AppLibrary/Observables/AppContext.swift | 2 +- .../CommonLibraryCore/Strategy/KeychainProfileRepository.swift | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/app-apple/Sources/AppLibrary/Observables/AppContext.swift b/app-apple/Sources/AppLibrary/Observables/AppContext.swift index 4c205878f..0ba4fef75 100644 --- a/app-apple/Sources/AppLibrary/Observables/AppContext.swift +++ b/app-apple/Sources/AppLibrary/Observables/AppContext.swift @@ -224,6 +224,7 @@ private extension AppContext { // MARK: - Internal lifecycle private extension AppContext { + // FIXME: #1909, Any failure here will break AppContext.onLaunch() irreparably with .couldNotLaunch. Double check that the launch task is reattempted after a new foreground event. func onLaunch() async throws { pspLog(.core, .notice, "Application did launch") @@ -393,7 +394,6 @@ private extension AppContext { throw ABI.AppError.couldNotLaunch(reason: error) } } - // FIXME: ###, Shouldn't this be returned by launchTask? didLaunch = true } diff --git a/app-apple/Sources/CommonLibraryCore/Strategy/KeychainProfileRepository.swift b/app-apple/Sources/CommonLibraryCore/Strategy/KeychainProfileRepository.swift index 0a846876c..c8ad3db95 100644 --- a/app-apple/Sources/CommonLibraryCore/Strategy/KeychainProfileRepository.swift +++ b/app-apple/Sources/CommonLibraryCore/Strategy/KeychainProfileRepository.swift @@ -31,7 +31,6 @@ public final class KeychainProfileRepository: ProfileRepository { eventsSubject = PassthroughStream() } - // FIXME: ###, Failures here might break AppContext.onLaunch() irreparably with .couldNotLaunch, should double check public func fetchProfiles() async throws -> [Profile] { let profiles = try keychain .allPasswordReferences() From b9e1b0d2f87af4704f5b58b72f1e4ccc0e6b57b3 Mon Sep 17 00:00:00 2001 From: Davide Date: Tue, 4 Aug 2026 17:35:27 +0200 Subject: [PATCH 13/13] [ci skip] --- app-apple/Sources/AppLibrary/Observables/AppContext.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/app-apple/Sources/AppLibrary/Observables/AppContext.swift b/app-apple/Sources/AppLibrary/Observables/AppContext.swift index 0ba4fef75..e03b15e6c 100644 --- a/app-apple/Sources/AppLibrary/Observables/AppContext.swift +++ b/app-apple/Sources/AppLibrary/Observables/AppContext.swift @@ -224,7 +224,6 @@ private extension AppContext { // MARK: - Internal lifecycle private extension AppContext { - // FIXME: #1909, Any failure here will break AppContext.onLaunch() irreparably with .couldNotLaunch. Double check that the launch task is reattempted after a new foreground event. func onLaunch() async throws { pspLog(.core, .notice, "Application did launch")