Procházet zdrojové kódy

Add remote services and improve BLE recording

bob.yuxinyang před 1 měsícem
rodič
revize
6098885861
32 změnil soubory, kde provedl 1197 přidání a 816 odebrání
  1. 16 12
      CelestiaTrace.xcodeproj/project.pbxproj
  2. 7 1
      CelestiaTrace/Models/BoundDevice.swift
  3. 27 3
      CelestiaTrace/Models/CelestiaSession.swift
  4. 2 2
      CelestiaTrace/Models/CelestiaTimelineEvent.swift
  5. 0 3
      CelestiaTrace/Models/UserAccount.swift
  6. 2 0
      CelestiaTrace/Resources/Info.plist
  7. 1 2
      CelestiaTrace/Services/Audio/AudioRecorderProtocol.swift
  8. 5 6
      CelestiaTrace/Services/Audio/RealAudioRecorder.swift
  9. 2 2
      CelestiaTrace/Services/Audio/RecordingEnvironmentDetector.swift
  10. 2 6
      CelestiaTrace/Services/Audio/SilenceDetector.swift
  11. 16 25
      CelestiaTrace/Services/Auth/AuthManager.swift
  12. 1 0
      CelestiaTrace/Services/Auth/AuthServiceProtocol.swift
  13. 0 203
      CelestiaTrace/Services/Auth/LocalAuthRepository.swift
  14. 157 0
      CelestiaTrace/Services/Auth/RemoteAuthService.swift
  15. 0 243
      CelestiaTrace/Services/Auth/RemoteAuthServiceStub.swift
  16. 32 45
      CelestiaTrace/Services/Bluetooth/BLEManager.swift
  17. 371 0
      CelestiaTrace/Services/Network/APIClient.swift
  18. 0 62
      CelestiaTrace/Services/Network/MockNetworkService.swift
  19. 5 9
      CelestiaTrace/Services/Network/NetworkServiceProtocol.swift
  20. 364 0
      CelestiaTrace/Services/Network/RemoteNetworkService.swift
  21. 23 72
      CelestiaTrace/ViewModels/PlaybackViewModel.swift
  22. 6 0
      CelestiaTrace/ViewModels/RecordingViewModel.swift
  23. 2 2
      CelestiaTrace/Views/Components/SyncAuthPromptModal.swift
  24. 10 72
      CelestiaTrace/Views/Detail/MultiTrackTimeline.swift
  25. 10 3
      CelestiaTrace/Views/History/SessionListView.swift
  26. 2 2
      CelestiaTrace/Views/Profile/Auth/AuthModalView.swift
  27. 20 0
      CelestiaTrace/Views/Profile/Devices/DeviceListView.swift
  28. 12 5
      CelestiaTrace/Views/Profile/Devices/DeviceScanView.swift
  29. 25 11
      CelestiaTrace/Views/Profile/ProfileView.swift
  30. 1 12
      CelestiaTrace/Views/Recording/LiveWaveformView.swift
  31. 53 13
      CelestiaTrace/Views/Settings/SettingsView.swift
  32. 23 0
      Tests/CelestiaTraceTests/BLEFilteringTests.swift

+ 16 - 12
CelestiaTrace.xcodeproj/project.pbxproj

@@ -7,7 +7,9 @@
 	objects = {
 
 /* Begin PBXBuildFile section */
-		10EAADFFB07A1A8721B25D6B /* MockNetworkService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24923AF5D038F81EB3248A4C /* MockNetworkService.swift */; };
+		10EAADFFB07A1A8721B25D6B /* RemoteNetworkService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24923AF5D038F81EB3248A4C /* RemoteNetworkService.swift */; };
+		A11C1E000000000000000001 /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11C1E000000000000000002 /* APIClient.swift */; };
+		A11C1E000000000000000003 /* BLEFilteringTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11C1E000000000000000004 /* BLEFilteringTests.swift */; };
 		128B1C7150980D1FBE511B79 /* RecordingEnvironmentDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB09EA3EE9CFFADB34B5B43A /* RecordingEnvironmentDetector.swift */; };
 		15EAFA0DBF80C789F02C5119 /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 086899076999CD4909BB9748 /* ProfileView.swift */; };
 		1A9161A601EB0954CD4B8A76 /* NetworkServiceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F2D8AEE2E58FE0F2EF531D0 /* NetworkServiceProtocol.swift */; };
@@ -29,7 +31,6 @@
 		7CA015A965BD30FAB3A92507 /* AuthManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 073CAFDB8C45F4EAA33FA0EA /* AuthManager.swift */; };
 		8B5C51BB4474283BDF4ADACB /* LiveWaveformView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1189E47F735DDB40D1E741E6 /* LiveWaveformView.swift */; };
 		8FBEE86CDB9635710EF85F1B /* BLEManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F52FB139E193DBAC41F9D3F6 /* BLEManager.swift */; };
-		8FF835BDBD2A0E3551462510 /* LocalAuthRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8211C786643CA85F12CCAB4 /* LocalAuthRepository.swift */; };
 		936A52ADE1ECF30D3650B8D8 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5353BBE8E9552B4B486D8701 /* Foundation.framework */; };
 		93F943133BCD792D991AA8E3 /* AudioChunk.swift in Sources */ = {isa = PBXBuildFile; fileRef = 320789BA1CB176C4DBC51E99 /* AudioChunk.swift */; };
 		96791550D01A4FD10EEB9BA3 /* RecordingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64180D833523B84195EE7E2A /* RecordingViewModel.swift */; };
@@ -50,7 +51,7 @@
 		DDC0D9C4883B4861CBC74C39 /* ChronoFeedRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9275B988BEBBEA80370E1AC4 /* ChronoFeedRow.swift */; };
 		E78FC729614A4C93C97D564D /* ColorTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95AF20E8EF4319BBEBA608C1 /* ColorTheme.swift */; };
 		F385E2FCB06D39917F51846E /* AudioRecorderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B407473A307EEAF2CB5EE94 /* AudioRecorderProtocol.swift */; };
-		F3CB51CC262E0F4929EB129B /* RemoteAuthServiceStub.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E13AE9CB7E8C9E3BACFC679 /* RemoteAuthServiceStub.swift */; };
+		F3CB51CC262E0F4929EB129B /* RemoteAuthService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E13AE9CB7E8C9E3BACFC679 /* RemoteAuthService.swift */; };
 		F5035B942DE1FEC10515D9D6 /* ImagePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = DADF67536C0CF9AE7F405C74 /* ImagePicker.swift */; };
 		FD30DBB57E19055AB8F0A956 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4883D42730E6133FCC3C81C2 /* SettingsView.swift */; };
 /* End PBXBuildFile section */
