feat(diagnosis): add opt-in Firebase diagnostic data reporting
This commit is contained in:
1 parent
b702ff8ee0
commit
22f09eaeee
23 files changed
+989
-13
No files matched your search
@@ -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<Bool, Never>?
|
||||
private var checkStarted: CheckedContinuation<Void, Never>?
|
||||
private var actionFinished: CheckedContinuation<Void, Never>?
|
||||
|
||||
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 }
|
||||
}
|
||||
}
|
||||
@@ -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" : {
|
||||
|
||||
@@ -5,7 +5,44 @@
|
||||
<key>NSPrivacyTracking</key>
|
||||
<false/>
|
||||
<key>NSPrivacyCollectedDataTypes</key>
|
||||
<array/>
|
||||
<array>
|
||||
<dict>
|
||||
<key>NSPrivacyCollectedDataType</key>
|
||||
<string>NSPrivacyCollectedDataTypeCrashData</string>
|
||||
<key>NSPrivacyCollectedDataTypeLinked</key>
|
||||
<false/>
|
||||
<key>NSPrivacyCollectedDataTypeTracking</key>
|
||||
<false/>
|
||||
<key>NSPrivacyCollectedDataTypePurposes</key>
|
||||
<array>
|
||||
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>NSPrivacyCollectedDataType</key>
|
||||
<string>NSPrivacyCollectedDataTypeOtherDiagnosticData</string>
|
||||
<key>NSPrivacyCollectedDataTypeLinked</key>
|
||||
<false/>
|
||||
<key>NSPrivacyCollectedDataTypeTracking</key>
|
||||
<false/>
|
||||
<key>NSPrivacyCollectedDataTypePurposes</key>
|
||||
<array>
|
||||
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>NSPrivacyCollectedDataType</key>
|
||||
<string>NSPrivacyCollectedDataTypeDeviceID</string>
|
||||
<key>NSPrivacyCollectedDataTypeLinked</key>
|
||||
<false/>
|
||||
<key>NSPrivacyCollectedDataTypeTracking</key>
|
||||
<false/>
|
||||
<key>NSPrivacyCollectedDataTypePurposes</key>
|
||||
<array>
|
||||
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>NSPrivacyAccessedAPITypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,10 @@ extension AppStrings {
|
||||
"Add Hosts"
|
||||
}
|
||||
|
||||
public static var allow: LocalizedStringResource {
|
||||
"Allow"
|
||||
}
|
||||
|
||||
public static var back: LocalizedStringResource {
|
||||
"Back"
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>FirebaseCrashlyticsCollectionEnabled</key>
|
||||
<false/>
|
||||
<key>FirebaseDataCollectionDefaultEnabled</key>
|
||||
<false/>
|
||||
<key>FIREBASE_ANALYTICS_COLLECTION_DEACTIVATED</key>
|
||||
<true/>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -158,6 +158,7 @@ struct SettingsPageView: View {
|
||||
SettingsFormPage {
|
||||
AppSettings()
|
||||
StartupSettings()
|
||||
DiagnosisSettingsSection(controller: app.diagnosis)
|
||||
AppInfoSettings()
|
||||
}
|
||||
case .notifications:
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>FirebaseCrashlyticsCollectionEnabled</key>
|
||||
<false/>
|
||||
<key>FirebaseDataCollectionDefaultEnabled</key>
|
||||
<false/>
|
||||
<key>FIREBASE_ANALYTICS_COLLECTION_DEACTIVATED</key>
|
||||
<true/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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" */;
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user