From 22f09eaeee4dcb6293d8fe2a599dff8e32e844fb Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Thu, 1 Oct 2026 08:41:46 +0800 Subject: [PATCH] feat(diagnosis): add opt-in Firebase diagnostic data reporting --- FrameworkTests/DiagnosisTests.swift | 195 ++++++++++++++++++ Frameworks/Resources/Localizable.xcstrings | 184 +++++++++++++++++ Frameworks/Resources/PrivacyInfo.xcprivacy | 39 +++- .../FeatureModels/DiagnosisController.swift | 33 +++ .../TermindApplication/SettingsKey.swift | 4 + .../FirebaseDiagnosticDataReporter.swift | 85 ++++++++ .../DiagnosisConsentView.swift | 93 +++++++++ .../TermindPresentation/Strings/Common.swift | 4 + .../Strings/Diagnosis.swift | 22 ++ TFI/App/MobileAppDependencies.swift | 2 + TFI/App/MobileAppModel.swift | 2 + TFI/App/MobileStartupView.swift | 12 +- .../Settings/MobileAppSettingsView.swift | 2 + .../Settings/MobileSettingsView.swift | 3 +- TFI/Info.plist | 6 + TFM/App/AppModel.swift | 3 + TFM/App/TermindApp.swift | 26 ++- TFM/Features/Settings/SettingsPageView.swift | 1 + TFM/Info.plist | 6 + TFMTests/ArchitectureBoundaryTests.swift | 16 ++ TFMTests/DiagnosisConsentRenderingTests.swift | 41 ++++ Termind.xcodeproj/project.pbxproj | 104 ++++++++++ .../xcshareddata/swiftpm/Package.resolved | 119 ++++++++++- 23 files changed, 989 insertions(+), 13 deletions(-) create mode 100644 FrameworkTests/DiagnosisTests.swift create mode 100644 Frameworks/TermindApplication/FeatureModels/DiagnosisController.swift create mode 100644 Frameworks/TermindInfrastructure/Diagnostics/FirebaseDiagnosticDataReporter.swift create mode 100644 Frameworks/TermindPresentation/DiagnosisConsentView.swift create mode 100644 Frameworks/TermindPresentation/Strings/Diagnosis.swift create mode 100644 TFMTests/DiagnosisConsentRenderingTests.swift diff --git a/FrameworkTests/DiagnosisTests.swift b/FrameworkTests/DiagnosisTests.swift new file mode 100644 index 00000000..872850bf --- /dev/null +++ b/FrameworkTests/DiagnosisTests.swift @@ -0,0 +1,195 @@ +import Foundation +import TermindApplication +import TermindInfrastructure +import Testing + +@Suite @MainActor +struct DiagnosisTests { + @Test + func firstLaunchWaitsForAnExplicitChoice() async throws { + try await withDefaults { defaults in + let reporter = RecordingReporter() + let controller = DiagnosisController(defaults: defaults, reporter: reporter) + #expect(controller.needsConsent) + #expect(!controller.isEnabled) + #expect(controller.consent == nil) + #expect(defaults.object(forKey: SettingsKey.diagnosisConsent) == nil) + #expect(reporter.values == [false]) + } + } + + @Test(arguments: [false, true]) + func bothChoicesPersistWithoutAskingAgain(enabled: Bool) async throws { + try await withDefaults { defaults in + let reporter = RecordingReporter() + let controller = DiagnosisController(defaults: defaults, reporter: reporter) + controller.setEnabled(enabled) + controller.setEnabled(enabled) + #expect(!controller.needsConsent) + #expect(controller.isEnabled == enabled) + #expect(reporter.values == [false, enabled]) + let nextLaunch = DiagnosisController(defaults: defaults, reporter: reporter) + #expect(!nextLaunch.needsConsent) + #expect(nextLaunch.consent == enabled) + #expect(reporter.values == [false, enabled, enabled]) + } + } + + @Test + func revocationPersistsBeforeApplyingItToTheSDK() async throws { + try await withDefaults { defaults in + defaults.set(true, forKey: "diagnostics.crashReportingConsent") + let reporter = RecordingReporter() + let controller = DiagnosisController(defaults: defaults, reporter: reporter) + reporter.didChange = { enabled in + #expect(defaults.object(forKey: SettingsKey.diagnosisConsent) as? Bool == enabled) + } + controller.setEnabled(false) + #expect(reporter.values == [true, false]) + #expect(!DiagnosisController(defaults: defaults).needsConsent) + } + } + + @Test + func testAndPreviewControllersDoNotPresentConsent() { + #expect(!DiagnosisController().needsConsent) + #expect(!DiagnosisController().isEnabled) + } + + @Test + func unknownAndDeclinedConsentNeverInitializeFirebase() async throws { + try await withDefaults { defaults in + var initializations = 0 + let reporter = FirebaseDiagnosticDataReporter(defaults: defaults) { + initializations += 1 + return nil + } + let controller = DiagnosisController(defaults: defaults, reporter: reporter) + controller.setEnabled(false) + #expect(initializations == 0) + controller.setEnabled(true) + #expect(initializations == 1) + // A missing developer configuration does not lose the user's choice or block startup. + #expect(controller.isEnabled && !controller.needsConsent) + } + } + + @Test + func optedInLaunchSendsPendingDiagnosticDataOnce() async throws { + try await withDefaults { defaults in + let client = DeferredDiagnosticDataClient() + let reporter = FirebaseDiagnosticDataReporter(defaults: defaults) { client } + reporter.setEnabled(true) + await client.waitForCheck() + client.finishCheck(hasData: true) + await client.waitForAction() + reporter.setEnabled(true) + #expect(client.sends == 1) + #expect(client.deletes == 0) + #expect(client.checks == 1) + } + } + + @Test + func consentRevokedDuringSDKCheckDeletesInsteadOfUploading() async throws { + try await withDefaults { defaults in + let client = DeferredDiagnosticDataClient() + let reporter = FirebaseDiagnosticDataReporter(defaults: defaults) { client } + reporter.setEnabled(true) + await client.waitForCheck() + reporter.setEnabled(false) + client.finishCheck(hasData: true) + await client.waitForAction() + #expect(client.sends == 0) + #expect(client.deletes == 1) + } + } + + @Test + func optingBackInDiscardsDiagnosticDataFromBeforeConsentWasRestored() async throws { + try await withDefaults { defaults in + let previousLaunch = FirebaseDiagnosticDataReporter(defaults: defaults) { nil } + previousLaunch.setEnabled(false) + #expect(defaults.bool(forKey: "diagnostics.discardPendingCrashReports")) + let client = DeferredDiagnosticDataClient() + let reporter = FirebaseDiagnosticDataReporter(defaults: defaults) { client } + reporter.setEnabled(true) + await client.waitForCheck() + client.finishCheck(hasData: true) + await client.waitForAction() + #expect(client.sends == 0 && client.deletes == 1) + + let nextClient = DeferredDiagnosticDataClient() + let nextLaunch = FirebaseDiagnosticDataReporter(defaults: defaults) { nextClient } + nextLaunch.setEnabled(true) + await nextClient.waitForCheck() + nextClient.finishCheck(hasData: true) + await nextClient.waitForAction() + #expect(nextClient.sends == 1 && nextClient.deletes == 0) + } + } + + private func withDefaults(_ body: (UserDefaults) async throws -> Void) async throws { + let domain = "DiagnosisTests.\(UUID())" + let defaults = try #require(UserDefaults(suiteName: domain)) + defer { defaults.removePersistentDomain(forName: domain) } + try await body(defaults) + } +} + +@MainActor +private final class RecordingReporter: DiagnosticDataReporting { + var values: [Bool] = [] + var didChange: ((Bool) -> Void)? + + func setEnabled(_ enabled: Bool) { + values.append(enabled) + didChange?(enabled) + } +} + +@MainActor +private final class DeferredDiagnosticDataClient: DiagnosticDataClient { + var sends = 0 + var deletes = 0 + var checks = 0 + private var pendingCheck: CheckedContinuation? + private var checkStarted: CheckedContinuation? + private var actionFinished: CheckedContinuation? + + func hasUnsentDiagnosticData() async -> Bool { + checks += 1 + return await withCheckedContinuation { continuation in + pendingCheck = continuation + checkStarted?.resume() + checkStarted = nil + } + } + + func waitForCheck() async { + if pendingCheck != nil { return } + await withCheckedContinuation { checkStarted = $0 } + } + + func finishCheck(hasData: Bool) { + pendingCheck?.resume(returning: hasData) + pendingCheck = nil + } + + func sendUnsentDiagnosticData() { + sends += 1 + actionFinished?.resume() + actionFinished = nil + } + + func deleteUnsentDiagnosticData() { + deletes += 1 + actionFinished?.resume() + actionFinished = nil + } + + func waitForAction() async { + if sends + deletes > 0 { return } + await withCheckedContinuation { actionFinished = $0 } + } +} diff --git a/Frameworks/Resources/Localizable.xcstrings b/Frameworks/Resources/Localizable.xcstrings index 6ce6228d..27f4af09 100644 --- a/Frameworks/Resources/Localizable.xcstrings +++ b/Frameworks/Resources/Localizable.xcstrings @@ -3092,6 +3092,29 @@ } } }, + "Allow Termind to send diagnostic data to Google? This includes crash details, app and operating system versions, device information, and diagnostic identifiers." : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Allow Termind to send diagnostic data to Google? This includes crash details, app and operating system versions, device information, and diagnostic identifiers." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "是否允许Termind向Google发送诊断数据?这些数据包含崩溃详情、应用和操作系统版本、设备信息及诊断标识符。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "是否允許Termind向Google傳送診斷資料?這些資料包含當機詳情、App與作業系統版本、裝置資訊及診斷識別碼。" + } + } + } + }, "Allow Termind to use Speech Recognition in Settings, then try again." : { "extractionState" : "manual", "localizations" : { @@ -9547,6 +9570,29 @@ } } }, + "Diagnosis" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Diagnosis" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "诊断" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "診斷" + } + } + } + }, "Did not run “%@”" : { "extractionState" : "manual", "localizations" : { @@ -9981,6 +10027,29 @@ } } }, + "Don't Send" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Don't Send" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "不发送" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "不要傳送" + } + } + } + }, "Done" : { "extractionState" : "manual", "localizations" : { @@ -13148,6 +13217,29 @@ } } }, + "Help make Termind more reliable" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Help make Termind more reliable" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "帮助提升Termind的稳定性" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "協助提升Termind的穩定性" + } + } + } + }, "Hermes Memory" : { "extractionState" : "manual", "localizations" : { @@ -24850,6 +24942,29 @@ } } }, + "Send Diagnostic Data" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Send Diagnostic Data" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "发送诊断数据" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "傳送診斷資料" + } + } + } + }, "Send Speed" : { "extractionState" : "manual", "localizations" : { @@ -24890,6 +25005,29 @@ } } }, + "Send diagnostic data to Google Firebase Crashlytics to help make Termind more reliable." : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Send diagnostic data to Google Firebase Crashlytics to help make Termind more reliable." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "向Google Firebase Crashlytics发送诊断数据以帮助提升Termind的稳定性。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "向Google Firebase Crashlytics傳送診斷資料以協助提升Termind的穩定性。" + } + } + } + }, "Send — ⌘⏎" : { "extractionState" : "manual", "localizations" : { @@ -27468,6 +27606,29 @@ } } }, + "Termind does not attach terminal output, chat content, passwords, or private keys to diagnostic data." : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Termind does not attach terminal output, chat content, passwords, or private keys to diagnostic data." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Termind不会在诊断数据中附加终端输出、聊天内容、密码或私钥。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "Termind不會在診斷資料中附加終端輸出、聊天內容、密碼或私鑰。" + } + } + } + }, "Termind returns only a one-shot handle to the MCP client. The value is bound to this session, destination, ssh_exec, and purpose." : { "extractionState" : "manual", "localizations" : { @@ -30529,6 +30690,29 @@ } } }, + "This is optional. Your choice applies to this device, and you can change it in Settings > App." : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This is optional. Your choice applies to this device, and you can change it in Settings > App." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此功能为可选项。您的选择仅适用于当前设备,可在“设置”>“应用”中更改。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "此功能為選用項目。您的選擇僅適用於目前裝置,可在「設定」>「App」中更改。" + } + } + } + }, "This key is not among the %lld trusted keys saved for %@. Verify it carefully before adding it." : { "extractionState" : "manual", "localizations" : { diff --git a/Frameworks/Resources/PrivacyInfo.xcprivacy b/Frameworks/Resources/PrivacyInfo.xcprivacy index d211d0bf..6483a74c 100644 --- a/Frameworks/Resources/PrivacyInfo.xcprivacy +++ b/Frameworks/Resources/PrivacyInfo.xcprivacy @@ -5,7 +5,44 @@ NSPrivacyTracking NSPrivacyCollectedDataTypes - + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeCrashData + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAppFunctionality + + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeOtherDiagnosticData + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAppFunctionality + + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeDeviceID + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAppFunctionality + + + NSPrivacyAccessedAPITypes diff --git a/Frameworks/TermindApplication/FeatureModels/DiagnosisController.swift b/Frameworks/TermindApplication/FeatureModels/DiagnosisController.swift new file mode 100644 index 00000000..6d04083d --- /dev/null +++ b/Frameworks/TermindApplication/FeatureModels/DiagnosisController.swift @@ -0,0 +1,33 @@ +import Foundation +import Observation + +@MainActor +public protocol DiagnosticDataReporting: AnyObject { + func setEnabled(_ enabled: Bool) +} + +/// Consent belongs to this installation, never to the synced Settings snapshot. +@MainActor @Observable +public final class DiagnosisController { + public private(set) var consent: Bool? + private let defaults: UserDefaults? + private let reporter: (any DiagnosticDataReporting)? + + public var needsConsent: Bool { defaults != nil && consent == nil } + public var isEnabled: Bool { consent == true } + + /// An unconfigured controller is inert for previews, screenshots and test hosts. + public init(defaults: UserDefaults? = nil, reporter: (any DiagnosticDataReporting)? = nil) { + self.defaults = defaults + self.reporter = reporter + consent = defaults?.object(forKey: SettingsKey.diagnosisConsent) as? Bool + reporter?.setEnabled(consent == true) + } + + public func setEnabled(_ enabled: Bool) { + guard consent != enabled else { return } + defaults?.set(enabled, forKey: SettingsKey.diagnosisConsent) + consent = enabled + reporter?.setEnabled(enabled) + } +} diff --git a/Frameworks/TermindApplication/SettingsKey.swift b/Frameworks/TermindApplication/SettingsKey.swift index dbb147ac..3b03a259 100644 --- a/Frameworks/TermindApplication/SettingsKey.swift +++ b/Frameworks/TermindApplication/SettingsKey.swift @@ -2,6 +2,10 @@ import Foundation import TermindDomain public nonisolated enum SettingsKey { + // Device-local privacy consent; absence means the user has not chosen yet. + // Preserve the original storage key so a terminology change does not reset consent. + public static let diagnosisConsent = "diagnostics.crashReportingConsent" + // Debug visibility and logging are device-local and never included in Settings sync. public static let debugSectionEnabled = "debug.section.enabled" public static let loggingSavingEnabled = "debug.logging.savingEnabled" diff --git a/Frameworks/TermindInfrastructure/Diagnostics/FirebaseDiagnosticDataReporter.swift b/Frameworks/TermindInfrastructure/Diagnostics/FirebaseDiagnosticDataReporter.swift new file mode 100644 index 00000000..76fa5518 --- /dev/null +++ b/Frameworks/TermindInfrastructure/Diagnostics/FirebaseDiagnosticDataReporter.swift @@ -0,0 +1,85 @@ +import FirebaseCore +import FirebaseCrashlytics +import Foundation +import TermindApplication + +/// A narrow SDK seam keeps consent and delayed report delivery testable without Firebase traffic. +@MainActor +public protocol DiagnosticDataClient: AnyObject { + func hasUnsentDiagnosticData() async -> Bool + func sendUnsentDiagnosticData() + func deleteUnsentDiagnosticData() +} + +@MainActor +public final class FirebaseDiagnosticDataReporter: DiagnosticDataReporting { + // Preserve the discard marker across terminology changes, just like consent. + private static let discardKey = "diagnostics.discardPendingCrashReports" + private let defaults: UserDefaults + private let makeClient: () -> (any DiagnosticDataClient)? + private var client: (any DiagnosticDataClient)? + private var isEnabled = false + + public init(defaults: UserDefaults, makeClient: @escaping () -> (any DiagnosticDataClient)?) { + self.defaults = defaults + self.makeClient = makeClient + } + + public static func live(defaults: UserDefaults = .standard, bundle: Bundle = .main) + -> FirebaseDiagnosticDataReporter + { + FirebaseDiagnosticDataReporter(defaults: defaults) { + // No Firebase app, installation registration or crash handler before consent. + guard let path = bundle.path(forResource: "GoogleService-Info", ofType: "plist"), + let options = FirebaseOptions(contentsOfFile: path), + options.bundleID == bundle.bundleIdentifier + else { return nil } + if FirebaseApp.app() == nil { + FirebaseApp.configure(options: options) + } + FirebaseApp.app()?.isDataCollectionDefaultEnabled = false + let crashlytics = Crashlytics.crashlytics() + // Never persist `true`: automatic startup upload would bypass a later revocation. + crashlytics.setCrashlyticsCollectionEnabled(false) + return FirebaseDiagnosticDataClient(crashlytics: crashlytics) + } + } + + public func setEnabled(_ enabled: Bool) { + isEnabled = enabled + guard enabled else { + // A crash after opting out must not be uploaded if consent is later granted again. + defaults.set(true, forKey: Self.discardKey) + return + } + guard client == nil, let client = makeClient() else { return } + self.client = client + Task { [weak self] in + let hasData = await client.hasUnsentDiagnosticData() + guard let self else { return } + // The user may revoke consent while the SDK checks the previous run's diagnostic data. + if !isEnabled || defaults.bool(forKey: Self.discardKey) { + client.deleteUnsentDiagnosticData() + if isEnabled { defaults.removeObject(forKey: Self.discardKey) } + } else if hasData { + client.sendUnsentDiagnosticData() + } + } + } +} + +@MainActor +private final class FirebaseDiagnosticDataClient: DiagnosticDataClient { + private let crashlytics: Crashlytics + + init(crashlytics: Crashlytics) { + self.crashlytics = crashlytics + } + + func hasUnsentDiagnosticData() async -> Bool { + await crashlytics.checkForUnsentReports() + } + + func sendUnsentDiagnosticData() { crashlytics.sendUnsentReports() } + func deleteUnsentDiagnosticData() { crashlytics.deleteUnsentReports() } +} diff --git a/Frameworks/TermindPresentation/DiagnosisConsentView.swift b/Frameworks/TermindPresentation/DiagnosisConsentView.swift new file mode 100644 index 00000000..48026f3c --- /dev/null +++ b/Frameworks/TermindPresentation/DiagnosisConsentView.swift @@ -0,0 +1,93 @@ +import SwiftUI +import TermindApplication + +public struct DiagnosisConsentView: View { + private let controller: DiagnosisController + + public init(controller: DiagnosisController) { + self.controller = controller + } + + public var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + Image(systemName: "heart.text.clipboard") + .font(.system(size: 48, weight: .light)) + .foregroundStyle(.tint) + .accessibilityHidden(true) + Text(AppStrings.Diagnosis.invitation) + .font(.largeTitle.bold()) + .fixedSize(horizontal: false, vertical: true) + .accessibilityAddTraits(.isHeader) + Text(AppStrings.Diagnosis.explanation) + Label { + Text(AppStrings.Diagnosis.privacy) + } icon: { + Image(systemName: "lock.shield") + } + Text(AppStrings.Diagnosis.choice) + .foregroundStyle(.secondary) + + Link(destination: Self.privacyURL) { + Text(AppStrings.Pro.privacyPolicy) + .underline() + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + + HStack { + declineAction + Spacer(minLength: 24) + allowButton + } + .controlSize(.large) + } + .padding(32) + .frame(maxWidth: 560, alignment: .leading) + .frame(maxWidth: .infinity) + } + .defaultScrollAnchor(.center, for: .alignment) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private static let privacyURL = URL(string: "https://termind.akinokaede.com/privacy/")! + + private var allowButton: some View { + Button(AppStrings.Common.allow.swiftUIKey) { + controller.setEnabled(true) + } + .buttonStyle(.borderedProminent) + .accessibilityIdentifier("diagnosis.allow") + } + + private var declineAction: some View { + Button(AppStrings.Diagnosis.decline.swiftUIKey) { + controller.setEnabled(false) + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + .accessibilityIdentifier("diagnosis.decline") + } +} + +public struct DiagnosisSettingsSection: View { + private let controller: DiagnosisController + + public init(controller: DiagnosisController) { + self.controller = controller + } + + public var body: some View { + Section { + Toggle( + AppStrings.Diagnosis.sendDiagnosticData.swiftUIKey, + isOn: Binding(get: { controller.isEnabled }, set: controller.setEnabled) + ) + .toggleStyle(.switch) + } header: { + Text(AppStrings.Diagnosis.title) + } footer: { + Text(AppStrings.Diagnosis.settingsExplanation) + } + } +} diff --git a/Frameworks/TermindPresentation/Strings/Common.swift b/Frameworks/TermindPresentation/Strings/Common.swift index c69793f6..2b1005de 100644 --- a/Frameworks/TermindPresentation/Strings/Common.swift +++ b/Frameworks/TermindPresentation/Strings/Common.swift @@ -14,6 +14,10 @@ extension AppStrings { "Add Hosts" } + public static var allow: LocalizedStringResource { + "Allow" + } + public static var back: LocalizedStringResource { "Back" } diff --git a/Frameworks/TermindPresentation/Strings/Diagnosis.swift b/Frameworks/TermindPresentation/Strings/Diagnosis.swift new file mode 100644 index 00000000..8488a12f --- /dev/null +++ b/Frameworks/TermindPresentation/Strings/Diagnosis.swift @@ -0,0 +1,22 @@ +import Foundation + +extension AppStrings { + public nonisolated enum Diagnosis { + public static var title: LocalizedStringResource { "Diagnosis" } + public static var invitation: LocalizedStringResource { "Help make Termind more reliable" } + public static var explanation: LocalizedStringResource { + "Allow Termind to send diagnostic data to Google? This includes crash details, app and operating system versions, device information, and diagnostic identifiers." + } + public static var privacy: LocalizedStringResource { + "Termind does not attach terminal output, chat content, passwords, or private keys to diagnostic data." + } + public static var choice: LocalizedStringResource { + "This is optional. Your choice applies to this device, and you can change it in Settings > App." + } + public static var decline: LocalizedStringResource { "Don't Send" } + public static var sendDiagnosticData: LocalizedStringResource { "Send Diagnostic Data" } + public static var settingsExplanation: LocalizedStringResource { + "Send diagnostic data to Google Firebase Crashlytics to help make Termind more reliable." + } + } +} diff --git a/TFI/App/MobileAppDependencies.swift b/TFI/App/MobileAppDependencies.swift index bdd314a6..6cf342ff 100644 --- a/TFI/App/MobileAppDependencies.swift +++ b/TFI/App/MobileAppDependencies.swift @@ -13,6 +13,7 @@ struct MobileAppDependencies { var workspaceConnections: MobileWorkspaceConnectionServices var metrics: MobileMetricsServices var appReview: AppReviewController + var diagnosis = DiagnosisController() var logging: any ApplicationLogManaging = DisabledApplicationLogManager.shared var connectionCoordinator: RemoteConnectionCoordinator = RemoteConnectionCoordinator() var container: ModelContainer? = nil @@ -34,6 +35,7 @@ struct MobileAppDependencies { workspaceConnections: .live(coordinator: coordinator), metrics: .live(coordinator: coordinator), appReview: .live(), + diagnosis: DiagnosisController(defaults: .standard, reporter: FirebaseDiagnosticDataReporter.live()), logging: logging, connectionCoordinator: coordinator, makeForwardings: { diff --git a/TFI/App/MobileAppModel.swift b/TFI/App/MobileAppModel.swift index f35d6359..3203abc0 100644 --- a/TFI/App/MobileAppModel.swift +++ b/TFI/App/MobileAppModel.swift @@ -140,6 +140,7 @@ final class MobileAppModel { let metrics: MobileMetricsModel let pro: ProAccessController let appReview: AppReviewController + let diagnosis: DiagnosisController let logging: any ApplicationLogManaging let liveActivities: MobileLiveActivityController @ObservationIgnored private var workspaceActivitySessions: [WorkspaceActivityAttributes.Session] = [] @@ -198,6 +199,7 @@ final class MobileAppModel { dependencies.workspaceStore ?? MobileWorkspaceStore(defaults: inMemory || hosts != nil ? nil : defaults) appReview = dependencies.appReview + diagnosis = dependencies.diagnosis logging = dependencies.logging let memoryOnly = inMemory || hosts != nil let storeURL = URL.applicationSupportDirectory.appending(path: "Termind.store") diff --git a/TFI/App/MobileStartupView.swift b/TFI/App/MobileStartupView.swift index 591ffddc..ca1208b9 100644 --- a/TFI/App/MobileStartupView.swift +++ b/TFI/App/MobileStartupView.swift @@ -22,9 +22,15 @@ struct MobileStartupView: View { var body: some View { switch startup { case .success(let app): - MobileQuickActionSceneRoot(app: app) - .modelContainer(app.container) - .preferredColorScheme(appearance == "dark" ? .dark : appearance == "light" ? .light : nil) + Group { + if app.diagnosis.needsConsent { + DiagnosisConsentView(controller: app.diagnosis) + } else { + MobileQuickActionSceneRoot(app: app) + } + } + .modelContainer(app.container) + .preferredColorScheme(appearance == "dark" ? .dark : appearance == "light" ? .light : nil) case .failure: ContentUnavailableView( AppStrings.Common.unableToOpenTermind.swiftUIKey, systemImage: "exclamationmark.triangle", diff --git a/TFI/Features/Settings/MobileAppSettingsView.swift b/TFI/Features/Settings/MobileAppSettingsView.swift index 2398e69c..f991eecf 100644 --- a/TFI/Features/Settings/MobileAppSettingsView.swift +++ b/TFI/Features/Settings/MobileAppSettingsView.swift @@ -6,6 +6,7 @@ struct MobileAppSettingsView: View { let workspaceSettings: WorkspaceSettingsModel var appReview: AppReviewController? var reviewScene: MobileSceneModel? + var diagnosis = DiagnosisController() @AppStorage("mobile.appearance") private var appearance = "system" @AppStorage(SettingsKey.debugSectionEnabled) private var debugSectionEnabled = false @@ -34,6 +35,7 @@ struct MobileAppSettingsView: View { } footer: { Text(AppStrings.Settings.workspaceNumberingExplanation) } + DiagnosisSettingsSection(controller: diagnosis) Section(AppStrings.Settings.version.swiftUIKey) { LabeledContent( AppStrings.Settings.app.swiftUIKey, diff --git a/TFI/Features/Settings/MobileSettingsView.swift b/TFI/Features/Settings/MobileSettingsView.swift index bd3fc047..e955059a 100644 --- a/TFI/Features/Settings/MobileSettingsView.swift +++ b/TFI/Features/Settings/MobileSettingsView.swift @@ -19,7 +19,8 @@ struct MobileSettingsView: View { MobileAppSettingsView( workspaceSettings: app.workspaceSettings, appReview: app.appReview, - reviewScene: reviewScene) + reviewScene: reviewScene, + diagnosis: app.diagnosis) } label: { Label(AppStrings.Settings.app.swiftUIKey, systemImage: "app.badge") } diff --git a/TFI/Info.plist b/TFI/Info.plist index 40046062..d6d89fd2 100644 --- a/TFI/Info.plist +++ b/TFI/Info.plist @@ -2,6 +2,12 @@ + FirebaseCrashlyticsCollectionEnabled + + FirebaseDataCollectionDefaultEnabled + + FIREBASE_ANALYTICS_COLLECTION_DEACTIVATED + CFBundleURLTypes diff --git a/TFM/App/AppModel.swift b/TFM/App/AppModel.swift index 7817a3d4..d39c323c 100644 --- a/TFM/App/AppModel.swift +++ b/TFM/App/AppModel.swift @@ -71,6 +71,7 @@ final class AppModel { let commands = AppCommandRegistry() let onboarding: OnboardingController let appReview: AppReviewController + let diagnosis: DiagnosisController let serverMetrics: ServerMetricsModel let windowSizeStore: MainWindowSizeStore let logging: any ApplicationLogManaging @@ -191,6 +192,7 @@ final class AppModel { proAccess: ProAccessController = .unrestrictedForTesting(), onboarding: OnboardingController = OnboardingController(), appReview: AppReviewController = AppReviewController(), + diagnosis: DiagnosisController = DiagnosisController(), serverMetrics suppliedMetrics: ServerMetricsModel? = nil, windowSizeStore: MainWindowSizeStore = MainWindowSizeStore() ) { @@ -220,6 +222,7 @@ final class AppModel { self.proAccess = proAccess self.onboarding = onboarding self.appReview = appReview + self.diagnosis = diagnosis knownHosts = KnownHostsLibraryModel(library: hosts) credentials = CredentialLibraryModel(library: hosts) snippets = SnippetLibraryModel(library: hosts) diff --git a/TFM/App/TermindApp.swift b/TFM/App/TermindApp.swift index 025c66f6..0dc4190c 100644 --- a/TFM/App/TermindApp.swift +++ b/TFM/App/TermindApp.swift @@ -56,6 +56,10 @@ struct TermindApp: App { #else let usesInMemoryStore = isRunningTests #endif + let diagnosis = + usesInMemoryStore + ? DiagnosisController() + : DiagnosisController(defaults: .standard, reporter: FirebaseDiagnosticDataReporter.live()) if !usesInMemoryStore { try DatabaseStartupGuard.checkRunningApplications() } let container = try TermindPersistence.makeContainer(inMemory: usesInMemoryStore) ContextAttachmentOpener.cleanPreviousSession() @@ -195,6 +199,7 @@ struct TermindApp: App { ), proAccess: proAccess, appReview: .live(), + diagnosis: diagnosis, windowSizeStore: MainWindowSizeStore(defaults: isRunningTests ? nil : .standard) ) ) @@ -228,13 +233,20 @@ struct TermindApp: App { /// destinations and dynamic Workspaces. var body: some Scene { WindowGroup(id: "main", for: UUID.self) { $requestID in - RootView(window: makeWindow(requestID: requestID), requestID: requestID) - .environment(app) - .environment(\.locale, launchLanguage.locale) - .modelContainer(modelContainer) - #if canImport(AppKit) - .onAppear { delegate.app = app } - #endif + Group { + if app.diagnosis.needsConsent { + DiagnosisConsentView(controller: app.diagnosis) + .frame(minWidth: 560, minHeight: 620) + } else { + RootView(window: makeWindow(requestID: requestID), requestID: requestID) + } + } + .environment(app) + .environment(\.locale, launchLanguage.locale) + .modelContainer(modelContainer) + #if canImport(AppKit) + .onAppear { delegate.app = app } + #endif } #if DEBUG // XCTest launches the real app. Gating individual tests does not stop diff --git a/TFM/Features/Settings/SettingsPageView.swift b/TFM/Features/Settings/SettingsPageView.swift index 19811ac2..ce74e177 100644 --- a/TFM/Features/Settings/SettingsPageView.swift +++ b/TFM/Features/Settings/SettingsPageView.swift @@ -158,6 +158,7 @@ struct SettingsPageView: View { SettingsFormPage { AppSettings() StartupSettings() + DiagnosisSettingsSection(controller: app.diagnosis) AppInfoSettings() } case .notifications: diff --git a/TFM/Info.plist b/TFM/Info.plist index 61be6f66..639b24cf 100644 --- a/TFM/Info.plist +++ b/TFM/Info.plist @@ -2,6 +2,12 @@ + FirebaseCrashlyticsCollectionEnabled + + FirebaseDataCollectionDefaultEnabled + + FIREBASE_ANALYTICS_COLLECTION_DEACTIVATED + NSAppTransportSecurity NSAllowsArbitraryLoads diff --git a/TFMTests/ArchitectureBoundaryTests.swift b/TFMTests/ArchitectureBoundaryTests.swift index df0ed02a..59adf95a 100644 --- a/TFMTests/ArchitectureBoundaryTests.swift +++ b/TFMTests/ArchitectureBoundaryTests.swift @@ -3,6 +3,22 @@ import Testing @Suite struct ArchitectureBoundaryTests { + @Test + func diagnosisSDKStaysBehindInfrastructureAndStartsWithCollectionDisabled() throws { + for module in ["TermindDomain", "TermindApplication", "TermindPresentation", "TermindPersistence"] { + try assertNoImports( + in: "../Frameworks/\(module)", forbidden: ["FirebaseCore", "FirebaseCrashlytics", "FirebaseAnalytics"]) + } + for path in ["Info.plist", "../TFI/Info.plist"] { + let data = try Data(contentsOf: sourceRoot.appending(path: path)) + let plist = try #require( + PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any]) + #expect(plist["FirebaseCrashlyticsCollectionEnabled"] as? Bool == false) + #expect(plist["FirebaseDataCollectionDefaultEnabled"] as? Bool == false) + #expect(plist["FIREBASE_ANALYTICS_COLLECTION_DEACTIVATED"] as? Bool == true) + } + } + @Test func forwardingBenchmarkRemainsIndependentFromAppsAndTests() throws { let repositoryRoot = sourceRoot.deletingLastPathComponent().standardizedFileURL diff --git a/TFMTests/DiagnosisConsentRenderingTests.swift b/TFMTests/DiagnosisConsentRenderingTests.swift new file mode 100644 index 00000000..a1e5924d --- /dev/null +++ b/TFMTests/DiagnosisConsentRenderingTests.swift @@ -0,0 +1,41 @@ +import AppKit +import SwiftUI +import TermindApplication +import TermindPresentation +import Testing + +@Suite(.requiresVisibleWindow, .serialized) @MainActor +struct DiagnosisConsentRenderingTests { + @Test(arguments: ["en", "zh-Hans", "zh-Hant"], [360.0, 560.0]) + func introductoryPageRendersWithoutChangingConsent(language: String, width: Double) async throws { + let domain = "DiagnosisRendering.\(UUID())" + let defaults = try #require(UserDefaults(suiteName: domain)) + defer { defaults.removePersistentDomain(forName: domain) } + let controller = DiagnosisController(defaults: defaults) + let content = DiagnosisConsentView(controller: controller) + .environment(\.locale, Locale(identifier: language)) + .frame(width: width, height: 680) + .background(.background) + let host = NSHostingController(rootView: content) + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: width, height: 680), + styleMask: [.titled], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentViewController = host + window.makeKeyAndOrderFront(nil) + defer { + window.orderOut(nil) + window.contentViewController = nil + window.close() + } + try await Task.sleep(for: .milliseconds(200)) + host.view.layoutSubtreeIfNeeded() + let bitmap = try #require(host.view.bitmapImageRepForCachingDisplay(in: host.view.bounds)) + host.view.cacheDisplay(in: host.view.bounds, to: bitmap) + let png = try #require(bitmap.representation(using: .png, properties: [:])) + #expect(png.count > 5_000) + #expect(controller.needsConsent) + #expect(defaults.object(forKey: SettingsKey.diagnosisConsent) == nil) + Attachment.record(png, named: "diagnosis-\(language)-\(Int(width)).png") + } +} diff --git a/Termind.xcodeproj/project.pbxproj b/Termind.xcodeproj/project.pbxproj index 3293842e..c152889c 100644 --- a/Termind.xcodeproj/project.pbxproj +++ b/Termind.xcodeproj/project.pbxproj @@ -82,6 +82,16 @@ F04700000000000000000001 /* TFMTests/SSHForwardingPumpTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F04700000000000000000011 /* TFMTests/SSHForwardingPumpTests.swift */; }; F04700000000000000000002 /* TFMTests/SSHForwardingWireTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F04700000000000000000012 /* TFMTests/SSHForwardingWireTests.swift */; }; F04700000000000000000003 /* Logging in Frameworks */ = {isa = PBXBuildFile; productRef = LOGG00000000000000000002 /* Logging */; }; + F1BA00000000000000000101 /* FirebaseCore in Frameworks */ = {isa = PBXBuildFile; productRef = F1BA00000000000000000001 /* FirebaseCore */; }; + F1BA00000000000000000102 /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = F1BA00000000000000000002 /* FirebaseCrashlytics */; }; + F1BA00000000000000000201 /* FirebaseCore in Frameworks */ = {isa = PBXBuildFile; productRef = F1BA00000000000000000001 /* FirebaseCore */; }; + F1BA00000000000000000202 /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = F1BA00000000000000000002 /* FirebaseCrashlytics */; }; + F1BA00000000000000000301 /* FirebaseCore in Frameworks */ = {isa = PBXBuildFile; productRef = F1BA00000000000000000001 /* FirebaseCore */; }; + F1BA00000000000000000302 /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = F1BA00000000000000000002 /* FirebaseCrashlytics */; }; + F1BA00000000000000000401 /* FirebaseCore in Frameworks */ = {isa = PBXBuildFile; productRef = F1BA00000000000000000001 /* FirebaseCore */; }; + F1BA00000000000000000402 /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = F1BA00000000000000000002 /* FirebaseCrashlytics */; }; + F1BA0000000000000000A001 /* Configuration/Firebase/GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = F1BA00000000000000000020 /* Configuration/Firebase/GoogleService-Info.plist */; }; + F1BA0000000000000000A002 /* Configuration/Firebase/GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = F1BA00000000000000000020 /* Configuration/Firebase/GoogleService-Info.plist */; }; F6ACDF3012081771F6C8F659 /* AgentKit in Frameworks */ = {isa = PBXBuildFile; productRef = TRMD0000000000000010001B /* AgentKit */; }; F996BFD771BC77E67AB91FFB /* TermindInfrastructure.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 67B48098792E58C2955768F2 /* TermindInfrastructure.framework */; }; FACB579B671967F922A33CD9 /* TermindApplication.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 10111AE8F047FB59B318813A /* TermindApplication.framework */; }; @@ -474,6 +484,7 @@ E28A00000000000000000001 /* V28.store */ = {isa = PBXFileReference; lastKnownFileType = file; name = V28.store; path = FrameworkTests/Fixtures/V28.store; sourceTree = SOURCE_ROOT; }; F04700000000000000000011 /* TFMTests/SSHForwardingPumpTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TFMTests/SSHForwardingPumpTests.swift; sourceTree = SOURCE_ROOT; }; F04700000000000000000012 /* TFMTests/SSHForwardingWireTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TFMTests/SSHForwardingWireTests.swift; sourceTree = SOURCE_ROOT; }; + F1BA00000000000000000020 /* Configuration/Firebase/GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "Configuration/Firebase/GoogleService-Info.plist"; sourceTree = SOURCE_ROOT; }; TRMD00000000000000000001 /* Termind.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Termind.app; sourceTree = BUILT_PRODUCTS_DIR; }; TRMD00000000000000020001 /* TFMTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TFMTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; TRMD00000000000000030002 /* termind-mcp */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = "termind-mcp"; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -640,6 +651,8 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + F1BA00000000000000000302 /* FirebaseCrashlytics in Frameworks */, + F1BA00000000000000000301 /* FirebaseCore in Frameworks */, DEVK00000000000000000003 /* DeviceKit in Frameworks */, LOGG00000000000000000105 /* Logging in Frameworks */, BAAD00000000000000000011 /* GRDB in Frameworks */, @@ -663,6 +676,8 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + F1BA00000000000000000102 /* FirebaseCrashlytics in Frameworks */, + F1BA00000000000000000101 /* FirebaseCore in Frameworks */, LOGG00000000000000000102 /* Logging in Frameworks */, ABCDEF020000000000000001 /* ZIPFoundation in Frameworks */, ABCDEF010000000000000001 /* TermindPersistence.framework in Frameworks */, @@ -712,6 +727,8 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + F1BA00000000000000000402 /* FirebaseCrashlytics in Frameworks */, + F1BA00000000000000000401 /* FirebaseCore in Frameworks */, LOGG00000000000000000106 /* Logging in Frameworks */, BAAD00000000000000000012 /* GRDB in Frameworks */, AA1200000000000000000010 /* MarkdownView in Frameworks */, @@ -737,6 +754,8 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + F1BA00000000000000000202 /* FirebaseCrashlytics in Frameworks */, + F1BA00000000000000000201 /* FirebaseCore in Frameworks */, DEVK00000000000000000004 /* DeviceKit in Frameworks */, LOGG00000000000000000104 /* Logging in Frameworks */, BAAD00000000000000000010 /* GRDB in Frameworks */, @@ -826,6 +845,7 @@ TRMD00000000000000000030 = { isa = PBXGroup; children = ( + F1BA00000000000000000020 /* Configuration/Firebase/GoogleService-Info.plist */, BENC00000000000000000010 /* Benchmarks/PortForwarding */, CB622EAF077B1253532F1E18 /* TFIWidgets */, FD8DC40513A4A80A71C41FCA /* WorkspaceActivitySupport */, @@ -875,6 +895,7 @@ AC89B83FA8E4154383D9AC81 /* Sources */, 7B09CFC5B32584951BD4C8F9 /* Frameworks */, 8F0C5245592A0E2AFB85E50F /* Resources */, + F1BA00000000000000003002 /* Upload Crashlytics Symbols */, ); buildRules = ( ); @@ -894,6 +915,8 @@ ); name = TFI; packageProductDependencies = ( + F1BA00000000000000000002 /* FirebaseCrashlytics */, + F1BA00000000000000000001 /* FirebaseCore */, DEVK00000000000000000002 /* DeviceKit */, LOGG00000000000000000002 /* Logging */, BAAD00000000000000000002 /* GRDB */, @@ -933,6 +956,8 @@ ); name = FrameworkTests; packageProductDependencies = ( + F1BA00000000000000000002 /* FirebaseCrashlytics */, + F1BA00000000000000000001 /* FirebaseCore */, LOGG00000000000000000002 /* Logging */, BAAD00000000000000000002 /* GRDB */, TRMD0000000000000010001B /* AgentKit */, @@ -1015,6 +1040,8 @@ ); name = TermindInfrastructure; packageProductDependencies = ( + F1BA00000000000000000002 /* FirebaseCrashlytics */, + F1BA00000000000000000001 /* FirebaseCore */, LOGG00000000000000000002 /* Logging */, TRMD00000000000000100015 /* ZIPFoundation */, ); @@ -1187,6 +1214,7 @@ TRMD00000000000000000020 /* Frameworks */, TRMD00000000000000000080 /* Resources */, TRMD00000000000000000085 /* Embed MCP Bridge */, + F1BA00000000000000002002 /* Upload Crashlytics Symbols */, ); buildRules = ( ); @@ -1205,6 +1233,8 @@ ); name = TFM; packageProductDependencies = ( + F1BA00000000000000000002 /* FirebaseCrashlytics */, + F1BA00000000000000000001 /* FirebaseCore */, DEVK00000000000000000002 /* DeviceKit */, LOGG00000000000000000002 /* Logging */, BAAD00000000000000000002 /* GRDB */, @@ -1337,6 +1367,7 @@ mainGroup = TRMD00000000000000000030; minimizedProjectReferenceProxies = 1; packageReferences = ( + F1BA00000000000000000010 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */, DEVK00000000000000000001 /* XCRemoteSwiftPackageReference "DeviceKit" */, BAAD00000000000000000001 /* XCRemoteSwiftPackageReference "GRDB.swift" */, LOGG00000000000000000001 /* XCRemoteSwiftPackageReference "swift-log" */, @@ -1420,6 +1451,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + F1BA0000000000000000A002 /* Configuration/Firebase/GoogleService-Info.plist in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1448,6 +1480,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + F1BA0000000000000000A001 /* Configuration/Firebase/GoogleService-Info.plist in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1460,6 +1493,55 @@ }; /* End PBXResourcesBuildPhase section */ +/* Begin PBXShellScriptBuildPhase section */ + F1BA00000000000000002002 /* Upload Crashlytics Symbols */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "$(BUILD_DIR)/../../SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/run", + "$(BUILD_DIR)/../../SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/upload-symbols", + "$(DWARF_DSYM_FOLDER_PATH)/$(DWARF_DSYM_FILE_NAME)", + "$(DWARF_DSYM_FOLDER_PATH)/$(DWARF_DSYM_FILE_NAME)/Contents/Info.plist", + "$(DWARF_DSYM_FOLDER_PATH)/$(DWARF_DSYM_FILE_NAME)/Contents/Resources/DWARF/$(PRODUCT_NAME)", + "$(TARGET_BUILD_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/GoogleService-Info.plist", + "$(TARGET_BUILD_DIR)/$(EXECUTABLE_PATH)", + "$(TARGET_BUILD_DIR)/$(INFOPLIST_PATH)", + ); + name = "Upload Crashlytics Symbols"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "if [ \"${CONFIGURATION}\" = \"Release\" ]; then\n \"${BUILD_DIR%/Build/*}/SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/run\" -gsp \"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GoogleService-Info.plist\"\nfi\n"; + }; + F1BA00000000000000003002 /* Upload Crashlytics Symbols */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "$(BUILD_DIR)/../../SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/run", + "$(BUILD_DIR)/../../SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/upload-symbols", + "$(DWARF_DSYM_FOLDER_PATH)/$(DWARF_DSYM_FILE_NAME)", + "$(DWARF_DSYM_FOLDER_PATH)/$(DWARF_DSYM_FILE_NAME)/Contents/Info.plist", + "$(DWARF_DSYM_FOLDER_PATH)/$(DWARF_DSYM_FILE_NAME)/Contents/Resources/DWARF/$(PRODUCT_NAME)", + "$(TARGET_BUILD_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/GoogleService-Info.plist", + "$(TARGET_BUILD_DIR)/$(EXECUTABLE_PATH)", + "$(TARGET_BUILD_DIR)/$(INFOPLIST_PATH)", + ); + name = "Upload Crashlytics Symbols"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "if [ \"${CONFIGURATION}\" = \"Release\" ]; then\n \"${BUILD_DIR%/Build/*}/SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/run\" -gsp \"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GoogleService-Info.plist\"\nfi\n"; + }; +/* End PBXShellScriptBuildPhase section */ + /* Begin PBXSourcesBuildPhase section */ 01260326BA2579B3E3586B26 /* Sources */ = { isa = PBXSourcesBuildPhase; @@ -1923,6 +2005,7 @@ CODE_SIGN_ENTITLEMENTS = TFI/TFI.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 54; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; GENERATE_INFOPLIST_FILE = YES; ICLOUD_CONTAINER_ENVIRONMENT = Development; INFOPLIST_FILE = TFI/Info.plist; @@ -2234,6 +2317,7 @@ CODE_SIGN_ENTITLEMENTS = TFI/TFI.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 54; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; GENERATE_INFOPLIST_FILE = YES; ICLOUD_CONTAINER_ENVIRONMENT = Production; INFOPLIST_FILE = TFI/Info.plist; @@ -2486,6 +2570,7 @@ COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 62; DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = ""; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; @@ -2523,6 +2608,7 @@ COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 62; DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = ""; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; @@ -2797,6 +2883,14 @@ minimumVersion = 5.9.0; }; }; + F1BA00000000000000000010 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/firebase/firebase-ios-sdk.git"; + requirement = { + kind = exactVersion; + version = 12.19.2; + }; + }; LOGG00000000000000000001 /* XCRemoteSwiftPackageReference "swift-log" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/apple/swift-log.git"; @@ -2882,6 +2976,16 @@ package = DEVK00000000000000000001 /* XCRemoteSwiftPackageReference "DeviceKit" */; productName = DeviceKit; }; + F1BA00000000000000000001 /* FirebaseCore */ = { + isa = XCSwiftPackageProductDependency; + package = F1BA00000000000000000010 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseCore; + }; + F1BA00000000000000000002 /* FirebaseCrashlytics */ = { + isa = XCSwiftPackageProductDependency; + package = F1BA00000000000000000010 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseCrashlytics; + }; LOGG00000000000000000002 /* Logging */ = { isa = XCSwiftPackageProductDependency; package = LOGG00000000000000000001 /* XCRemoteSwiftPackageReference "swift-log" */; diff --git a/Termind.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Termind.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 23d0e2d8..92355744 100644 --- a/Termind.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Termind.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,6 +1,15 @@ { - "originHash" : "ea2353e26e14490b696f9bc5c68235bf92924ce177756bb5217c4cc234874a31", + "originHash" : "db5fde46a07b58b12a3db946618d16496080b8cba0501fc05694825a35f1a1b3", "pins" : [ + { + "identity" : "abseil-cpp-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/abseil-cpp-binary.git", + "state" : { + "revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5", + "version" : "1.2024072200.0" + } + }, { "identity" : "agentkit", "kind" : "remoteSourceControl", @@ -10,6 +19,15 @@ "version" : "0.18.1" } }, + { + "identity" : "app-check", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/app-check.git", + "state" : { + "revision" : "97f7d74dd0e8f3d0fe5fc75cc8957aa73bdc948a", + "version" : "11.3.2" + } + }, { "identity" : "choreographer", "kind" : "remoteSourceControl", @@ -28,6 +46,15 @@ "version" : "5.9.0" } }, + { + "identity" : "firebase-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/firebase-ios-sdk.git", + "state" : { + "revision" : "8c29ca981990a32e626c8617544fa65d22b1834f", + "version" : "12.19.2" + } + }, { "identity" : "glyphixtextfx", "kind" : "remoteSourceControl", @@ -37,6 +64,42 @@ "version" : "2.3.6" } }, + { + "identity" : "google-ads-on-device-conversion-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk", + "state" : { + "revision" : "0208e2681ec81f8a9c81084696f60fcce26cb7ee", + "version" : "3.7.0" + } + }, + { + "identity" : "googleappmeasurement", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleAppMeasurement.git", + "state" : { + "revision" : "b8455dd2ff55cc269fda7a4f9eac6c1e3588c53f", + "version" : "12.19.2" + } + }, + { + "identity" : "googledatatransport", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleDataTransport.git", + "state" : { + "revision" : "ba3358d3c3dbae8ef230b58a46b97ad65e84e974", + "version" : "10.1.1" + } + }, + { + "identity" : "googleutilities", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleUtilities.git", + "state" : { + "revision" : "cc31f5ed464b111f877ce8554cbef4455d410d91", + "version" : "8.1.4" + } + }, { "identity" : "grdb.swift", "kind" : "remoteSourceControl", @@ -46,6 +109,24 @@ "version" : "7.11.1" } }, + { + "identity" : "grpc-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/grpc-binary.git", + "state" : { + "revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6", + "version" : "1.69.1" + } + }, + { + "identity" : "gtm-session-fetcher", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/gtm-session-fetcher.git", + "state" : { + "revision" : "724a52eea6329b7e12d3ad8300d76ca9f3895fcc", + "version" : "5.3.1" + } + }, { "identity" : "highlightr", "kind" : "remoteSourceControl", @@ -55,6 +136,24 @@ "version" : "2.3.0" } }, + { + "identity" : "interop-ios-for-google-sdks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/interop-ios-for-google-sdks.git", + "state" : { + "revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe", + "version" : "101.0.0" + } + }, + { + "identity" : "leveldb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/leveldb.git", + "state" : { + "revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1", + "version" : "1.22.5" + } + }, { "identity" : "libghostty-spm", "kind" : "remoteSourceControl", @@ -118,6 +217,15 @@ "version" : "2.2.0" } }, + { + "identity" : "nanopb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/nanopb.git", + "state" : { + "revision" : "3851d94a41890dea16dc3db34caf60e585cb4163", + "version" : "2.30910.1" + } + }, { "identity" : "openssl-spm", "kind" : "remoteSourceControl", @@ -127,6 +235,15 @@ "version" : "3.6.2" } }, + { + "identity" : "promises", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/promises.git", + "state" : { + "revision" : "f4a19a3c313dc2616c70bb49d29a799fb16be837", + "version" : "2.4.1" + } + }, { "identity" : "respring", "kind" : "remoteSourceControl",