@@ -72,14 +73,16 @@
 		1189E47F735DDB40D1E741E6 /* LiveWaveformView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LiveWaveformView.swift; sourceTree = "<group>"; };
 		1DC9982D9E53E1A77CDB0470 /* MultiTrackTimeline.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MultiTrackTimeline.swift; sourceTree = "<group>"; };
 		1EF9EF0F961DB2B546B9C0F1 /* BoundDevice.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BoundDevice.swift; sourceTree = "<group>"; };
-		24923AF5D038F81EB3248A4C /* MockNetworkService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MockNetworkService.swift; sourceTree = "<group>"; };
+		24923AF5D038F81EB3248A4C /* RemoteNetworkService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RemoteNetworkService.swift; sourceTree = "<group>"; };
+		A11C1E000000000000000002 /* APIClient.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = "<group>"; };
+		A11C1E000000000000000004 /* BLEFilteringTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BLEFilteringTests.swift; sourceTree = "<group>"; };
 		320789BA1CB176C4DBC51E99 /* AudioChunk.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AudioChunk.swift; sourceTree = "<group>"; };
 		3C76F629F3460D6F077C3708 /* SyncAuthPromptModal.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SyncAuthPromptModal.swift; sourceTree = "<group>"; };
 		3FE663DCAAC5D1765BA31A3D /* SessionDetailView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SessionDetailView.swift; sourceTree = "<group>"; };
 		4883D42730E6133FCC3C81C2 /* SettingsView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
 		4B407473A307EEAF2CB5EE94 /* AudioRecorderProtocol.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AudioRecorderProtocol.swift; sourceTree = "<group>"; };
 		4DAA0FBAEC0BF91F45D15B1E /* CelestiaTimelineEvent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CelestiaTimelineEvent.swift; sourceTree = "<group>"; };
-		4E13AE9CB7E8C9E3BACFC679 /* RemoteAuthServiceStub.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RemoteAuthServiceStub.swift; sourceTree = "<group>"; };
+		4E13AE9CB7E8C9E3BACFC679 /* RemoteAuthService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RemoteAuthService.swift; sourceTree = "<group>"; };
 		4F4B4B59E0916CE9ACFF362F /* DiscreetWarningBanner.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DiscreetWarningBanner.swift; sourceTree = "<group>"; };
 		51A1BE84E0C74D9F7BE9DC3E /* UserAccount.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UserAccount.swift; sourceTree = "<group>"; };
 		5353BBE8E9552B4B486D8701 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; };
@@ -104,7 +107,6 @@
 		AA13A08133A50094CBFFDDF9 /* EditProfileModalView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = EditProfileModalView.swift; sourceTree = "<group>"; };
 		AEED3C75367F66D6D6CE745D /* SilenceDetector.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SilenceDetector.swift; sourceTree = "<group>"; };
 		B7B9693BD9924C08A1F2ECD1 /* ActiveRecordingView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ActiveRecordingView.swift; sourceTree = "<group>"; };
-		B8211C786643CA85F12CCAB4 /* LocalAuthRepository.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LocalAuthRepository.swift; sourceTree = "<group>"; };
 		DADF67536C0CF9AE7F405C74 /* ImagePicker.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ImagePicker.swift; sourceTree = "<group>"; };
 		DB09EA3EE9CFFADB34B5B43A /* RecordingEnvironmentDetector.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecordingEnvironmentDetector.swift; sourceTree = "<group>"; };
 		DB56F0DFCB1FE77D7636B43D /* CelestiaTrace.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CelestiaTrace.app; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -178,6 +180,7 @@
 		32200F0540C3761CA9D1495A /* CelestiaTraceTests */ = {
 			isa = PBXGroup;
 			children = (
+				A11C1E000000000000000004 /* BLEFilteringTests.swift */,
 			);
 			name = CelestiaTraceTests;
 			path = Tests/CelestiaTraceTests;
@@ -204,9 +207,8 @@
 		5BCD8C04CE34AA3B16B8C831 /* Auth */ = {
 			isa = PBXGroup;
 			children = (
-				B8211C786643CA85F12CCAB4 /* LocalAuthRepository.swift */,
 				8CDFF4B7FD8488111C99DADF /* AuthServiceProtocol.swift */,
-				4E13AE9CB7E8C9E3BACFC679 /* RemoteAuthServiceStub.swift */,
+				4E13AE9CB7E8C9E3BACFC679 /* RemoteAuthService.swift */,
 				073CAFDB8C45F4EAA33FA0EA /* AuthManager.swift */,
 			);
 			path = Auth;
@@ -365,7 +367,8 @@
 			isa = PBXGroup;
 			children = (
 				7F2D8AEE2E58FE0F2EF531D0 /* NetworkServiceProtocol.swift */,
-				24923AF5D038F81EB3248A4C /* MockNetworkService.swift */,
+				A11C1E000000000000000002 /* APIClient.swift */,
+				24923AF5D038F81EB3248A4C /* RemoteNetworkService.swift */,
 			);
 			path = Network;
 			sourceTree = "<group>";
@@ -487,6 +490,7 @@
 			isa = PBXSourcesBuildPhase;
 			buildActionMask = 2147483647;
 			files = (
+				A11C1E000000000000000003 /* BLEFilteringTests.swift in Sources */,
 			);
 			runOnlyForDeploymentPostprocessing = 0;
 		};
@@ -523,13 +527,13 @@
 				9C5A0C9CDFCB8CFB10FF0B06 /* DeviceScanView.swift in Sources */,
 				B262A6AF17A835DB666B2FAB /* SessionListView.swift in Sources */,
 				4F4476444CB6838B2BE6EE87 /* ContentView.swift in Sources */,
-				8FF835BDBD2A0E3551462510 /* LocalAuthRepository.swift in Sources */,
 				26D05B0A440FC9B26E89E4F0 /* AuthServiceProtocol.swift in Sources */,
-				F3CB51CC262E0F4929EB129B /* RemoteAuthServiceStub.swift in Sources */,
+				F3CB51CC262E0F4929EB129B /* RemoteAuthService.swift in Sources */,
 				7CA015A965BD30FAB3A92507 /* AuthManager.swift in Sources */,
 				24D8D6477ED27DF7009D383C /* HapticManager.swift in Sources */,
 				1A9161A601EB0954CD4B8A76 /* NetworkServiceProtocol.swift in Sources */,
-				10EAADFFB07A1A8721B25D6B /* MockNetworkService.swift in Sources */,
+				A11C1E000000000000000001 /* APIClient.swift in Sources */,
+				10EAADFFB07A1A8721B25D6B /* RemoteNetworkService.swift in Sources */,
 				503E109CBBC69E3015AE37A3 /* SilenceDetector.swift in Sources */,
 				128B1C7150980D1FBE511B79 /* RecordingEnvironmentDetector.swift in Sources */,
 				CD45B55AB993D054E50A43BE /* RealAudioRecorder.swift in Sources */,

+ 7 - 1
CelestiaTrace/Models/BoundDevice.swift

@@ -14,6 +14,8 @@ struct BoundDevice: Codable, Identifiable, Equatable {
     var hardwareMAC: String? // Hardware identity returned by PQ_BLE&MAC
     var freeStorageMB: Int?
     var totalStorageMB: Int?
+    var cloudID: String?
+    var cloudSyncError: String?
     
     init(
         id: String = UUID().uuidString,
@@ -27,7 +29,9 @@ struct BoundDevice: Codable, Identifiable, Equatable {
         advertisedName: String? = nil,
         hardwareMAC: String? = nil,
         freeStorageMB: Int? = nil,
-        totalStorageMB: Int? = nil
+        totalStorageMB: Int? = nil,
+        cloudID: String? = nil,
+        cloudSyncError: String? = nil
     ) {
         self.id = id
         self.name = name
@@ -41,6 +45,8 @@ struct BoundDevice: Codable, Identifiable, Equatable {
         self.hardwareMAC = hardwareMAC
         self.freeStorageMB = freeStorageMB
         self.totalStorageMB = totalStorageMB
+        self.cloudID = cloudID
+        self.cloudSyncError = cloudSyncError
     }
 }
 

+ 27 - 3
CelestiaTrace/Models/CelestiaSession.swift

@@ -10,6 +10,11 @@ final class CelestiaSession {
     var localAudioPath: String?
     var isSynced: Bool
     var cloudSessionId: String?
+    var ownerUserID: String?
+    var serverRevision: Int64
+    var syncStateRaw: String
+    var lastSyncError: String?
+    var lastSyncedAt: Date?
     
     @Relationship(deleteRule: .cascade, inverse: \CelestiaTimelineEvent.session)
     var events: [CelestiaTimelineEvent]
@@ -42,15 +47,34 @@ final class CelestiaSession {
         events.filter { $0.eventType == "NOTE" || $0.eventType == "MARKER" }.count
     }
     
-    init(title: String, localAudioPath: String? = nil) {
-        self.id = UUID()
+    var syncState: SessionSyncState {
+        get { SessionSyncState(rawValue: syncStateRaw) ?? .localOnly }
+        set { syncStateRaw = newValue.rawValue }
+    }
+
+    init(id: UUID = UUID(), title: String, localAudioPath: String? = nil, startTime: Date = Date()) {
+        self.id = id
         self.title = title
-        self.startTime = Date()
+        self.startTime = startTime
         self.endTime = nil
         self.localAudioPath = localAudioPath
         self.isSynced = false
         self.cloudSessionId = nil
+        self.ownerUserID = nil
+        self.serverRevision = 0
+        self.syncStateRaw = SessionSyncState.localOnly.rawValue
+        self.lastSyncError = nil
+        self.lastSyncedAt = nil
         self.events = []
         self.audioChunks = []
     }
 }
+
+enum SessionSyncState: String, Codable {
+    case localOnly
+    case pending
+    case syncing
+    case synced
+    case failed
+    case conflict
+}

+ 2 - 2
CelestiaTrace/Models/CelestiaTimelineEvent.swift

@@ -35,8 +35,8 @@ final class CelestiaTimelineEvent {
         }
     }
     
-    init(relativeTimeMs: Int64, eventType: String) {
-        self.id = UUID()
+    init(id: UUID = UUID(), relativeTimeMs: Int64, eventType: String) {
+        self.id = id
         self.relativeTimeMs = relativeTimeMs
         self.eventType = eventType
         self.createdAt = Date()

+ 0 - 3
CelestiaTrace/Models/UserAccount.swift

@@ -7,7 +7,6 @@ struct UserAccount: Codable, Identifiable, Equatable {
     var email: String?
     var phoneNumber: String?
     var avatarURL: String?
-    var token: String
     var registeredAt: Date
     
     init(
@@ -16,7 +15,6 @@ struct UserAccount: Codable, Identifiable, Equatable {
         email: String? = nil,
         phoneNumber: String? = nil,
         avatarURL: String? = nil,
-        token: String = UUID().uuidString,
         registeredAt: Date = Date()
     ) {
         self.id = id
@@ -24,7 +22,6 @@ struct UserAccount: Codable, Identifiable, Equatable {
         self.email = email
         self.phoneNumber = phoneNumber
         self.avatarURL = avatarURL
-        self.token = token
         self.registeredAt = registeredAt
     }
 }

+ 2 - 0
CelestiaTrace/Resources/Info.plist

@@ -4,6 +4,8 @@
 <dict>
 	<key>CFBundleDevelopmentRegion</key>
 	<string>$(DEVELOPMENT_LANGUAGE)</string>
+	<key>APIBaseURL</key>
+	<string>https://celestia-trace.ccdw.life/v1</string>
 	<key>CFBundleDisplayName</key>
 	<string>星痕</string>
 	<key>CFBundleExecutable</key>

+ 1 - 2
CelestiaTrace/Services/Audio/AudioRecorderProtocol.swift

@@ -45,7 +45,6 @@ enum AudioRecorderError: LocalizedError {
 }
 
 /// Protocol abstracting audio recording capabilities.
-/// Allows swapping between Mock and Real (AVAudioEngine) implementations.
 protocol AudioRecorderProtocol: ObservableObject {
     /// Current recording state
     var isRecording: Bool { get }
@@ -55,7 +54,7 @@ protocol AudioRecorderProtocol: ObservableObject {
     var currentAmplitude: Float { get }
     /// Rolling buffer of recent amplitude samples for waveform drawing
     var waveformSamples: [Float] { get }
-    /// The local file URL of the current/last recording. nil for mock.
+    /// The local file URL of the current/last recording.
     var outputFileURL: URL? { get }
     /// Whether the active recording is paused
     var isPaused: Bool { get }

+ 5 - 6
CelestiaTrace/Services/Audio/RealAudioRecorder.swift

@@ -42,8 +42,7 @@ final class RealAudioRecorder: AudioRecorderProtocol {
         outputFileURL = nil
         
         // Request Permission
-        let session = AVAudioSession.sharedInstance()
-        session.requestRecordPermission { [weak self] granted in
+        AVAudioApplication.requestRecordPermission { [weak self] granted in
             guard let self else { return }
             if granted {
                 DispatchQueue.main.async {
@@ -59,7 +58,7 @@ final class RealAudioRecorder: AudioRecorderProtocol {
         let session = AVAudioSession.sharedInstance()
         do {
             // Set up audio session category and mode for recording, ducking other apps
-            try session.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker, .allowBluetooth, .duckOthers])
+            try session.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker, .allowBluetoothHFP, .duckOthers])
             try session.setActive(true)
             
             // Create target file URL
@@ -300,8 +299,9 @@ struct AudioMerger {
         
         switch exportSession.status {
         case .completed:
-            let mergedDuration = CMTimeGetSeconds(AVURLAsset(url: mergedURL).duration)
-            print("[AudioMerger] Successfully merged audio to: \(mergedURL.lastPathComponent) (\(mergedDuration)s)")
+            let mergedDuration = try? await AVURLAsset(url: mergedURL).load(.duration)
+            let mergedSeconds = mergedDuration.map(CMTimeGetSeconds) ?? 0
+            print("[AudioMerger] Successfully merged audio to: \(mergedURL.lastPathComponent) (\(mergedSeconds)s)")
             return mergedURL
         default:
             print("[AudioMerger] Audio export failed: \(String(describing: exportSession.error))")
@@ -309,4 +309,3 @@ struct AudioMerger {
         }
     }
 }
-

+ 2 - 2
CelestiaTrace/Services/Audio/RecordingEnvironmentDetector.swift

@@ -11,7 +11,7 @@ final class RecordingEnvironmentDetector {
     
     var isOtherAudioPlaying: Bool = false
     var isVolumeTooHigh: Bool = false
-    var micPermissionStatus: AVAudioSession.RecordPermission = .undetermined
+    var micPermissionStatus: AVAudioApplication.recordPermission = .undetermined
     
     /// List of human-readable warnings based on current environment state.
     var activeWarnings: [String] = []
@@ -39,7 +39,7 @@ final class RecordingEnvironmentDetector {
         self.isVolumeTooHigh = session.outputVolume > 0.7
         
         // 3. Check microphone permission status
-        self.micPermissionStatus = session.recordPermission
+        self.micPermissionStatus = AVAudioApplication.shared.recordPermission
         
         // Compile warning strings
         var warnings: [String] = []

+ 2 - 6
CelestiaTrace/Services/Audio/SilenceDetector.swift

@@ -28,13 +28,9 @@ public final class SilenceDetector: Sendable {
         thresholdDB: Float = -40.0,
         minDuration: TimeInterval = 2.0
     ) async -> [SilenceRange] {
-        // Fallback to mock data if the file doesn't exist locally (for simulator and mock prototyping)
         guard let validURL = AudioPathHelper.resolveURL(for: audioURL.path) else {
-            print("[SilenceDetector] File not found or empty path: \(audioURL.path). Returning mock silence ranges.")
-            return [
-                SilenceRange(start: 5.0, end: 12.0),
-                SilenceRange(start: 25.0, end: 32.0)
-            ]
+            print("[SilenceDetector] File not found or empty path: \(audioURL.path).")
+            return []
         }
         
         let task = Task.detached(priority: .userInitiated) { () -> [SilenceRange] in

+ 16 - 25
CelestiaTrace/Services/Auth/AuthManager.swift

@@ -9,20 +9,31 @@ final class AuthManager: ObservableObject {
     @Published var isProcessing: Bool = false
     @Published var authErrorMessage: String?
     
-    @Published var isSyncing: Bool = false
-    @Published var lastSyncDate: Date?
-    @Published var syncErrorMessage: String?
+    @Published private(set) var isRestoringSession: Bool = true
     
     var isLoggedIn: Bool {
         currentUser != nil
     }
     
-    /// Auth repository interface. Can be swapped for `RemoteAuthServiceStub()` or mock services.
+    /// Production server authentication service.
     private let authService: AuthServiceProtocol
     
-    init(authService: AuthServiceProtocol = LocalAuthRepository()) {
+    init(authService: AuthServiceProtocol = RemoteAuthService()) {
         self.authService = authService
         self.currentUser = authService.getCurrentUser()
+        authService.restoreSession { [weak self] result in
+            DispatchQueue.main.async {
+                guard let self else { return }
+                self.isRestoringSession = false
+                switch result {
+                case .success(let user):
+                    self.currentUser = user
+                case .failure(let error):
+                    self.currentUser = nil
+                    self.authErrorMessage = error.localizedDescription
+                }
+            }
+        }
     }
     
     // MARK: - Auth Actions
@@ -123,24 +134,4 @@ final class AuthManager: ObservableObject {
         }
     }
     
-    // MARK: - Audio Sync
-    
-    /// Triggers audio synchronization. Requires authenticated user.
-    func syncRecordings(onRequireAuth: @escaping () -> Void, onComplete: @escaping (Bool) -> Void) {
-        guard isLoggedIn else {
-            onRequireAuth()
-            return
-        }
-        
-        isSyncing = true
-        syncErrorMessage = nil
-        
-        // Mock sync delay
-        DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { [weak self] in
-            guard let self = self else { return }
-            self.isSyncing = false
-            self.lastSyncDate = Date()
-            onComplete(true)
-        }
-    }
 }

+ 1 - 0
CelestiaTrace/Services/Auth/AuthServiceProtocol.swift

@@ -66,6 +66,7 @@ struct ChangePasswordRequest {
 /// Abstract contract for authentication service implementations.
 /// Allows swapping between local persistence and remote server HTTP API seamlessly.
 protocol AuthServiceProtocol {
+    func restoreSession(completion: @escaping (AuthResult<UserAccount?>) -> Void)
     func login(request: LoginRequest, completion: @escaping (AuthResult<UserAccount>) -> Void)
     func register(request: RegisterRequest, completion: @escaping (AuthResult<UserAccount>) -> Void)
     func updateProfile(request: UpdateProfileRequest, completion: @escaping (AuthResult<UserAccount>) -> Void)

+ 0 - 203
CelestiaTrace/Services/Auth/LocalAuthRepository.swift

@@ -1,203 +0,0 @@
-import Foundation
-
-/// Internal struct to persist user credentials locally.
-private struct LocalUserRecord: Codable {
-    var user: UserAccount
-    var passwordHash: String
-}
-
-/// Concrete local implementation of `AuthServiceProtocol`.
-/// Handles registration, authentication, profile updates, and password changes purely in local storage (UserDefaults / sandbox).
-final class LocalAuthRepository: AuthServiceProtocol {
-    
-    private let usersKey = "com.celestia.trace.registered_users_db"
-    private let currentUserKey = "com.celestia.trace.current_user_session"
-    
-    init() {}
-    
-    // MARK: - AuthServiceProtocol
-    
-    func login(request: LoginRequest, completion: @escaping (AuthResult<UserAccount>) -> Void) {
-        let identifier = request.identifier.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
-        let password = request.password
-        
-        guard !identifier.isEmpty, password.count >= 6 else {
-            completion(.failure(.invalidInput("请输入有效账号与不少于 6 位的密码")))
-            return
-        }
-        
-        var records = loadAllRecords()
-        
-        // Match identifier by email, phone, or matching username/id
-        guard let matchingIndex = records.firstIndex(where: { rec in
-            rec.user.email?.lowercased() == identifier ||
-            rec.user.phoneNumber == identifier ||
-            rec.user.username.lowercased() == identifier ||
-            rec.user.id == identifier
-        }) else {
-            // Auto-fallback for demo convenience if no users registered yet, or error out:
-            completion(.failure(.invalidCredentials))
-            return
-        }
-        
-        let record = records[matchingIndex]
-        if record.passwordHash == hashPassword(password) {
-            var updatedUser = record.user
-            updatedUser.token = "jwt_token_" + UUID().uuidString.prefix(12)
-            records[matchingIndex].user = updatedUser
-            saveAllRecords(records)
-            saveCurrentSession(updatedUser)
-            completion(.success(updatedUser))
-        } else {
-            completion(.failure(.invalidCredentials))
-        }
-    }
-    
-    func register(request: RegisterRequest, completion: @escaping (AuthResult<UserAccount>) -> Void) {
-        let username = request.username.trimmingCharacters(in: .whitespacesAndNewlines)
-        let identifier = request.identifier.trimmingCharacters(in: .whitespacesAndNewlines)
-        let password = request.password
-        
-        guard !username.isEmpty else {
-            completion(.failure(.invalidInput("请输入用户名/昵称")))
-            return
-        }
-        
-        guard !identifier.isEmpty else {
-            completion(.failure(.invalidInput("请输入手机号或邮箱")))
-            return
-        }
-        
-        guard password.count >= 6 else {
-            completion(.failure(.invalidInput("密码长度不能小于 6 位")))
-            return
-        }
-        
-        var records = loadAllRecords()
-        let identifierLower = identifier.lowercased()
-        
-        let alreadyExists = records.contains { rec in
-            rec.user.email?.lowercased() == identifierLower ||
-            rec.user.phoneNumber == identifierLower ||
-            rec.user.username.lowercased() == username.lowercased()
-        }
-        
-        if alreadyExists {
-            completion(.failure(.userAlreadyExists))
-            return
-        }
-        
-        let isEmail = identifier.contains("@")
-        let newUser = UserAccount(
-            id: "usr_" + UUID().uuidString.prefix(8),
-            username: username,
-            email: isEmail ? identifier : nil,
-            phoneNumber: isEmail ? nil : identifier,
-            avatarURL: "preset_avatar_\((records.count % 5) + 1)",
-            token: "jwt_token_" + UUID().uuidString.prefix(12),
-            registeredAt: Date()
-        )
-        
-        let record = LocalUserRecord(user: newUser, passwordHash: hashPassword(password))
-        records.append(record)
-        
-        saveAllRecords(records)
-        saveCurrentSession(newUser)
-        completion(.success(newUser))
-    }
-    
-    func updateProfile(request: UpdateProfileRequest, completion: @escaping (AuthResult<UserAccount>) -> Void) {
-        guard var currentUser = getCurrentUser() else {
-            completion(.failure(.userNotFound))
-            return
-        }
-        
-        if let username = request.username, !username.trimmingCharacters(in: .whitespaces).isEmpty {
-            currentUser.username = username
-        }
-        if let email = request.email {
-            currentUser.email = email.isEmpty ? nil : email
-        }
-        if let phone = request.phoneNumber {
-            currentUser.phoneNumber = phone.isEmpty ? nil : phone
-        }
-        if let avatar = request.avatarURL {
-            currentUser.avatarURL = avatar
-        }
-        
-        var records = loadAllRecords()
-        if let idx = records.firstIndex(where: { $0.user.id == currentUser.id }) {
-            records[idx].user = currentUser
-            saveAllRecords(records)
-        }
-        
-        saveCurrentSession(currentUser)
-        completion(.success(currentUser))
-    }
-    
-    func changePassword(request: ChangePasswordRequest, completion: @escaping (AuthResult<Bool>) -> Void) {
-        guard let currentUser = getCurrentUser() else {
-            completion(.failure(.userNotFound))
-            return
-        }
-        
-        guard request.newPassword.count >= 6 else {
-            completion(.failure(.invalidInput("新密码长度不能少于 6 位")))
-            return
-        }
-        
-        var records = loadAllRecords()
-        guard let idx = records.firstIndex(where: { $0.user.id == currentUser.id }) else {
-            completion(.failure(.userNotFound))
-            return
-        }
-        
-        if records[idx].passwordHash != hashPassword(request.oldPassword) {
-            completion(.failure(.incorrectPassword))
-            return
-        }
-        
-        records[idx].passwordHash = hashPassword(request.newPassword)
-        saveAllRecords(records)
-        completion(.success(true))
-    }
-    
-    func logout(completion: @escaping () -> Void) {
-        UserDefaults.standard.removeObject(forKey: currentUserKey)
-        completion()
-    }
-    
-    func getCurrentUser() -> UserAccount? {
-        guard let data = UserDefaults.standard.data(forKey: currentUserKey),
-              let user = try? JSONDecoder().decode(UserAccount.self, from: data) else {
-            return nil
-        }
-        return user
-    }
-    
-    // MARK: - Private Helpers
-    
-    private func loadAllRecords() -> [LocalUserRecord] {
-        guard let data = UserDefaults.standard.data(forKey: usersKey),
-              let records = try? JSONDecoder().decode([LocalUserRecord].self, from: data) else {
-            return []
-        }
-        return records
-    }
-    
-    private func saveAllRecords(_ records: [LocalUserRecord]) {
-        if let data = try? JSONEncoder().encode(records) {
-            UserDefaults.standard.set(data, forKey: usersKey)
-        }
-    }
-    
-    private func saveCurrentSession(_ user: UserAccount) {
-        if let data = try? JSONEncoder().encode(user) {
-            UserDefaults.standard.set(data, forKey: currentUserKey)
-        }
-    }
-    
-    private func hashPassword(_ raw: String) -> String {
-        return "hashed_" + String(raw.reversed()) + "_salt"
-    }
-}

+ 157 - 0
CelestiaTrace/Services/Auth/RemoteAuthService.swift

@@ -0,0 +1,157 @@
+import Foundation
+
+private struct AuthPayload: Decodable {
+    let user: UserAccount
+    let token: String
+    let refreshToken: String
+    let expiresAt: Date
+}
+
+private struct LoginBody: Encodable {
+    let identifier: String
+    let password: String
+}
+
+private struct RegisterBody: Encodable {
+    let username: String
+    let identifier: String
+    let password: String
+}
+
+private struct UpdateProfileBody: Encodable {
+    let username: String?
+    let email: String?
+    let phoneNumber: String?
+    let avatarURL: String?
+}
+
+private struct ChangePasswordBody: Encodable {
+    let oldPassword: String
+    let newPassword: String
+}
+
+final class RemoteAuthService: AuthServiceProtocol {
+    private let client: APIClient
+    private let profileKey = "com.celestia.trace.cached_server_profile"
+    private var cachedUser: UserAccount?
+
+    init(client: APIClient = .shared) {
+        self.client = client
+        // Remove credentials created by the retired local-only auth repository.
+        // These keys never contained a valid server session.
+        UserDefaults.standard.removeObject(forKey: "com.celestia.trace.registered_users_db")
+        UserDefaults.standard.removeObject(forKey: "com.celestia.trace.current_user_session")
+        if let data = UserDefaults.standard.data(forKey: profileKey) {
+            cachedUser = try? JSONDecoder().decode(UserAccount.self, from: data)
+        }
+    }
+
+    func restoreSession(completion: @escaping (AuthResult<UserAccount?>) -> Void) {
+        Task {
+            guard await client.hasStoredSession() else {
+                clearCachedProfile()
+                completion(.success(nil))
+                return
+            }
+            do {
+                let user: UserAccount = try await client.request("user/profile", authenticated: true)
+                cache(user)
+                completion(.success(user))
+            } catch APIError.transport {
+                completion(.success(cachedUser))
+            } catch {
+                await client.clearSession()
+                clearCachedProfile()
+                completion(.failure(map(error)))
+            }
+        }
+    }
+
+    func login(request: LoginRequest, completion: @escaping (AuthResult<UserAccount>) -> Void) {
+        performAuthentication(path: "auth/login", body: LoginBody(identifier: request.identifier, password: request.password), completion: completion)
+    }
+
+    func register(request: RegisterRequest, completion: @escaping (AuthResult<UserAccount>) -> Void) {
+        performAuthentication(
+            path: "auth/register",
+            body: RegisterBody(username: request.username, identifier: request.identifier, password: request.password),
+            completion: completion
+        )
+    }
+
+    func updateProfile(request: UpdateProfileRequest, completion: @escaping (AuthResult<UserAccount>) -> Void) {
+        Task {
+            do {
+                let body = UpdateProfileBody(username: request.username, email: request.email, phoneNumber: request.phoneNumber, avatarURL: request.avatarURL)
+                let user: UserAccount = try await client.request("user/profile", method: .put, body: try JSONEncoder().encode(body), authenticated: true)
+                cache(user)
+                completion(.success(user))
+            } catch {
+                completion(.failure(map(error)))
+            }
+        }
+    }
+
+    func changePassword(request: ChangePasswordRequest, completion: @escaping (AuthResult<Bool>) -> Void) {
+        Task {
+            do {
+                let body = ChangePasswordBody(oldPassword: request.oldPassword, newPassword: request.newPassword)
+                try await client.requestVoid("user/change-password", method: .post, body: try JSONEncoder().encode(body), authenticated: true)
+                completion(.success(true))
+            } catch {
+                completion(.failure(map(error)))
+            }
+        }
+    }
+
+    func logout(completion: @escaping () -> Void) {
+        Task {
+            try? await client.requestVoid("auth/logout", method: .post, authenticated: true)
+            await client.clearSession()
+            clearCachedProfile()
+            completion()
+        }
+    }
+
+    func getCurrentUser() -> UserAccount? {
+        cachedUser
+    }
+
+    private func performAuthentication<Body: Encodable>(
+        path: String,
+        body: Body,
+        completion: @escaping (AuthResult<UserAccount>) -> Void
+    ) {
+        Task {
+            do {
+                let payload: AuthPayload = try await client.request(path, method: .post, body: try JSONEncoder().encode(body))
+                try await client.storeTokens(accessToken: payload.token, refreshToken: payload.refreshToken, expiresAt: payload.expiresAt)
+                cache(payload.user)
+                completion(.success(payload.user))
+            } catch {
+                completion(.failure(map(error)))
+            }
+        }
+    }
+
+    private func cache(_ user: UserAccount) {
+        cachedUser = user
+        if let data = try? JSONEncoder().encode(user) {
+            UserDefaults.standard.set(data, forKey: profileKey)
+        }
+    }
+
+    private func clearCachedProfile() {
+        cachedUser = nil
+        UserDefaults.standard.removeObject(forKey: profileKey)
+    }
+
+    private func map(_ error: Error) -> AuthError {
+        if case APIError.unauthorized = error { return .invalidCredentials }
+        if case APIError.server(let code, _) = error {
+            if code == 409 { return .userAlreadyExists }
+            if code == 401 { return .invalidCredentials }
+        }
+        return .networkError(error.localizedDescription)
+    }
+}

+ 0 - 243
CelestiaTrace/Services/Auth/RemoteAuthServiceStub.swift

@@ -1,243 +0,0 @@
-import Foundation
-
-/// DTO helper structures matching Go backend API schema
-private struct BackendResponse<T: Decodable>: Decodable {
-    let code: Int
-    let message: String
-    let data: T?
-}
-
-private struct BackendAuthData: Decodable {
-    let user: BackendUser
-    let token: String
-}
-
-private struct BackendUser: Decodable {
-    let id: String
-    let username: String
-    let email: String?
-    let phoneNumber: String?
-    let avatarURL: String?
-    let registeredAt: Date
-    
-    func toUserAccount(token: String) -> UserAccount {
-        return UserAccount(
-            id: id,
-            username: username,
-            email: email,
-            phoneNumber: phoneNumber,
-            avatarURL: avatarURL,
-            token: token,
-            registeredAt: registeredAt
-        )
-    }
-}
-
-/// Remote server adapter implementing `AuthServiceProtocol`.
-/// Connects to the CelestiaTrace Go backend server at https://celestia-trace.ccdw.life/v1
-final class RemoteAuthServiceStub: AuthServiceProtocol {
-    private let baseURL: URL
-    private let session: URLSession
-    private var cachedUser: UserAccount?
-    
-    init(baseURL: URL = URL(string: "https://celestia-trace.ccdw.life/v1")!, session: URLSession = .shared) {
-        self.baseURL = baseURL
-        self.session = session
-    }
-    
-    private var decoder: JSONDecoder {
-        let dec = JSONDecoder()
-        dec.dateDecodingStrategy = .custom { decoder in
-            let container = try decoder.singleValueContainer()
-            let dateStr = try container.decode(String.self)
-            
-            let formatter = ISO8601DateFormatter()
-            formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
-            if let date = formatter.date(from: dateStr) {
-                return date
-            }
-            formatter.formatOptions = [.withInternetDateTime]
-            if let date = formatter.date(from: dateStr) {
-                return date
-            }
-            throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid date format: \(dateStr)")
-        }
-        return dec
-    }
-    
-    func login(request: LoginRequest, completion: @escaping (AuthResult<UserAccount>) -> Void) {
-        let endpoint = baseURL.appendingPathComponent("auth/login")
-        var urlReq = URLRequest(url: endpoint)
-        urlReq.httpMethod = "POST"
-        urlReq.setValue("application/json", forHTTPHeaderField: "Content-Type")
-        
-        let body: [String: Any] = [
-            "identifier": request.identifier,
-            "password": request.password
-        ]
-        urlReq.httpBody = try? JSONSerialization.data(withJSONObject: body)
-        
-        performRequest(urlReq, completion: completion)
-    }
-    
-    func register(request: RegisterRequest, completion: @escaping (AuthResult<UserAccount>) -> Void) {
-        let endpoint = baseURL.appendingPathComponent("auth/register")
-        var urlReq = URLRequest(url: endpoint)
-        urlReq.httpMethod = "POST"
-        urlReq.setValue("application/json", forHTTPHeaderField: "Content-Type")
-        
-        let body: [String: Any] = [
-            "username": request.username,
-            "identifier": request.identifier,
-            "password": request.password
-        ]
-        urlReq.httpBody = try? JSONSerialization.data(withJSONObject: body)
-        
-        performRequest(urlReq, completion: completion)
-    }
-    
-    func updateProfile(request: UpdateProfileRequest, completion: @escaping (AuthResult<UserAccount>) -> Void) {
-        guard let token = cachedUser?.token else {
-            completion(.failure(.userNotFound))
-            return
-        }
-        
-        let endpoint = baseURL.appendingPathComponent("user/profile")
-        var urlReq = URLRequest(url: endpoint)
-        urlReq.httpMethod = "PUT"
-        urlReq.setValue("application/json", forHTTPHeaderField: "Content-Type")
-        urlReq.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
-        
-        var body: [String: Any] = [:]
-        if let username = request.username { body["username"] = username }
-        if let email = request.email { body["email"] = email }
-        if let phone = request.phoneNumber { body["phoneNumber"] = phone }
-        if let avatar = request.avatarURL { body["avatarURL"] = avatar }
-        
-        urlReq.httpBody = try? JSONSerialization.data(withJSONObject: body)
-        
-        let task = session.dataTask(with: urlReq) { [weak self] data, response, error in
-            guard let self = self else { return }
-            if let error = error {
-                completion(.failure(.networkError(error.localizedDescription)))
-                return
-            }
-            guard let data = data else {
-                completion(.failure(.networkError("未收到服务器响应数据")))
-                return
-            }
-            
-            do {
-                let resp = try self.decoder.decode(BackendResponse<BackendUser>.self, from: data)
-                if resp.code == 0, let bUser = resp.data {
-                    let userAccount = bUser.toUserAccount(token: token)
-                    self.cachedUser = userAccount
-                    completion(.success(userAccount))
-                } else {
-                    completion(.failure(.networkError(resp.message)))
-                }
-            } catch {
-                completion(.failure(.networkError("响应解析失败: \(error.localizedDescription)")))
-            }
-        }
-        task.resume()
-    }
-    
-    func changePassword(request: ChangePasswordRequest, completion: @escaping (AuthResult<Bool>) -> Void) {
-        guard let token = cachedUser?.token else {
-            completion(.failure(.userNotFound))
-            return
-        }
-        
-        let endpoint = baseURL.appendingPathComponent("user/change-password")
-        var urlReq = URLRequest(url: endpoint)
-        urlReq.httpMethod = "POST"
-        urlReq.setValue("application/json", forHTTPHeaderField: "Content-Type")
-        urlReq.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
-        
-        let body: [String: Any] = [
-            "oldPassword": request.oldPassword,
-            "newPassword": request.newPassword
-        ]
-        urlReq.httpBody = try? JSONSerialization.data(withJSONObject: body)
-        
-        let task = session.dataTask(with: urlReq) { data, response, error in
-            if let error = error {
-                completion(.failure(.networkError(error.localizedDescription)))
-                return
-            }
-            guard let data = data else {
-                completion(.failure(.networkError("未收到服务器响应数据")))
-                return
-            }
-            
-            do {
-                if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
-                   let code = json["code"] as? Int {
-                    if code == 0 {
-                        completion(.success(true))
-                    } else {
-                        let msg = json["message"] as? String ?? "密码修改失败"
-                        completion(.failure(.incorrectPassword))
-                    }
-                } else {
-                    completion(.failure(.networkError("非法服务器响应格式")))
-                }
-            } catch {
-                completion(.failure(.networkError(error.localizedDescription)))
-            }
-        }
-        task.resume()
-    }
-    
-    func logout(completion: @escaping () -> Void) {
-        if let token = cachedUser?.token {
-            let endpoint = baseURL.appendingPathComponent("auth/logout")
-            var urlReq = URLRequest(url: endpoint)
-            urlReq.httpMethod = "POST"
-            urlReq.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
-            let task = session.dataTask(with: urlReq) { _, _, _ in }
-            task.resume()
-        }
-        cachedUser = nil
-        completion()
-    }
-    
-    func getCurrentUser() -> UserAccount? {
-        return cachedUser
-    }
-    
-    private func performRequest(_ urlReq: URLRequest, completion: @escaping (AuthResult<UserAccount>) -> Void) {
-        let task = session.dataTask(with: urlReq) { [weak self] data, response, error in
-            guard let self = self else { return }
-            if let error = error {
-                completion(.failure(.networkError(error.localizedDescription)))
-                return
-            }
-            guard let data = data else {
-                completion(.failure(.networkError("未收到服务器响应数据")))
-                return
-            }
-            
-            do {
-                let resp = try self.decoder.decode(BackendResponse<BackendAuthData>.self, from: data)
-                if resp.code == 0, let authData = resp.data {
-                    let userAccount = authData.user.toUserAccount(token: authData.token)
-                    self.cachedUser = userAccount
-                    completion(.success(userAccount))
-                } else {
-                    if resp.code == 409 {
-                        completion(.failure(.userAlreadyExists))
-                    } else if resp.code == 401 {
-                        completion(.failure(.invalidCredentials))
-                    } else {
-                        completion(.failure(.networkError(resp.message)))
-                    }
-                }
-            } catch {
-                completion(.failure(.networkError("响应解析失败: \(error.localizedDescription)")))
-            }
-        }
-        task.resume()
-    }
-}

+ 32 - 45
CelestiaTrace/Services/Bluetooth/BLEManager.swift

@@ -54,6 +54,14 @@ final class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CB
     static let writeUUID = CBUUID(string: "001120a2-2233-4455-6677-88995a5b5c5d")
     static let commandNotifyUUID = CBUUID(string: "001120a3-2233-4455-6677-88995a5b5c5d")
 
+    static func isSparkAdvertisement(name: String?, serviceUUIDs: [CBUUID]) -> Bool {
+        let normalizedName = name?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? ""
+        return serviceUUIDs.contains(serviceUUID)
+            || normalizedName.contains("spark")
+            || normalizedName.contains("mr20")
+            || normalizedName.contains("微光")
+    }
+
     @Published private(set) var state: CBManagerState = .unknown
     @Published private(set) var isScanning: Bool = false
     @Published private(set) var discoveredDevices: [DiscoveredBLEDevice] = []
@@ -150,6 +158,13 @@ final class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CB
         saveBoundDevices()
     }
 
+    func updateCloudRegistration(deviceID: String, cloudID: String?, error: String?) {
+        updateBoundDevice(id: deviceID) {
+            $0.cloudID = cloudID
+            $0.cloudSyncError = error
+        }
+    }
+
     // MARK: - Scanning and binding
 
     func startScanning() {
@@ -163,26 +178,13 @@ final class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CB
         }
 
         isScanning = true
-        // The vendor sheet doesn't guarantee that the service UUID is advertised,
-        // so discovery is broad and results are filtered by service/name hints.
+        // Some Spark firmware versions do not advertise the service UUID. Scan
+        // broadly, but only surface peripherals that explicitly advertise the
+        // Spark service or a known product name.
         centralManager.scanForPeripherals(
             withServices: nil,
             options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]
         )
-
-#if targetEnvironment(simulator)
-        DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
-            guard let self, self.isScanning else { return }
-            self.discoveredDevices = [
-                DiscoveredBLEDevice(
-                    id: "MOCK-SPARK-01",
-                    name: "MR20 Spark Demo",
-                    rssi: -55,
-                    peripheralUUID: "MOCK-SPARK-01"
-                )
-            ]
-        }
-#endif
     }
 
     func stopScanning() {
@@ -198,27 +200,6 @@ final class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CB
         userId: String,
         completion: @escaping (Result<BoundDevice, Error>) -> Void
     ) {
-#if targetEnvironment(simulator)
-        if device.id.hasPrefix("MOCK-") {
-            let bound = BoundDevice(
-                id: device.id,
-                name: "微光(Spark)",
-                peripheralUUID: device.peripheralUUID,
-                isConnected: true,
-                batteryLevel: 86,
-                firmwareVersion: "Demo",
-                boundUserId: userId,
-                advertisedName: device.name,
-                freeStorageMB: 1_024,
-                totalStorageMB: 2_048
-            )
-            upsertBoundDevice(bound)
-            connectionPhases[device.id] = .ready
-            completion(.success(bound))
-            return
-        }
-#endif
-
         guard centralManager.state == .poweredOn else {
             completion(.failure(SparkBLEError.bluetoothUnavailable))
             return
@@ -335,20 +316,17 @@ final class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CB
         let id = peripheral.identifier.uuidString
         let advertisedName = advertisementData[CBAdvertisementDataLocalNameKey] as? String
             ?? peripheral.name
-            ?? "微光录音设备"
         let advertisedServices = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] ?? []
-        let normalizedName = advertisedName.lowercased()
-        let matchesProduct = advertisedServices.contains(Self.serviceUUID)
-            || normalizedName.contains("spark")
-            || normalizedName.contains("mr20")
-            || normalizedName.contains("微光")
-        guard matchesProduct else { return }
+        guard Self.isSparkAdvertisement(name: advertisedName, serviceUUIDs: advertisedServices) else { return }
+
+        let displayName = advertisedName?.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard RSSI.intValue != 127 else { return }
 
         peripherals[id] = peripheral
         peripheral.delegate = self
         let device = DiscoveredBLEDevice(
             id: id,
-            name: advertisedName,
+            name: displayName?.isEmpty == false ? displayName! : "微光(Spark)",
             rssi: RSSI.intValue,
             peripheralUUID: id
         )
@@ -357,6 +335,7 @@ final class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CB
         } else {
             discoveredDevices.append(device)
         }
+        discoveredDevices.sort { $0.rssi > $1.rssi }
     }
 
     func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
@@ -742,6 +721,14 @@ final class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CB
     private func loadBoundDevices() {
         guard let data = UserDefaults.standard.data(forKey: boundDevicesKey),
               var devices = try? JSONDecoder().decode([BoundDevice].self, from: data) else { return }
+        let mockDeviceID = "MOCK-SPARK-01"
+        if devices.contains(where: { $0.id == mockDeviceID || $0.peripheralUUID == mockDeviceID }) {
+            devices.removeAll { $0.id == mockDeviceID || $0.peripheralUUID == mockDeviceID }
+            deletePairingPassword(for: mockDeviceID)
+            if let cleaned = try? JSONEncoder().encode(devices) {
+                UserDefaults.standard.set(cleaned, forKey: boundDevicesKey)
+            }
+        }
         for index in devices.indices {
             devices[index].isConnected = false
         }

+ 371 - 0
CelestiaTrace/Services/Network/APIClient.swift

@@ -0,0 +1,371 @@
+import Foundation
+import Security
+
+enum HTTPMethod: String {
+    case get = "GET"
+    case post = "POST"
+    case put = "PUT"
+    case delete = "DELETE"
+}
+
+struct APIEnvelope<T: Decodable>: Decodable {
+    let code: Int
+    let message: String
+    let data: T?
+}
+
+struct EmptyAPIData: Decodable { }
+
+enum APIError: LocalizedError {
+    case invalidConfiguration
+    case invalidResponse
+    case unauthorized
+    case server(code: Int, message: String)
+    case transport(String)
+    case decoding(String)
+    case missingData
+
+    var errorDescription: String? {
+        switch self {
+        case .invalidConfiguration: return "服务地址配置无效"
+        case .invalidResponse: return "服务器返回了无效响应"
+        case .unauthorized: return "登录状态已失效,请重新登录"
+        case .server(_, let message): return message
+        case .transport(let message): return "网络连接失败:\(message)"
+        case .decoding(let message): return "服务器数据解析失败:\(message)"
+        case .missingData: return "服务器响应缺少必要数据"
+        }
+    }
+}
+
+struct AuthTokens: Codable, Sendable {
+    let accessToken: String
+    let refreshToken: String
+    let expiresAt: Date
+}
+
+final class TokenStore: @unchecked Sendable {
+    static let shared = TokenStore()
+
+    private let service = "com.celestia.trace.authentication"
+    private let account = "current-session"
+
+    func load() -> AuthTokens? {
+        let query: [String: Any] = [
+            kSecClass as String: kSecClassGenericPassword,
+            kSecAttrService as String: service,
+            kSecAttrAccount as String: account,
+            kSecReturnData as String: true,
+            kSecMatchLimit as String: kSecMatchLimitOne
+        ]
+        var result: CFTypeRef?
+        guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
+              let data = result as? Data else { return nil }
+        return try? JSONDecoder().decode(AuthTokens.self, from: data)
+    }
+
+    func save(_ tokens: AuthTokens) throws {
+        let data = try JSONEncoder().encode(tokens)
+        let baseQuery: [String: Any] = [
+            kSecClass as String: kSecClassGenericPassword,
+            kSecAttrService as String: service,
+            kSecAttrAccount as String: account
+        ]
+        let attributes: [String: Any] = [
+            kSecValueData as String: data,
+            kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
+        ]
+        let status = SecItemUpdate(baseQuery as CFDictionary, attributes as CFDictionary)
+        if status == errSecItemNotFound {
+            var insert = baseQuery
+            attributes.forEach { insert[$0.key] = $0.value }
+            guard SecItemAdd(insert as CFDictionary, nil) == errSecSuccess else {
+                throw APIError.transport("无法安全保存登录凭据")
+            }
+        } else if status != errSecSuccess {
+            throw APIError.transport("无法更新登录凭据")
+        }
+    }
+
+    func clear() {
+        let query: [String: Any] = [
+            kSecClass as String: kSecClassGenericPassword,
+            kSecAttrService as String: service,
+            kSecAttrAccount as String: account
+        ]
+        SecItemDelete(query as CFDictionary)
+    }
+}
+
+private struct RefreshPayload: Decodable {
+    let token: String
+    let refreshToken: String
+    let expiresAt: Date
+}
+
+actor APIClient {
+    static let shared = APIClient()
+
+    private let baseURL: URL
+    private let session: URLSession
+    private let tokenStore: TokenStore
+
+    init(
+        baseURL: URL? = nil,
+        session: URLSession? = nil,
+        tokenStore: TokenStore = .shared
+    ) {
+        let configured = baseURL ?? (Bundle.main.object(forInfoDictionaryKey: "APIBaseURL") as? String).flatMap(URL.init(string:))
+        self.baseURL = configured ?? URL(string: "https://celestia-trace.ccdw.life/v1")!
+        if let session {
+            self.session = session
+        } else {
+            let configuration = URLSessionConfiguration.default
+            configuration.waitsForConnectivity = true
+            configuration.timeoutIntervalForRequest = 30
+            configuration.timeoutIntervalForResource = 300
+            self.session = URLSession(configuration: configuration)
+        }
+        self.tokenStore = tokenStore
+    }
+
+    func hasStoredSession() -> Bool {
+        tokenStore.load() != nil
+    }
+
+    func storeTokens(accessToken: String, refreshToken: String, expiresAt: Date) throws {
+        try tokenStore.save(AuthTokens(accessToken: accessToken, refreshToken: refreshToken, expiresAt: expiresAt))
+    }
+
+    func clearSession() {
+        tokenStore.clear()
+    }
+
+    func request<T: Decodable>(
+        _ path: String,
+        method: HTTPMethod = .get,
+        body: Data? = nil,
+        authenticated: Bool = false
+    ) async throws -> T {
+        let envelope: APIEnvelope<T> = try await execute(
+            path,
+            method: method,
+            body: body,
+            authenticated: authenticated,
+            canRefresh: true
+        )
+        guard let data = envelope.data else { throw APIError.missingData }
+        return data
+    }
+
+    func requestVoid(
+        _ path: String,
+        method: HTTPMethod,
+        body: Data? = nil,
+        authenticated: Bool = false
+    ) async throws {
+        let _: APIEnvelope<EmptyAPIData> = try await execute(
+            path,
+            method: method,
+            body: body,
+            authenticated: authenticated,
+            canRefresh: true
+        )
+    }
+
+    func upload<T: Decodable>(
+        _ path: String,
+        fileURL: URL,
+        fileName: String,
+        mimeType: String,
+        fields: [String: String]
+    ) async throws -> T {
+        do {
+            return try await performUpload(path, fileURL: fileURL, fileName: fileName, mimeType: mimeType, fields: fields)
+        } catch APIError.unauthorized {
+            try await refreshTokens()
+            return try await performUpload(path, fileURL: fileURL, fileName: fileName, mimeType: mimeType, fields: fields)
+        }
+    }
+
+    func download(_ path: String, to destinationURL: URL) async throws {
+        do {
+            try await performDownload(path, to: destinationURL)
+        } catch APIError.unauthorized {
+            try await refreshTokens()
+            try await performDownload(path, to: destinationURL)
+        }
+    }
+
+    private func execute<T: Decodable>(
+        _ path: String,
+        method: HTTPMethod,
+        body: Data?,
+        authenticated: Bool,
+        canRefresh: Bool
+    ) async throws -> APIEnvelope<T> {
+        var request = try makeRequest(path: path, method: method, body: body)
+        if authenticated {
+            guard let accessToken = tokenStore.load()?.accessToken else { throw APIError.unauthorized }
+            request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
+        }
+        let data: Data
+        let response: URLResponse
+        do {
+            (data, response) = try await session.data(for: request)
+        } catch {
+            throw APIError.transport(error.localizedDescription)
+        }
+        guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse }
+        if http.statusCode == 401, authenticated, canRefresh {
+            try await refreshTokens()
+            return try await execute(path, method: method, body: body, authenticated: true, canRefresh: false)
+        }
+        let envelope: APIEnvelope<T>
+        do {
+            envelope = try Self.decoder.decode(APIEnvelope<T>.self, from: data)
+        } catch {
+            throw APIError.decoding(error.localizedDescription)
+        }
+        guard (200..<300).contains(http.statusCode), envelope.code == 0 else {
+            if http.statusCode == 401 { throw APIError.unauthorized }
+            throw APIError.server(code: envelope.code, message: envelope.message)
+        }
+        return envelope
+    }
+
+    private func refreshTokens() async throws {
+        guard let current = tokenStore.load() else { throw APIError.unauthorized }
+        let body = try JSONSerialization.data(withJSONObject: ["refreshToken": current.refreshToken])
+        let envelope: APIEnvelope<RefreshPayload> = try await execute(
+            "auth/refresh",
+            method: .post,
+            body: body,
+            authenticated: false,
+            canRefresh: false
+        )
+        guard let payload = envelope.data else { throw APIError.unauthorized }
+        try tokenStore.save(AuthTokens(accessToken: payload.token, refreshToken: payload.refreshToken, expiresAt: payload.expiresAt))
+    }
+
+    private func performUpload<T: Decodable>(
+        _ path: String,
+        fileURL: URL,
+        fileName: String,
+        mimeType: String,
+        fields: [String: String]
+    ) async throws -> T {
+        guard let token = tokenStore.load()?.accessToken else { throw APIError.unauthorized }
+        let boundary = "CelestiaBoundary-\(UUID().uuidString)"
+        let temporaryURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + ".upload")
+        guard FileManager.default.createFile(atPath: temporaryURL.path, contents: nil),
+              let output = try? FileHandle(forWritingTo: temporaryURL) else {
+            throw APIError.transport("无法准备上传文件")
+        }
+        defer {
+            try? output.close()
+            try? FileManager.default.removeItem(at: temporaryURL)
+        }
+        for (key, value) in fields.sorted(by: { $0.key < $1.key }) {
+            try output.write(contentsOf: Data("--\(boundary)\r\nContent-Disposition: form-data; name=\"\(key)\"\r\n\r\n\(value)\r\n".utf8))
+        }
+        try output.write(contentsOf: Data("--\(boundary)\r\nContent-Disposition: form-data; name=\"file\"; filename=\"\(fileName)\"\r\nContent-Type: \(mimeType)\r\n\r\n".utf8))
+        let input = try FileHandle(forReadingFrom: fileURL)
+        while let chunk = try input.read(upToCount: 1024 * 1024), !chunk.isEmpty {
+            try output.write(contentsOf: chunk)
+        }
+        try input.close()
+        try output.write(contentsOf: Data("\r\n--\(boundary)--\r\n".utf8))
+        try output.synchronize()
+
+        var request = try makeRequest(path: path, method: .post, body: nil)
+        request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
+        request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
+        let data: Data
+        let response: URLResponse
+        do {
+            (data, response) = try await session.upload(for: request, fromFile: temporaryURL)
+        } catch {
+            throw APIError.transport(error.localizedDescription)
+        }
+        guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse }
+        if http.statusCode == 401 { throw APIError.unauthorized }
+        let envelope: APIEnvelope<T>
+        do {
+            envelope = try Self.decoder.decode(APIEnvelope<T>.self, from: data)
+        } catch {
+            throw APIError.decoding(error.localizedDescription)
+        }
+        guard (200..<300).contains(http.statusCode), envelope.code == 0 else {
+            throw APIError.server(code: envelope.code, message: envelope.message)
+        }
+        guard let result = envelope.data else { throw APIError.missingData }
+        return result
+    }
+
+    private func performDownload(_ path: String, to destinationURL: URL) async throws {
+        guard let token = tokenStore.load()?.accessToken else { throw APIError.unauthorized }
+        var request = try makeRequest(path: path, method: .get, body: nil)
+        request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
+
+        let temporaryURL: URL
+        let response: URLResponse
+        do {
+            (temporaryURL, response) = try await session.download(for: request)
+        } catch {
+            throw APIError.transport(error.localizedDescription)
+        }
+        guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse }
+        if http.statusCode == 401 { throw APIError.unauthorized }
+        guard (200..<300).contains(http.statusCode) else {
+            throw APIError.server(code: http.statusCode, message: "云端文件下载失败")
+        }
+
+        let fileManager = FileManager.default
+        try fileManager.createDirectory(at: destinationURL.deletingLastPathComponent(), withIntermediateDirectories: true)
+        if fileManager.fileExists(atPath: destinationURL.path) {
+            return
+        }
+        do {
+            try fileManager.moveItem(at: temporaryURL, to: destinationURL)
+        } catch {
+            throw APIError.transport("无法保存云端文件:\(error.localizedDescription)")
+        }
+    }
+
+    private func makeRequest(path: String, method: HTTPMethod, body: Data?) throws -> URLRequest {
+        let pathAndQuery = path.split(separator: "?", maxSplits: 1, omittingEmptySubsequences: false)
+        let cleanPath = String(pathAndQuery[0]).trimmingCharacters(in: CharacterSet(charactersIn: "/"))
+        let pathURL = cleanPath.split(separator: "/").reduce(baseURL) { partial, component in
+            partial.appendingPathComponent(String(component))
+        }
+        var components = URLComponents(url: pathURL, resolvingAgainstBaseURL: false)
+        if pathAndQuery.count == 2 {
+            components?.percentEncodedQuery = String(pathAndQuery[1])
+        }
+        guard let url = components?.url else { throw APIError.invalidConfiguration }
+        var request = URLRequest(url: url)
+        request.httpMethod = method.rawValue
+        request.setValue("application/json", forHTTPHeaderField: "Accept")
+        if let body {
+            request.httpBody = body
+            request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+        }
+        return request
+    }
+
+    static let decoder: JSONDecoder = {
+        let decoder = JSONDecoder()
+        decoder.dateDecodingStrategy = .custom { decoder in
+            let container = try decoder.singleValueContainer()
+            let value = try container.decode(String.self)
+            let formatter = ISO8601DateFormatter()
+            formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+            if let date = formatter.date(from: value) { return date }
+            formatter.formatOptions = [.withInternetDateTime]
+            if let date = formatter.date(from: value) { return date }
+            throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid ISO-8601 date")
+        }
+        return decoder
+    }()
+}

+ 0 - 62
CelestiaTrace/Services/Network/MockNetworkService.swift

@@ -1,62 +0,0 @@
-import Foundation
-import Combine
-
-/// Mock network service simulating cloud sync progress and AI report generation.
-final class MockNetworkService: NetworkServiceProtocol {
-    // MARK: - NetworkServiceProtocol
-    
-    func syncSession(_ sessionId: UUID) -> AnyPublisher<Double, Error> {
-        // Simulate upload progress from 0.0 to 1.0 over ~3 seconds
-        let totalSteps = 30
-        let interval: TimeInterval = 0.1
-        
-        return Timer.publish(every: interval, on: .main, in: .common)
-            .autoconnect()
-            .scan(0) { count, _ in count + 1 }
-            .map { step in
-                min(Double(step) / Double(totalSteps), 1.0)
-            }
-            .prefix(while: { $0 <= 1.0 })
-            .append(1.0)
-            .removeDuplicates()
-            .setFailureType(to: Error.self)
-            .eraseToAnyPublisher()
-    }
-    
-    func fetchAIReport(for cloudSessionId: String) async throws -> String {
-        // Simulate network latency
-        try await Task.sleep(for: .seconds(1))
-        
-        return """
-        # 田野调查 AI 分析报告
-        
-        **会话编号**: \(cloudSessionId)
-        **生成时间**: \(DateFormatter.localizedString(from: Date(), dateStyle: .medium, timeStyle: .short))
-        
-        ## 摘要
-        
-        本次田野调查共记录音频片段 3 段,拍摄现场照片 5 张,添加文字笔记 8 条。调查地点位于城市边缘的工业遗址区域,\
-        主要关注废弃厂房的建筑结构保存状况及周边生态恢复情况。
-        
-        ## 关键发现
-        
-        1. **建筑结构**:主厂房钢结构框架保存完好,屋顶覆盖层已大面积脱落。东侧附属建筑存在明显沉降迹象。
-        2. **生态恢复**:厂区内部已有大量自生植被覆盖,观察到至少 3 种本地鸟类在废弃管道中筑巢。
-        3. **环境风险**:西北角地面检测到轻微土壤污染痕迹,建议后续进行专业采样分析。
-        
-        ## 音频转写片段
-        
-        > "……这里的钢梁连接处可以看到明显的铆接工艺,这是典型的上世纪八十年代的建造方式。注意看这个节点,\
-        焊接补强的痕迹说明后期做过结构加固……"
-        
-        ## 建议后续行动
-        
-        - 对西北角区域进行土壤采样送检
-        - 联系当地档案馆获取原始建筑图纸
-        - 安排无人机航拍以获取完整厂区俯瞰影像
-        
-        ---
-        *此报告由星痕现场记录 AI 引擎自动生成,仅供参考。*
-        """
-    }
-}

+ 5 - 9
CelestiaTrace/Services/Network/NetworkServiceProtocol.swift

@@ -1,13 +1,9 @@
 import Foundation
-import Combine
 
-/// Reserved protocol for cloud sync with Celestia Track backend.
 protocol NetworkServiceProtocol {
-    /// Upload a complete research session including audio, photos, notes and timeline JSON.
-    /// - Returns: Publisher emitting upload progress (0.0 - 1.0)
-    func syncSession(_ sessionId: UUID) -> AnyPublisher<Double, Error>
-    
-    /// Fetch AI transcription and report generation status.
-    /// - Returns: Transcribed text or AI summary report
-    func fetchAIReport(for cloudSessionId: String) async throws -> String
+    func fetchSessions() async throws -> [RemoteSession]
+    func syncSession(_ session: CelestiaSession) async throws -> RemoteSession
+    func uploadAsset(sessionID: String, clientID: String, kind: String, fileURL: URL) async throws -> RemoteAsset
+    func downloadAsset(sessionID: String, asset: RemoteAsset, destinationURL: URL) async throws
+    func recordSyncCheckpoint() async throws
 }

+ 364 - 0
CelestiaTrace/Services/Network/RemoteNetworkService.swift

@@ -0,0 +1,364 @@
+import Foundation
+import SwiftData
+import UniformTypeIdentifiers
+
+struct RemoteAsset: Decodable {
+    let id: String
+    let clientId: String
+    let kind: String
+    let fileName: String
+    let mimeType: String
+    let sizeBytes: Int64
+    let sha256: String
+}
+
+struct RemoteEvent: Decodable {
+    let id: String
+    let clientId: String?
+    let relativeTimeMs: Int64
+    let eventType: String
+    let textContent: String?
+    let voiceStartOffsetMs: Int64?
+    let voiceEndOffsetMs: Int64?
+}
+
+struct RemoteSession: Decodable {
+    let id: String
+    let clientId: String?
+    let title: String
+    let startTime: Date
+    let endTime: Date?
+    let durationMs: Int64
+    let revision: Int64
+    let deletedAt: Date?
+    let events: [RemoteEvent]?
+    let assets: [RemoteAsset]?
+}
+
+private struct SessionUpload: Encodable {
+    let clientId: String
+    let title: String
+    let startTime: Date
+    let endTime: Date?
+    let durationMs: Int64
+    let events: [EventUpload]
+}
+
+private struct EventUpload: Encodable {
+    let clientId: String
+    let relativeTimeMs: Int64
+    let eventType: String
+    let textContent: String?
+    let voiceStartOffsetMs: Int64?
+    let voiceEndOffsetMs: Int64?
+}
+
+private struct SyncCheckpoint: Decodable {
+    let syncedAt: Date
+}
+
+final class RemoteNetworkService: NetworkServiceProtocol {
+    private let client: APIClient
+
+    init(client: APIClient = .shared) {
+        self.client = client
+    }
+
+    func fetchSessions() async throws -> [RemoteSession] {
+        try await client.request("sessions?includeDeleted=true", authenticated: true)
+    }
+
+    func syncSession(_ session: CelestiaSession) async throws -> RemoteSession {
+        let payload = SessionUpload(
+            clientId: session.id.uuidString,
+            title: session.title,
+            startTime: session.startTime,
+            endTime: session.endTime,
+            durationMs: session.durationMs,
+            events: session.events.map {
+                EventUpload(
+                    clientId: $0.id.uuidString,
+                    relativeTimeMs: $0.relativeTimeMs,
+                    eventType: $0.eventType,
+                    textContent: $0.textContent,
+                    voiceStartOffsetMs: $0.voiceStartOffsetMs,
+                    voiceEndOffsetMs: $0.voiceEndOffsetMs
+                )
+            }
+        )
+        let encoder = JSONEncoder()
+        encoder.dateEncodingStrategy = .iso8601
+        return try await client.request("sessions", method: .post, body: try encoder.encode(payload), authenticated: true)
+    }
+
+    func uploadAsset(sessionID: String, clientID: String, kind: String, fileURL: URL) async throws -> RemoteAsset {
+        let mimeType = UTType(filenameExtension: fileURL.pathExtension)?.preferredMIMEType ?? "application/octet-stream"
+        return try await client.upload(
+            "sessions/\(sessionID)/assets",
+            fileURL: fileURL,
+            fileName: fileURL.lastPathComponent,
+            mimeType: mimeType,
+            fields: ["clientId": clientID, "kind": kind]
+        )
+    }
+
+    func downloadAsset(sessionID: String, asset: RemoteAsset, destinationURL: URL) async throws {
+        try await client.download("sessions/\(sessionID)/assets/\(asset.id)", to: destinationURL)
+    }
+
+    func recordSyncCheckpoint() async throws {
+        let _: SyncCheckpoint = try await client.request("sync/trigger", method: .post, authenticated: true)
+    }
+}
+
+struct RemoteBoundDevice: Decodable {
+    let id: String
+    let name: String
+    let peripheralUUID: String
+}
+
+private struct BindDeviceBody: Encodable {
+    let name: String
+    let peripheralUUID: String
+    let hardwareMAC: String?
+    let firmwareVersion: String?
+    let batteryLevel: Int?
+    let freeStorageMB: Int?
+    let totalStorageMB: Int?
+}
+
+private struct UpdateDeviceBody: Encodable {
+    let name: String?
+    let batteryLevel: Int?
+    let isConnected: Bool?
+    let firmwareVersion: String?
+    let freeStorageMB: Int?
+    let totalStorageMB: Int?
+}
+
+final class DeviceCloudService {
+    static let shared = DeviceCloudService()
+    private let client: APIClient
+
+    init(client: APIClient = .shared) {
+        self.client = client
+    }
+
+    func register(_ device: BoundDevice) async throws -> RemoteBoundDevice {
+        let body = BindDeviceBody(
+            name: device.name,
+            peripheralUUID: device.peripheralUUID,
+            hardwareMAC: device.hardwareMAC,
+            firmwareVersion: device.firmwareVersion,
+            batteryLevel: device.batteryLevel,
+            freeStorageMB: device.freeStorageMB,
+            totalStorageMB: device.totalStorageMB
+        )
+        return try await client.request("devices", method: .post, body: try JSONEncoder().encode(body), authenticated: true)
+    }
+
+    func update(_ device: BoundDevice) async throws -> RemoteBoundDevice {
+        guard let cloudID = device.cloudID else { return try await register(device) }
+        let body = UpdateDeviceBody(
+            name: device.name,
+            batteryLevel: device.batteryLevel,
+            isConnected: device.isConnected,
+            firmwareVersion: device.firmwareVersion,
+            freeStorageMB: device.freeStorageMB,
+            totalStorageMB: device.totalStorageMB
+        )
+        return try await client.request("devices/\(cloudID)", method: .put, body: try JSONEncoder().encode(body), authenticated: true)
+    }
+
+    func remove(cloudID: String) async throws {
+        try await client.requestVoid("devices/\(cloudID)", method: .delete, authenticated: true)
+    }
+}
+
+@MainActor
+final class SyncManager: ObservableObject {
+    static let shared = SyncManager()
+
+    @Published private(set) var isSyncing = false
+    @Published private(set) var lastSyncDate: Date?
+    @Published private(set) var lastErrorMessage: String?
+    @Published private(set) var completedCount = 0
+    @Published private(set) var totalCount = 0
+
+    private let service: NetworkServiceProtocol
+
+    init(service: NetworkServiceProtocol = RemoteNetworkService()) {
+        self.service = service
+        lastSyncDate = UserDefaults.standard.object(forKey: "com.celestia.trace.last_server_sync") as? Date
+    }
+
+    func sync(
+        sessions: [CelestiaSession],
+        modelContext: ModelContext,
+        userID: String
+    ) async -> Bool {
+        guard !isSyncing else { return false }
+        isSyncing = true
+        lastErrorMessage = nil
+        completedCount = 0
+        totalCount = sessions.filter {
+            ($0.ownerUserID == nil || $0.ownerUserID == userID) && (!$0.isSynced || $0.cloudSessionId == nil)
+        }.count
+        defer { isSyncing = false }
+
+        do {
+            let remoteSessions = try await service.fetchSessions()
+            let mergedSessions = merge(remoteSessions, into: sessions, modelContext: modelContext, userID: userID)
+            for (remote, local) in mergedSessions {
+                try await downloadMissingAssets(for: local, remote: remote)
+            }
+            try modelContext.save()
+
+            let eligible = sessions.filter {
+                ($0.ownerUserID == nil || $0.ownerUserID == userID) && (!$0.isSynced || $0.cloudSessionId == nil)
+            }
+            var failures: [String] = []
+            for session in eligible {
+                session.ownerUserID = userID
+                session.syncState = .syncing
+                session.lastSyncError = nil
+                do {
+                    let remote = try await service.syncSession(session)
+                    session.cloudSessionId = remote.id
+                    session.serverRevision = remote.revision
+                    try await uploadLocalAssets(for: session, remote: remote)
+                    session.isSynced = true
+                    session.syncState = .synced
+                    session.lastSyncedAt = Date()
+                    completedCount += 1
+                } catch {
+                    session.isSynced = false
+                    session.syncState = .failed
+                    session.lastSyncError = error.localizedDescription
+                    failures.append("\(session.title):\(error.localizedDescription)")
+                }
+                try modelContext.save()
+            }
+            guard failures.isEmpty else {
+                lastErrorMessage = failures.joined(separator: "\n")
+                return false
+            }
+            try await service.recordSyncCheckpoint()
+            let now = Date()
+            lastSyncDate = now
+            UserDefaults.standard.set(now, forKey: "com.celestia.trace.last_server_sync")
+            return true
+        } catch {
+            lastErrorMessage = error.localizedDescription
+            return false
+        }
+    }
+
+    private func uploadLocalAssets(for session: CelestiaSession, remote: RemoteSession) async throws {
+        guard let cloudID = session.cloudSessionId else { throw APIError.missingData }
+        let existingIDs = Set((remote.assets ?? []).map(\.clientId))
+        if let path = session.localAudioPath {
+            guard let url = AudioPathHelper.resolveURL(for: path) else {
+                throw APIError.transport("本地录音文件不存在:\(path)")
+            }
+            let clientID = "\(session.id.uuidString)-audio"
+            if !existingIDs.contains(clientID) {
+                _ = try await service.uploadAsset(sessionID: cloudID, clientID: clientID, kind: "AUDIO", fileURL: url)
+            }
+        }
+        for event in session.events where event.eventType == "PHOTO" {
+            guard let path = event.localFilePath else { continue }
+            guard let url = AudioPathHelper.resolveURL(for: path) else {
+                throw APIError.transport("照片文件不存在:\(path)")
+            }
+            let clientID = "\(event.id.uuidString)-photo"
+            if !existingIDs.contains(clientID) {
+                _ = try await service.uploadAsset(sessionID: cloudID, clientID: clientID, kind: "PHOTO", fileURL: url)
+            }
+        }
+    }
+
+    private func downloadMissingAssets(for session: CelestiaSession, remote: RemoteSession) async throws {
+        let assets = remote.assets ?? []
+        if (session.localAudioPath == nil || AudioPathHelper.resolveURL(for: session.localAudioPath) == nil),
+           let audio = assets.first(where: { $0.kind.uppercased() == "AUDIO" }) {
+            let destination = downloadDestination(for: audio)
+            try await service.downloadAsset(sessionID: remote.id, asset: audio, destinationURL: destination)
+            session.localAudioPath = AudioPathHelper.relativePath(from: destination.path)
+        }
+
+        let photoAssets = Dictionary(uniqueKeysWithValues: assets
+            .filter { $0.kind.uppercased() == "PHOTO" }
+            .map { ($0.clientId.lowercased(), $0) })
+        for event in session.events where event.eventType == "PHOTO" {
+            guard event.localFilePath == nil || AudioPathHelper.resolveURL(for: event.localFilePath) == nil else { continue }
+            let key = "\(event.id.uuidString)-photo".lowercased()
+            guard let asset = photoAssets[key] else { continue }
+            let destination = downloadDestination(for: asset)
+            try await service.downloadAsset(sessionID: remote.id, asset: asset, destinationURL: destination)
+            event.localFilePath = AudioPathHelper.relativePath(from: destination.path)
+        }
+    }
+
+    private func downloadDestination(for asset: RemoteAsset) -> URL {
+        let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
+        let fileExtension = (asset.fileName as NSString).pathExtension
+        let suffix = fileExtension.isEmpty ? "" : ".\(fileExtension.lowercased())"
+        return documents.appendingPathComponent("cloud_\(asset.id)\(suffix)")
+    }
+
+    private func merge(
+        _ remoteSessions: [RemoteSession],
+        into localSessions: [CelestiaSession],
+        modelContext: ModelContext,
+        userID: String
+    ) -> [(RemoteSession, CelestiaSession)] {
+        var byClientID = Dictionary(uniqueKeysWithValues: localSessions.map { ($0.id.uuidString.lowercased(), $0) })
+        var merged: [(RemoteSession, CelestiaSession)] = []
+        for remote in remoteSessions {
+            guard let clientID = remote.clientId?.lowercased() else { continue }
+            if let local = byClientID[clientID] {
+                if remote.deletedAt != nil, local.isSynced {
+                    modelContext.delete(local)
+                    continue
+                }
+                if local.isSynced, remote.revision > local.serverRevision {
+                    apply(remote, to: local, userID: userID)
+                }
+                if remote.deletedAt == nil {
+                    merged.append((remote, local))
+                }
+            } else if remote.deletedAt == nil {
+                let id = UUID(uuidString: clientID) ?? UUID()
+                let session = CelestiaSession(id: id, title: remote.title, startTime: remote.startTime)
+                apply(remote, to: session, userID: userID)
+                modelContext.insert(session)
+                byClientID[clientID] = session
+                merged.append((remote, session))
+            }
+        }
+        try? modelContext.save()
+        return merged
+    }
+
+    private func apply(_ remote: RemoteSession, to local: CelestiaSession, userID: String) {
+        local.title = remote.title
+        local.startTime = remote.startTime
+        local.endTime = remote.endTime
+        local.cloudSessionId = remote.id
+        local.ownerUserID = userID
+        local.serverRevision = remote.revision
+        local.isSynced = true
+        local.syncState = .synced
+        local.lastSyncError = nil
+        local.events.removeAll()
+        for remoteEvent in remote.events ?? [] {
+            let id = remoteEvent.clientId.flatMap(UUID.init(uuidString:)) ?? UUID()
+            let event = CelestiaTimelineEvent(id: id, relativeTimeMs: remoteEvent.relativeTimeMs, eventType: remoteEvent.eventType)
+            event.textContent = remoteEvent.textContent
+            event.voiceStartOffsetMs = remoteEvent.voiceStartOffsetMs
+            event.voiceEndOffsetMs = remoteEvent.voiceEndOffsetMs
+            local.events.append(event)
+        }
+    }
+}

+ 23 - 72
CelestiaTrace/ViewModels/PlaybackViewModel.swift

@@ -17,6 +17,9 @@ final class PlaybackViewModel: NSObject, AVAudioPlayerDelegate {
     /// Whether audio is currently playing.
     private(set) var isPlaying: Bool = false
 
+    /// A user-facing reason why the selected recording cannot be played.
+    private(set) var playbackError: String?
+
     /// Total session duration in milliseconds.
     var totalDurationMs: Double = 0
     
@@ -80,14 +83,17 @@ final class PlaybackViewModel: NSObject, AVAudioPlayerDelegate {
 
     // MARK: - Player Preparation
 
-    /// Prepares the audio player for the given file path or generates a sample audio fallback.
+    /// Prepares the audio player for the given local recording file.
     func preparePlayer(path: String?, durationMs: Double = 0) {
         stopPlaybackTimer()
         audioPlayer?.stop()
         audioPlayer = nil
+        activeAudioURL = nil
+        playbackError = nil
 
         guard let targetURL = AudioPathHelper.resolveURL(for: path) else {
             print("[PlaybackViewModel] No valid audio file found for path: \(path ?? "nil")")
+            playbackError = "未找到本地录音文件,请先从云端同步或重新录制"
             if durationMs > 0 {
                 self.totalDurationMs = durationMs
             }
@@ -110,6 +116,7 @@ final class PlaybackViewModel: NSObject, AVAudioPlayerDelegate {
             }
         } catch {
             print("[PlaybackViewModel] Failed to initialize AVAudioPlayer: \(error.localizedDescription)")
+            playbackError = "录音文件无法播放:\(error.localizedDescription)"
         }
     }
 
@@ -128,12 +135,17 @@ final class PlaybackViewModel: NSObject, AVAudioPlayerDelegate {
     /// Starts or resumes playback from the current position.
     func play() {
         guard !isPlaying else { return }
+        guard let player = audioPlayer else {
+            playbackError = "当前记录没有可播放的本地录音文件"
+            return
+        }
 
         setupAudioSession()
 
-        if let player = audioPlayer {
-            player.currentTime = currentPlaybackTimeMs / 1000.0
-            player.play()
+        player.currentTime = currentPlaybackTimeMs / 1000.0
+        guard player.play() else {
+            playbackError = "录音播放启动失败"
+            return
         }
 
         isPlaying = true
@@ -218,10 +230,8 @@ final class PlaybackViewModel: NSObject, AVAudioPlayerDelegate {
     func analyzeSilence(audioURL: URL?) {
         let targetPath = audioURL?.path ?? activeAudioURL?.path
         guard let url = AudioPathHelper.resolveURL(for: targetPath) else {
-            self.silentRanges = [
-                SilenceRange(start: 5.0, end: 12.0),
-                SilenceRange(start: 25.0, end: 32.0)
-            ]
+            silentRanges = []
+            isAnalyzingSilence = false
             return
         }
         
@@ -251,11 +261,12 @@ final class PlaybackViewModel: NSObject, AVAudioPlayerDelegate {
 
     /// Advances playback by one tick interval, syncing with audioPlayer if active.
     private func tick() {
-        if let player = audioPlayer, player.isPlaying {
-            currentPlaybackTimeMs = player.currentTime * 1000.0
-        } else {
-            currentPlaybackTimeMs += tickInterval * 1000
+        guard let player = audioPlayer, player.isPlaying else {
+            isPlaying = false
+            stopPlaybackTimer()
+            return
         }
+        currentPlaybackTimeMs = player.currentTime * 1000.0
         
         checkForSilenceAndSkip()
 
@@ -304,64 +315,4 @@ final class PlaybackViewModel: NSObject, AVAudioPlayerDelegate {
             }
     }
 
-    // MARK: - Helper Tone Generator
-
-    private func generateFallbackAudioFile(durationSeconds: Double) -> URL? {
-        let tempDir = FileManager.default.temporaryDirectory
-        let fileURL = tempDir.appendingPathComponent("sample_playback.wav")
-        if FileManager.default.fileExists(atPath: fileURL.path) {
-            return fileURL
-        }
-
-        let sampleRate: Double = 22050.0
-        let numSamples = Int(sampleRate * durationSeconds)
-        let numChannels: UInt16 = 1
-        let bitsPerSample: UInt16 = 16
-        let byteRate = UInt32(Double(sampleRate) * Double(numChannels) * Double(bitsPerSample / 8))
-        let blockAlign = UInt16(numChannels * (bitsPerSample / 8))
-        let dataSize = UInt32(numSamples * Int(blockAlign))
-        let chunkSize = 36 + dataSize
-
-        var data = Data()
-
-        // RIFF header
-        data.append(contentsOf: Array("RIFF".utf8))
-        data.append(withUnsafeBytes(of: chunkSize.littleEndian) { Data($0) })
-        data.append(contentsOf: Array("WAVE".utf8))
-
-        // fmt chunk
-        data.append(contentsOf: Array("fmt ".utf8))
-        data.append(withUnsafeBytes(of: UInt32(16).littleEndian) { Data($0) })
-        data.append(withUnsafeBytes(of: UInt16(1).littleEndian) { Data($0) })
-        data.append(withUnsafeBytes(of: numChannels.littleEndian) { Data($0) })
-        data.append(withUnsafeBytes(of: UInt32(sampleRate).littleEndian) { Data($0) })
-        data.append(withUnsafeBytes(of: byteRate.littleEndian) { Data($0) })
-        data.append(withUnsafeBytes(of: blockAlign.littleEndian) { Data($0) })
-        data.append(withUnsafeBytes(of: bitsPerSample.littleEndian) { Data($0) })
-
-        // data chunk
-        data.append(contentsOf: Array("data".utf8))
-        data.append(withUnsafeBytes(of: dataSize.littleEndian) { Data($0) })
-
-        let frequency1: Double = 440.0
-        let frequency2: Double = 554.37
-        for i in 0..<numSamples {
-            let t = Double(i) / sampleRate
-            let sampleVal1 = sin(2.0 * .pi * frequency1 * t)
-            let sampleVal2 = sin(2.0 * .pi * frequency2 * t)
-            let envelope = min(1.0, min(t * 2.0, (durationSeconds - t) * 2.0))
-            let combined = (sampleVal1 * 0.4 + sampleVal2 * 0.3) * envelope
-            let pcmValue = Int16(clamping: Int(combined * 16384.0))
-            data.append(withUnsafeBytes(of: pcmValue.littleEndian) { Data($0) })
-        }
-
-        do {
-            try data.write(to: fileURL)
-            return fileURL
-        } catch {
-            print("[PlaybackViewModel] Failed to write fallback audio WAV: \(error.localizedDescription)")
-            return nil
-        }
-    }
 }
-

+ 6 - 0
CelestiaTrace/ViewModels/RecordingViewModel.swift

@@ -126,6 +126,8 @@ final class RecordingViewModel {
         let event = CelestiaTimelineEvent(relativeTimeMs: relativeMs, eventType: "PHOTO")
         event.localFilePath = localFilePath
         session.events.append(event)
+        session.isSynced = false
+        session.syncState = .pending
         return event
     }
 
@@ -140,6 +142,8 @@ final class RecordingViewModel {
         let event = CelestiaTimelineEvent(relativeTimeMs: relativeMs, eventType: "NOTE")
         event.textContent = text
         session.events.append(event)
+        session.isSynced = false
+        session.syncState = .pending
         return event
     }
 
@@ -154,6 +158,8 @@ final class RecordingViewModel {
         let event = CelestiaTimelineEvent(relativeTimeMs: relativeMs, eventType: "MARKER")
         event.textContent = text
         session.events.append(event)
+        session.isSynced = false
+        session.syncState = .pending
         return event
     }
 

+ 2 - 2
CelestiaTrace/Views/Components/SyncAuthPromptModal.swift

@@ -38,8 +38,8 @@ struct SyncAuthPromptModal: View {
                 
                 // Value propositions
                 VStack(alignment: .leading, spacing: 12) {
-                    featureRow(icon: "lock.shield", text: "端到端云端加密备份")
-                    featureRow(icon: "laptopcomputer.and.iphone", text: "多设备实时同步记录")
+                    featureRow(icon: "lock.shield", text: "HTTPS 安全传输与账号隔离")
+                    featureRow(icon: "laptopcomputer.and.iphone", text: "账号下的记录增量同步")
                     featureRow(icon: "mic.fill", text: "专属录音设备绑定与管理")
                 }
                 .padding(16)

+ 10 - 72
CelestiaTrace/Views/Detail/MultiTrackTimeline.swift

@@ -2,7 +2,7 @@ import SwiftUI
 
 // MARK: - MultiTrackTimeline
 /// A three-track horizontal timeline visualization showing:
-/// - Track 1: Audio waveform (mock sine wave outline)
+/// - Track 1: Audio availability and silence markers
 /// - Track 2: Photo markers (camera outlines at event positions)
 /// - Track 3: Note/marker outline icons at event positions
 /// Includes a time ruler and a central playhead cursor.
@@ -115,78 +115,16 @@ struct MultiTrackTimeline: View {
     // MARK: - Audio Track
 
     private var audioTrack: some View {
-        Canvas { context, size in
-            let sampleCount = Int(size.width)
-            guard sampleCount > 0 else { return }
-            
-            let waveformPath = Path { path in
-                for i in 0..<sampleCount {
-                    let x = CGFloat(i)
-                    let progress = Double(i) / Double(sampleCount)
-                    let time = progress * (totalDurationMs / 1000.0)
-
-                    let isInSilence = silentRanges.contains(where: { time >= $0.start && time < $0.end })
-                    let amplitude: Double
-                    if isInSilence {
-                        amplitude = 0.02
-                    } else {
-                        amplitude = abs(
-                            sin(time * 3.0) * 0.45 +
-                            sin(time * 7.3) * 0.2 +
-                            sin(time * 13.7) * 0.1 +
-                            sin(time * 0.8) * 0.15
-                        )
-                    }
-
-                    let height = amplitude * Double(size.height * 0.7)
-                    let centerY = size.height / 2
-
-                    if i == 0 {
-                        path.move(to: CGPoint(x: x, y: centerY - height / 2))
-                    } else {
-                        path.addLine(to: CGPoint(x: x, y: centerY - height / 2))
-                    }
-                }
-
-                // Mirror back
-                for i in stride(from: sampleCount - 1, through: 0, by: -1) {
-                    let x = CGFloat(i)
-                    let progress = Double(i) / Double(sampleCount)
-                    let time = progress * (totalDurationMs / 1000.0)
-
-                    let isInSilence = silentRanges.contains(where: { time >= $0.start && time < $0.end })
-                    let amplitude: Double
-                    if isInSilence {
-                        amplitude = 0.02
-                    } else {
-                        amplitude = abs(
-                            sin(time * 3.0) * 0.45 +
-                            sin(time * 7.3) * 0.2 +
-                            sin(time * 13.7) * 0.1 +
-                            sin(time * 0.8) * 0.15
-                        )
-                    }
-
-                    let height = amplitude * Double(size.height * 0.7)
-                    let centerY = size.height / 2
-
-                    path.addLine(to: CGPoint(x: x, y: centerY + height / 2))
-                }
-                path.closeSubpath()
-            }
-
-            // Fill with a very subtle neutral monochrome shade
-            context.fill(waveformPath, with: .color(Color.primary.opacity(0.08)))
-            
-            // Draw clean outline
-            context.stroke(waveformPath, with: .color(Color.primary.opacity(0.35)), lineWidth: 0.75)
-
-            // Center line
-            let centerLine = Path { path in
-                path.move(to: CGPoint(x: 0, y: size.height / 2))
-                path.addLine(to: CGPoint(x: size.width, y: size.height / 2))
+        ZStack {
+            Rectangle()
+                .fill(Color.primary.opacity(0.12))
+                .frame(height: 1)
+            ForEach(Array(silentRanges.enumerated()), id: \.offset) { _, range in
+                Rectangle()
+                    .fill(Color.secondary.opacity(0.16))
+                    .frame(width: max(1, xPosition(for: range.end * 1000) - xPosition(for: range.start * 1000)))
+                    .offset(x: xPosition(for: range.start * 1000) - timelineWidth / 2)
             }
-            context.stroke(centerLine, with: .color(Color.primary.opacity(0.1)), lineWidth: 0.5)
         }
     }
 

+ 10 - 3
CelestiaTrace/Views/History/SessionListView.swift

@@ -12,6 +12,8 @@ struct SessionListView: View {
     @State private var searchText = ""
     @State private var showSyncAuthPrompt = false
     @State private var showAuthModal = false
+    @ObservedObject private var authManager: AuthManager = .shared
+    @ObservedObject private var syncManager: SyncManager = .shared
 
     var body: some View {
         NavigationStack {
@@ -29,18 +31,23 @@ struct SessionListView: View {
             .toolbar {
                 ToolbarItem(placement: .topBarTrailing) {
                     Button {
-                        AuthManager.shared.syncRecordings {
+                        guard let userID = authManager.currentUser?.id else {
                             showSyncAuthPrompt = true
-                        } onComplete: { _ in }
+                            return
+                        }
+                        Task {
+                            _ = await syncManager.sync(sessions: sessions, modelContext: modelContext, userID: userID)
+                        }
                     } label: {
                         HStack(spacing: 4) {
                             Image(systemName: "icloud.and.arrow.up")
                                 .font(.system(size: 13))
-                            Text("同步")
+                            Text(syncManager.isSyncing ? "同步中" : "同步")
                                 .font(.system(size: 13, weight: .medium))
                         }
                         .foregroundStyle(Color.primary)
                     }
+                    .disabled(syncManager.isSyncing)
                 }
             }
             .searchable(

+ 2 - 2
CelestiaTrace/Views/Profile/Auth/AuthModalView.swift

@@ -1,6 +1,6 @@
 import SwiftUI
 
-/// Modal Sheet allowing users to Login or Register an account locally.
+/// Modal sheet for server-backed account login and registration.
 struct AuthModalView: View {
     @Environment(\.dismiss) private var dismiss
     @ObservedObject var authManager: AuthManager = .shared
@@ -42,7 +42,7 @@ struct AuthModalView: View {
                         .font(.system(size: 22, weight: .bold))
                         .foregroundStyle(Color.primary)
                     
-                    Text(selectedTab == .login ? "登录以同步录音并绑定专属录音设备" : "注册后解锁全功能本地沙盒与云同步机制")
+                    Text(selectedTab == .login ? "登录以同步录音并管理专属设备" : "注册后可将本机记录安全同步至云端")
                         .font(.system(size: 12))
                         .foregroundStyle(Color.secondary)
                         .multilineTextAlignment(.center)

+ 20 - 0
CelestiaTrace/Views/Profile/Devices/DeviceListView.swift

@@ -70,6 +70,9 @@ struct DeviceListView: View {
             Button("解绑设备", role: .destructive) {
                 if let dev = deviceToUnbind {
                     bleManager.unbindDevice(id: dev.id)
+                    if let cloudID = dev.cloudID {
+                        Task { try? await DeviceCloudService.shared.remove(cloudID: cloudID) }
+                    }
                 }
             }
             Button("取消", role: .cancel) { }
@@ -87,6 +90,16 @@ struct DeviceListView: View {
             Button("保存") {
                 if let device = deviceToRename {
                     bleManager.renameDevice(id: device.id, to: proposedDeviceName)
+                    var updated = device
+                    updated.name = proposedDeviceName.trimmingCharacters(in: .whitespacesAndNewlines)
+                    Task {
+                        do {
+                            let remote = try await DeviceCloudService.shared.update(updated)
+                            bleManager.updateCloudRegistration(deviceID: updated.id, cloudID: remote.id, error: nil)
+                        } catch {
+                            bleManager.updateCloudRegistration(deviceID: updated.id, cloudID: updated.cloudID, error: error.localizedDescription)
+                        }
+                    }
                 }
                 deviceToRename = nil
             }
@@ -204,6 +217,13 @@ struct DeviceListView: View {
                             .foregroundStyle(Color.secondary)
                     }
                 }
+
+                if let cloudError = device.cloudSyncError {
+                    Text("云端登记待重试:\(cloudError)")
+                        .font(.system(size: 10))
+                        .foregroundStyle(Color.recordingRed)
+                        .lineLimit(1)
+                }
             }
             
             Spacer()

+ 12 - 5
CelestiaTrace/Views/Profile/Devices/DeviceScanView.swift

@@ -193,11 +193,18 @@ struct DeviceScanView: View {
                         bindingDeviceID = nil
                         switch result {
                         case .success(let bound):
-                            withAnimation {
-                                boundSuccessMessage = "已成功绑定设备「\(bound.name)」"
-                            }
-                            DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) {
-                                dismiss()
+                            Task {
+                                do {
+                                    let remote = try await DeviceCloudService.shared.register(bound)
+                                    bleManager.updateCloudRegistration(deviceID: bound.id, cloudID: remote.id, error: nil)
+                                    boundSuccessMessage = "设备已绑定并登记到云端"
+                                } catch {
+                                    bleManager.updateCloudRegistration(deviceID: bound.id, cloudID: nil, error: error.localizedDescription)
+                                    boundSuccessMessage = "设备已在本机绑定,云端登记将在稍后重试"
+                                }
+                                DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) {
+                                    dismiss()
+                                }
                             }
                         case .failure(let error):
                             bindingErrorMessage = error.localizedDescription

+ 25 - 11
CelestiaTrace/Views/Profile/ProfileView.swift

@@ -1,9 +1,13 @@
 import SwiftUI
+import SwiftData
 
 /// Main "我" (Mine) Tab View for user authentication, profile management, cloud sync status, and BLE device management.
 struct ProfileView: View {
     @ObservedObject var authManager: AuthManager = .shared
     @ObservedObject var bleManager: BLEManager = .shared
+    @ObservedObject private var syncManager: SyncManager = .shared
+    @Environment(\.modelContext) private var modelContext
+    @Query(sort: \CelestiaSession.startTime, order: .reverse) private var sessions: [CelestiaSession]
     
     @State private var showAuthModal = false
     @State private var showEditProfileModal = false
@@ -101,7 +105,7 @@ struct ProfileView: View {
                                .font(.system(size: 18, weight: .bold))
                                 .foregroundStyle(Color.primary)
                             
-                            Text("本地授权")
+                            Text("云端账号")
                                 .font(.system(size: 9, weight: .bold, design: .monospaced))
                                 .foregroundStyle(Color.spaceBlack)
                                 .padding(.horizontal, 6)
@@ -277,7 +281,7 @@ struct ProfileView: View {
                         .font(.system(size: 12))
                         .foregroundStyle(Color.secondary)
                     Spacer()
-                    if let lastDate = authManager.lastSyncDate {
+                    if let lastDate = syncManager.lastSyncDate {
                         Text(lastDate.formatted(date: .numeric, time: .shortened))
                             .font(.system(size: 12, design: .monospaced))
                             .foregroundStyle(Color.primary)
@@ -304,14 +308,14 @@ struct ProfileView: View {
                 triggerAudioSync()
             } label: {
                 HStack(spacing: 8) {
-                    if authManager.isSyncing {
+                    if syncManager.isSyncing {
                         ProgressView()
                             .tint(Color.primary)
                     } else {
                         Image(systemName: "arrow.triangle.2.circlepath")
                             .font(.system(size: 13))
                     }
-                    Text(authManager.isSyncing ? "正在同步音频与记录..." : "立即同步录音文件")
+                    Text(syncManager.isSyncing ? "正在同步 \(syncManager.completedCount)/\(syncManager.totalCount)..." : "立即同步录音文件")
                         .font(.system(size: 13, weight: .medium))
                 }
                 .foregroundStyle(Color.primary)
@@ -322,7 +326,14 @@ struct ProfileView: View {
                         .stroke(Color.primary.opacity(0.3), lineWidth: 1)
                 )
             }
-            .disabled(authManager.isSyncing)
+            .disabled(syncManager.isSyncing)
+
+            if let error = syncManager.lastErrorMessage {
+                Text(error)
+                    .font(.system(size: 11))
+                    .foregroundStyle(Color.recordingRed)
+                    .lineLimit(3)
+            }
         }
         .padding(16)
         .background(Color.cardBackground.opacity(0.25))
@@ -343,9 +354,9 @@ struct ProfileView: View {
             }
             
             VStack(spacing: 10) {
-                infoRow(label: "数据存储模式", value: "沙盒 SQLite / UserDefaults")
-                infoRow(label: "后端接口状态", value: "已适配 Local & Remote 两套架构")
-                infoRow(label: "数据加密机制", value: "AES-256 本地私钥保护")
+                infoRow(label: "数据存储模式", value: "SwiftData 本地优先 + 云端同步")
+                infoRow(label: "登录凭据", value: "iOS Keychain 安全存储")
+                infoRow(label: "传输保护", value: "HTTPS / TLS")
             }
         }
         .padding(16)
@@ -376,12 +387,15 @@ struct ProfileView: View {
     }
     
     private func triggerAudioSync() {
-        authManager.syncRecordings {
+        guard let userID = authManager.currentUser?.id else {
             showSyncAuthPrompt = true
-        } onComplete: { success in
+            return
+        }
+        Task {
+            let success = await syncManager.sync(sessions: sessions, modelContext: modelContext, userID: userID)
             if success {
                 withAnimation {
-                    syncToastMessage = "录音数据与元数据已成功同步!"
+                    syncToastMessage = "服务器已确认全部记录与可用附件。"
                 }
                 DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
                     syncToastMessage = nil

+ 1 - 12
CelestiaTrace/Views/Recording/LiveWaveformView.swift

@@ -102,18 +102,7 @@ struct LiveWaveformView: View {
     ZStack {
         Color.spaceBlack.ignoresSafeArea()
 
-        VStack(spacing: 20) {
-            LiveWaveformView(
-                samples: (0..<80).map { _ in Float.random(in: 0.05...0.9) }
-            )
-            .padding(.horizontal)
-
-            LiveWaveformView(
-                samples: (0..<40).map { i in
-                    Float(sin(Double(i) * 0.3) * 0.4 + 0.5)
-                }
-            )
+        LiveWaveformView(samples: [])
             .padding(.horizontal)
-        }
     }
 }

+ 53 - 13
CelestiaTrace/Views/Settings/SettingsView.swift

@@ -6,10 +6,8 @@ import SwiftUI
 /// Redesigned to use a minimalist wireframe business style.
 struct SettingsView: View {
     @State private var showClearCacheAlert = false
-
-    // Mock storage values
-    private let usedStorage: Double = 1.2
-    private let totalStorage: Double = 5.0
+    @State private var usedStorageBytes: Int64 = 0
+    @State private var availableStorageBytes: Int64 = 0
 
     var body: some View {
         NavigationStack {
@@ -44,11 +42,13 @@ struct SettingsView: View {
             .alert("清理缓存", isPresented: $showClearCacheAlert) {
                 Button("取消", role: .cancel) { }
                 Button("清理", role: .destructive) {
+                    clearTemporaryCache()
                     HapticManager.trigger(.tapFeedback)
                 }
             } message: {
-                Text("将清除所有本地缓存数据。此操作不可撤销。")
+                Text("只清除系统缓存目录,不会删除录音、照片或会话记录。")
             }
+            .task { reloadStorageMetrics() }
         }
     }
 
@@ -72,7 +72,7 @@ struct SettingsView: View {
                             RoundedRectangle(cornerRadius: 3)
                                 .fill(Color.primary)
                                 .frame(
-                                    width: geometry.size.width * (usedStorage / totalStorage),
+                                    width: geometry.size.width * storageUsageRatio,
                                     height: 5
                                 )
                         }
@@ -80,13 +80,13 @@ struct SettingsView: View {
                     .frame(height: 5)
 
                     HStack {
-                        Text(String(format: "已使用 %.1f GB", usedStorage))
+                        Text("App 数据 \(formattedBytes(usedStorageBytes))")
                             .font(.system(size: 11, weight: .regular))
                             .foregroundStyle(Color.primary.opacity(0.8))
 
                         Spacer()
 
-                        Text(String(format: "总容量 %.0f GB", totalStorage))
+                        Text("设备可用 \(formattedBytes(availableStorageBytes))")
                             .font(.system(size: 11, weight: .regular))
                             .foregroundStyle(Color.secondary)
                     }
@@ -120,7 +120,7 @@ struct SettingsView: View {
     private var debugSection: some View {
         settingsCard {
             VStack(alignment: .leading, spacing: 14) {
-                sectionHeader(icon: "hammer", title: "开发调试")
+                sectionHeader(icon: "externaldrive.connected.to.line.below", title: "数据与同步")
 
                 // Data Storage Mode
                 VStack(alignment: .leading, spacing: 4) {
@@ -129,12 +129,12 @@ struct SettingsView: View {
                             .font(.system(size: 13, weight: .regular))
                             .foregroundStyle(Color.primary)
                         Spacer()
-                        Text("纯本地保存")
+                        Text("本地优先")
                             .font(.system(size: 13, weight: .medium))
                             .foregroundStyle(Color.secondary)
                     }
 
-                    Text("所有会议记录均保存在设备沙盒 SQLite 中,无网络上传")
+                    Text("记录先保存在本机;登录后由用户主动同步到服务器")
                         .font(.system(size: 10))
                         .foregroundStyle(Color.secondary.opacity(0.6))
                 }
@@ -220,9 +220,9 @@ struct SettingsView: View {
 
                 VStack(spacing: 10) {
                     infoRow(label: "应用名称", value: "现场记录 (Celestia Trace)")
-                    infoRow(label: "版本信息", value: "0.1.0 (本地持久化单机版)")
+                    infoRow(label: "版本信息", value: "1.0.0")
                     infoRow(label: "底层框架", value: "SwiftUI + SwiftData")
-                    infoRow(label: "系统支持", value: "iOS 17.0+ (模拟器)")
+                    infoRow(label: "系统支持", value: "iOS 17.0+")
                 }
                 .padding(.top, 4)
             }
@@ -279,6 +279,46 @@ struct SettingsView: View {
                 .foregroundStyle(Color.primary)
         }
     }
+
+    private var storageUsageRatio: Double {
+        let total = usedStorageBytes + availableStorageBytes
+        guard total > 0 else { return 0 }
+        return min(1, Double(usedStorageBytes) / Double(total))
+    }
+
+    private func formattedBytes(_ bytes: Int64) -> String {
+        ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file)
+    }
+
+    private func reloadStorageMetrics() {
+        let fileManager = FileManager.default
+        let roots = [
+            fileManager.urls(for: .documentDirectory, in: .userDomainMask).first,
+            fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
+        ].compactMap { $0 }
+        usedStorageBytes = roots.reduce(0) { total, root in
+            let keys: Set<URLResourceKey> = [.isRegularFileKey, .fileSizeKey]
+            guard let enumerator = fileManager.enumerator(at: root, includingPropertiesForKeys: Array(keys)) else { return total }
+            var subtotal: Int64 = 0
+            for case let url as URL in enumerator {
+                guard let values = try? url.resourceValues(forKeys: keys), values.isRegularFile == true else { continue }
+                subtotal += Int64(values.fileSize ?? 0)
+            }
+            return total + subtotal
+        }
+        let documents = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first
+        availableStorageBytes = Int64((try? documents?.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey]).volumeAvailableCapacityForImportantUsage) ?? 0)
+    }
+
+    private func clearTemporaryCache() {
+        let fileManager = FileManager.default
+        guard let cacheURL = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first,
+              let children = try? fileManager.contentsOfDirectory(at: cacheURL, includingPropertiesForKeys: nil) else { return }
+        for child in children {
+            try? fileManager.removeItem(at: child)
+        }
+        reloadStorageMetrics()
+    }
 }
 
 // MARK: - Preview

+ 23 - 0
Tests/CelestiaTraceTests/BLEFilteringTests.swift

@@ -0,0 +1,23 @@
+import XCTest
+import CoreBluetooth
+@testable import CelestiaTrace
+
+final class BLEFilteringTests: XCTestCase {
+    func testAnonymousUnrelatedPeripheralIsRejected() {
+        XCTAssertFalse(BLEManager.isSparkAdvertisement(name: nil, serviceUUIDs: []))
+    }
+
+    func testKnownServiceIsAcceptedWithoutName() {
+        XCTAssertTrue(BLEManager.isSparkAdvertisement(name: nil, serviceUUIDs: [BLEManager.serviceUUID]))
+    }
+
+    func testKnownProductNamesAreAccepted() {
+        XCTAssertTrue(BLEManager.isSparkAdvertisement(name: "MR20", serviceUUIDs: []))
+        XCTAssertTrue(BLEManager.isSparkAdvertisement(name: "Celestia Spark", serviceUUIDs: []))
+        XCTAssertTrue(BLEManager.isSparkAdvertisement(name: "微光录音设备", serviceUUIDs: []))
+    }
+
+    func testUnrelatedNamedPeripheralIsRejected() {
+        XCTAssertFalse(BLEManager.isSparkAdvertisement(name: "Living Room TV", serviceUUIDs: []))
+    }
+}