12 Revize eb2d5c7fed ... 5bf347bc7d

Autor SHA1 Zpráva Datum
  bob.yuxinyang 5bf347bc7d feat(live-activity): 支持 iOS 实时活动 (Live Activity)、灵动岛录音状态显示与后台音频权限配置 před 1 měsícem
  bob.yuxinyang 3969b5fea9 feat(recording): 增加录音暂停/继续控制、优化状态指示器与背景双击结束快捷手势 před 1 měsícem
  bob.yuxinyang f199ed4749 style(copy): 统一“记录/设备”状态文案与按钮文本 před 1 měsícem
  bob.yuxinyang e4ebf2e5c1 chore(branding): 更新 App 显示名称为“星痕 CelestiaTrace”及相关工程配置 před 1 měsícem
  bob.yuxinyang 5973c81754 feat(recording): 简化主页录音入口、优化全屏录音跳转与记录名称修改体验 před 1 měsícem
  bob.yuxinyang 190982a03d feat(ble): 引入 BLE 协议兼容探测、通信日志诊断面板及单元测试 před 1 měsícem
  bob.yuxinyang 01391b6e52 feat(audio): 统一波形振幅归一化、优化单次解码静音与波形分析、增强多轨时间轴拖拽交互 před 1 měsícem
  bob.yuxinyang de5d7ffbc7 feat(auth): 优化 AuthModalView 键盘焦点流、校验提示与提交状态 před 1 měsícem
  bob.yuxinyang d67dd66dfc feat(network): 更新 API Base URL 并优化 TLS 错误与超时配置 před 1 měsícem
  bob.yuxinyang 1090547b1c fix(model): 为 CelestiaSession 补全默认值以支持 SwiftData 迁移 před 1 měsícem
  bob.yuxinyang 6098885861 Add remote services and improve BLE recording před 1 měsícem
  bob.yuxinyang bb7b5735cf feat: add Spark BLE recording support před 1 měsícem
46 změnil soubory, kde provedl 4805 přidání a 1224 odebrání
  1. 216 37
      CelestiaTrace.xcodeproj/project.pbxproj
  2. 91 0
      CelestiaTrace.xcodeproj/xcshareddata/xcschemes/CelestiaTrace App.xcscheme
  3. 125 0
      CelestiaTrace.xcodeproj/xcshareddata/xcschemes/CelestiaTraceLiveActivity.xcscheme
  4. 14 0
      CelestiaTrace/LiveActivity/RecordingActivityAttributes.swift
  5. 21 3
      CelestiaTrace/Models/BoundDevice.swift
  6. 31 4
      CelestiaTrace/Models/CelestiaSession.swift
  7. 2 2
      CelestiaTrace/Models/CelestiaTimelineEvent.swift
  8. 0 3
      CelestiaTrace/Models/UserAccount.swift
  9. binární
      CelestiaTrace/Resources/Assets.xcassets/SplashArtwork.imageset/SplashArtwork.png
  10. 62 0
      CelestiaTrace/Resources/Info.plist
  11. 77 2
      CelestiaTrace/Services/Audio/AudioRecorderProtocol.swift
  12. 23 24
      CelestiaTrace/Services/Audio/RealAudioRecorder.swift
  13. 2 2
      CelestiaTrace/Services/Audio/RecordingEnvironmentDetector.swift
  14. 54 17
      CelestiaTrace/Services/Audio/SilenceDetector.swift
  15. 16 25
      CelestiaTrace/Services/Auth/AuthManager.swift
  16. 1 0
      CelestiaTrace/Services/Auth/AuthServiceProtocol.swift
  17. 0 203
      CelestiaTrace/Services/Auth/LocalAuthRepository.swift
  18. 157 0
      CelestiaTrace/Services/Auth/RemoteAuthService.swift
  19. 0 243
      CelestiaTrace/Services/Auth/RemoteAuthServiceStub.swift
  20. 1420 84
      CelestiaTrace/Services/Bluetooth/BLEManager.swift
  21. 393 0
      CelestiaTrace/Services/Network/APIClient.swift
  22. 0 62
      CelestiaTrace/Services/Network/MockNetworkService.swift
  23. 5 9
      CelestiaTrace/Services/Network/NetworkServiceProtocol.swift
  24. 364 0
      CelestiaTrace/Services/Network/RemoteNetworkService.swift
  25. 80 0
      CelestiaTrace/Services/RecordingLiveActivityManager.swift
  26. 57 85
      CelestiaTrace/ViewModels/PlaybackViewModel.swift
  27. 37 1
      CelestiaTrace/ViewModels/RecordingViewModel.swift
  28. 2 2
      CelestiaTrace/Views/Components/SyncAuthPromptModal.swift
  29. 1 1
      CelestiaTrace/Views/ContentView.swift
  30. 120 75
      CelestiaTrace/Views/Detail/MultiTrackTimeline.swift
  31. 48 16
      CelestiaTrace/Views/Detail/SessionDetailView.swift
  32. 10 3
      CelestiaTrace/Views/History/SessionListView.swift
  33. 177 152
      CelestiaTrace/Views/Home/HomeView.swift
  34. 100 19
      CelestiaTrace/Views/Profile/Auth/AuthModalView.swift
  35. 71 13
      CelestiaTrace/Views/Profile/Devices/DeviceListView.swift
  36. 341 43
      CelestiaTrace/Views/Profile/Devices/DeviceScanView.swift
  37. 25 11
      CelestiaTrace/Views/Profile/ProfileView.swift
  38. 334 54
      CelestiaTrace/Views/Recording/ActiveRecordingView.swift
  39. 1 12
      CelestiaTrace/Views/Recording/LiveWaveformView.swift
  40. 54 14
      CelestiaTrace/Views/Settings/SettingsView.swift
  41. 1 1
      CelestiaTrace/Views/Splash/SplashView.swift
  42. 9 0
      CelestiaTraceLiveActivity/CelestiaTraceLiveActivityBundle.swift
  43. 29 0
      CelestiaTraceLiveActivity/Info.plist
  44. 157 0
      CelestiaTraceLiveActivity/RecordingLiveActivityWidget.swift
  45. 25 0
      Tests/CelestiaTraceTests/BLEFilteringTests.swift
  46. 52 2
      generate_project.rb

+ 216 - 37
CelestiaTrace.xcodeproj/project.pbxproj

@@ -7,7 +7,8 @@
 	objects = {
 
 /* Begin PBXBuildFile section */
-		10EAADFFB07A1A8721B25D6B /* MockNetworkService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24923AF5D038F81EB3248A4C /* MockNetworkService.swift */; };
+		01C6B879019C60235B096D80 /* CelestiaTraceLiveActivity.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 626376A6BF69F473261DF616 /* CelestiaTraceLiveActivity.appex */; };
+		10EAADFFB07A1A8721B25D6B /* RemoteNetworkService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24923AF5D038F81EB3248A4C /* RemoteNetworkService.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 */; };
@@ -20,39 +21,46 @@
 		3A7E453FDCA5DA9F96194B23 /* CelestiaTraceApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 628549E7FC16C58A15C9E1B7 /* CelestiaTraceApp.swift */; };
 		403219D9D8C972EC2AA8F84E /* EditProfileModalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA13A08133A50094CBFFDDF9 /* EditProfileModalView.swift */; };
 		44732C9690578EEFC6840D05 /* DeviceListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8CB67D1B2BDECAA772F3935 /* DeviceListView.swift */; };
+		46F4F91D30D4C26A44A4D570 /* RecordingLiveActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A48C59DD78DE74AF832D246 /* RecordingLiveActivityManager.swift */; };
 		490825791613AC52D06ECFA7 /* CelestiaSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83CE4996BF0CC501E3B7C567 /* CelestiaSession.swift */; };
 		4F4476444CB6838B2BE6EE87 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71787388F9456A4784534BCC /* ContentView.swift */; };
 		503E109CBBC69E3015AE37A3 /* SilenceDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEED3C75367F66D6D6CE745D /* SilenceDetector.swift */; };
 		58EFA65370C61ED9A8EE6627 /* ActiveRecordingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7B9693BD9924C08A1F2ECD1 /* ActiveRecordingView.swift */; };
 		70A52B64861B541B4C9423AC /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5353BBE8E9552B4B486D8701 /* Foundation.framework */; };
 		75AE08DD135D81A3E19190EE /* DiscreetWarningBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F4B4B59E0916CE9ACFF362F /* DiscreetWarningBanner.swift */; };
+		7892677460CB60EACD115895 /* RecordingLiveActivityWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCFA418F675B567DAF8E8905 /* RecordingLiveActivityWidget.swift */; };
 		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 */; };
+		96D67D2F80255A2EAEE6ECEC /* RecordingActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08B11040B6E3800958078529 /* RecordingActivityAttributes.swift */; };
 		9BDA680497BF5DD72A7BA8F3 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6305E60488EEABAFE2E1D6D8 /* HomeView.swift */; };
 		9C5A0C9CDFCB8CFB10FF0B06 /* DeviceScanView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7063E6702335B02B65775F4 /* DeviceScanView.swift */; };
 		9E4554980E7FF484BF400458 /* MultiTrackTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DC9982D9E53E1A77CDB0470 /* MultiTrackTimeline.swift */; };
+		A11C1E000000000000000001 /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11C1E000000000000000002 /* APIClient.swift */; };
+		A11C1E000000000000000003 /* BLEFilteringTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11C1E000000000000000004 /* BLEFilteringTests.swift */; };
+		A1B2C3D4E5F6012345678901 /* AudioPathHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6012345678902 /* AudioPathHelper.swift */; };
 		A3F43A5200A626CBFB70395E /* CelestiaTimelineEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DAA0FBAEC0BF91F45D15B1E /* CelestiaTimelineEvent.swift */; };
+		A9B8C7D6E5F4000000000001 /* SplashView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9B8C7D6E5F4000000000002 /* SplashView.swift */; };
+		A9B8C7D6E5F4000000000003 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A9B8C7D6E5F4000000000004 /* Assets.xcassets */; };
+		A9B8C7D6E5F4000000000007 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = A9B8C7D6E5F4000000000008 /* LaunchScreen.storyboard */; };
 		AF09E026C9AB15874D870E1F /* PulsingRecordButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77D23B83EDDD5489004E42DC /* PulsingRecordButton.swift */; };
 		B262A6AF17A835DB666B2FAB /* SessionListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EBB5FC2AC0B9D31C7A04715 /* SessionListView.swift */; };
+		BCC851C8A3DF4F885F179FCE /* RecordingActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08B11040B6E3800958078529 /* RecordingActivityAttributes.swift */; };
 		BE12EF8734ABF645B2B28BBB /* PlaybackViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E528009D607763F9E68EFBAD /* PlaybackViewModel.swift */; };
 		C2807F9F2794AFA8BF03F104 /* ChangePasswordModalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66BE0E22B2A472E2AFC9CB98 /* ChangePasswordModalView.swift */; };
+		C7FCC7859292F4917426A553 /* CelestiaTraceLiveActivityBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72CA6784C282FC33FAD436B8 /* CelestiaTraceLiveActivityBundle.swift */; };
 		C8794B9720CD85F518A82473 /* BoundDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EF9EF0F961DB2B546B9C0F1 /* BoundDevice.swift */; };
 		CD45B55AB993D054E50A43BE /* RealAudioRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 977EB01CFA491A1573D7CF22 /* RealAudioRecorder.swift */; };
 		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 */; };
+		F3E8621D553D266CAE80F6A0 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5353BBE8E9552B4B486D8701 /* Foundation.framework */; };
 		F5035B942DE1FEC10515D9D6 /* ImagePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = DADF67536C0CF9AE7F405C74 /* ImagePicker.swift */; };
 		FD30DBB57E19055AB8F0A956 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4883D42730E6133FCC3C81C2 /* SettingsView.swift */; };
-		A1B2C3D4E5F6012345678901 /* AudioPathHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6012345678902 /* AudioPathHelper.swift */; };
-		A9B8C7D6E5F4000000000001 /* SplashView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9B8C7D6E5F4000000000002 /* SplashView.swift */; };
-		A9B8C7D6E5F4000000000003 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A9B8C7D6E5F4000000000004 /* Assets.xcassets */; };
-		A9B8C7D6E5F4000000000007 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = A9B8C7D6E5F4000000000008 /* LaunchScreen.storyboard */; };
 /* End PBXBuildFile section */
 
 /* Begin PBXContainerItemProxy section */
@@ -63,50 +71,79 @@
 			remoteGlobalIDString = EC79DCF0EBB2620B0FC21573;
 			remoteInfo = CelestiaTrace;
 		};
+		DABA98985DEBD8FCC304C6F7 /* PBXContainerItemProxy */ = {
+			isa = PBXContainerItemProxy;
+			containerPortal = 979128538DFF0B361F857241 /* Project object */;
+			proxyType = 1;
+			remoteGlobalIDString = F5E2F19ABDFEEE6B93651416;
+			remoteInfo = CelestiaTraceLiveActivity;
+		};
 /* End PBXContainerItemProxy section */
 
+/* Begin PBXCopyFilesBuildPhase section */
+		A95144330D7365DA09C7087E /* Embed Foundation Extensions */ = {
+			isa = PBXCopyFilesBuildPhase;
+			buildActionMask = 2147483647;
+			dstPath = "";
+			dstSubfolderSpec = 13;
+			files = (
+				01C6B879019C60235B096D80 /* CelestiaTraceLiveActivity.appex in Embed Foundation Extensions */,
+			);
+			name = "Embed Foundation Extensions";
+			runOnlyForDeploymentPostprocessing = 0;
+		};
+/* End PBXCopyFilesBuildPhase section */
+
 /* Begin PBXFileReference section */
 		073CAFDB8C45F4EAA33FA0EA /* AuthManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AuthManager.swift; sourceTree = "<group>"; };
 		086899076999CD4909BB9748 /* ProfileView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = "<group>"; };
+		08B11040B6E3800958078529 /* RecordingActivityAttributes.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecordingActivityAttributes.swift; sourceTree = "<group>"; };
 		0EBB5FC2AC0B9D31C7A04715 /* SessionListView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SessionListView.swift; sourceTree = "<group>"; };
 		1189E47F735DDB40D1E741E6 /* LiveWaveformView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LiveWaveformView.swift; sourceTree = "<group>"; };
+		1A48C59DD78DE74AF832D246 /* RecordingLiveActivityManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecordingLiveActivityManager.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>"; };
 		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; };
+		626376A6BF69F473261DF616 /* CelestiaTraceLiveActivity.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = CelestiaTraceLiveActivity.appex; sourceTree = BUILT_PRODUCTS_DIR; };
 		628549E7FC16C58A15C9E1B7 /* CelestiaTraceApp.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CelestiaTraceApp.swift; sourceTree = "<group>"; };
 		6305E60488EEABAFE2E1D6D8 /* HomeView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = "<group>"; };
 		64180D833523B84195EE7E2A /* RecordingViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecordingViewModel.swift; sourceTree = "<group>"; };
 		66BE0E22B2A472E2AFC9CB98 /* ChangePasswordModalView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ChangePasswordModalView.swift; sourceTree = "<group>"; };
 		71729F8F4A48D77C1898C27B /* CelestiaTraceTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CelestiaTraceTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
 		71787388F9456A4784534BCC /* ContentView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
+		72CA6784C282FC33FAD436B8 /* CelestiaTraceLiveActivityBundle.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CelestiaTraceLiveActivityBundle.swift; sourceTree = "<group>"; };
 		77D23B83EDDD5489004E42DC /* PulsingRecordButton.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PulsingRecordButton.swift; sourceTree = "<group>"; };
 		7F2D8AEE2E58FE0F2EF531D0 /* NetworkServiceProtocol.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NetworkServiceProtocol.swift; sourceTree = "<group>"; };
 		83CE4996BF0CC501E3B7C567 /* CelestiaSession.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CelestiaSession.swift; sourceTree = "<group>"; };
+		899B3C2E923AC5479D0AE32A /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
 		8CDFF4B7FD8488111C99DADF /* AuthServiceProtocol.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AuthServiceProtocol.swift; sourceTree = "<group>"; };
 		9275B988BEBBEA80370E1AC4 /* ChronoFeedRow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ChronoFeedRow.swift; sourceTree = "<group>"; };
 		95AF20E8EF4319BBEBA608C1 /* ColorTheme.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ColorTheme.swift; sourceTree = "<group>"; };
 		977EB01CFA491A1573D7CF22 /* RealAudioRecorder.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RealAudioRecorder.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>"; };
 		A1B2C3D4E5F6012345678902 /* AudioPathHelper.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AudioPathHelper.swift; sourceTree = "<group>"; };
 		A9B8C7D6E5F4000000000002 /* SplashView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SplashView.swift; sourceTree = "<group>"; };
 		A9B8C7D6E5F4000000000004 /* Assets.xcassets */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
 		A9B8C7D6E5F4000000000008 /* LaunchScreen.storyboard */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = "<group>"; };
+		A9B8C7D6E5F4000000000009 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
 		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; };
+		DCFA418F675B567DAF8E8905 /* RecordingLiveActivityWidget.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecordingLiveActivityWidget.swift; sourceTree = "<group>"; };
 		E10ED811D3048B961C4754A6 /* AuthModalView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AuthModalView.swift; sourceTree = "<group>"; };
 		E528009D607763F9E68EFBAD /* PlaybackViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PlaybackViewModel.swift; sourceTree = "<group>"; };
 		E7063E6702335B02B65775F4 /* DeviceScanView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DeviceScanView.swift; sourceTree = "<group>"; };
@@ -132,6 +169,14 @@
 			);
 			runOnlyForDeploymentPostprocessing = 0;
 		};
+		8F2F64E3A21692B84286B36E /* Frameworks */ = {
+			isa = PBXFrameworksBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+				F3E8621D553D266CAE80F6A0 /* Foundation.framework in Frameworks */,
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
 /* End PBXFrameworksBuildPhase section */
 
 /* Begin PBXGroup section */
@@ -150,6 +195,7 @@
 			children = (
 				DB56F0DFCB1FE77D7636B43D /* CelestiaTrace.app */,
 				71729F8F4A48D77C1898C27B /* CelestiaTraceTests.xctest */,
+				626376A6BF69F473261DF616 /* CelestiaTraceLiveActivity.appex */,
 			);
 			name = Products;
 			sourceTree = "<group>";
@@ -177,6 +223,7 @@
 		32200F0540C3761CA9D1495A /* CelestiaTraceTests */ = {
 			isa = PBXGroup;
 			children = (
+				A11C1E000000000000000004 /* BLEFilteringTests.swift */,
 			);
 			name = CelestiaTraceTests;
 			path = Tests/CelestiaTraceTests;
@@ -192,6 +239,15 @@
 			path = Profile;
 			sourceTree = "<group>";
 		};
+		55A6E794C53AADF4C0BD6748 /* LiveActivity */ = {
+			isa = PBXGroup;
+			children = (
+				08B11040B6E3800958078529 /* RecordingActivityAttributes.swift */,
+			);
+			name = LiveActivity;
+			path = LiveActivity;
+			sourceTree = "<group>";
+		};
 		565448EB878982B445E267AF /* Frameworks */ = {
 			isa = PBXGroup;
 			children = (
@@ -203,9 +259,8 @@
 		5BCD8C04CE34AA3B16B8C831 /* Auth */ = {
 			isa = PBXGroup;
 			children = (
-				B8211C786643CA85F12CCAB4 /* LocalAuthRepository.swift */,
 				8CDFF4B7FD8488111C99DADF /* AuthServiceProtocol.swift */,
-				4E13AE9CB7E8C9E3BACFC679 /* RemoteAuthServiceStub.swift */,
+				4E13AE9CB7E8C9E3BACFC679 /* RemoteAuthService.swift */,
 				073CAFDB8C45F4EAA33FA0EA /* AuthManager.swift */,
 			);
 			path = Auth;
@@ -221,6 +276,17 @@
 			path = Auth;
 			sourceTree = "<group>";
 		};
+		76DF21F5990CBBC48E108438 /* CelestiaTraceLiveActivity */ = {
+			isa = PBXGroup;
+			children = (
+				72CA6784C282FC33FAD436B8 /* CelestiaTraceLiveActivityBundle.swift */,
+				DCFA418F675B567DAF8E8905 /* RecordingLiveActivityWidget.swift */,
+				899B3C2E923AC5479D0AE32A /* Info.plist */,
+			);
+			name = CelestiaTraceLiveActivity;
+			path = CelestiaTraceLiveActivity;
+			sourceTree = "<group>";
+		};
 		7B90B8D292E18F60A02CDFE2 /* Views */ = {
 			isa = PBXGroup;
 			children = (
@@ -245,6 +311,24 @@
 			path = History;
 			sourceTree = "<group>";
 		};
+		A9B8C7D6E5F4000000000005 /* Splash */ = {
+			isa = PBXGroup;
+			children = (
+				A9B8C7D6E5F4000000000002 /* SplashView.swift */,
+			);
+			path = Splash;
+			sourceTree = "<group>";
+		};
+		A9B8C7D6E5F4000000000006 /* Resources */ = {
+			isa = PBXGroup;
+			children = (
+				A9B8C7D6E5F4000000000004 /* Assets.xcassets */,
+				A9B8C7D6E5F4000000000008 /* LaunchScreen.storyboard */,
+				A9B8C7D6E5F4000000000009 /* Info.plist */,
+			);
+			path = Resources;
+			sourceTree = "<group>";
+		};
 		AA9F20B44A68E171B39266B0 /* iOS */ = {
 			isa = PBXGroup;
 			children = (
@@ -280,6 +364,7 @@
 				DB59940538488B1E753621A8 /* Network */,
 				FD4D76A3358AAF1519C279F1 /* Audio */,
 				AF823CBC596A095A54F096C1 /* Bluetooth */,
+				1A48C59DD78DE74AF832D246 /* RecordingLiveActivityManager.swift */,
 			);
 			path = Services;
 			sourceTree = "<group>";
@@ -293,6 +378,7 @@
 				2626EC2897F60B86D833D875 /* Models */,
 				7B90B8D292E18F60A02CDFE2 /* Views */,
 				B33580BE3AF0DC9085B1D3A1 /* Services */,
+				55A6E794C53AADF4C0BD6748 /* LiveActivity */,
 			);
 			path = CelestiaTrace;
 			sourceTree = "<group>";
@@ -330,6 +416,7 @@
 				565448EB878982B445E267AF /* Frameworks */,
 				B5CB52A86415BBB0B9DCE932 /* CelestiaTrace */,
 				32200F0540C3761CA9D1495A /* CelestiaTraceTests */,
+				76DF21F5990CBBC48E108438 /* CelestiaTraceLiveActivity */,
 			);
 			sourceTree = "<group>";
 		};
@@ -346,7 +433,8 @@
 			isa = PBXGroup;
 			children = (
 				7F2D8AEE2E58FE0F2EF531D0 /* NetworkServiceProtocol.swift */,
-				24923AF5D038F81EB3248A4C /* MockNetworkService.swift */,
+				A11C1E000000000000000002 /* APIClient.swift */,
+				24923AF5D038F81EB3248A4C /* RemoteNetworkService.swift */,
 			);
 			path = Network;
 			sourceTree = "<group>";
@@ -360,23 +448,6 @@
 			path = Devices;
 			sourceTree = "<group>";
 		};
-		A9B8C7D6E5F4000000000005 /* Splash */ = {
-			isa = PBXGroup;
-			children = (
-				A9B8C7D6E5F4000000000002 /* SplashView.swift */,
-			);
-			path = Splash;
-			sourceTree = "<group>";
-		};
-		A9B8C7D6E5F4000000000006 /* Resources */ = {
-			isa = PBXGroup;
-			children = (
-				A9B8C7D6E5F4000000000004 /* Assets.xcassets */,
-				A9B8C7D6E5F4000000000008 /* LaunchScreen.storyboard */,
-			);
-			path = Resources;
-			sourceTree = "<group>";
-		};
 		FD4D76A3358AAF1519C279F1 /* Audio */ = {
 			isa = PBXGroup;
 			children = (
@@ -417,16 +488,35 @@
 				9E60C6991F09AB5E80EF4283 /* Sources */,
 				6FF5C5F71B01A15E70EBC20C /* Frameworks */,
 				2B844E255303FD43D7D16A02 /* Resources */,
+				A95144330D7365DA09C7087E /* Embed Foundation Extensions */,
 			);
 			buildRules = (
 			);
 			dependencies = (
+				FAC05647B7B91065690E0EAB /* PBXTargetDependency */,
 			);
 			name = CelestiaTrace;
 			productName = CelestiaTrace;
 			productReference = DB56F0DFCB1FE77D7636B43D /* CelestiaTrace.app */;
 			productType = "com.apple.product-type.application";
 		};
+		F5E2F19ABDFEEE6B93651416 /* CelestiaTraceLiveActivity */ = {
+			isa = PBXNativeTarget;
+			buildConfigurationList = 45022A0FA83D32670D30B200 /* Build configuration list for PBXNativeTarget "CelestiaTraceLiveActivity" */;
+			buildPhases = (
+				F73E5C57E12A8709FF074E12 /* Sources */,
+				8F2F64E3A21692B84286B36E /* Frameworks */,
+				469C396BB9D89A7898530847 /* Resources */,
+			);
+			buildRules = (
+			);
+			dependencies = (
+			);
+			name = CelestiaTraceLiveActivity;
+			productName = CelestiaTraceLiveActivity;
+			productReference = 626376A6BF69F473261DF616 /* CelestiaTraceLiveActivity.appex */;
+			productType = "com.apple.product-type.app-extension";
+		};
 /* End PBXNativeTarget section */
 
 /* Begin PBXProject section */
@@ -457,6 +547,7 @@
 			targets = (
 				EC79DCF0EBB2620B0FC21573 /* CelestiaTrace */,
 				D5681575EDB1ABEE4093C509 /* CelestiaTraceTests */,
+				F5E2F19ABDFEEE6B93651416 /* CelestiaTraceLiveActivity */,
 			);
 		};
 /* End PBXProject section */
@@ -471,6 +562,13 @@
 			);
 			runOnlyForDeploymentPostprocessing = 0;
 		};
+		469C396BB9D89A7898530847 /* Resources */ = {
+			isa = PBXResourcesBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
 		D875758EF038ED00E0A14D96 /* Resources */ = {
 			isa = PBXResourcesBuildPhase;
 			buildActionMask = 2147483647;
@@ -485,6 +583,7 @@
 			isa = PBXSourcesBuildPhase;
 			buildActionMask = 2147483647;
 			files = (
+				A11C1E000000000000000003 /* BLEFilteringTests.swift in Sources */,
 			);
 			runOnlyForDeploymentPostprocessing = 0;
 		};
@@ -521,19 +620,31 @@
 				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 */,
 				F385E2FCB06D39917F51846E /* AudioRecorderProtocol.swift in Sources */,
 				A1B2C3D4E5F6012345678901 /* AudioPathHelper.swift in Sources */,
 				8FBEE86CDB9635710EF85F1B /* BLEManager.swift in Sources */,
+				46F4F91D30D4C26A44A4D570 /* RecordingLiveActivityManager.swift in Sources */,
+				BCC851C8A3DF4F885F179FCE /* RecordingActivityAttributes.swift in Sources */,
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
+		F73E5C57E12A8709FF074E12 /* Sources */ = {
+			isa = PBXSourcesBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+				C7FCC7859292F4917426A553 /* CelestiaTraceLiveActivityBundle.swift in Sources */,
+				7892677460CB60EACD115895 /* RecordingLiveActivityWidget.swift in Sources */,
+				96D67D2F80255A2EAEE6ECEC /* RecordingActivityAttributes.swift in Sources */,
 			);
 			runOnlyForDeploymentPostprocessing = 0;
 		};
@@ -546,9 +657,37 @@
 			target = EC79DCF0EBB2620B0FC21573 /* CelestiaTrace */;
 			targetProxy = 55C3FF8383E750C9C9453EC5 /* PBXContainerItemProxy */;
 		};
+		FAC05647B7B91065690E0EAB /* PBXTargetDependency */ = {
+			isa = PBXTargetDependency;
+			name = CelestiaTraceLiveActivity;
+			target = F5E2F19ABDFEEE6B93651416 /* CelestiaTraceLiveActivity */;
+			targetProxy = DABA98985DEBD8FCC304C6F7 /* PBXContainerItemProxy */;
+		};
 /* End PBXTargetDependency section */
 
 /* Begin XCBuildConfiguration section */
+		02AF74A06F9C06B4F4B287E7 /* Release */ = {
+			isa = XCBuildConfiguration;
+			buildSettings = {
+				APPLICATION_EXTENSION_API_ONLY = YES;
+				CLANG_ENABLE_OBJC_WEAK = NO;
+				CODE_SIGN_STYLE = Automatic;
+				CURRENT_PROJECT_VERSION = 1;
+				DEVELOPMENT_TEAM = 5ERVQEJ497;
+				GENERATE_INFOPLIST_FILE = NO;
+				INFOPLIST_FILE = CelestiaTraceLiveActivity/Info.plist;
+				IPHONEOS_DEPLOYMENT_TARGET = 17.0;
+				LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../../Frameworks";
+				MARKETING_VERSION = 1.0.0;
+				PRODUCT_BUNDLE_IDENTIFIER = "com.celestia.trace.live-activity";
+				SDKROOT = iphoneos;
+				SKIP_INSTALL = YES;
+				SWIFT_VERSION = 5.9;
+				TARGETED_DEVICE_FAMILY = 1;
+				VALIDATE_PRODUCT = YES;
+			};
+			name = Release;
+		};
 		76F4E0F0A78EC9D84B954B50 /* Debug */ = {
 			isa = XCBuildConfiguration;
 			buildSettings = {
@@ -610,17 +749,42 @@
 			};
 			name = Debug;
 		};
+		84826E351EF861B4360FFC19 /* Debug */ = {
+			isa = XCBuildConfiguration;
+			buildSettings = {
+				APPLICATION_EXTENSION_API_ONLY = YES;
+				CLANG_ENABLE_OBJC_WEAK = NO;
+				CODE_SIGN_STYLE = Automatic;
+				CURRENT_PROJECT_VERSION = 1;
+				DEVELOPMENT_TEAM = 5ERVQEJ497;
+				GENERATE_INFOPLIST_FILE = NO;
+				INFOPLIST_FILE = CelestiaTraceLiveActivity/Info.plist;
+				IPHONEOS_DEPLOYMENT_TARGET = 17.0;
+				LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../../Frameworks";
+				MARKETING_VERSION = 1.0.0;
+				PRODUCT_BUNDLE_IDENTIFIER = "com.celestia.trace.live-activity";
+				SDKROOT = iphoneos;
+				SKIP_INSTALL = YES;
+				SWIFT_VERSION = 5.9;
+				TARGETED_DEVICE_FAMILY = 1;
+			};
+			name = Debug;
+		};
 		95AB591F506D02959044D749 /* Debug */ = {
 			isa = XCBuildConfiguration;
 			buildSettings = {
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+				ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
 				CLANG_ENABLE_OBJC_WEAK = NO;
 				CODE_SIGN_STYLE = Automatic;
+				CURRENT_PROJECT_VERSION = 1;
 				DEVELOPMENT_ASSET_PATHS = "\"\"";
 				DEVELOPMENT_TEAM = 5ERVQEJ497;
-				GENERATE_INFOPLIST_FILE = YES;
-				INFOPLIST_KEY_CFBundleDisplayName = "星痕";
+				GENERATE_INFOPLIST_FILE = NO;
+				INFOPLIST_FILE = CelestiaTrace/Resources/Info.plist;
+				INFOPLIST_KEY_CFBundleDisplayName = "星痕 CelestiaTrace";
+				INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
 				INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "需要蓝牙权限以扫描和绑定 BLE 录音设备";
 				INFOPLIST_KEY_NSBluetoothPeripheralUsageDescription = "需要蓝牙权限以与录音设备传输数据";
 				INFOPLIST_KEY_NSCameraUsageDescription = "需要相机权限以在现场录制中拍摄照片记录";
@@ -630,6 +794,7 @@
 				INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
 				IPHONEOS_DEPLOYMENT_TARGET = 17.0;
 				LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
+				MARKETING_VERSION = 1.0.0;
 				PRODUCT_BUNDLE_IDENTIFIER = com.celestia.trace;
 				SDKROOT = iphoneos;
 				SWIFT_VERSION = 5.9;
@@ -642,12 +807,16 @@
 			buildSettings = {
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+				ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
 				CLANG_ENABLE_OBJC_WEAK = NO;
 				CODE_SIGN_STYLE = Automatic;
+				CURRENT_PROJECT_VERSION = 1;
 				DEVELOPMENT_ASSET_PATHS = "\"\"";
 				DEVELOPMENT_TEAM = 5ERVQEJ497;
-				GENERATE_INFOPLIST_FILE = YES;
-				INFOPLIST_KEY_CFBundleDisplayName = "星痕";
+				GENERATE_INFOPLIST_FILE = NO;
+				INFOPLIST_FILE = CelestiaTrace/Resources/Info.plist;
+				INFOPLIST_KEY_CFBundleDisplayName = "星痕 CelestiaTrace";
+				INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
 				INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "需要蓝牙权限以扫描和绑定 BLE 录音设备";
 				INFOPLIST_KEY_NSBluetoothPeripheralUsageDescription = "需要蓝牙权限以与录音设备传输数据";
 				INFOPLIST_KEY_NSCameraUsageDescription = "需要相机权限以在现场录制中拍摄照片记录";
@@ -657,6 +826,7 @@
 				INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
 				IPHONEOS_DEPLOYMENT_TARGET = 17.0;
 				LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
+				MARKETING_VERSION = 1.0.0;
 				PRODUCT_BUNDLE_IDENTIFIER = com.celestia.trace;
 				SDKROOT = iphoneos;
 				SWIFT_VERSION = 5.9;
@@ -759,6 +929,15 @@
 			defaultConfigurationIsVisible = 0;
 			defaultConfigurationName = Release;
 		};
+		45022A0FA83D32670D30B200 /* Build configuration list for PBXNativeTarget "CelestiaTraceLiveActivity" */ = {
+			isa = XCConfigurationList;
+			buildConfigurations = (
+				02AF74A06F9C06B4F4B287E7 /* Release */,
+				84826E351EF861B4360FFC19 /* Debug */,
+			);
+			defaultConfigurationIsVisible = 0;
+			defaultConfigurationName = Release;
+		};
 		902A4F79574BE80E9469463E /* Build configuration list for PBXNativeTarget "CelestiaTrace" */ = {
 			isa = XCConfigurationList;
 			buildConfigurations = (

+ 91 - 0
CelestiaTrace.xcodeproj/xcshareddata/xcschemes/CelestiaTrace App.xcscheme

@@ -0,0 +1,91 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<Scheme
+   LastUpgradeVersion = "2650"
+   version = "1.7">
+   <BuildAction
+      parallelizeBuildables = "YES"
+      buildImplicitDependencies = "YES"
+      buildArchitectures = "Automatic">
+      <BuildActionEntries>
+         <BuildActionEntry
+            buildForTesting = "YES"
+            buildForRunning = "YES"
+            buildForProfiling = "YES"
+            buildForArchiving = "YES"
+            buildForAnalyzing = "YES">
+            <BuildableReference
+               BuildableIdentifier = "primary"
+               BlueprintIdentifier = "EC79DCF0EBB2620B0FC21573"
+               BuildableName = "CelestiaTrace.app"
+               BlueprintName = "CelestiaTrace"
+               ReferencedContainer = "container:CelestiaTrace.xcodeproj">
+            </BuildableReference>
+         </BuildActionEntry>
+      </BuildActionEntries>
+   </BuildAction>
+   <TestAction
+      buildConfiguration = "Debug"
+      selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
+      selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
+      shouldUseLaunchSchemeArgsEnv = "YES"
+      shouldAutocreateTestPlan = "YES">
+      <Testables>
+         <TestableReference
+            skipped = "NO"
+            parallelizable = "YES">
+            <BuildableReference
+               BuildableIdentifier = "primary"
+               BlueprintIdentifier = "D5681575EDB1ABEE4093C509"
+               BuildableName = "CelestiaTraceTests.xctest"
+               BlueprintName = "CelestiaTraceTests"
+               ReferencedContainer = "container:CelestiaTrace.xcodeproj">
+            </BuildableReference>
+         </TestableReference>
+      </Testables>
+   </TestAction>
+   <LaunchAction
+      buildConfiguration = "Debug"
+      selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
+      selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
+      launchStyle = "0"
+      useCustomWorkingDirectory = "NO"
+      ignoresPersistentStateOnLaunch = "NO"
+      debugDocumentVersioning = "YES"
+      debugServiceExtension = "internal"
+      allowLocationSimulation = "YES">
+      <BuildableProductRunnable
+         runnableDebuggingMode = "0">
+         <BuildableReference
+            BuildableIdentifier = "primary"
+            BlueprintIdentifier = "EC79DCF0EBB2620B0FC21573"
+            BuildableName = "CelestiaTrace.app"
+            BlueprintName = "CelestiaTrace"
+            ReferencedContainer = "container:CelestiaTrace.xcodeproj">
+         </BuildableReference>
+      </BuildableProductRunnable>
+   </LaunchAction>
+   <ProfileAction
+      buildConfiguration = "Release"
+      shouldUseLaunchSchemeArgsEnv = "YES"
+      savedToolIdentifier = ""
+      useCustomWorkingDirectory = "NO"
+      debugDocumentVersioning = "YES">
+      <BuildableProductRunnable
+         runnableDebuggingMode = "0">
+         <BuildableReference
+            BuildableIdentifier = "primary"
+            BlueprintIdentifier = "EC79DCF0EBB2620B0FC21573"
+            BuildableName = "CelestiaTrace.app"
+            BlueprintName = "CelestiaTrace"
+            ReferencedContainer = "container:CelestiaTrace.xcodeproj">
+         </BuildableReference>
+      </BuildableProductRunnable>
+   </ProfileAction>
+   <AnalyzeAction
+      buildConfiguration = "Debug">
+   </AnalyzeAction>
+   <ArchiveAction
+      buildConfiguration = "Release"
+      revealArchiveInOrganizer = "YES">
+   </ArchiveAction>
+</Scheme>

+ 125 - 0
CelestiaTrace.xcodeproj/xcshareddata/xcschemes/CelestiaTraceLiveActivity.xcscheme

@@ -0,0 +1,125 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<Scheme
+   LastUpgradeVersion = "2660"
+   wasCreatedForAppExtension = "YES"
+   version = "2.0">
+   <BuildAction
+      parallelizeBuildables = "YES"
+      buildImplicitDependencies = "YES"
+      buildArchitectures = "Automatic">
+      <BuildActionEntries>
+         <BuildActionEntry
+            buildForTesting = "YES"
+            buildForRunning = "YES"
+            buildForProfiling = "YES"
+            buildForArchiving = "YES"
+            buildForAnalyzing = "YES">
+            <BuildableReference
+               BuildableIdentifier = "primary"
+               BlueprintIdentifier = "F5E2F19ABDFEEE6B93651416"
+               BuildableName = "CelestiaTraceLiveActivity.appex"
+               BlueprintName = "CelestiaTraceLiveActivity"
+               ReferencedContainer = "container:CelestiaTrace.xcodeproj">
+            </BuildableReference>
+         </BuildActionEntry>
+         <BuildActionEntry
+            buildForTesting = "YES"
+            buildForRunning = "YES"
+            buildForProfiling = "YES"
+            buildForArchiving = "YES"
+            buildForAnalyzing = "YES">
+            <BuildableReference
+               BuildableIdentifier = "primary"
+               BlueprintIdentifier = "EC79DCF0EBB2620B0FC21573"
+               BuildableName = "CelestiaTrace.app"
+               BlueprintName = "CelestiaTrace"
+               ReferencedContainer = "container:CelestiaTrace.xcodeproj">
+            </BuildableReference>
+         </BuildActionEntry>
+      </BuildActionEntries>
+   </BuildAction>
+   <TestAction
+      buildConfiguration = "Debug"
+      selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
+      selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
+      shouldUseLaunchSchemeArgsEnv = "YES"
+      shouldAutocreateTestPlan = "YES">
+   </TestAction>
+   <LaunchAction
+      buildConfiguration = "Debug"
+      selectedDebuggerIdentifier = ""
+      selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
+      launchStyle = "0"
+      askForAppToLaunch = "Yes"
+      useCustomWorkingDirectory = "NO"
+      ignoresPersistentStateOnLaunch = "NO"
+      debugDocumentVersioning = "YES"
+      debugServiceExtension = "internal"
+      allowLocationSimulation = "YES"
+      launchAutomaticallySubstyle = "2"
+      queueDebuggingEnableBacktraceRecording = "Yes">
+      <RemoteRunnable
+         runnableDebuggingMode = "2"
+         BundleIdentifier = "com.apple.springboard">
+         <BuildableReference
+            BuildableIdentifier = "primary"
+            BlueprintIdentifier = "F5E2F19ABDFEEE6B93651416"
+            BuildableName = "CelestiaTraceLiveActivity.appex"
+            BlueprintName = "CelestiaTraceLiveActivity"
+            ReferencedContainer = "container:CelestiaTrace.xcodeproj">
+         </BuildableReference>
+      </RemoteRunnable>
+      <MacroExpansion>
+         <BuildableReference
+            BuildableIdentifier = "primary"
+            BlueprintIdentifier = "EC79DCF0EBB2620B0FC21573"
+            BuildableName = "CelestiaTrace.app"
+            BlueprintName = "CelestiaTrace"
+            ReferencedContainer = "container:CelestiaTrace.xcodeproj">
+         </BuildableReference>
+      </MacroExpansion>
+      <EnvironmentVariables>
+         <EnvironmentVariable
+            key = "_XCWidgetKind"
+            value = ""
+            isEnabled = "YES">
+         </EnvironmentVariable>
+         <EnvironmentVariable
+            key = "_XCWidgetDefaultView"
+            value = "timeline"
+            isEnabled = "YES">
+         </EnvironmentVariable>
+         <EnvironmentVariable
+            key = "_XCWidgetFamily"
+            value = "systemMedium"
+            isEnabled = "YES">
+         </EnvironmentVariable>
+      </EnvironmentVariables>
+   </LaunchAction>
+   <ProfileAction
+      buildConfiguration = "Release"
+      shouldUseLaunchSchemeArgsEnv = "YES"
+      savedToolIdentifier = ""
+      useCustomWorkingDirectory = "NO"
+      debugDocumentVersioning = "YES"
+      askForAppToLaunch = "Yes"
+      launchAutomaticallySubstyle = "2">
+      <BuildableProductRunnable
+         runnableDebuggingMode = "0">
+         <BuildableReference
+            BuildableIdentifier = "primary"
+            BlueprintIdentifier = "EC79DCF0EBB2620B0FC21573"
+            BuildableName = "CelestiaTrace.app"
+            BlueprintName = "CelestiaTrace"
+            ReferencedContainer = "container:CelestiaTrace.xcodeproj">
+         </BuildableReference>
+      </BuildableProductRunnable>
+   </ProfileAction>
+   <AnalyzeAction
+      buildConfiguration = "Debug">
+   </AnalyzeAction>
+   <ArchiveAction
+      buildConfiguration = "Release"
+      revealArchiveInOrganizer = "YES">
+   </ArchiveAction>
+</Scheme>

+ 14 - 0
CelestiaTrace/LiveActivity/RecordingActivityAttributes.swift

@@ -0,0 +1,14 @@
+import ActivityKit
+import Foundation
+
+struct RecordingActivityAttributes: ActivityAttributes {
+    struct ContentState: Codable, Hashable {
+        var timerStartDate: Date
+        var elapsedSeconds: TimeInterval
+        var isPaused: Bool
+    }
+
+    var sessionID: UUID
+    var title: String
+    var sourceName: String
+}

+ 21 - 3
CelestiaTrace/Models/BoundDevice.swift

@@ -2,14 +2,20 @@ import Foundation
 
 /// Represents a physical recording device bound to a user account via BLE.
 struct BoundDevice: Codable, Identifiable, Equatable {
-    let id: String           // Unique device hardware ID / MAC / Serial
-    var name: String         // Display name of the device
+    let id: String           // CoreBluetooth peripheral identifier
+    var name: String         // User-defined display name
     var peripheralUUID: String // CoreBluetooth Peripheral UUID
     var boundAt: Date        // Date when bound to user account
     var isConnected: Bool    // Connection status (runtime state)
     var batteryLevel: Int?   // Battery percentage (0 - 100)
     var firmwareVersion: String? // Firmware version
     var boundUserId: String  // User account ID owning this device
+    var advertisedName: String? // Name reported in the BLE advertisement
+    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,
@@ -19,7 +25,13 @@ struct BoundDevice: Codable, Identifiable, Equatable {
         isConnected: Bool = false,
         batteryLevel: Int? = 85,
         firmwareVersion: String? = "v1.2.0",
-        boundUserId: String
+        boundUserId: String,
+        advertisedName: String? = nil,
+        hardwareMAC: String? = nil,
+        freeStorageMB: Int? = nil,
+        totalStorageMB: Int? = nil,
+        cloudID: String? = nil,
+        cloudSyncError: String? = nil
     ) {
         self.id = id
         self.name = name
@@ -29,6 +41,12 @@ struct BoundDevice: Codable, Identifiable, Equatable {
         self.batteryLevel = batteryLevel
         self.firmwareVersion = firmwareVersion
         self.boundUserId = boundUserId
+        self.advertisedName = advertisedName
+        self.hardwareMAC = hardwareMAC
+        self.freeStorageMB = freeStorageMB
+        self.totalStorageMB = totalStorageMB
+        self.cloudID = cloudID
+        self.cloudSyncError = cloudSyncError
     }
 }
 

+ 31 - 4
CelestiaTrace/Models/CelestiaSession.swift

@@ -10,6 +10,14 @@ final class CelestiaSession {
     var localAudioPath: String?
     var isSynced: Bool
     var cloudSessionId: String?
+    var ownerUserID: String?
+    // Defaults must live on the persisted properties so SwiftData can backfill
+    // existing rows during a lightweight migration. Initializer defaults only
+    // apply to newly-created model instances.
+    var serverRevision: Int64 = 0
+    var syncStateRaw: String = SessionSyncState.localOnly.rawValue
+    var lastSyncError: String?
+    var lastSyncedAt: Date?
     
     @Relationship(deleteRule: .cascade, inverse: \CelestiaTimelineEvent.session)
     var events: [CelestiaTimelineEvent]
@@ -23,7 +31,7 @@ final class CelestiaSession {
     }
     
     var durationFormatted: String {
-        guard let end = endTime else { return "正在录..." }
+        guard let end = endTime else { return "正在录..." }
         let interval = end.timeIntervalSince(startTime)
         let hours = Int(interval) / 3600
         let minutes = (Int(interval) % 3600) / 60
@@ -42,15 +50,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
     }
 }

binární
CelestiaTrace/Resources/Assets.xcassets/SplashArtwork.imageset/SplashArtwork.png


+ 62 - 0
CelestiaTrace/Resources/Info.plist

@@ -0,0 +1,62 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+	<key>CFBundleDevelopmentRegion</key>
+	<string>$(DEVELOPMENT_LANGUAGE)</string>
+	<key>APIBaseURL</key>
+	<string>https://api.ccdw.life/celestia-trace/v1</string>
+	<key>CFBundleDisplayName</key>
+	<string>星痕 CelestiaTrace</string>
+	<key>CFBundleExecutable</key>
+	<string>$(EXECUTABLE_NAME)</string>
+	<key>CFBundleIdentifier</key>
+	<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
+	<key>CFBundleInfoDictionaryVersion</key>
+	<string>6.0</string>
+	<key>CFBundleName</key>
+	<string>$(PRODUCT_NAME)</string>
+	<key>CFBundlePackageType</key>
+	<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
+	<key>CFBundleShortVersionString</key>
+	<string>$(MARKETING_VERSION)</string>
+	<key>CFBundleVersion</key>
+	<string>$(CURRENT_PROJECT_VERSION)</string>
+	<key>LSRequiresIPhoneOS</key>
+	<true/>
+	<key>NSSupportsLiveActivities</key>
+	<true/>
+	<key>NSBluetoothAlwaysUsageDescription</key>
+	<string>需要蓝牙权限以搜索、绑定并使用微光(Spark)录音设备</string>
+	<key>NSBluetoothPeripheralUsageDescription</key>
+	<string>需要蓝牙权限以与微光(Spark)传输录音数据</string>
+	<key>NSCameraUsageDescription</key>
+	<string>需要相机权限以在现场录制中拍摄照片记录</string>
+	<key>NSMicrophoneUsageDescription</key>
+	<string>需要麦克风权限以使用 iPhone 进行现场录音</string>
+	<key>NSPhotoLibraryUsageDescription</key>
+	<string>需要相册权限以选择照片记录</string>
+	<key>UIBackgroundModes</key>
+	<array>
+		<string>audio</string>
+		<string>bluetooth-central</string>
+	</array>
+	<key>CFBundleURLTypes</key>
+	<array>
+		<dict>
+			<key>CFBundleTypeRole</key>
+			<string>Editor</string>
+			<key>CFBundleURLSchemes</key>
+			<array>
+				<string>celestiatrace</string>
+			</array>
+		</dict>
+	</array>
+	<key>UILaunchStoryboardName</key>
+	<string>LaunchScreen</string>
+	<key>UISupportedInterfaceOrientations</key>
+	<array>
+		<string>UIInterfaceOrientationPortrait</string>
+	</array>
+</dict>
+</plist>

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

@@ -1,8 +1,65 @@
 import Foundation
 import Combine
 
+/// Shared metering conversion used by both live recording and playback analysis.
+enum AudioLevelNormalizer {
+    static let minimumDecibels: Float = -50
+
+    static func normalizedLevel(
+        decibels: Float,
+        minimumDecibels: Float = AudioLevelNormalizer.minimumDecibels
+    ) -> Float {
+        guard decibels.isFinite else { return 0 }
+        if decibels < minimumDecibels { return 0 }
+        if decibels >= 0 { return 1 }
+        return min(max((decibels - minimumDecibels) / -minimumDecibels, 0), 1)
+    }
+}
+
+/// The physical source selected for one recording segment.
+enum RecordingSourceChoice: Identifiable, Equatable {
+    case iPhone
+    case spark(deviceID: String, displayName: String)
+
+    var id: String {
+        switch self {
+        case .iPhone:
+            return "iphone"
+        case .spark(let deviceID, _):
+            return "spark-\(deviceID)"
+        }
+    }
+
+    var displayName: String {
+        switch self {
+        case .iPhone:
+            return "iPhone 麦克风"
+        case .spark(_, let displayName):
+            return displayName
+        }
+    }
+
+    var systemImage: String {
+        switch self {
+        case .iPhone: return "iphone"
+        case .spark: return "wave.3.right.circle.fill"
+        }
+    }
+}
+
+enum AudioRecorderError: LocalizedError {
+    case failedToStart(String)
+    case failedToStop(String)
+
+    var errorDescription: String? {
+        switch self {
+        case .failedToStart(let message), .failedToStop(let message):
+            return message
+        }
+    }
+}
+
 /// Protocol abstracting audio recording capabilities.
-/// Allows swapping between Mock and Real (AVAudioEngine) implementations.
 protocol AudioRecorderProtocol: ObservableObject {
     /// Current recording state
     var isRecording: Bool { get }
@@ -12,13 +69,31 @@ 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 }
+    /// User-facing name of the active recording source.
+    var sourceDisplayName: String { get }
+    /// Current source status, such as connecting or recording.
+    var statusMessage: String { get }
+    /// A recoverable error that should be shown in the recording UI.
+    var errorMessage: String? { get }
     
     func startRecording()
     func stopRecording()
+    func stopRecording(completion: @escaping (Result<URL?, Error>) -> Void)
     func pauseRecording()
     func resumeRecording()
 }
+
+extension AudioRecorderProtocol {
+    var sourceDisplayName: String { "iPhone 本机" }
+    var statusMessage: String { isRecording ? "正在记录" : "正在准备" }
+    var errorMessage: String? { nil }
+
+    func stopRecording(completion: @escaping (Result<URL?, Error>) -> Void) {
+        stopRecording()
+        completion(.success(outputFileURL))
+    }
+}

+ 23 - 24
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
@@ -79,15 +78,15 @@ final class RealAudioRecorder: AudioRecorderProtocol {
             let recorder = try AVAudioRecorder(url: fileURL, settings: settings)
             recorder.isMeteringEnabled = true
             
-            if recorder.prepareToRecord() {
-                recorder.record()
+            if recorder.prepareToRecord(), recorder.record() {
                 self.audioRecorder = recorder
                 self.isRecording = true
                 self.isPaused = false
                 self.startTimer()
                 print("[RealAudioRecorder] Recording started: \(fileURL.lastPathComponent)")
             } else {
-                print("[RealAudioRecorder] Prepare to record failed")
+                try? session.setActive(false, options: .notifyOthersOnDeactivation)
+                print("[RealAudioRecorder] Failed to prepare or start recording")
             }
         } catch {
             print("[RealAudioRecorder] Setup failed: \(error.localizedDescription)")
@@ -105,7 +104,7 @@ final class RealAudioRecorder: AudioRecorderProtocol {
         currentAmplitude = 0
         
         // Deactivate audio session
-        try? AVAudioSession.sharedInstance().setActive(false)
+        try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
         print("[RealAudioRecorder] Recording stopped. File saved.")
     }
     
@@ -113,15 +112,23 @@ final class RealAudioRecorder: AudioRecorderProtocol {
         guard isRecording else { return }
         audioRecorder?.pause()
         isPaused = true
+        currentAmplitude = 0
         invalidateTimer()
     }
     
     func resumeRecording() {
         guard isRecording else { return }
-        try? AVAudioSession.sharedInstance().setActive(true)
-        audioRecorder?.record()
-        isPaused = false
-        startTimer()
+        do {
+            try AVAudioSession.sharedInstance().setActive(true)
+            guard audioRecorder?.record() == true else {
+                print("[RealAudioRecorder] Failed to resume recording")
+                return
+            }
+            isPaused = false
+            startTimer()
+        } catch {
+            print("[RealAudioRecorder] Failed to reactivate audio session: \(error.localizedDescription)")
+        }
     }
     
     // MARK: - Private Helpers
@@ -151,16 +158,8 @@ final class RealAudioRecorder: AudioRecorderProtocol {
         // Get average decibels for channel 0
         let power = recorder.averagePower(forChannel: 0)
         
-        // Map decibels (-50.0 dB to 0.0 dB) to 0.0 - 1.0 amplitude
-        let minDb: Float = -50.0
-        let level: Float
-        if power < minDb {
-            level = 0
-        } else if power >= 0 {
-            level = 1
-        } else {
-            level = (power - minDb) / -minDb
-        }
+        // Keep live and playback waveform levels on the same scale.
+        let level = AudioLevelNormalizer.normalizedLevel(decibels: power)
         
         currentAmplitude = level
         waveformSamples.append(level)
@@ -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] = []

+ 54 - 17
CelestiaTrace/Services/Audio/SilenceDetector.swift

@@ -16,6 +16,17 @@ public struct SilenceRange: Codable, Hashable, Sendable {
     }
 }
 
+/// Playback analysis generated in one pass over the decoded audio.
+public struct AudioAnalysisResult: Sendable {
+    public let waveformSamples: [Float]
+    public let silentRanges: [SilenceRange]
+
+    public init(waveformSamples: [Float], silentRanges: [SilenceRange]) {
+        self.waveformSamples = waveformSamples
+        self.silentRanges = silentRanges
+    }
+}
+
 public final class SilenceDetector: Sendable {
     /// Detects silent ranges in the specified audio file.
     /// - Parameters:
@@ -28,39 +39,57 @@ 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)
+        await analyze(
+            audioURL,
+            silenceThresholdDB: thresholdDB,
+            minimumSilenceDuration: minDuration
+        ).silentRanges
+    }
+
+    /// Extracts waveform levels and silence ranges together to avoid decoding twice.
+    /// Waveform samples use the same 50ms cadence and dB normalization as live recording.
+    public static func analyze(
+        _ audioURL: URL,
+        silenceThresholdDB: Float = -40.0,
+        minimumSilenceDuration: TimeInterval = 2.0
+    ) async -> AudioAnalysisResult {
         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 AudioAnalysisResult(waveformSamples: [], silentRanges: [])
         }
         
-        let task = Task.detached(priority: .userInitiated) { () -> [SilenceRange] in
+        let task = Task.detached(priority: .userInitiated) { () -> AudioAnalysisResult in
             guard let audioFile = try? AVAudioFile(forReading: validURL) else {
-                return []
+                return AudioAnalysisResult(waveformSamples: [], silentRanges: [])
             }
             
             let format = audioFile.processingFormat
             let sampleRate = format.sampleRate
-            guard sampleRate > 0 else { return [] }
+            guard sampleRate > 0 else {
+                return AudioAnalysisResult(waveformSamples: [], silentRanges: [])
+            }
             
             // Buffer size of ~1.0 second
             let bufferSize = AVAudioFrameCount(sampleRate)
             guard let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: bufferSize) else {
-                return []
+                return AudioAnalysisResult(waveformSamples: [], silentRanges: [])
             }
             
+            var waveformSamples: [Float] = []
+            waveformSamples.reserveCapacity(Int(Double(audioFile.length) / sampleRate / 0.05) + 1)
             var silentRanges: [SilenceRange] = []
             var isSilent = false
             var silenceStart: TimeInterval = 0
             
-            // Sub-chunk size of 100ms
-            let subChunkSize = Int(sampleRate * 0.1)
+            // Match RealAudioRecorder's 50ms metering cadence.
+            let subChunkSize = max(1, Int(sampleRate * 0.05))
             
             do {
                 while audioFile.framePosition < audioFile.length {
+                    if Task.isCancelled {
+                        return AudioAnalysisResult(waveformSamples: [], silentRanges: [])
+                    }
+
                     let framesToRead = min(bufferSize, AVAudioFrameCount(audioFile.length - audioFile.framePosition))
                     if framesToRead <= 0 { break }
                     
@@ -83,12 +112,15 @@ public final class SilenceDetector: Sendable {
                         
                         let rms = sqrt(sum / Float(currentSubChunkSize))
                         let db = rms > 0 ? 20 * log10(rms) : -100.0
+                        waveformSamples.append(
+                            AudioLevelNormalizer.normalizedLevel(decibels: db)
+                        )
                         
                         // Calculate absolute time of this sub-chunk
                         let absoluteFramePosition = audioFile.framePosition - Int64(frameLength) + Int64(offset)
                         let timeInSeconds = Double(absoluteFramePosition) / sampleRate
                         
-                        let isChunkSilent = db < thresholdDB
+                        let isChunkSilent = db < silenceThresholdDB
                         
                         if isChunkSilent {
                             if !isSilent {
@@ -99,7 +131,7 @@ public final class SilenceDetector: Sendable {
                             if isSilent {
                                 isSilent = false
                                 let silenceEnd = timeInSeconds
-                                if silenceEnd - silenceStart >= minDuration {
+                                if silenceEnd - silenceStart >= minimumSilenceDuration {
                                     silentRanges.append(SilenceRange(start: silenceStart, end: silenceEnd))
                                 }
                             }
@@ -112,7 +144,7 @@ public final class SilenceDetector: Sendable {
                 // If still silent at the end of the file
                 if isSilent {
                     let silenceEnd = Double(audioFile.length) / sampleRate
-                    if silenceEnd - silenceStart >= minDuration {
+                    if silenceEnd - silenceStart >= minimumSilenceDuration {
                         silentRanges.append(SilenceRange(start: silenceStart, end: silenceEnd))
                     }
                 }
@@ -120,8 +152,13 @@ public final class SilenceDetector: Sendable {
                 print("[SilenceDetector] Error reading audio file: \(error.localizedDescription)")
             }
             
-            print("[SilenceDetector] Detection complete. Found \(silentRanges.count) silence ranges.")
-            return silentRanges
+            print(
+                "[SilenceDetector] Analysis complete. Generated \(waveformSamples.count) waveform samples and found \(silentRanges.count) silence ranges."
+            )
+            return AudioAnalysisResult(
+                waveformSamples: waveformSamples,
+                silentRanges: silentRanges
+            )
         }
         return await task.value
     }

+ 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()
-    }
-}

+ 1420 - 84
CelestiaTrace/Services/Bluetooth/BLEManager.swift

@@ -1,127 +1,1463 @@
 import Foundation
 import CoreBluetooth
 import Combine
+import Security
 
-/// Manages Bluetooth Low Energy (BLE) scanning, device connection, and binding to user accounts.
+enum SparkConnectionPhase: Equatable {
+    case disconnected
+    case connecting
+    case discovering
+    case authenticating
+    case ready
+    case recording
+    case reconnecting
+    case failed(String)
+}
+
+enum SparkRecorderConnectionEvent {
+    case recording(fileName: String)
+    case reconnecting
+    case ready
+    case failed(String)
+}
+
+struct BLECommunicationLogEntry: Identifiable, Equatable {
+    let id = UUID()
+    let deviceID: String
+    let timestamp: Date
+    let kind: String
+    let message: String
+}
+
+struct ObservedBLEAdvertisement: Identifiable, Equatable {
+    let id: String
+    var name: String
+    var serviceUUIDs: [String]
+    var rssi: Int
+    var peakRSSI: Int
+    var firstSeenAt: Date
+    var lastSeenAt: Date
+    var lastTransitionAt: Date
+    var isPresent: Bool
+    var reappearanceCount: Int
+
+    var matchesSpark: Bool {
+        BLEManager.isSparkAdvertisement(
+            name: name,
+            serviceUUIDs: serviceUUIDs.map { CBUUID(string: $0) }
+        )
+    }
+
+    var isStrongSignal: Bool {
+        rssi >= -60
+    }
+}
+
+enum SparkBLEError: LocalizedError {
+    case bluetoothUnavailable
+    case deviceUnavailable
+    case incompatibleDevice
+    case connectionFailed(String)
+    case commandUnavailable
+    case commandTimedOut(String)
+    case pairingFailed
+    case deviceReported(String)
+
+    var errorDescription: String? {
+        switch self {
+        case .bluetoothUnavailable: return "蓝牙当前不可用,请确认系统蓝牙已开启。"
+        case .deviceUnavailable: return "找不到该微光设备,请让设备靠近 iPhone 后重试。"
+        case .incompatibleDevice: return "该设备没有提供微光录音服务。"
+        case .connectionFailed(let reason): return "连接微光失败:\(reason)"
+        case .commandUnavailable: return "微光指令通道尚未就绪。"
+        case .commandTimedOut(let command): return "设备未及时响应指令 \(command)。"
+        case .pairingFailed: return "微光密钥配对失败,请重置设备后重试。"
+        case .deviceReported(let message): return message
+        }
+    }
+}
+
+/// Owns the CoreBluetooth central role and implements the MR20/Spark command protocol.
 final class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CBPeripheralDelegate {
     static let shared = BLEManager()
 
+    private static let debugMinimumRSSI = -75
+    private static let advertisementDisappearanceInterval: TimeInterval = 4
+    private static let disappearedAdvertisementRetentionInterval: TimeInterval = 30
+
+    static let serviceUUID = CBUUID(string: "001120a0-2233-4455-6677-88995a5b5c5d")
+    static let audioNotifyUUID = CBUUID(string: "001120a1-2233-4455-6677-88995a5b5c5d")
+    static let writeUUID = CBUUID(string: "001120a2-2233-4455-6677-88995a5b5c5d")
+    static let commandNotifyUUID = CBUUID(string: "001120a3-2233-4455-6677-88995a5b5c5d")
+
+    static func isYLF20AdvertisementName(_ name: String?) -> Bool {
+        let normalizedName = name?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? ""
+        return normalizedName == "ylf20" || normalizedName.hasPrefix("ylf20_")
+    }
+
+    static func isSparkAdvertisement(name: String?, serviceUUIDs: [CBUUID]) -> Bool {
+        let normalizedName = name?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? ""
+        return serviceUUIDs.contains(serviceUUID)
+            || isYLF20AdvertisementName(name)
+            || normalizedName.contains("spark")
+            || normalizedName.contains("mr20")
+            || normalizedName.contains("微光")
+    }
+
     @Published private(set) var state: CBManagerState = .unknown
-    @Published var isScanning: Bool = false
-    @Published var discoveredDevices: [DiscoveredBLEDevice] = []
-    @Published var boundDevices: [BoundDevice] = []
-    
+    @Published private(set) var isScanning: Bool = false
+    @Published private(set) var discoveredDevices: [DiscoveredBLEDevice] = []
+    @Published private(set) var observedPeripheralCount: Int = 0
+    @Published private(set) var observedAdvertisements: [ObservedBLEAdvertisement] = []
+    @Published private(set) var communicationLogs: [BLECommunicationLogEntry] = []
+    @Published private(set) var boundDevices: [BoundDevice] = []
+    @Published private(set) var connectionPhases: [String: SparkConnectionPhase] = [:]
+    @Published private(set) var recordingDeviceID: String?
+    @Published private(set) var lastErrorMessage: String?
+
+    private struct SparkCharacteristics {
+        var audioNotify: CBCharacteristic?
+        var commandNotify: CBCharacteristic?
+        var write: CBCharacteristic?
+
+        var isComplete: Bool {
+            audioNotify != nil && commandNotify != nil && write != nil
+        }
+    }
+
+    private struct PendingBinding {
+        let discoveredDevice: DiscoveredBLEDevice
+        let userID: String
+        let password: String
+        let completion: (Result<BoundDevice, Error>) -> Void
+    }
+
+    private struct PendingCommand {
+        let token: UUID
+        let completion: (Result<String, Error>) -> Void
+    }
+
+    private enum ProbeWireFormat: CaseIterable {
+        case documented
+        case documentedCRLF
+        case documentedNull
+        case legacyPrefix
+        case legacyPrefixCRLF
+        case legacyPrefixNull
+
+        var label: String {
+            switch self {
+            case .documented: return "文档原格式"
+            case .documentedCRLF: return "文档格式 + CRLF"
+            case .documentedNull: return "文档格式 + NUL"
+            case .legacyPrefix: return "无 PQ_ 前缀"
+            case .legacyPrefixCRLF: return "无 PQ_ 前缀 + CRLF"
+            case .legacyPrefixNull: return "无 PQ_ 前缀 + NUL"
+            }
+        }
+
+        func data(for command: String) -> Data {
+            let base: String
+            switch self {
+            case .legacyPrefix, .legacyPrefixCRLF, .legacyPrefixNull:
+                base = command.replacingOccurrences(of: "PQ_BLE&", with: "BLE&")
+            default:
+                base = command
+            }
+
+            switch self {
+            case .documentedCRLF, .legacyPrefixCRLF:
+                return Data("\(base)\r\n".utf8)
+            case .documentedNull, .legacyPrefixNull:
+                var data = Data(base.utf8)
+                data.append(0)
+                return data
+            default:
+                return Data(base.utf8)
+            }
+        }
+    }
+
     private var centralManager: CBCentralManager!
     private let boundDevicesKey = "com.celestia.trace.bound_devices"
-    
+    private let restorationIdentifier = "com.celestia.trace.spark.central"
+    private let keychainService = "com.celestia.trace.spark.pairing"
+
+    private var peripherals: [String: CBPeripheral] = [:]
+    private var characteristics: [String: SparkCharacteristics] = [:]
+    private var pendingBindings: [String: PendingBinding] = [:]
+    private var authenticatingDeviceIDs: Set<String> = []
+    private var pendingStarts: [String: PendingCommand] = [:]
+    private var pendingStops: [String: PendingCommand] = [:]
+    private var audioConsumers: [String: (Data) -> Void] = [:]
+    private var recorderStateConsumers: [String: (SparkRecorderConnectionEvent) -> Void] = [:]
+    private var scanRequested = false
+    private var observedPeripheralIDs: Set<String> = []
+    private var observedAdvertisementsByID: [String: ObservedBLEAdvertisement] = [:]
+    private var advertisementRefreshTimer: Timer?
+    private var pendingDiagnosticResponses: [String: [String: String]] = [:]
+    private var diagnosticTokens: [String: UUID] = [:]
+    private var protocolProbeTokens: [String: UUID] = [:]
+    private var activeProbeFormats: [String: ProbeWireFormat] = [:]
+
+    var scanUnavailableMessage: String? {
+        switch state {
+        case .unauthorized:
+            return "蓝牙权限未开启。请前往“设置”允许星痕访问蓝牙。"
+        case .poweredOff:
+            return "系统蓝牙已关闭,请先在控制中心或“设置”中开启蓝牙。"
+        case .unsupported:
+            return "当前运行设备不支持 BLE 扫描。请使用支持蓝牙的 iPhone 真机测试。"
+        default:
+            return nil
+        }
+    }
+
     override private init() {
         super.init()
-        self.centralManager = CBCentralManager(delegate: self, queue: nil)
         loadBoundDevices()
+        centralManager = CBCentralManager(
+            delegate: self,
+            queue: nil,
+            options: [CBCentralManagerOptionRestoreIdentifierKey: restorationIdentifier]
+        )
+    }
+
+    // MARK: - Public device access
+
+    func devices(forUserId userId: String) -> [BoundDevice] {
+        boundDevices.filter { $0.boundUserId == userId }
     }
-    
-    // MARK: - CentralManager Delegate
-    
+
+    func connectedDevices(forUserId userId: String) -> [BoundDevice] {
+        devices(forUserId: userId).filter { $0.isConnected }
+    }
+
+    func phase(for deviceID: String) -> SparkConnectionPhase {
+        connectionPhases[deviceID] ?? .disconnected
+    }
+
+    func communicationLogs(for deviceID: String) -> [BLECommunicationLogEntry] {
+        communicationLogs.filter { $0.deviceID == deviceID }
+    }
+
+    func renameDevice(id: String, to proposedName: String) {
+        let trimmed = proposedName.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !trimmed.isEmpty, let index = boundDevices.firstIndex(where: { $0.id == id }) else { return }
+        boundDevices[index].name = String(trimmed.prefix(30))
+        saveBoundDevices()
+    }
+
+    func unbindDevice(id: String) {
+        var peripheralToDisconnect: CBPeripheral?
+        if boundDevices.first(where: { $0.id == id })?.isConnected == true {
+            try? writeCommand("PQ_BLE&BLE&OFF", to: id)
+            peripheralToDisconnect = peripherals[id]
+        }
+        deletePairingPassword(for: id)
+        boundDevices.removeAll(where: { $0.id == id })
+        connectionPhases[id] = .disconnected
+        if let peripheralToDisconnect {
+            DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self, weak peripheralToDisconnect] in
+                guard let self, let peripheralToDisconnect else { return }
+                self.centralManager.cancelPeripheralConnection(peripheralToDisconnect)
+            }
+        } else if let peripheral = peripherals[id] {
+            centralManager.cancelPeripheralConnection(peripheral)
+        }
+        saveBoundDevices()
+    }
+
+    func updateCloudRegistration(deviceID: String, cloudID: String?, error: String?) {
+        updateBoundDevice(id: deviceID) {
+            $0.cloudID = cloudID
+            $0.cloudSyncError = error
+        }
+    }
+
+    // MARK: - Scanning and binding
+
+    func startScanning() {
+        discoveredDevices.removeAll()
+        observedPeripheralIDs.removeAll()
+        observedPeripheralCount = 0
+        observedAdvertisementsByID.removeAll()
+        observedAdvertisements.removeAll()
+        lastErrorMessage = nil
+        scanRequested = true
+        startAdvertisementRefreshTimer()
+
+        guard centralManager.state == .poweredOn else {
+            // Keep the request pending while CoreBluetooth initializes. The
+            // delegate starts scanning if the state later becomes powered on.
+            isScanning = false
+            return
+        }
+
+        isScanning = true
+        // 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: true]
+        )
+    }
+
+    func stopScanning() {
+        scanRequested = false
+        advertisementRefreshTimer?.invalidate()
+        advertisementRefreshTimer = nil
+        if centralManager.state == .poweredOn {
+            centralManager.stopScan()
+        }
+        isScanning = false
+    }
+
+    func connectAndBind(
+        _ device: DiscoveredBLEDevice,
+        userId: String,
+        completion: @escaping (Result<BoundDevice, Error>) -> Void
+    ) {
+        guard centralManager.state == .poweredOn else {
+            completion(.failure(SparkBLEError.bluetoothUnavailable))
+            return
+        }
+        guard let peripheral = peripherals[device.id] else {
+            completion(.failure(SparkBLEError.deviceUnavailable))
+            return
+        }
+
+        communicationLogs.removeAll { $0.deviceID == device.id }
+        appendCommunicationLog(
+            deviceID: device.id,
+            kind: "INFO",
+            message: "开始绑定 \(device.name),RSSI \(device.rssi) dBm"
+        )
+        let password = generatePairingPassword()
+        pendingBindings[device.id] = PendingBinding(
+            discoveredDevice: device,
+            userID: userId,
+            password: password,
+            completion: completion
+        )
+        connectionPhases[device.id] = .connecting
+        peripheral.delegate = self
+        stopScanning()
+        appendCommunicationLog(deviceID: device.id, kind: "INFO", message: "正在建立 BLE 连接")
+        centralManager.connect(peripheral, options: nil)
+    }
+
+    func runProtocolDiagnostics(deviceID: String) {
+        guard peripherals[deviceID]?.state == .connected,
+              characteristics[deviceID]?.isComplete == true else {
+            appendCommunicationLog(
+                deviceID: deviceID,
+                kind: "ERROR",
+                message: "通信通道尚未就绪,无法发送诊断指令"
+            )
+            return
+        }
+        appendCommunicationLog(
+            deviceID: deviceID,
+            kind: "INFO",
+            message: "重新执行协议诊断:状态、电量、容量、固件、MAC、校时"
+        )
+        queryDeviceStatus(deviceID)
+    }
+
+    // MARK: - Spark recording control
+
+    func startSparkRecording(
+        deviceID: String,
+        onAudioData: @escaping (Data) -> Void,
+        onStateChange: @escaping (SparkRecorderConnectionEvent) -> Void,
+        completion: @escaping (Result<String, Error>) -> Void
+    ) {
+        guard boundDevices.first(where: { $0.id == deviceID })?.isConnected == true else {
+            completion(.failure(SparkBLEError.deviceUnavailable))
+            return
+        }
+
+        audioConsumers[deviceID] = onAudioData
+        recorderStateConsumers[deviceID] = onStateChange
+        let token = UUID()
+        pendingStarts[deviceID] = PendingCommand(token: token, completion: completion)
+
+        do {
+            try writeCommand("PQ_BLE&STA", to: deviceID)
+            scheduleTimeout(for: deviceID, command: "STA", token: token, isStart: true)
+        } catch {
+            pendingStarts.removeValue(forKey: deviceID)
+            completion(.failure(error))
+        }
+    }
+
+    func stopSparkRecording(
+        deviceID: String,
+        completion: @escaping (Result<String, Error>) -> Void
+    ) {
+        guard boundDevices.first(where: { $0.id == deviceID })?.isConnected == true else {
+            completion(.failure(SparkBLEError.deviceUnavailable))
+            return
+        }
+
+        let token = UUID()
+        pendingStops[deviceID] = PendingCommand(token: token, completion: completion)
+        do {
+            try writeCommand("PQ_BLE&STO", to: deviceID)
+            scheduleTimeout(for: deviceID, command: "STO", token: token, isStart: false)
+        } catch {
+            pendingStops.removeValue(forKey: deviceID)
+            completion(.failure(error))
+        }
+    }
+
+    func detachRecorder(deviceID: String) {
+        audioConsumers.removeValue(forKey: deviceID)
+        recorderStateConsumers.removeValue(forKey: deviceID)
+    }
+
+    // MARK: - CBCentralManagerDelegate
+
     func centralManagerDidUpdateState(_ central: CBCentralManager) {
-        DispatchQueue.main.async {
-            self.state = central.state
+        state = central.state
+        guard central.state == .poweredOn else {
+            isScanning = false
+            markAllDevicesDisconnected()
+            return
+        }
+        reconnectBoundDevices()
+        if scanRequested {
+            isScanning = true
+            central.scanForPeripherals(
+                withServices: nil,
+                options: [CBCentralManagerScanOptionAllowDuplicatesKey: true]
+            )
         }
     }
-    
-    func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) {
-        let name = peripheral.name ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? "现场录音设备"
+
+    func centralManager(_ central: CBCentralManager, willRestoreState dict: [String: Any]) {
+        let restored = dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral] ?? []
+        for peripheral in restored {
+            let id = peripheral.identifier.uuidString
+            peripherals[id] = peripheral
+            peripheral.delegate = self
+            connectionPhases[id] = peripheral.state == .connected ? .discovering : .reconnecting
+            if peripheral.state == .connected {
+                peripheral.discoverServices([Self.serviceUUID])
+            } else {
+                central.connect(peripheral, options: nil)
+            }
+        }
+    }
+
+    func centralManager(
+        _ central: CBCentralManager,
+        didDiscover peripheral: CBPeripheral,
+        advertisementData: [String: Any],
+        rssi RSSI: NSNumber
+    ) {
+        let id = peripheral.identifier.uuidString
+        observedPeripheralIDs.insert(id)
+        observedPeripheralCount = observedPeripheralIDs.count
+
+        let advertisedName = advertisementData[CBAdvertisementDataLocalNameKey] as? String
+            ?? peripheral.name
+        let advertisedServices = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] ?? []
+
+        guard RSSI.intValue != 127 else { return }
+        let now = Date()
+        let trimmedAdvertisedName = advertisedName?
+            .trimmingCharacters(in: .whitespacesAndNewlines)
+        let displayName = trimmedAdvertisedName?.isEmpty == false
+            ? trimmedAdvertisedName!
+            : "未提供名称"
+        let serviceUUIDStrings = advertisedServices.map(\.uuidString)
+
+        if var existing = observedAdvertisementsByID[id] {
+            let hadDisappeared = !existing.isPresent
+                || now.timeIntervalSince(existing.lastSeenAt) >= Self.advertisementDisappearanceInterval
+            existing.name = displayName
+            existing.serviceUUIDs = serviceUUIDStrings
+            existing.rssi = RSSI.intValue
+            existing.peakRSSI = max(existing.peakRSSI, RSSI.intValue)
+            existing.lastSeenAt = now
+            existing.isPresent = true
+            if hadDisappeared {
+                existing.reappearanceCount += 1
+                existing.lastTransitionAt = now
+            }
+            observedAdvertisementsByID[id] = existing
+        } else if RSSI.intValue >= Self.debugMinimumRSSI {
+            observedAdvertisementsByID[id] = ObservedBLEAdvertisement(
+                id: id,
+                name: displayName,
+                serviceUUIDs: serviceUUIDStrings,
+                rssi: RSSI.intValue,
+                peakRSSI: RSSI.intValue,
+                firstSeenAt: now,
+                lastSeenAt: now,
+                lastTransitionAt: now,
+                isPresent: true,
+                reappearanceCount: 0
+            )
+        }
+        publishObservedAdvertisements(now: now)
+
+        // The formal binding list is intentionally restricted to the confirmed
+        // production broadcast name. Service/characteristic UUIDs are verified
+        // after connecting.
+        guard Self.isYLF20AdvertisementName(advertisedName) else { return }
+
+        peripherals[id] = peripheral
+        peripheral.delegate = self
         let device = DiscoveredBLEDevice(
-            id: peripheral.identifier.uuidString,
-            name: name,
+            id: id,
+            name: trimmedAdvertisedName?.isEmpty == false ? trimmedAdvertisedName! : "微光(Spark)",
             rssi: RSSI.intValue,
-            peripheralUUID: peripheral.identifier.uuidString
+            peripheralUUID: id
         )
-        
-        DispatchQueue.main.async {
-            if !self.discoveredDevices.contains(where: { $0.id == device.id }) {
-                self.discoveredDevices.append(device)
+        if let index = discoveredDevices.firstIndex(where: { $0.id == id }) {
+            discoveredDevices[index] = device
+        } else {
+            discoveredDevices.append(device)
+        }
+        discoveredDevices.sort { $0.rssi > $1.rssi }
+    }
+
+    private func startAdvertisementRefreshTimer() {
+        advertisementRefreshTimer?.invalidate()
+        advertisementRefreshTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
+            self?.publishObservedAdvertisements(now: Date())
+        }
+    }
+
+    private func publishObservedAdvertisements(now: Date) {
+        for (id, var advertisement) in observedAdvertisementsByID {
+            let elapsed = now.timeIntervalSince(advertisement.lastSeenAt)
+            if advertisement.isPresent,
+               elapsed >= Self.advertisementDisappearanceInterval {
+                advertisement.isPresent = false
+                advertisement.lastTransitionAt = now
+                observedAdvertisementsByID[id] = advertisement
             }
         }
+
+        observedAdvertisementsByID = observedAdvertisementsByID.filter {
+            now.timeIntervalSince($0.value.lastSeenAt)
+                < Self.disappearedAdvertisementRetentionInterval
+        }
+
+        observedAdvertisements = Array(observedAdvertisementsByID.values
+            .filter {
+                ($0.isPresent
+                    ? $0.rssi >= Self.debugMinimumRSSI
+                    : $0.peakRSSI >= Self.debugMinimumRSSI)
+                    && ($0.isPresent || now.timeIntervalSince($0.lastSeenAt) < 15)
+            }
+            .sorted {
+                let leftTransitioning = now.timeIntervalSince($0.lastTransitionAt) < 8
+                let rightTransitioning = now.timeIntervalSince($1.lastTransitionAt) < 8
+                if leftTransitioning != rightTransitioning {
+                    return leftTransitioning
+                }
+                if $0.isStrongSignal != $1.isStrongSignal {
+                    return $0.isStrongSignal
+                }
+                if $0.matchesSpark != $1.matchesSpark {
+                    return $0.matchesSpark
+                }
+                return $0.rssi > $1.rssi
+            }
+            .prefix(50))
     }
-    
-    // MARK: - Scanning & Mocking
-    
-    func startScanning() {
-        discoveredDevices.removeAll()
-        isScanning = true
-        
-        if centralManager.state == .poweredOn {
-            centralManager.scanForPeripherals(withServices: nil, options: [CBCentralManagerScanOptionAllowDuplicatesKey: false])
-        }
-        
-        // Populate mock devices for simulator or testing environment
-        DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { [weak self] in
-            guard let self = self, self.isScanning else { return }
-            let mockDevices = [
-                DiscoveredBLEDevice(id: "MOCK-BLE-01", name: "星痕专业录音麦克风 01", rssi: -58, peripheralUUID: "0000180A-0000-1000-8000-00805F9B34FB"),
-                DiscoveredBLEDevice(id: "MOCK-BLE-02", name: "星痕无线音频集线器", rssi: -72, peripheralUUID: "0000180F-0000-1000-8000-00805F9B34FB"),
-                DiscoveredBLEDevice(id: "MOCK-BLE-03", name: "智能胸卡录音器 A2", rssi: -85, peripheralUUID: "0000181A-0000-1000-8000-00805F9B34FB")
-            ]
-            for device in mockDevices {
-                if !self.discoveredDevices.contains(where: { $0.id == device.id }) {
-                    self.discoveredDevices.append(device)
+
+    func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
+        let id = peripheral.identifier.uuidString
+        appendCommunicationLog(deviceID: id, kind: "OK", message: "BLE 连接成功")
+        appendCommunicationLog(
+            deviceID: id,
+            kind: "INFO",
+            message: "查询主服务 \(Self.serviceUUID.uuidString)"
+        )
+        peripherals[id] = peripheral
+        peripheral.delegate = self
+        connectionPhases[id] = .discovering
+        peripheral.discoverServices([Self.serviceUUID])
+    }
+
+    func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
+        let id = peripheral.identifier.uuidString
+        appendCommunicationLog(
+            deviceID: id,
+            kind: "ERROR",
+            message: "BLE 连接失败:\(error?.localizedDescription ?? "未知错误")"
+        )
+        let failure = SparkBLEError.connectionFailed(error?.localizedDescription ?? "未知错误")
+        connectionPhases[id] = .failed(failure.localizedDescription)
+        finishPendingBinding(deviceID: id, result: .failure(failure))
+    }
+
+    func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
+        let id = peripheral.identifier.uuidString
+        appendCommunicationLog(
+            deviceID: id,
+            kind: error == nil ? "INFO" : "ERROR",
+            message: error == nil
+                ? "BLE 连接已断开"
+                : "BLE 异常断开:\(error!.localizedDescription)"
+        )
+        updateBoundDevice(id: id) { $0.isConnected = false }
+        characteristics.removeValue(forKey: id)
+        authenticatingDeviceIDs.remove(id)
+
+        if recordingDeviceID == id {
+            connectionPhases[id] = .reconnecting
+            recorderStateConsumers[id]?(.reconnecting)
+        } else {
+            connectionPhases[id] = .disconnected
+        }
+
+        if let pending = pendingStarts.removeValue(forKey: id) {
+            pending.completion(.failure(SparkBLEError.connectionFailed("设备已断开")))
+        }
+        if let pending = pendingStops.removeValue(forKey: id) {
+            pending.completion(.failure(SparkBLEError.connectionFailed("停止录音前设备已断开")))
+        }
+        finishPendingBinding(deviceID: id, result: .failure(SparkBLEError.connectionFailed("设备已断开")))
+
+        guard boundDevices.contains(where: { $0.id == id }) else { return }
+        DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self, weak peripheral] in
+            guard let self, let peripheral, self.centralManager.state == .poweredOn else { return }
+            self.connectionPhases[id] = .reconnecting
+            self.centralManager.connect(peripheral, options: nil)
+        }
+    }
+
+    // MARK: - CBPeripheralDelegate
+
+    func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
+        let id = peripheral.identifier.uuidString
+        guard error == nil,
+              let service = peripheral.services?.first(where: { $0.uuid == Self.serviceUUID }) else {
+            let discovered = peripheral.services?.map(\.uuid.uuidString).joined(separator: ", ") ?? "无"
+            appendCommunicationLog(
+                deviceID: id,
+                kind: "ERROR",
+                message: "未发现协议主服务;设备提供:\(discovered)"
+            )
+            let failure = SparkBLEError.incompatibleDevice
+            connectionPhases[id] = .failed(failure.localizedDescription)
+            finishPendingBinding(deviceID: id, result: .failure(failure))
+            centralManager.cancelPeripheralConnection(peripheral)
+            return
+        }
+        appendCommunicationLog(
+            deviceID: id,
+            kind: "OK",
+            message: "发现协议主服务 \(service.uuid.uuidString)"
+        )
+        appendCommunicationLog(deviceID: id, kind: "INFO", message: "查询音频 Notify、写、指令 Notify 特征")
+        peripheral.discoverCharacteristics(
+            [Self.audioNotifyUUID, Self.commandNotifyUUID, Self.writeUUID],
+            for: service
+        )
+    }
+
+    func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
+        let id = peripheral.identifier.uuidString
+        guard error == nil else {
+            appendCommunicationLog(
+                deviceID: id,
+                kind: "ERROR",
+                message: "发现特征失败:\(error?.localizedDescription ?? "未知错误")"
+            )
+            failTransport(deviceID: id, message: error?.localizedDescription ?? "发现特征失败")
+            return
+        }
+
+        var set = SparkCharacteristics()
+        for characteristic in service.characteristics ?? [] {
+            switch characteristic.uuid {
+            case Self.audioNotifyUUID: set.audioNotify = characteristic
+            case Self.commandNotifyUUID: set.commandNotify = characteristic
+            case Self.writeUUID: set.write = characteristic
+            default: break
+            }
+        }
+        guard set.isComplete, let audio = set.audioNotify, let command = set.commandNotify else {
+            let discovered = service.characteristics?.map(\.uuid.uuidString).joined(separator: ", ") ?? "无"
+            appendCommunicationLog(
+                deviceID: id,
+                kind: "ERROR",
+                message: "协议特征不完整;设备提供:\(discovered)"
+            )
+            failTransport(deviceID: id, message: "设备缺少必要的录音特征")
+            return
+        }
+        appendCommunicationLog(
+            deviceID: id,
+            kind: "OK",
+            message: "三个协议特征齐全:A1 音频 Notify、A2 写、A3 指令 Notify"
+        )
+        characteristics[id] = set
+        appendCommunicationLog(deviceID: id, kind: "INFO", message: "订阅 A1 音频与 A3 指令通知")
+        peripheral.setNotifyValue(true, for: command)
+        peripheral.setNotifyValue(true, for: audio)
+    }
+
+    func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) {
+        let id = peripheral.identifier.uuidString
+        guard error == nil else {
+            appendCommunicationLog(
+                deviceID: id,
+                kind: "ERROR",
+                message: "订阅 \(characteristic.uuid.uuidString) 失败:\(error?.localizedDescription ?? "未知错误")"
+            )
+            failTransport(deviceID: id, message: error?.localizedDescription ?? "订阅设备通知失败")
+            return
+        }
+        appendCommunicationLog(
+            deviceID: id,
+            kind: characteristic.isNotifying ? "OK" : "ERROR",
+            message: "\(characteristic.uuid.uuidString) 通知\(characteristic.isNotifying ? "已开启" : "未开启")"
+        )
+        guard let set = characteristics[id],
+              set.audioNotify?.isNotifying == true,
+              set.commandNotify?.isNotifying == true else { return }
+        transportDidBecomeReady(deviceID: id)
+    }
+
+    func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
+        let id = peripheral.identifier.uuidString
+        guard error == nil, let data = characteristic.value else { return }
+
+        if characteristic.uuid == Self.audioNotifyUUID {
+            audioConsumers[id]?(data)
+        } else if characteristic.uuid == Self.commandNotifyUUID {
+            for message in decodeCommandMessages(data) {
+                appendCommunicationLog(deviceID: id, kind: "RX", message: message)
+                handleCommand(message, deviceID: id)
+            }
+        }
+    }
+
+    func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) {
+        guard let error else { return }
+        let id = peripheral.identifier.uuidString
+        appendCommunicationLog(
+            deviceID: id,
+            kind: "ERROR",
+            message: "写入 \(characteristic.uuid.uuidString) 失败:\(error.localizedDescription)"
+        )
+        lastErrorMessage = "发送设备指令失败:\(error.localizedDescription)"
+        recorderStateConsumers[id]?(.failed(lastErrorMessage ?? "发送设备指令失败"))
+    }
+
+    // MARK: - Protocol handling
+
+    private func transportDidBecomeReady(deviceID: String) {
+        appendCommunicationLog(deviceID: deviceID, kind: "OK", message: "A1 与 A3 通知均已订阅,通信通道就绪")
+        if let pending = pendingBindings[deviceID] {
+            connectionPhases[deviceID] = .authenticating
+            appendCommunicationLog(
+                deviceID: deviceID,
+                kind: "TX",
+                message: "PQ_BLE&SK&••••••••••••••••(16 位绑定密码已隐藏)"
+            )
+            do {
+                try writeCommand("PQ_BLE&SK&\(pending.password)", to: deviceID)
+            } catch {
+                finishPendingBinding(deviceID: deviceID, result: .failure(error))
+            }
+            return
+        }
+
+        if let password = pairingPassword(for: deviceID) {
+            authenticatingDeviceIDs.insert(deviceID)
+            connectionPhases[deviceID] = .authenticating
+            do {
+                try writeCommand("PQ_BLE&SK&\(password)", to: deviceID)
+            } catch {
+                authenticatingDeviceIDs.remove(deviceID)
+                markDeviceReady(deviceID)
+            }
+        } else {
+            // Existing pre-Spark installations have no key in Keychain. Keep them
+            // usable and let the first explicit rebind establish one.
+            markDeviceReady(deviceID)
+        }
+    }
+
+    private func handleCommand(_ message: String, deviceID: String) {
+        // Some YLF20 firmware omits the documented "PQ_" prefix in replies
+        // (for example, DEV&UNKNOWN). Normalize it for the protocol parser
+        // while preserving the raw RX value in the debug log.
+        let message = message.hasPrefix("DEV&") ? "PQ_\(message)" : message
+        let fields = message.components(separatedBy: "&")
+        guard fields.count >= 2 else { return }
+        recordDiagnosticResponse(message, deviceID: deviceID)
+        recordProtocolProbeResponse(message, deviceID: deviceID)
+
+        if message == "DEV&UNKNOWN" || message == "PQ_DEV&UNKNOWN" {
+            appendCommunicationLog(
+                deviceID: deviceID,
+                kind: "ERROR",
+                message: "设备收到写入,但不识别当前命令格式"
+            )
+            if pendingBindings[deviceID] != nil,
+               protocolProbeTokens[deviceID] == nil {
+                startCompatibilityProbe(deviceID: deviceID)
+            }
+            return
+        }
+
+        if message.hasPrefix("PQ_DEV&SK&OK") {
+            appendCommunicationLog(deviceID: deviceID, kind: "OK", message: "设备确认密钥配对成功")
+            if let pending = pendingBindings[deviceID] {
+                savePairingPassword(pending.password, for: deviceID)
+                var bound = BoundDevice(
+                    id: deviceID,
+                    name: "微光(Spark)",
+                    peripheralUUID: pending.discoveredDevice.peripheralUUID,
+                    boundAt: Date(),
+                    isConnected: true,
+                    batteryLevel: nil,
+                    firmwareVersion: nil,
+                    boundUserId: pending.userID,
+                    advertisedName: pending.discoveredDevice.name
+                )
+                if let existing = boundDevices.first(where: { $0.id == deviceID }) {
+                    bound.name = existing.name
                 }
+                upsertBoundDevice(bound)
+                connectionPhases[deviceID] = .ready
+                pending.completion(.success(bound))
+                pendingBindings.removeValue(forKey: deviceID)
+                queryDeviceStatus(deviceID)
+            } else if authenticatingDeviceIDs.remove(deviceID) != nil {
+                markDeviceReady(deviceID)
+            }
+            return
+        }
+
+        if message.hasPrefix("PQ_DEV&SK&ERR") {
+            appendCommunicationLog(deviceID: deviceID, kind: "ERROR", message: "设备拒绝密钥配对")
+            authenticatingDeviceIDs.remove(deviceID)
+            finishPendingBinding(deviceID: deviceID, result: .failure(SparkBLEError.pairingFailed))
+            connectionPhases[deviceID] = .failed(SparkBLEError.pairingFailed.localizedDescription)
+            return
+        }
+
+        if message.hasPrefix("PQ_DEV&STA&") {
+            let fileName = fields.dropFirst(2).joined(separator: "&")
+            recordingDeviceID = deviceID
+            connectionPhases[deviceID] = .recording
+            recorderStateConsumers[deviceID]?(.recording(fileName: fileName))
+            pendingStarts.removeValue(forKey: deviceID)?.completion(.success(fileName))
+            return
+        }
+
+        if message == "PQ_DEV&STO" || message.hasPrefix("PQ_DEV&STO&") {
+            recordingDeviceID = nil
+            connectionPhases[deviceID] = .ready
+            pendingStops.removeValue(forKey: deviceID)?.completion(.success(message))
+            recorderStateConsumers[deviceID]?(.ready)
+            return
+        }
+
+        if message.hasPrefix("PQ_DEV&RT&") {
+            recordingDeviceID = deviceID
+            connectionPhases[deviceID] = .recording
+            if fields.count >= 4 {
+                recorderStateConsumers[deviceID]?(.recording(fileName: fields[2]))
+            }
+            return
+        }
+
+        if message.hasPrefix("PQ_DEV&BAT&"), let value = fields.last.flatMap(Int.init) {
+            updateBoundDevice(id: deviceID) { $0.batteryLevel = min(100, max(0, value)) }
+        } else if message.hasPrefix("PQ_DEV&STE&"), let value = fields.last.flatMap(Int.init) {
+            if value == 1 {
+                recordingDeviceID = deviceID
+                connectionPhases[deviceID] = .recording
+            } else if recordingDeviceID == deviceID {
+                recordingDeviceID = nil
+                connectionPhases[deviceID] = .ready
+            }
+        } else if message.hasPrefix("PQ_DEV&FW&"), fields.count >= 3 {
+            updateBoundDevice(id: deviceID) { $0.firmwareVersion = fields.dropFirst(2).joined(separator: "&") }
+        } else if message.hasPrefix("PQ_DEV&SPA&"), fields.count >= 4 {
+            updateBoundDevice(id: deviceID) {
+                $0.freeStorageMB = Int(fields[2])
+                $0.totalStorageMB = Int(fields[3])
             }
+        } else if message.hasPrefix("PQ_DEV&MAC&"), fields.count >= 3 {
+            updateBoundDevice(id: deviceID) { $0.hardwareMAC = fields[2] }
+        } else if message == "PQ_EV&REC&ERR" || message == "PQ_DEV&REC&ERR" {
+            reportRecordingError(deviceID: deviceID, message: "微光报告录音失败,请检查设备后重试。")
+        } else if message == "PQ_DEV&DISK&ERR" {
+            reportRecordingError(deviceID: deviceID, message: "微光存储空间已满,录音无法继续。")
         }
     }
-    
-    func stopScanning() {
-        if centralManager.state == .poweredOn {
-            centralManager.stopScan()
+
+    private func decodeCommandMessages(_ data: Data) -> [String] {
+        guard var text = String(data: data, encoding: .utf8) else { return [] }
+        text = text.replacingOccurrences(of: "\0", with: "")
+            .trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !text.isEmpty else { return [] }
+
+        let lineMessages = text.components(separatedBy: .newlines)
+            .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
+            .filter { !$0.isEmpty }
+        if lineMessages.count > 1 { return lineMessages }
+
+        // Also tolerate multiple prefix-delimited messages in one notification.
+        let pattern = "(?=PQ_(?:DEV|EV)&)"
+        if let regex = try? NSRegularExpression(pattern: pattern) {
+            let range = NSRange(text.startIndex..., in: text)
+            let matches = regex.matches(in: text, range: range)
+            if matches.count > 1 {
+                return matches.enumerated().compactMap { index, match in
+                    let start = match.range.location
+                    let end = index + 1 < matches.count ? matches[index + 1].range.location : range.length
+                    guard let swiftRange = Range(NSRange(location: start, length: end - start), in: text) else { return nil }
+                    return String(text[swiftRange])
+                }
+            }
         }
-        isScanning = false
+        return [text]
+    }
+
+    private func writeCommand(_ command: String, to deviceID: String) throws {
+        try writeCommand(command, to: deviceID, wireFormat: .documented)
+    }
+
+    private func writeCommand(
+        _ command: String,
+        to deviceID: String,
+        wireFormat: ProbeWireFormat,
+        logAsProbe: Bool = false
+    ) throws {
+        guard let peripheral = peripherals[deviceID],
+              peripheral.state == .connected,
+              let characteristic = characteristics[deviceID]?.write else {
+            throw SparkBLEError.commandUnavailable
+        }
+        let data = wireFormat.data(for: command)
+        let writeType: CBCharacteristicWriteType
+        if characteristic.properties.contains(.write) {
+            writeType = .withResponse
+        } else if characteristic.properties.contains(.writeWithoutResponse) {
+            writeType = .withoutResponse
+        } else {
+            throw SparkBLEError.commandUnavailable
+        }
+        guard data.count <= peripheral.maximumWriteValueLength(for: writeType) else {
+            throw SparkBLEError.deviceReported("设备指令超过单帧写入长度。")
+        }
+        if logAsProbe {
+            appendCommunicationLog(
+                deviceID: deviceID,
+                kind: "TX",
+                message: "兼容探测[\(wireFormat.label)]:PQ_BLE&STE(\(data.count) bytes)"
+            )
+        } else if !command.hasPrefix("PQ_BLE&SK&") {
+            appendCommunicationLog(deviceID: deviceID, kind: "TX", message: command)
+        }
+        peripheral.writeValue(data, for: characteristic, type: writeType)
+    }
+
+    private func startCompatibilityProbe(deviceID: String) {
+        let token = UUID()
+        protocolProbeTokens[deviceID] = token
+        appendCommunicationLog(
+            deviceID: deviceID,
+            kind: "INFO",
+            message: "开始只读兼容探测,不会改变录音或设备设置"
+        )
+
+        for (index, format) in ProbeWireFormat.allCases.enumerated() {
+            DispatchQueue.main.asyncAfter(deadline: .now() + 0.5 + Double(index) * 0.9) { [weak self] in
+                guard let self, self.protocolProbeTokens[deviceID] == token else { return }
+                self.activeProbeFormats[deviceID] = format
+                do {
+                    try self.writeCommand(
+                        "PQ_BLE&STE",
+                        to: deviceID,
+                        wireFormat: format,
+                        logAsProbe: true
+                    )
+                } catch {
+                    self.appendCommunicationLog(
+                        deviceID: deviceID,
+                        kind: "ERROR",
+                        message: "兼容探测写入失败:\(error.localizedDescription)"
+                    )
+                }
+            }
+        }
+
+        DispatchQueue.main.asyncAfter(deadline: .now() + 7) { [weak self] in
+            guard let self, self.protocolProbeTokens[deviceID] == token else { return }
+            self.protocolProbeTokens.removeValue(forKey: deviceID)
+            self.activeProbeFormats.removeValue(forKey: deviceID)
+            self.appendCommunicationLog(
+                deviceID: deviceID,
+                kind: "ERROR",
+                message: "六种安全格式均未得到录音状态回复;固件协议与当前文档不一致"
+            )
+        }
+    }
+
+    private func recordProtocolProbeResponse(_ message: String, deviceID: String) {
+        guard protocolProbeTokens[deviceID] != nil,
+              message.hasPrefix("PQ_DEV&STE&") || message.hasPrefix("DEV&STE&"),
+              let format = activeProbeFormats[deviceID] else { return }
+        protocolProbeTokens.removeValue(forKey: deviceID)
+        activeProbeFormats.removeValue(forKey: deviceID)
+        appendCommunicationLog(
+            deviceID: deviceID,
+            kind: "OK",
+            message: "兼容探测成功:设备接受“\(format.label)”"
+        )
+
+        guard format != .documented,
+              let pending = pendingBindings[deviceID] else {
+            if pendingBindings[deviceID] != nil {
+                appendCommunicationLog(
+                    deviceID: deviceID,
+                    kind: "ERROR",
+                    message: "普通查询可用,但当前固件不支持文档中的 SK 配对命令"
+                )
+            }
+            return
+        }
+
+        appendCommunicationLog(
+            deviceID: deviceID,
+            kind: "TX",
+            message: "使用“\(format.label)”重试 16 位配对命令(密码已隐藏)"
+        )
+        do {
+            try writeCommand(
+                "PQ_BLE&SK&\(pending.password)",
+                to: deviceID,
+                wireFormat: format
+            )
+        } catch {
+            appendCommunicationLog(
+                deviceID: deviceID,
+                kind: "ERROR",
+                message: "兼容配对写入失败:\(error.localizedDescription)"
+            )
+        }
+    }
+
+    private func queryDeviceStatus(_ deviceID: String) {
+        let token = UUID()
+        diagnosticTokens[deviceID] = token
+        pendingDiagnosticResponses[deviceID] = [
+            "录音状态": "PQ_DEV&STE&",
+            "电量": "PQ_DEV&BAT&",
+            "容量": "PQ_DEV&SPA&",
+            "固件": "PQ_DEV&FW&",
+            "MAC": "PQ_DEV&MAC&",
+            "校时": "PQ_DEV&T&OK"
+        ]
+        let commands = ["PQ_BLE&STE", "PQ_BLE&BAT", "PQ_BLE&SPACE", "PQ_BLE&FW", "PQ_BLE&MAC", currentTimeCommand()]
+        for (index, command) in commands.enumerated() {
+            DispatchQueue.main.asyncAfter(deadline: .now() + Double(index) * 0.25) { [weak self] in
+                try? self?.writeCommand(command, to: deviceID)
+            }
+        }
+        DispatchQueue.main.asyncAfter(deadline: .now() + 6) { [weak self] in
+            guard let self, self.diagnosticTokens[deviceID] == token else { return }
+            let missing = self.pendingDiagnosticResponses[deviceID]?.keys.sorted() ?? []
+            if missing.isEmpty {
+                self.appendCommunicationLog(
+                    deviceID: deviceID,
+                    kind: "OK",
+                    message: "协议诊断完成,全部查询均收到预期格式回复"
+                )
+            } else {
+                self.appendCommunicationLog(
+                    deviceID: deviceID,
+                    kind: "ERROR",
+                    message: "协议诊断超时,未收到:\(missing.joined(separator: "、"))"
+                )
+            }
+            self.pendingDiagnosticResponses.removeValue(forKey: deviceID)
+            self.diagnosticTokens.removeValue(forKey: deviceID)
+        }
+    }
+
+    private func recordDiagnosticResponse(_ message: String, deviceID: String) {
+        guard var pending = pendingDiagnosticResponses[deviceID] else { return }
+        let matched = pending.first { message.hasPrefix($0.value) }
+        guard let matched else { return }
+        pending.removeValue(forKey: matched.key)
+        pendingDiagnosticResponses[deviceID] = pending
+        appendCommunicationLog(
+            deviceID: deviceID,
+            kind: "OK",
+            message: "\(matched.key)回复格式正确"
+        )
+    }
+
+    private func appendCommunicationLog(deviceID: String, kind: String, message: String) {
+        communicationLogs.append(
+            BLECommunicationLogEntry(
+                deviceID: deviceID,
+                timestamp: Date(),
+                kind: kind,
+                message: message
+            )
+        )
+        if communicationLogs.count > 300 {
+            communicationLogs.removeFirst(communicationLogs.count - 300)
+        }
+    }
+
+    private func currentTimeCommand() -> String {
+        let formatter = DateFormatter()
+        formatter.locale = Locale(identifier: "en_US_POSIX")
+        formatter.dateFormat = "yyyyMMddHHmmss"
+        return "PQ_BLE&T&\(formatter.string(from: Date()))"
     }
-    
-    // MARK: - Binding Management
-    
-    /// Binds a discovered BLE device to the specified user account ID.
-    func bindDevice(_ device: DiscoveredBLEDevice, userId: String) -> BoundDevice {
-        let boundDevice = BoundDevice(
-            id: device.id,
-            name: device.name,
-            peripheralUUID: device.peripheralUUID,
-            boundAt: Date(),
-            isConnected: true,
-            batteryLevel: Int.random(in: 75...99),
-            firmwareVersion: "v1.4.2",
-            boundUserId: userId
-        )
-        
-        // Unbind any existing duplicate
-        boundDevices.removeAll(where: { $0.id == boundDevice.id })
-        boundDevices.append(boundDevice)
+
+    // MARK: - State and persistence
+
+    private func markDeviceReady(_ deviceID: String) {
+        updateBoundDevice(id: deviceID) { $0.isConnected = true }
+        connectionPhases[deviceID] = recordingDeviceID == deviceID ? .recording : .ready
+        recorderStateConsumers[deviceID]?(.ready)
+        queryDeviceStatus(deviceID)
+    }
+
+    private func reconnectBoundDevices() {
+        let identifiers = boundDevices.compactMap { UUID(uuidString: $0.peripheralUUID) }
+        guard !identifiers.isEmpty else { return }
+        for peripheral in centralManager.retrievePeripherals(withIdentifiers: identifiers) {
+            let id = peripheral.identifier.uuidString
+            peripherals[id] = peripheral
+            peripheral.delegate = self
+            connectionPhases[id] = .reconnecting
+            centralManager.connect(peripheral, options: nil)
+        }
+    }
+
+    private func markAllDevicesDisconnected() {
+        for index in boundDevices.indices {
+            boundDevices[index].isConnected = false
+            connectionPhases[boundDevices[index].id] = .disconnected
+        }
         saveBoundDevices()
-        return boundDevice
     }
-    
-    /// Unbinds a device by its ID.
-    func unbindDevice(id: String) {
-        boundDevices.removeAll(where: { $0.id == id })
+
+    private func updateBoundDevice(id: String, mutation: (inout BoundDevice) -> Void) {
+        guard let index = boundDevices.firstIndex(where: { $0.id == id }) else { return }
+        mutation(&boundDevices[index])
         saveBoundDevices()
     }
-    
-    /// Returns devices bound to the given user account.
-    func devices(forUserId userId: String) -> [BoundDevice] {
-        return boundDevices.filter { $0.boundUserId == userId }
+
+    private func upsertBoundDevice(_ device: BoundDevice) {
+        boundDevices.removeAll(where: { $0.id == device.id })
+        boundDevices.append(device)
+        saveBoundDevices()
+    }
+
+    private func finishPendingBinding(deviceID: String, result: Result<BoundDevice, Error>) {
+        guard let pending = pendingBindings.removeValue(forKey: deviceID) else { return }
+        pending.completion(result)
+    }
+
+    private func failTransport(deviceID: String, message: String) {
+        let error = SparkBLEError.connectionFailed(message)
+        connectionPhases[deviceID] = .failed(error.localizedDescription)
+        finishPendingBinding(deviceID: deviceID, result: .failure(error))
+        recorderStateConsumers[deviceID]?(.failed(error.localizedDescription))
     }
-    
-    // MARK: - Local Persistence
-    
+
+    private func reportRecordingError(deviceID: String, message: String) {
+        lastErrorMessage = message
+        recorderStateConsumers[deviceID]?(.failed(message))
+        pendingStarts.removeValue(forKey: deviceID)?.completion(.failure(SparkBLEError.deviceReported(message)))
+    }
+
+    private func scheduleTimeout(for deviceID: String, command: String, token: UUID, isStart: Bool) {
+        DispatchQueue.main.asyncAfter(deadline: .now() + 8) { [weak self] in
+            guard let self else { return }
+            if isStart, self.pendingStarts[deviceID]?.token == token {
+                self.pendingStarts.removeValue(forKey: deviceID)?.completion(.failure(SparkBLEError.commandTimedOut(command)))
+            } else if !isStart, self.pendingStops[deviceID]?.token == token {
+                self.pendingStops.removeValue(forKey: deviceID)?.completion(.failure(SparkBLEError.commandTimedOut(command)))
+            }
+        }
+    }
+
     private func saveBoundDevices() {
         if let data = try? JSONEncoder().encode(boundDevices) {
             UserDefaults.standard.set(data, forKey: boundDevicesKey)
         }
     }
-    
+
     private func loadBoundDevices() {
-        if let data = UserDefaults.standard.data(forKey: boundDevicesKey),
-           let devices = try? JSONDecoder().decode([BoundDevice].self, from: data) {
-            self.boundDevices = devices
+        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
         }
+        boundDevices = devices
+    }
+
+    // MARK: - Pairing secret
+
+    private func generatePairingPassword() -> String {
+        let alphabet = Array("ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789")
+        var bytes = [UInt8](repeating: 0, count: 16)
+        let result = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
+        if result == errSecSuccess {
+            return String(bytes.map { alphabet[Int($0) % alphabet.count] })
+        }
+        return String(UUID().uuidString.replacingOccurrences(of: "-", with: "").prefix(16))
+    }
+
+    private func savePairingPassword(_ password: String, for deviceID: String) {
+        deletePairingPassword(for: deviceID)
+        let query: [String: Any] = [
+            kSecClass as String: kSecClassGenericPassword,
+            kSecAttrService as String: keychainService,
+            kSecAttrAccount as String: deviceID,
+            kSecValueData as String: Data(password.utf8),
+            kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
+        ]
+        SecItemAdd(query as CFDictionary, nil)
+    }
+
+    private func pairingPassword(for deviceID: String) -> String? {
+        let query: [String: Any] = [
+            kSecClass as String: kSecClassGenericPassword,
+            kSecAttrService as String: keychainService,
+            kSecAttrAccount as String: deviceID,
+            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 String(data: data, encoding: .utf8)
+    }
+
+    private func deletePairingPassword(for deviceID: String) {
+        let query: [String: Any] = [
+            kSecClass as String: kSecClassGenericPassword,
+            kSecAttrService as String: keychainService,
+            kSecAttrAccount as String: deviceID
+        ]
+        SecItemDelete(query as CFDictionary)
+    }
+}
+
+// MARK: - Spark audio recorder
+
+/// Records the MP3 notification stream produced by a bound Spark device.
+final class SparkAudioRecorder: AudioRecorderProtocol {
+    @Published var isRecording: Bool = false
+    @Published var elapsedTime: TimeInterval = 0
+    @Published var currentAmplitude: Float = 0
+    @Published var waveformSamples: [Float] = []
+    @Published var outputFileURL: URL?
+    @Published var isPaused: Bool = false
+    @Published var statusMessage: String = "正在连接设备"
+    @Published var errorMessage: String?
+
+    let sourceDisplayName: String
+
+    private let deviceID: String
+    private let manager: BLEManager
+    private let ioQueue = DispatchQueue(label: "com.celestia.trace.spark.audio-io")
+    private var fileHandle: FileHandle?
+    private var timer: Timer?
+    private var recordingStartedAt: Date?
+    private var stopCompletion: ((Result<URL?, Error>) -> Void)?
+
+    init(deviceID: String, displayName: String, manager: BLEManager = .shared) {
+        self.deviceID = deviceID
+        self.sourceDisplayName = displayName
+        self.manager = manager
+    }
+
+    func startRecording() {
+        guard !isRecording, fileHandle == nil else { return }
+        errorMessage = nil
+        statusMessage = "正在启动 \(sourceDisplayName)"
+        elapsedTime = 0
+        waveformSamples = []
+        currentAmplitude = 0
+
+        do {
+            let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
+            let url = documents.appendingPathComponent("spark_\(UUID().uuidString).mp3")
+            FileManager.default.createFile(atPath: url.path, contents: nil)
+            fileHandle = try FileHandle(forWritingTo: url)
+            outputFileURL = url
+        } catch {
+            errorMessage = "无法创建微光录音文件:\(error.localizedDescription)"
+            statusMessage = "启动失败"
+            return
+        }
+
+        manager.startSparkRecording(
+            deviceID: deviceID,
+            onAudioData: { [weak self] data in self?.appendAudioData(data) },
+            onStateChange: { [weak self] event in self?.handleStateEvent(event) }
+        ) { [weak self] result in
+            guard let self else { return }
+            DispatchQueue.main.async {
+                switch result {
+                case .success:
+                    self.isRecording = true
+                    self.statusMessage = "正在记录"
+                    self.recordingStartedAt = Date()
+                    self.startTimer()
+                case .failure(let error):
+                    self.errorMessage = error.localizedDescription
+                    self.statusMessage = "启动失败"
+                    self.finishFile { _ in }
+                }
+            }
+        }
+    }
+
+    func stopRecording() {
+        stopRecording { _ in }
+    }
+
+    func stopRecording(completion: @escaping (Result<URL?, Error>) -> Void) {
+        guard fileHandle != nil else {
+            completion(.success(outputFileURL))
+            return
+        }
+        guard isRecording else {
+            finishFile(completion: completion)
+            return
+        }
+
+        statusMessage = "正在保存设备录音"
+        stopCompletion = completion
+        manager.stopSparkRecording(deviceID: deviceID) { [weak self] result in
+            guard let self else { return }
+            DispatchQueue.main.async {
+                switch result {
+                case .success:
+                    self.isRecording = false
+                    self.invalidateTimer()
+                    self.finishFile { result in
+                        self.manager.detachRecorder(deviceID: self.deviceID)
+                        self.stopCompletion?(result)
+                        self.stopCompletion = nil
+                    }
+                case .failure(let error):
+                    self.errorMessage = error.localizedDescription
+                    self.statusMessage = "设备尚未确认停止"
+                    self.stopCompletion?(.failure(error))
+                    self.stopCompletion = nil
+                }
+            }
+        }
+    }
+
+    func pauseRecording() {
+        errorMessage = "微光暂不支持暂停,请结束当前录音。"
+    }
+
+    func resumeRecording() {}
+
+    private func appendAudioData(_ data: Data) {
+        guard !data.isEmpty else { return }
+        ioQueue.async { [weak self] in
+            guard let self else { return }
+            do {
+                try self.fileHandle?.write(contentsOf: data)
+            } catch {
+                DispatchQueue.main.async {
+                    self.errorMessage = "保存微光音频失败:\(error.localizedDescription)"
+                }
+            }
+        }
+
+        // This represents stream activity until incremental MP3 PCM metering is available.
+        let activity = min(1, max(0.08, Float(data.count) / 244.0))
+        DispatchQueue.main.async { [weak self] in
+            guard let self else { return }
+            self.currentAmplitude = activity
+            self.waveformSamples.append(activity)
+            if self.waveformSamples.count > 200 {
+                self.waveformSamples.removeFirst(self.waveformSamples.count - 200)
+            }
+        }
+    }
+
+    private func handleStateEvent(_ event: SparkRecorderConnectionEvent) {
+        DispatchQueue.main.async { [weak self] in
+            guard let self else { return }
+            switch event {
+            case .recording:
+                if self.isRecording { self.statusMessage = "正在记录" }
+            case .reconnecting:
+                self.statusMessage = "连接中断,正在重连"
+            case .ready:
+                if self.isRecording { self.statusMessage = "正在记录" }
+            case .failed(let message):
+                self.errorMessage = message
+                self.statusMessage = "设备异常"
+            }
+        }
+    }
+
+    private func startTimer() {
+        invalidateTimer()
+        timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in
+            guard let self, let startedAt = self.recordingStartedAt else { return }
+            self.elapsedTime = Date().timeIntervalSince(startedAt)
+        }
+        if let timer { RunLoop.main.add(timer, forMode: .common) }
+    }
+
+    private func invalidateTimer() {
+        timer?.invalidate()
+        timer = nil
+    }
+
+    private func finishFile(completion: @escaping (Result<URL?, Error>) -> Void) {
+        let url = outputFileURL
+        ioQueue.async { [weak self] in
+            guard let self else { return }
+            do {
+                try self.fileHandle?.synchronize()
+                try self.fileHandle?.close()
+                self.fileHandle = nil
+                DispatchQueue.main.async { completion(.success(url)) }
+            } catch {
+                self.fileHandle = nil
+                DispatchQueue.main.async { completion(.failure(error)) }
+            }
+        }
+    }
+
+    deinit {
+        invalidateTimer()
+        try? fileHandle?.close()
+        manager.detachRecorder(deviceID: deviceID)
     }
 }

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

@@ -0,0 +1,393 @@
+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 secureConnection
+    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 .secureConnection: return "无法安全连接登录服务器,请检查服务器 HTTPS 证书配置"
+        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://api.ccdw.life/celestia-trace/v1")!
+        if let session {
+            self.session = session
+        } else {
+            let configuration = URLSessionConfiguration.default
+            // Authentication is user-initiated. Fail promptly when the device is
+            // offline or TLS negotiation fails instead of leaving the UI waiting.
+            configuration.waitsForConnectivity = false
+            configuration.timeoutIntervalForRequest = 15
+            configuration.timeoutIntervalForResource = 60
+            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 let error as URLError where Self.isSecureConnectionError(error.code) {
+            throw APIError.secureConnection
+        } 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
+    }()
+
+    private static func isSecureConnectionError(_ code: URLError.Code) -> Bool {
+        switch code {
+        case .secureConnectionFailed,
+             .serverCertificateHasBadDate,
+             .serverCertificateUntrusted,
+             .serverCertificateHasUnknownRoot,
+             .serverCertificateNotYetValid,
+             .clientCertificateRejected,
+             .clientCertificateRequired,
+             .appTransportSecurityRequiresSecureConnection:
+            return true
+        default:
+            return false
+        }
+    }
+}

+ 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)
+        }
+    }
+}

+ 80 - 0
CelestiaTrace/Services/RecordingLiveActivityManager.swift

@@ -0,0 +1,80 @@
+import ActivityKit
+import Foundation
+
+@MainActor
+final class RecordingLiveActivityManager {
+    static let shared = RecordingLiveActivityManager()
+
+    private var activity: Activity<RecordingActivityAttributes>?
+
+    private init() {}
+
+    func start(
+        sessionID: UUID,
+        title: String,
+        sourceName: String,
+        elapsedSeconds: TimeInterval
+    ) {
+        guard ActivityAuthorizationInfo().areActivitiesEnabled else { return }
+
+        Task {
+            await endExistingActivities()
+
+            let attributes = RecordingActivityAttributes(
+                sessionID: sessionID,
+                title: title,
+                sourceName: sourceName
+            )
+            let state = makeState(elapsedSeconds: elapsedSeconds, isPaused: false)
+
+            do {
+                activity = try Activity.request(
+                    attributes: attributes,
+                    content: ActivityContent(state: state, staleDate: nil),
+                    pushType: nil
+                )
+            } catch {
+                print("[RecordingLiveActivity] Failed to start: \(error.localizedDescription)")
+            }
+        }
+    }
+
+    func update(elapsedSeconds: TimeInterval, isPaused: Bool) {
+        guard let activity else { return }
+        let state = makeState(elapsedSeconds: elapsedSeconds, isPaused: isPaused)
+
+        Task {
+            await activity.update(ActivityContent(state: state, staleDate: nil))
+        }
+    }
+
+    func end(elapsedSeconds: TimeInterval) {
+        guard let activity else { return }
+        self.activity = nil
+        let finalState = makeState(elapsedSeconds: elapsedSeconds, isPaused: true)
+
+        Task {
+            await activity.end(
+                ActivityContent(state: finalState, staleDate: nil),
+                dismissalPolicy: .immediate
+            )
+        }
+    }
+
+    private func endExistingActivities() async {
+        for existingActivity in Activity<RecordingActivityAttributes>.activities {
+            await existingActivity.end(nil, dismissalPolicy: .immediate)
+        }
+    }
+
+    private func makeState(
+        elapsedSeconds: TimeInterval,
+        isPaused: Bool
+    ) -> RecordingActivityAttributes.ContentState {
+        RecordingActivityAttributes.ContentState(
+            timerStartDate: Date().addingTimeInterval(-elapsedSeconds),
+            elapsedSeconds: elapsedSeconds,
+            isPaused: isPaused
+        )
+    }
+}

+ 57 - 85
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
     
@@ -31,9 +34,16 @@ final class PlaybackViewModel: NSObject, AVAudioPlayerDelegate {
     /// Detected silent intervals
     var silentRanges: [SilenceRange] = []
 
+    /// Amplitude levels extracted from the playback file on the live-recording scale.
+    var waveformSamples: [Float] = []
+
+    /// Whether playback waveform and silence analysis is active.
+    var isAnalyzingAudio: Bool = false
+
     // MARK: - Audio Player Private State
     private var audioPlayer: AVAudioPlayer?
     private var activeAudioURL: URL?
+    private var audioAnalysisTask: Task<Void, Never>?
 
     // MARK: - Computed Properties
 
@@ -75,19 +85,23 @@ final class PlaybackViewModel: NSObject, AVAudioPlayerDelegate {
 
     deinit {
         playbackTimer?.invalidate()
+        audioAnalysisTask?.cancel()
         audioPlayer?.stop()
     }
 
     // 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
             }
@@ -103,20 +117,22 @@ final class PlaybackViewModel: NSObject, AVAudioPlayerDelegate {
             player.prepareToPlay()
             player.volume = 1.0
             self.audioPlayer = player
-            if durationMs <= 0 {
-                self.totalDurationMs = player.duration * 1000.0
-            } else {
-                self.totalDurationMs = durationMs
-            }
+            // The decoded file is the authoritative clock for playback and waveform data.
+            self.totalDurationMs = player.duration.isFinite && player.duration > 0
+                ? player.duration * 1000.0
+                : durationMs
         } catch {
             print("[PlaybackViewModel] Failed to initialize AVAudioPlayer: \(error.localizedDescription)")
+            playbackError = "录音文件无法播放:\(error.localizedDescription)"
         }
     }
 
     private func setupAudioSession() {
         let session = AVAudioSession.sharedInstance()
         do {
-            try session.setCategory(.playback, mode: .default, options: [.defaultToSpeaker])
+            // `.playback` already routes through the speaker by default.
+            // `.defaultToSpeaker` is valid only with `.playAndRecord`.
+            try session.setCategory(.playback, mode: .default)
             try session.setActive(true)
         } catch {
             print("[PlaybackViewModel] Failed to setup AVAudioSession: \(error.localizedDescription)")
@@ -128,12 +144,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
@@ -216,22 +237,33 @@ final class PlaybackViewModel: NSObject, AVAudioPlayerDelegate {
 
     /// Runs silence detection asynchronously.
     func analyzeSilence(audioURL: URL?) {
+        analyzeAudio(audioURL: audioURL)
+    }
+
+    /// Extracts the real waveform and silence ranges in one background pass.
+    func analyzeAudio(audioURL: URL?) {
+        audioAnalysisTask?.cancel()
         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)
-            ]
+            waveformSamples = []
+            silentRanges = []
+            isAnalyzingSilence = false
+            isAnalyzingAudio = false
             return
         }
         
+        waveformSamples = []
+        silentRanges = []
         isAnalyzingSilence = true
-        Task {
-            let ranges = await SilenceDetector.detectSilence(in: url)
-            await MainActor.run {
-                self.silentRanges = ranges
-                self.isAnalyzingSilence = false
-            }
+        isAnalyzingAudio = true
+        audioAnalysisTask = Task { @MainActor [weak self] in
+            let result = await SilenceDetector.analyze(url)
+            guard !Task.isCancelled else { return }
+            guard let self else { return }
+            self.waveformSamples = result.waveformSamples
+            self.silentRanges = result.silentRanges
+            self.isAnalyzingSilence = false
+            self.isAnalyzingAudio = false
         }
     }
     
@@ -243,7 +275,6 @@ final class PlaybackViewModel: NSObject, AVAudioPlayerDelegate {
             if currentTimeSec >= range.start && currentTimeSec < range.end {
                 let targetMs = range.end * 1000.0
                 seekTo(timeMs: targetMs)
-                print("[PlaybackViewModel] Auto-skipped silence from \(range.start)s to \(range.end)s")
                 break
             }
         }
@@ -251,11 +282,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 +336,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
-        }
-    }
 }
-

+ 37 - 1
CelestiaTrace/ViewModels/RecordingViewModel.swift

@@ -17,6 +17,9 @@ final class RecordingViewModel {
     private(set) var currentAmplitude: Float = 0
     private(set) var waveformSamples: [Float] = []
     private(set) var isPaused: Bool = false
+    private(set) var sourceDisplayName: String
+    private(set) var statusMessage: String = "正在准备"
+    private(set) var errorMessage: String?
 
     // MARK: - Computed Properties
 
@@ -46,10 +49,22 @@ final class RecordingViewModel {
 
     init() {
         self.recorder = RealAudioRecorder()
+        self.sourceDisplayName = "iPhone 本机"
+    }
+
+    init(source: RecordingSourceChoice) {
+        switch source {
+        case .iPhone:
+            self.recorder = RealAudioRecorder()
+        case .spark(let deviceID, let displayName):
+            self.recorder = SparkAudioRecorder(deviceID: deviceID, displayName: displayName)
+        }
+        self.sourceDisplayName = source.displayName
     }
 
     init(recorder: any AudioRecorderProtocol) {
         self.recorder = recorder
+        self.sourceDisplayName = recorder.sourceDisplayName
     }
 
     deinit {
@@ -64,7 +79,7 @@ final class RecordingViewModel {
         self.initialDuration = initialDuration
         self.elapsedTime = initialDuration
         recorder.startRecording()
-        isRecording = true
+        syncState()
         startSyncTimer()
     }
 
@@ -76,6 +91,18 @@ final class RecordingViewModel {
         syncState() // Final sync
     }
 
+    /// Stops the active source and reports whether the source confirmed that it
+    /// is safe to finalize the session. Spark uses this to await PQ_DEV&STO.
+    func stopRecording(completion: @escaping (Result<URL?, Error>) -> Void) {
+        recorder.stopRecording { [weak self] result in
+            guard let self else { return }
+            self.isRecording = false
+            self.stopSyncTimer()
+            self.syncState()
+            completion(result)
+        }
+    }
+
     /// Pauses the current recording session.
     func pauseRecording() {
         recorder.pauseRecording()
@@ -99,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
     }
 
@@ -113,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
     }
 
@@ -127,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
     }
 
@@ -156,5 +189,8 @@ final class RecordingViewModel {
         currentAmplitude = recorder.currentAmplitude
         waveformSamples = recorder.waveformSamples
         isPaused = recorder.isPaused
+        sourceDisplayName = recorder.sourceDisplayName
+        statusMessage = recorder.statusMessage
+        errorMessage = recorder.errorMessage
     }
 }

+ 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)

+ 1 - 1
CelestiaTrace/Views/ContentView.swift

@@ -12,7 +12,7 @@ struct ContentView: View {
 
     var body: some View {
         TabView(selection: $selectedTab) {
-            HomeView(onSwitchToHistory: { selectedTab = .history })
+            HomeView()
                 .tabItem {
                     Label(Tab.home.title, systemImage: Tab.home.icon)
                 }

+ 120 - 75
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.
@@ -11,8 +11,11 @@ struct MultiTrackTimeline: View {
     let events: [CelestiaTimelineEvent]
     @Binding var currentTimeMs: Double
     let totalDurationMs: Double
+    var waveformSamples: [Float] = []
     var silentRanges: [SilenceRange] = []
 
+    @State private var playheadDragStartTimeMs: Double?
+
     /// Scale: points per second
     private let pointsPerSecond: CGFloat = 2.5
     /// Minimum timeline width
@@ -62,6 +65,7 @@ struct MultiTrackTimeline: View {
                 .clipShape(RoundedRectangle(cornerRadius: 10))
                 .businessBorder(cornerRadius: 10)
                 .onChange(of: currentTimeMs) { _, _ in
+                    guard playheadDragStartTimeMs == nil else { return }
                     withAnimation(.easeOut(duration: 0.2)) {
                         scrollProxy.scrollTo("playhead", anchor: .center)
                     }
@@ -116,77 +120,64 @@ struct MultiTrackTimeline: View {
 
     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()
+            let centerY = size.height / 2
+            let barWidth: CGFloat = 1
+            let barSpacing: CGFloat = 2
+            let barSlot = barWidth + barSpacing
+            let startX = xPosition(for: 0)
+            let endX = xPosition(for: totalDurationMs)
+            let waveformWidth = max(0, endX - startX)
+            let barCount = max(0, Int(waveformWidth / barSlot))
+
+            for range in silentRanges {
+                let rangeStart = max(startX, xPosition(for: range.start * 1000))
+                let rangeEnd = min(endX, xPosition(for: range.end * 1000))
+                guard rangeEnd > rangeStart else { continue }
+                context.fill(
+                    Path(CGRect(
+                        x: rangeStart,
+                        y: 0,
+                        width: rangeEnd - rangeStart,
+                        height: size.height
+                    )),
+                    with: .color(Color.secondary.opacity(0.10))
+                )
             }
 
-            // 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))
+                path.move(to: CGPoint(x: startX, y: centerY))
+                path.addLine(to: CGPoint(x: endX, y: centerY))
+            }
+            context.stroke(
+                centerLine,
+                with: .color(Color.primary.opacity(0.1)),
+                lineWidth: 1
+            )
+
+            guard barCount > 0, !waveformSamples.isEmpty else { return }
+
+            for index in 0..<barCount {
+                let sampleStart = index * waveformSamples.count / barCount
+                let sampleEnd = max(
+                    sampleStart + 1,
+                    (index + 1) * waveformSamples.count / barCount
+                )
+                let upperBound = min(sampleEnd, waveformSamples.count)
+                guard sampleStart < upperBound else { continue }
+
+                let amplitude = waveformSamples[sampleStart..<upperBound].max() ?? 0
+                let barHeight = max(CGFloat(max(amplitude, 0)) * size.height * 0.75, 1)
+                let rect = CGRect(
+                    x: startX + CGFloat(index) * barSlot,
+                    y: centerY - barHeight / 2,
+                    width: barWidth,
+                    height: barHeight
+                )
+                context.fill(
+                    Path(rect),
+                    with: .color(Color.primary.opacity(0.85))
+                )
             }
-            context.stroke(centerLine, with: .color(Color.primary.opacity(0.1)), lineWidth: 0.5)
         }
     }
 
@@ -231,11 +222,50 @@ struct MultiTrackTimeline: View {
 
     private var playhead: some View {
         let x = xPosition(for: currentTimeMs)
-        return Rectangle()
-            .fill(Color.primary) // Dynamic primary color for playhead line
-            .frame(width: 1.0)
-            .offset(x: x)
-            .animation(.easeOut(duration: 0.15), value: currentTimeMs)
+        return ZStack {
+            Color.clear
+                .contentShape(Rectangle())
+
+            Rectangle()
+                .fill(Color.primary)
+                .frame(width: 1)
+        }
+            .frame(width: 28)
+            .offset(x: x - 14)
+            .animation(
+                playheadDragStartTimeMs == nil ? .easeOut(duration: 0.15) : nil,
+                value: currentTimeMs
+            )
+            .highPriorityGesture(playheadDragGesture)
+            .accessibilityLabel("当前播放位置")
+            .accessibilityValue(formattedTime(currentTimeMs))
+            .accessibilityAdjustableAction { direction in
+                let stepMs = 1_000.0
+                switch direction {
+                case .increment:
+                    currentTimeMs = min(totalDurationMs, currentTimeMs + stepMs)
+                case .decrement:
+                    currentTimeMs = max(0, currentTimeMs - stepMs)
+                @unknown default:
+                    break
+                }
+            }
+    }
+
+    private var playheadDragGesture: some Gesture {
+        DragGesture(minimumDistance: 0, coordinateSpace: .global)
+            .onChanged { value in
+                let startTime = playheadDragStartTimeMs ?? currentTimeMs
+                if playheadDragStartTimeMs == nil {
+                    playheadDragStartTimeMs = startTime
+                }
+
+                let startX = xPosition(for: startTime)
+                currentTimeMs = timeMs(forXPosition: startX + value.translation.width)
+            }
+            .onEnded { _ in
+                playheadDragStartTimeMs = nil
+            }
     }
 
     // MARK: - Track Divider
@@ -272,10 +302,24 @@ struct MultiTrackTimeline: View {
         guard totalDurationMs > 0 else { return 30 }
         let padding: CGFloat = 30
         let usableWidth = timelineWidth - padding * 2
-        let ratio = timeMs / totalDurationMs
+        let ratio = min(max(timeMs / totalDurationMs, 0), 1)
         return padding + usableWidth * CGFloat(ratio)
     }
 
+    private func timeMs(forXPosition x: CGFloat) -> Double {
+        guard totalDurationMs > 0 else { return 0 }
+        let padding: CGFloat = 30
+        let usableWidth = timelineWidth - padding * 2
+        guard usableWidth > 0 else { return 0 }
+        let ratio = min(max((x - padding) / usableWidth, 0), 1)
+        return Double(ratio) * totalDurationMs
+    }
+
+    private func formattedTime(_ timeMs: Double) -> String {
+        let totalSeconds = max(0, Int(timeMs / 1_000))
+        return String(format: "%d:%02d", totalSeconds / 60, totalSeconds % 60)
+    }
+
     private var photoEvents: [CelestiaTimelineEvent] {
         events.filter { $0.eventType == "PHOTO" }
     }
@@ -292,7 +336,8 @@ struct MultiTrackTimeline: View {
         MultiTrackTimeline(
             events: [],
             currentTimeMs: .constant(30000),
-            totalDurationMs: 120000
+            totalDurationMs: 120000,
+            waveformSamples: [0.1, 0.3, 0.8, 0.4, 0.2]
         )
         .frame(height: 140)
         .padding()

+ 48 - 16
CelestiaTrace/Views/Detail/SessionDetailView.swift

@@ -8,6 +8,8 @@ import SwiftData
 /// Redesigned to use a minimalist wireframe business style.
 struct SessionDetailView: View {
     @Environment(\.modelContext) private var modelContext
+    @ObservedObject private var bleManager: BLEManager = .shared
+    @ObservedObject private var authManager: AuthManager = .shared
 
     let session: CelestiaSession
 
@@ -16,6 +18,8 @@ struct SessionDetailView: View {
     @State private var detector = RecordingEnvironmentDetector()
     @State private var showPrepAlert = false
     @State private var activeWarningMsg = ""
+    @State private var showRecordingSourcePicker = false
+    @State private var selectedRecordingSource: RecordingSourceChoice = .iPhone
 
     var body: some View {
         ZStack {
@@ -53,13 +57,7 @@ struct SessionDetailView: View {
             }
             ToolbarItem(placement: .topBarTrailing) {
                 Button {
-                    detector.checkEnvironment()
-                    if let firstWarning = detector.activeWarnings.first {
-                        activeWarningMsg = firstWarning
-                        showPrepAlert = true
-                    } else {
-                        navigateToRecording = true
-                    }
+                    prepareContinuation()
                 } label: {
                     HStack(spacing: 4) {
                         Image(systemName: "plus")
@@ -77,7 +75,7 @@ struct SessionDetailView: View {
         }
         .alert("录音环境提醒", isPresented: $showPrepAlert) {
             Button("继续录制", role: .none) {
-                navigateToRecording = true
+                chooseSourceAndContinue()
             }
             Button("取消", role: .cancel) {}
         } message: {
@@ -86,20 +84,53 @@ struct SessionDetailView: View {
         .fullScreenCover(isPresented: $navigateToRecording) {
             ActiveRecordingView(
                 session: session,
-                initialDuration: Double(session.durationMs) / 1000.0
+                initialDuration: Double(session.durationMs) / 1000.0,
+                recordingSource: selectedRecordingSource
             ) {
                 playbackVM.totalDurationMs = Double(session.durationMs)
                 playbackVM.preparePlayer(path: session.localAudioPath, durationMs: Double(session.durationMs))
-                playbackVM.analyzeSilence(audioURL: AudioPathHelper.resolveURL(for: session.localAudioPath))
+                playbackVM.analyzeAudio(audioURL: AudioPathHelper.resolveURL(for: session.localAudioPath))
+            }
+        }
+        .sheet(isPresented: $showRecordingSourcePicker) {
+            RecordingSourcePickerView(devices: connectedSparkDevices) { source in
+                selectedRecordingSource = source
+                showRecordingSourcePicker = false
+                navigateToRecording = true
             }
         }
         .onAppear {
             playbackVM.totalDurationMs = Double(session.durationMs)
             playbackVM.preparePlayer(path: session.localAudioPath, durationMs: Double(session.durationMs))
-            playbackVM.analyzeSilence(audioURL: AudioPathHelper.resolveURL(for: session.localAudioPath))
+            playbackVM.analyzeAudio(audioURL: AudioPathHelper.resolveURL(for: session.localAudioPath))
         }
     }
 
+    private func prepareContinuation() {
+        detector.checkEnvironment()
+        if let firstWarning = detector.activeWarnings.first {
+            activeWarningMsg = firstWarning
+            showPrepAlert = true
+        } else {
+            chooseSourceAndContinue()
+        }
+    }
+
+    private func chooseSourceAndContinue() {
+        if connectedSparkDevices.isEmpty {
+            selectedRecordingSource = .iPhone
+            navigateToRecording = true
+        } else {
+            showRecordingSourcePicker = true
+        }
+    }
+
+    private var connectedSparkDevices: [BoundDevice] {
+        guard let userID = authManager.currentUser?.id else { return [] }
+        return bleManager.connectedDevices(forUserId: userID)
+            .sorted { $0.boundAt < $1.boundAt }
+    }
+
     // MARK: - Session Info Card
 
     private var sessionInfoCard: some View {
@@ -166,7 +197,7 @@ struct SessionDetailView: View {
 
                 // Sync status
                 VStack(spacing: 4) {
-                    Image(systemName: session.isSynced ? "cloud" : "cloud.dashed")
+                    Image(systemName: session.isSynced ? "cloud.fill" : "cloud")
                         .font(.system(size: 14, weight: .light))
                         .foregroundStyle(Color.secondary)
 
@@ -211,7 +242,8 @@ struct SessionDetailView: View {
                     get: { playbackVM.currentPlaybackTimeMs },
                     set: { playbackVM.seekTo(timeMs: $0) }
                 ),
-                totalDurationMs: Double(session.durationMs),
+                totalDurationMs: playbackVM.totalDurationMs,
+                waveformSamples: playbackVM.waveformSamples,
                 silentRanges: playbackVM.silentRanges
             )
             .frame(height: 140)
@@ -240,7 +272,7 @@ struct SessionDetailView: View {
                 value: Binding(
                     get: { playbackVM.progress },
                     set: { newValue in
-                        playbackVM.seekTo(timeMs: newValue * Double(session.durationMs))
+                        playbackVM.seekTo(timeMs: newValue * playbackVM.totalDurationMs)
                     }
                 ),
                 in: 0...1
@@ -277,7 +309,7 @@ struct SessionDetailView: View {
 
                 // Forward 10s
                 Button {
-                    let target = min(Double(session.durationMs), playbackVM.currentPlaybackTimeMs + 10_000)
+                    let target = min(playbackVM.totalDurationMs, playbackVM.currentPlaybackTimeMs + 10_000)
                     playbackVM.seekTo(timeMs: target)
                 } label: {
                     Image(systemName: "goforward.10")
@@ -377,7 +409,7 @@ struct SessionDetailView: View {
     }
 
     private var totalTimeFormatted: String {
-        let totalSeconds = Int(session.durationMs / 1000)
+        let totalSeconds = Int(playbackVM.totalDurationMs / 1000)
         let hours = totalSeconds / 3600
         let minutes = (totalSeconds % 3600) / 60
         let seconds = totalSeconds % 60

+ 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(

+ 177 - 152
CelestiaTrace/Views/Home/HomeView.swift

@@ -3,20 +3,20 @@ import SwiftData
 
 // MARK: - HomeView
 /// The main launch screen of CelestiaTrace.
-/// Features a minimalist, line-drawn aesthetic with a central record button,
-/// bordered session cards, and a subtle technical grid background.
+/// Features a minimalist, line-drawn aesthetic with a central record button
+/// and a subtle technical grid background.
 struct HomeView: View {
     @Environment(\.modelContext) private var modelContext
-    @Query(sort: \CelestiaSession.startTime, order: .reverse) private var sessions: [CelestiaSession]
+    @ObservedObject private var bleManager: BLEManager = .shared
+    @ObservedObject private var authManager: AuthManager = .shared
 
-    @State private var navigateToRecording = false
-    @State private var activeSession: CelestiaSession?
+    @State private var activeRecording: ActiveRecordingPresentation?
+    @State private var completedRecordingSession: CelestiaSession?
     @State private var selectedSessionForDetail: CelestiaSession?
     @State private var detector = RecordingEnvironmentDetector()
     @State private var dismissedWarnings: Set<String> = []
-
-    /// Callback to switch to the History tab from the parent TabView.
-    var onSwitchToHistory: (() -> Void)?
+    @State private var showRecordingSourcePicker = false
+    @State private var pendingRecordingSource: RecordingSourceChoice?
 
     var body: some View {
         NavigationStack {
@@ -47,12 +47,6 @@ struct HomeView: View {
                     recordSection
 
                     Spacer()
-
-                    recentSessionsSection
-                        .padding(.bottom, 16)
-
-                    historyLink
-                        .padding(.bottom, 24)
                 }
                 .padding(.horizontal, 24)
             }
@@ -60,11 +54,27 @@ struct HomeView: View {
             .onAppear {
                 detector.checkEnvironment()
             }
-            .fullScreenCover(isPresented: $navigateToRecording) {
-                if let session = activeSession {
-                    ActiveRecordingView(session: session) {
-                        selectedSessionForDetail = session
-                    }
+            .fullScreenCover(item: $activeRecording, onDismiss: {
+                if let session = completedRecordingSession {
+                    selectedSessionForDetail = session
+                    completedRecordingSession = nil
+                }
+            }) { recording in
+                ActiveRecordingView(
+                    session: recording.session,
+                    recordingSource: recording.source
+                ) {
+                    completedRecordingSession = recording.session
+                }
+            }
+            .sheet(isPresented: $showRecordingSourcePicker, onDismiss: {
+                guard let source = pendingRecordingSource else { return }
+                pendingRecordingSource = nil
+                createSessionAndNavigate(source: source)
+            }) {
+                RecordingSourcePickerView(devices: connectedSparkDevices) { source in
+                    pendingRecordingSource = source
+                    showRecordingSourcePicker = false
                 }
             }
             .navigationDestination(item: $selectedSessionForDetail) { session in
@@ -88,19 +98,6 @@ struct HomeView: View {
                     .foregroundStyle(Color.secondary)
             }
             Spacer()
-
-            // Status indicator
-            HStack(spacing: 6) {
-                Circle()
-                    .fill(Color.primary.opacity(0.7))
-                    .frame(width: 5, height: 5)
-                Text("就绪")
-                    .font(.system(size: 9, weight: .semibold, design: .monospaced))
-                    .foregroundStyle(Color.secondary)
-            }
-            .padding(.horizontal, 8)
-            .padding(.vertical, 4)
-            .businessBorder(cornerRadius: 4)
         }
     }
 
@@ -109,89 +106,13 @@ struct HomeView: View {
     private var recordSection: some View {
         VStack(spacing: 24) {
             PulsingRecordButton {
-                createSessionAndNavigate()
-            }
-
-            VStack(spacing: 6) {
-                Text("新建现场记录")
-                    .font(.system(size: 16, weight: .medium))
-                    .foregroundStyle(Color.primary)
-
-                Text("点击开启全新现场录音")
-                    .font(.system(size: 9, weight: .medium, design: .monospaced))
-                    .foregroundStyle(Color.secondary.opacity(0.7))
-                    .tracking(2)
-            }
-        }
-    }
-
-    // MARK: - Recent Sessions
-
-    private var recentSessionsSection: some View {
-        VStack(alignment: .leading, spacing: 12) {
-            HStack {
-                Image(systemName: "list.dash")
-                    .font(.system(size: 12))
-                    .foregroundStyle(Color.secondary)
-                Text("最近记录")
-                    .font(.system(size: 13, weight: .semibold))
-                    .foregroundStyle(Color.primary.opacity(0.8))
-            }
-
-            if sessions.isEmpty {
-                emptyRecentState
-            } else {
-                ScrollView(.horizontal, showsIndicators: false) {
-                    HStack(spacing: 12) {
-                        ForEach(sessions.prefix(3)) { session in
-                            Button {
-                                selectedSessionForDetail = session
-                            } label: {
-                                RecentSessionCard(session: session)
-                            }
-                            .buttonStyle(.plain)
-                        }
-                    }
-                    .padding(.vertical, 2)
-                }
+                beginNewRecording()
             }
-        }
-    }
 
-    private var emptyRecentState: some View {
-        HStack {
-            Spacer()
-            VStack(spacing: 8) {
-                Image(systemName: "plus.square.dashed")
-                    .font(.system(size: 20))
-                    .foregroundStyle(Color.secondary.opacity(0.4))
-                Text("暂无记录")
-                    .font(.system(size: 12))
-                    .foregroundStyle(Color.secondary.opacity(0.5))
-            }
-            .padding(.vertical, 32)
-            Spacer()
-        }
-        .background(Color.cardBackground.opacity(0.3))
-        .businessBorder(cornerRadius: 8)
-    }
-
-    // MARK: - History Link
-
-    private var historyLink: some View {
-        Button {
-            onSwitchToHistory?()
-        } label: {
-            HStack(spacing: 6) {
-                Text("查看历史列表")
-                    .font(.system(size: 13, weight: .regular))
-                Image(systemName: "chevron.right")
-                    .font(.system(size: 10))
-            }
-            .foregroundStyle(Color.primary.opacity(0.7))
-            .padding(.horizontal, 14)
-            .padding(.vertical, 8)
-            .businessBorder(cornerRadius: 6)
+            Text("开始")
+                .font(.system(size: 16, weight: .medium))
+                .foregroundStyle(Color.primary.opacity(0.88))
+                .tracking(0.4)
         }
     }
 
@@ -224,18 +145,31 @@ struct HomeView: View {
 
     // MARK: - Actions
 
-    private func createSessionAndNavigate() {
+    private func beginNewRecording() {
+        if connectedSparkDevices.isEmpty {
+            createSessionAndNavigate(source: .iPhone)
+        } else {
+            showRecordingSourcePicker = true
+        }
+    }
+
+    private func createSessionAndNavigate(source: RecordingSourceChoice) {
         let formatter = DateFormatter()
-        formatter.dateFormat = "yyyy.MM.dd HH:mm"
-        let title = "现场记录 · \(formatter.string(from: Date()))"
+        formatter.dateFormat = "yyyyMMdd_HHmm"
+        let title = "现场记录_\(formatter.string(from: Date()))"
 
         let session = CelestiaSession(title: title)
         modelContext.insert(session)
         try? modelContext.save()
 
-        activeSession = session
         HapticManager.trigger(.recordStart)
-        navigateToRecording = true
+        activeRecording = ActiveRecordingPresentation(session: session, source: source)
+    }
+
+    private var connectedSparkDevices: [BoundDevice] {
+        guard let userID = authManager.currentUser?.id else { return [] }
+        return bleManager.connectedDevices(forUserId: userID)
+            .sorted { $0.boundAt < $1.boundAt }
     }
 
     // MARK: - Helpers
@@ -248,50 +182,141 @@ struct HomeView: View {
     }
 }
 
-// MARK: - Recent Session Card
-
-private struct RecentSessionCard: View {
+private struct ActiveRecordingPresentation: Identifiable {
+    let id = UUID()
     let session: CelestiaSession
+    let source: RecordingSourceChoice
+}
+
+// MARK: - Recording source picker
+
+/// Shared by new recordings and continuation recordings. When at least one
+/// connected Spark exists, the first Spark is selected by default.
+struct RecordingSourcePickerView: View {
+    @Environment(\.dismiss) private var dismiss
+
+    let devices: [BoundDevice]
+    let onConfirm: (RecordingSourceChoice) -> Void
+    @State private var selection: RecordingSourceChoice
+
+    init(devices: [BoundDevice], onConfirm: @escaping (RecordingSourceChoice) -> Void) {
+        self.devices = devices
+        self.onConfirm = onConfirm
+        if let first = devices.first {
+            _selection = State(initialValue: .spark(deviceID: first.id, displayName: first.name))
+        } else {
+            _selection = State(initialValue: .iPhone)
+        }
+    }
 
     var body: some View {
-        VStack(alignment: .leading, spacing: 8) {
-            Text(session.title)
-                .font(.system(size: 13, weight: .medium))
-                .foregroundStyle(Color.primary)
-                .lineLimit(1)
-
-            HStack(spacing: 4) {
-                Image(systemName: "waveform")
-                    .font(.system(size: 9))
-                    .foregroundStyle(Color.secondary)
-                Text(session.durationFormatted)
-                    .font(.system(size: 11, weight: .regular, design: .monospaced))
-                    .foregroundStyle(Color.secondary)
+        NavigationStack {
+            ZStack {
+                Color.spaceBlack.ignoresSafeArea()
+                VStack(alignment: .leading, spacing: 14) {
+                    Text("选择本次现场记录使用的录音设备")
+                        .font(.system(size: 13))
+                        .foregroundStyle(Color.secondary)
+
+                    ForEach(Array(devices.enumerated()), id: \.element.id) { index, device in
+                        sourceRow(
+                            source: .spark(deviceID: device.id, displayName: device.name),
+                            title: device.name,
+                            subtitle: sparkSubtitle(device),
+                            badge: index == 0 ? "默认" : nil
+                        )
+                    }
+
+                    sourceRow(
+                        source: .iPhone,
+                        title: "iPhone 麦克风",
+                        subtitle: "使用手机内置或当前系统音频输入",
+                        badge: devices.isEmpty ? "默认" : nil
+                    )
+
+                    Button {
+                        onConfirm(selection)
+                    } label: {
+                        Text("开始现场记录")
+                            .font(.system(size: 14, weight: .semibold))
+                            .foregroundStyle(Color.spaceBlack)
+                            .frame(maxWidth: .infinity)
+                            .padding(.vertical, 12)
+                            .background(Color.primary)
+                            .cornerRadius(8)
+                    }
+                    .padding(.top, 8)
+
+                    Spacer()
+                }
+                .padding(20)
+            }
+            .navigationTitle("录音设备")
+            .navigationBarTitleDisplayMode(.inline)
+            .toolbar {
+                ToolbarItem(placement: .cancellationAction) {
+                    Button("取消") { dismiss() }
+                        .foregroundStyle(Color.secondary)
+                }
             }
+        }
+        .presentationDetents([.medium, .large])
+        .presentationBackground(Color.spaceBlack)
+    }
 
+    private func sourceRow(
+        source: RecordingSourceChoice,
+        title: String,
+        subtitle: String,
+        badge: String?
+    ) -> some View {
+        Button {
+            selection = source
+        } label: {
             HStack(spacing: 12) {
-                HStack(spacing: 3) {
-                    Image(systemName: "camera")
-                        .font(.system(size: 9))
-                    Text("\(session.photoCount)")
-                        .font(.system(size: 10, design: .monospaced))
+                Image(systemName: source.systemImage)
+                    .font(.system(size: 19, weight: .light))
+                    .foregroundStyle(Color.primary)
+                    .frame(width: 34)
+
+                VStack(alignment: .leading, spacing: 4) {
+                    HStack(spacing: 7) {
+                        Text(title)
+                            .font(.system(size: 14, weight: .semibold))
+                            .foregroundStyle(Color.primary)
+                        if let badge {
+                            Text(badge)
+                                .font(.system(size: 9, weight: .bold))
+                                .foregroundStyle(Color.spaceBlack)
+                                .padding(.horizontal, 6)
+                                .padding(.vertical, 2)
+                                .background(Color.primary)
+                                .cornerRadius(4)
+                        }
+                    }
+                    Text(subtitle)
+                        .font(.system(size: 11))
+                        .foregroundStyle(Color.secondary)
                 }
-                .foregroundStyle(Color.secondary)
 
-                HStack(spacing: 3) {
-                    Image(systemName: "doc.text")
-                        .font(.system(size: 9))
-                    Text("\(session.noteCount)")
-                        .font(.system(size: 10, design: .monospaced))
-                }
-                .foregroundStyle(Color.secondary)
+                Spacer()
+
+                Image(systemName: selection == source ? "checkmark.circle.fill" : "circle")
+                    .font(.system(size: 18))
+                    .foregroundStyle(selection == source ? Color.primary : Color.secondary)
             }
-            .padding(.top, 4)
+            .padding(14)
+            .background(Color.cardBackground.opacity(selection == source ? 0.55 : 0.25))
+            .businessBorder(cornerRadius: 10)
         }
-        .padding(12)
-        .frame(width: 150, alignment: .leading)
-        .background(Color.cardBackground.opacity(0.3))
-        .businessBorder(cornerRadius: 8)
+        .buttonStyle(.plain)
+    }
+
+    private func sparkSubtitle(_ device: BoundDevice) -> String {
+        var parts = ["已连接"]
+        if let battery = device.batteryLevel { parts.append("电量 \(battery)%") }
+        if let free = device.freeStorageMB { parts.append("剩余 \(free) MB") }
+        return parts.joined(separator: " · ")
     }
 }
 

+ 100 - 19
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
@@ -11,11 +11,20 @@ struct AuthModalView: View {
     @State private var passwordInput: String = ""
     @State private var confirmPasswordInput: String = ""
     @State private var errorMessage: String?
+    @State private var isSubmitting = false
+    @FocusState private var focusedField: AuthField?
     
     enum AuthTab {
         case login
         case register
     }
+
+    private enum AuthField: Hashable {
+        case username
+        case identifier
+        case password
+        case confirmPassword
+    }
     
     var body: some View {
         ZStack {
@@ -42,7 +51,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)
@@ -65,21 +74,31 @@ struct AuthModalView: View {
                         inputField(
                             icon: "person",
                             placeholder: "设置昵称 (如: 星痕探索者)",
-                            text: $usernameInput
+                            text: $usernameInput,
+                            field: .username,
+                            contentType: .nickname,
+                            keyboardType: .default,
+                            submitLabel: .next
                         )
                     }
                     
                     inputField(
                         icon: "envelope",
                         placeholder: "手机号或电子邮箱",
-                        text: $identifierInput
+                        text: $identifierInput,
+                        field: .identifier,
+                        contentType: .username,
+                        submitLabel: .next
                     )
                     
                     inputField(
                         icon: "lock",
                         placeholder: "密码 (至少 6 位)",
                         text: $passwordInput,
-                        isSecure: true
+                        field: .password,
+                        isSecure: true,
+                        contentType: selectedTab == .login ? .password : .newPassword,
+                        submitLabel: selectedTab == .login ? .go : .next
                     )
                     
                     if selectedTab == .register {
@@ -87,7 +106,10 @@ struct AuthModalView: View {
                             icon: "lock.shield",
                             placeholder: "再次确认密码",
                             text: $confirmPasswordInput,
-                            isSecure: true
+                            field: .confirmPassword,
+                            isSecure: true,
+                            contentType: .newPassword,
+                            submitLabel: .go
                         )
                     }
                     
@@ -108,11 +130,12 @@ struct AuthModalView: View {
                 Button {
                     handleAuthSubmit()
                 } label: {
-                    if authManager.isProcessing {
-                        ProgressView()
-                            .tint(Color.spaceBlack)
-                    } else {
-                        Text(selectedTab == .login ? "安全登录" : "注册并自动登录")
+                    HStack(spacing: 8) {
+                        if isSubmitting {
+                            ProgressView()
+                                .tint(Color.spaceBlack)
+                        }
+                        Text(submitButtonTitle)
                             .font(.system(size: 15, weight: .semibold))
                             .foregroundStyle(Color.spaceBlack)
                     }
@@ -123,9 +146,10 @@ struct AuthModalView: View {
                 .cornerRadius(8)
                 .padding(.horizontal, 24)
                 .padding(.bottom, 32)
-                .disabled(authManager.isProcessing)
+                .disabled(isSubmitting)
             }
         }
+        .interactiveDismissDisabled(isSubmitting)
     }
     
     private func tabButton(title: String, tab: AuthTab) -> some View {
@@ -133,6 +157,7 @@ struct AuthModalView: View {
             withAnimation(.snappy) {
                 selectedTab = tab
                 errorMessage = nil
+                focusedField = nil
             }
         } label: {
             Text(title)
@@ -148,7 +173,16 @@ struct AuthModalView: View {
         }
     }
     
-    private func inputField(icon: String, placeholder: String, text: Binding<String>, isSecure: Bool = false) -> some View {
+    private func inputField(
+        icon: String,
+        placeholder: String,
+        text: Binding<String>,
+        field: AuthField,
+        isSecure: Bool = false,
+        contentType: UITextContentType?,
+        keyboardType: UIKeyboardType = .asciiCapable,
+        submitLabel: SubmitLabel
+    ) -> some View {
         HStack(spacing: 12) {
             Image(systemName: icon)
                 .font(.system(size: 14))
@@ -158,11 +192,23 @@ struct AuthModalView: View {
             if isSecure {
                 SecureField(placeholder, text: text)
                     .font(.system(size: 14))
+                    .textContentType(contentType)
+                    .keyboardType(keyboardType)
+                    .textInputAutocapitalization(.never)
+                    .autocorrectionDisabled()
+                    .focused($focusedField, equals: field)
+                    .submitLabel(submitLabel)
+                    .onSubmit { handleSubmit(from: field) }
             } else {
                 TextField(placeholder, text: text)
                     .font(.system(size: 14))
-                    .autocapitalization(.none)
-                    .disableAutocorrection(true)
+                    .textContentType(contentType)
+                    .keyboardType(keyboardType)
+                    .textInputAutocapitalization(.never)
+                    .autocorrectionDisabled()
+                    .focused($focusedField, equals: field)
+                    .submitLabel(submitLabel)
+                    .onSubmit { handleSubmit(from: field) }
             }
         }
         .padding(.horizontal, 14)
@@ -170,21 +216,48 @@ struct AuthModalView: View {
         .background(Color.cardBackground.opacity(0.3))
         .businessBorder(cornerRadius: 8)
     }
+
+    private var submitButtonTitle: String {
+        if isSubmitting {
+            return selectedTab == .login ? "正在登录…" : "正在注册…"
+        }
+        return selectedTab == .login ? "安全登录" : "注册并自动登录"
+    }
+
+    private func handleSubmit(from field: AuthField) {
+        switch field {
+        case .username:
+            focusedField = .identifier
+        case .identifier:
+            focusedField = .password
+        case .password where selectedTab == .register:
+            focusedField = .confirmPassword
+        case .password, .confirmPassword:
+            handleAuthSubmit()
+        }
+    }
     
     private func handleAuthSubmit() {
+        guard !isSubmitting else { return }
         errorMessage = nil
         
         if selectedTab == .login {
-            guard !identifierInput.trimmingCharacters(in: .whitespaces).isEmpty else {
+            let identifier = identifierInput.trimmingCharacters(in: .whitespacesAndNewlines)
+            guard !identifier.isEmpty else {
                 errorMessage = "请输入手机号或邮箱"
+                focusedField = .identifier
                 return
             }
             guard passwordInput.count >= 6 else {
                 errorMessage = "密码不能少于 6 位"
+                focusedField = .password
                 return
             }
             
-            authManager.login(identifier: identifierInput, password: passwordInput) { success, err in
+            focusedField = nil
+            isSubmitting = true
+            authManager.login(identifier: identifier, password: passwordInput) { success, err in
+                isSubmitting = false
                 if success {
                     dismiss()
                 } else {
@@ -195,22 +268,30 @@ struct AuthModalView: View {
             let name = usernameInput.trimmingCharacters(in: .whitespaces)
             guard !name.isEmpty else {
                 errorMessage = "请输入用户昵称"
+                focusedField = .username
                 return
             }
-            guard !identifierInput.trimmingCharacters(in: .whitespaces).isEmpty else {
+            let identifier = identifierInput.trimmingCharacters(in: .whitespacesAndNewlines)
+            guard !identifier.isEmpty else {
                 errorMessage = "请输入注册手机号或邮箱"
+                focusedField = .identifier
                 return
             }
             guard passwordInput.count >= 6 else {
                 errorMessage = "密码不能少于 6 位"
+                focusedField = .password
                 return
             }
             guard passwordInput == confirmPasswordInput else {
                 errorMessage = "两次输入的密码不一致"
+                focusedField = .confirmPassword
                 return
             }
             
-            authManager.register(username: name, identifier: identifierInput, password: passwordInput) { success, err in
+            focusedField = nil
+            isSubmitting = true
+            authManager.register(username: name, identifier: identifier, password: passwordInput) { success, err in
+                isSubmitting = false
                 if success {
                     dismiss()
                 } else {

+ 71 - 13
CelestiaTrace/Views/Profile/Devices/DeviceListView.swift

@@ -7,6 +7,8 @@ struct DeviceListView: View {
     
     var onAddDeviceClicked: () -> Void
     @State private var deviceToUnbind: BoundDevice?
+    @State private var deviceToRename: BoundDevice?
+    @State private var proposedDeviceName = ""
     
     private var userBoundDevices: [BoundDevice] {
         guard let userId = authManager.currentUser?.id else { return [] }
@@ -68,12 +70,45 @@ 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) { }
         } message: {
             Text("解绑后该设备将从你的账号名下移除。")
         }
+        .alert(
+            "给微光取个名字",
+            isPresented: Binding(
+                get: { deviceToRename != nil },
+                set: { if !$0 { deviceToRename = nil } }
+            )
+        ) {
+            TextField("设备名称", text: $proposedDeviceName)
+            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
+            }
+            Button("取消", role: .cancel) {
+                deviceToRename = nil
+            }
+        } message: {
+            Text("这个名称只保存在你的 App 中,不会修改设备的蓝牙广播名。")
+        }
     }
     
     // MARK: - Empty Device Card
@@ -175,25 +210,48 @@ struct DeviceListView: View {
                             .font(.system(size: 11, design: .monospaced))
                             .foregroundStyle(Color.secondary)
                     }
+
+                    if let freeStorage = device.freeStorageMB {
+                        Text("剩余: \(freeStorage) MB")
+                            .font(.system(size: 11, design: .monospaced))
+                            .foregroundStyle(Color.secondary)
+                    }
+                }
+
+                if let cloudError = device.cloudSyncError {
+                    Text("云端登记待重试:\(cloudError)")
+                        .font(.system(size: 10))
+                        .foregroundStyle(Color.recordingRed)
+                        .lineLimit(1)
                 }
             }
             
             Spacer()
             
-            // Unbind Button
-            Button {
-                deviceToUnbind = device
-            } label: {
-                Text("解绑")
-                    .font(.system(size: 12, weight: .regular))
-                    .foregroundStyle(Color.recordingRed)
-                    .padding(.horizontal, 10)
-                    .padding(.vertical, 5)
-                    .overlay(
-                        RoundedRectangle(cornerRadius: 6)
-                            .stroke(Color.recordingRed.opacity(0.3), lineWidth: 1)
-                    )
+            VStack(spacing: 8) {
+                Button {
+                    proposedDeviceName = device.name
+                    deviceToRename = device
+                } label: {
+                    Text("重命名")
+                        .font(.system(size: 12, weight: .regular))
+                        .foregroundStyle(Color.primary)
+                }
+
+                Button {
+                    deviceToUnbind = device
+                } label: {
+                    Text("解绑")
+                        .font(.system(size: 12, weight: .regular))
+                        .foregroundStyle(Color.recordingRed)
+                }
             }
+            .padding(.horizontal, 10)
+            .padding(.vertical, 7)
+            .overlay(
+                RoundedRectangle(cornerRadius: 6)
+                    .stroke(Color.primary.opacity(0.2), lineWidth: 1)
+            )
         }
         .padding(14)
         .background(Color.cardBackground.opacity(0.35))

+ 341 - 43
CelestiaTrace/Views/Profile/Devices/DeviceScanView.swift

@@ -1,4 +1,5 @@
 import SwiftUI
+import UIKit
 
 /// Modal sheet for scanning nearby BLE audio recording devices and binding them to the logged-in user.
 struct DeviceScanView: View {
@@ -7,6 +8,10 @@ struct DeviceScanView: View {
     @ObservedObject var authManager: AuthManager = .shared
     
     @State private var boundSuccessMessage: String?
+    @State private var bindingDeviceID: String?
+    @State private var bindingErrorMessage: String?
+    @State private var debugDeviceID: String?
+    @State private var copiedCompatibilityLog = false
     
     var body: some View {
         ZStack {
@@ -50,7 +55,7 @@ struct DeviceScanView: View {
                             .foregroundStyle(Color.primary)
                     }
                     
-                    Text(bleManager.isScanning ? "正在扫描附近的蓝牙录音设备..." : "扫描已停止")
+                    Text(scanStatusText)
                         .font(.system(size: 13, weight: .medium))
                         .foregroundStyle(Color.primary)
                     
@@ -78,6 +83,60 @@ struct DeviceScanView: View {
                 .background(Color.cardBackground.opacity(0.4))
                 .businessBorder(cornerRadius: 10)
                 .padding(.horizontal, 20)
+
+                if let issue = bleManager.scanUnavailableMessage {
+                    VStack(alignment: .leading, spacing: 10) {
+                        Label(issue, systemImage: "exclamationmark.triangle.fill")
+                            .font(.system(size: 12))
+                            .foregroundStyle(Color.orange)
+
+                        if bleManager.state == .unauthorized {
+                            Button("打开系统设置") {
+                                guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
+                                UIApplication.shared.open(url)
+                            }
+                            .font(.system(size: 12, weight: .medium))
+                        }
+                    }
+                    .frame(maxWidth: .infinity, alignment: .leading)
+                    .padding(14)
+                    .background(Color.orange.opacity(0.08))
+                    .businessBorder(cornerRadius: 10)
+                    .padding(.horizontal, 20)
+                }
+
+                formalBindingSection
+
+                if let debugDeviceID {
+                    communicationDebugPanel(deviceID: debugDeviceID)
+                } else if !bleManager.observedAdvertisements.isEmpty {
+                    VStack(alignment: .leading, spacing: 10) {
+                        HStack {
+                            Text("BLE 广播调试")
+                                .font(.system(size: 13, weight: .semibold))
+                                .foregroundStyle(Color.primary)
+
+                            Spacer()
+
+                            Text("\(bleManager.observedAdvertisements.count) 个设备 · 最多显示 50 个")
+                                .font(.system(size: 11, design: .monospaced))
+                                .foregroundStyle(Color.secondary)
+                        }
+
+                        ScrollView {
+                            LazyVStack(spacing: 8) {
+                                ForEach(bleManager.observedAdvertisements) { advertisement in
+                                    observedAdvertisementRow(advertisement)
+                                }
+                            }
+                        }
+                        .frame(height: 300)
+                    }
+                    .padding(14)
+                    .background(Color.cardBackground.opacity(0.4))
+                    .businessBorder(cornerRadius: 10)
+                    .padding(.horizontal, 20)
+                }
                 
                 // Success Toast Banner
                 if let successMsg = boundSuccessMessage {
@@ -95,39 +154,249 @@ struct DeviceScanView: View {
                     .transition(.move(edge: .top).combined(with: .opacity))
                 }
                 
-                // Discovered Devices List
-                if bleManager.discoveredDevices.isEmpty {
-                    VStack(spacing: 12) {
-                        Spacer()
-                        Image(systemName: "antenna.radiowaves.left.and.right")
-                            .font(.system(size: 36, weight: .ultraLight))
-                            .foregroundStyle(Color.secondary.opacity(0.4))
-                        
-                        Text("未搜索到可用的蓝牙录音设备")
-                            .font(.system(size: 13))
-                            .foregroundStyle(Color.secondary)
-                        Spacer()
+            }
+        }
+        .onAppear {
+            bleManager.startScanning()
+        }
+        .onDisappear {
+            bleManager.stopScanning()
+        }
+        .alert("无法绑定微光", isPresented: Binding(
+            get: { bindingErrorMessage != nil },
+            set: { if !$0 { bindingErrorMessage = nil } }
+        )) {
+            Button("知道了", role: .cancel) { bindingErrorMessage = nil }
+        } message: {
+            Text(bindingErrorMessage ?? "请稍后重试。")
+        }
+    }
+
+    @ViewBuilder
+    private var formalBindingSection: some View {
+        VStack(alignment: .leading, spacing: 10) {
+            Text("正式绑定设备(仅显示 YLF20)")
+                .font(.system(size: 13, weight: .semibold))
+                .foregroundStyle(Color.primary)
+
+            if bleManager.discoveredDevices.isEmpty {
+                Text(
+                    bleManager.observedPeripheralCount > 0
+                        ? "暂未发现名称以 YLF20 开头的设备。"
+                        : "正在等待 YLF20 设备广播…"
+                )
+                .font(.system(size: 11))
+                .foregroundStyle(Color.secondary)
+                .frame(maxWidth: .infinity, minHeight: 54, alignment: .center)
+            } else {
+                ScrollView {
+                    LazyVStack(spacing: 8) {
+                        ForEach(bleManager.discoveredDevices) { device in
+                            discoveredDeviceRow(device)
+                        }
                     }
-                } else {
-                    ScrollView {
-                        VStack(spacing: 12) {
-                            ForEach(bleManager.discoveredDevices) { device in
-                                discoveredDeviceRow(device)
+                }
+                .frame(maxHeight: 150)
+            }
+        }
+        .padding(14)
+        .background(Color.cardBackground.opacity(0.4))
+        .businessBorder(cornerRadius: 10)
+        .padding(.horizontal, 20)
+    }
+
+    private func communicationDebugPanel(deviceID: String) -> some View {
+        let logs = bleManager.communicationLogs(for: deviceID)
+
+        return VStack(alignment: .leading, spacing: 10) {
+            HStack {
+                Text("绑定与协议通信调试")
+                    .font(.system(size: 13, weight: .semibold))
+                    .foregroundStyle(Color.primary)
+
+                Spacer()
+
+                Button(copiedCompatibilityLog ? "已复制" : "复制兼容日志") {
+                    copyCompatibilityLogs(deviceID: deviceID)
+                }
+                .font(.system(size: 11, weight: .medium))
+                .disabled(compatibilityLogs(deviceID: deviceID).isEmpty)
+
+                Button("重新测试") {
+                    bleManager.runProtocolDiagnostics(deviceID: deviceID)
+                }
+                .font(.system(size: 11, weight: .medium))
+                .disabled(bindingDeviceID != nil)
+            }
+
+            ScrollViewReader { proxy in
+                ScrollView {
+                    LazyVStack(alignment: .leading, spacing: 5) {
+                        ForEach(logs) { entry in
+                            HStack(alignment: .top, spacing: 7) {
+                                Text(entry.timestamp.formatted(date: .omitted, time: .standard))
+                                    .foregroundStyle(Color.secondary)
+
+                                Text(entry.kind)
+                                    .foregroundStyle(logColor(entry.kind))
+                                    .frame(width: 38, alignment: .leading)
+
+                                Text(entry.message)
+                                    .foregroundStyle(Color.primary)
+                                    .textSelection(.enabled)
                             }
+                            .font(.system(size: 9, design: .monospaced))
+                            .id(entry.id)
+                        }
+                    }
+                }
+                .onChange(of: logs.count) {
+                    if let last = logs.last {
+                        withAnimation {
+                            proxy.scrollTo(last.id, anchor: .bottom)
                         }
-                        .padding(.horizontal, 20)
-                        .padding(.bottom, 20)
                     }
                 }
             }
+            .frame(height: 300)
         }
-        .onAppear {
-            bleManager.startScanning()
+        .padding(14)
+        .background(Color.cardBackground.opacity(0.4))
+        .businessBorder(cornerRadius: 10)
+        .padding(.horizontal, 20)
+    }
+
+    private func compatibilityLogs(deviceID: String) -> [BLECommunicationLogEntry] {
+        let logs = bleManager.communicationLogs(for: deviceID)
+        guard let startIndex = logs.lastIndex(where: {
+            $0.message.contains("开始只读兼容探测")
+        }) else {
+            return []
         }
-        .onDisappear {
-            bleManager.stopScanning()
+        return Array(logs[startIndex...])
+    }
+
+    private func copyCompatibilityLogs(deviceID: String) {
+        let logs = compatibilityLogs(deviceID: deviceID)
+        guard !logs.isEmpty else { return }
+
+        let formatter = DateFormatter()
+        formatter.locale = Locale(identifier: "en_US_POSIX")
+        formatter.dateFormat = "HH:mm:ss.SSS"
+
+        let lines = logs.map {
+            "\(formatter.string(from: $0.timestamp)) [\($0.kind)] \($0.message)"
+        }
+        let header = [
+            "YLF20 BLE 兼容探测日志",
+            "设备标识:\(deviceID)",
+            "导出时间:\(Date().formatted(date: .numeric, time: .standard))",
+            String(repeating: "-", count: 36)
+        ]
+        UIPasteboard.general.string = (header + lines).joined(separator: "\n")
+        copiedCompatibilityLog = true
+        DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
+            copiedCompatibilityLog = false
         }
     }
+
+    private func logColor(_ kind: String) -> Color {
+        switch kind {
+        case "OK": return .green
+        case "ERROR": return .red
+        case "TX": return .blue
+        case "RX": return .orange
+        default: return .secondary
+        }
+    }
+
+    private var scanStatusText: String {
+        if bleManager.isScanning {
+            return "正在扫描附近的蓝牙录音设备..."
+        }
+        switch bleManager.state {
+        case .unknown, .resetting:
+            return "正在初始化系统蓝牙..."
+        case .poweredOn:
+            return "扫描已停止"
+        case .poweredOff:
+            return "系统蓝牙未开启"
+        case .unauthorized:
+            return "没有蓝牙访问权限"
+        case .unsupported:
+            return "当前设备不支持 BLE 扫描"
+        @unknown default:
+            return "蓝牙状态不可用"
+        }
+    }
+
+    private func observedAdvertisementRow(_ advertisement: ObservedBLEAdvertisement) -> some View {
+        VStack(alignment: .leading, spacing: 3) {
+            HStack(spacing: 8) {
+                Text(advertisement.name)
+                    .font(.system(size: 12, weight: .medium))
+                    .foregroundStyle(Color.primary)
+                    .lineLimit(1)
+
+                if advertisement.matchesSpark {
+                    Text("匹配 Spark")
+                        .font(.system(size: 9, weight: .semibold))
+                        .foregroundStyle(Color.green)
+                        .padding(.horizontal, 6)
+                        .padding(.vertical, 2)
+                        .background(Color.green.opacity(0.12))
+                        .clipShape(Capsule())
+                }
+
+                if !advertisement.isPresent {
+                    debugBadge("刚消失", color: .red)
+                } else if advertisement.reappearanceCount > 0 {
+                    debugBadge("重新出现 ×\(advertisement.reappearanceCount)", color: .orange)
+                } else if Date().timeIntervalSince(advertisement.firstSeenAt) < 8 {
+                    debugBadge("新出现", color: .blue)
+                }
+
+                if advertisement.isStrongSignal {
+                    debugBadge("强信号", color: .green)
+                }
+
+                Spacer()
+
+                Text("\(advertisement.rssi) dBm")
+                    .font(.system(size: 10, design: .monospaced))
+                    .foregroundStyle(Color.secondary)
+            }
+
+            Text("设备:\(advertisement.id) · \(advertisement.lastSeenAt.formatted(date: .omitted, time: .standard))")
+                .font(.system(size: 9, design: .monospaced))
+                .foregroundStyle(Color.secondary.opacity(0.8))
+                .textSelection(.enabled)
+
+            Text(
+                advertisement.serviceUUIDs.isEmpty
+                    ? "Service UUID:广播中未提供"
+                    : "Service UUID:\(advertisement.serviceUUIDs.joined(separator: ", "))"
+            )
+            .font(.system(size: 9, design: .monospaced))
+            .foregroundStyle(Color.secondary.opacity(0.8))
+            .textSelection(.enabled)
+
+        }
+        .padding(7)
+        .background(Color.spaceBlack.opacity(0.35))
+        .clipShape(RoundedRectangle(cornerRadius: 7))
+        .opacity(advertisement.isPresent ? 1 : 0.65)
+    }
+
+    private func debugBadge(_ text: String, color: Color) -> some View {
+        Text(text)
+            .font(.system(size: 9, weight: .semibold))
+            .foregroundStyle(color)
+            .padding(.horizontal, 6)
+            .padding(.vertical, 2)
+            .background(color.opacity(0.12))
+            .clipShape(Capsule())
+    }
     
     private func discoveredDeviceRow(_ device: DiscoveredBLEDevice) -> some View {
         let isAlreadyBound = bleManager.boundDevices.contains(where: { $0.id == device.id })
@@ -144,7 +413,7 @@ struct DeviceScanView: View {
             }
             
             VStack(alignment: .leading, spacing: 4) {
-                Text(device.name)
+                Text("微光(Spark)")
                     .font(.system(size: 14, weight: .medium))
                     .foregroundStyle(Color.primary)
                 
@@ -161,35 +430,64 @@ struct DeviceScanView: View {
                         .font(.system(size: 11, design: .monospaced))
                         .foregroundStyle(Color.secondary)
                 }
+
+                Text("广播名称: \(device.name)")
+                    .font(.system(size: 10))
+                    .foregroundStyle(Color.secondary.opacity(0.8))
             }
             
             Spacer()
             
             if isAlreadyBound {
-                Text("已绑定")
-                    .font(.system(size: 12, weight: .regular))
-                    .foregroundStyle(Color.secondary)
-                    .padding(.horizontal, 12)
-                    .padding(.vertical, 6)
+                Button("测试通信") {
+                    debugDeviceID = device.id
+                    bleManager.runProtocolDiagnostics(deviceID: device.id)
+                }
+                .font(.system(size: 12, weight: .medium))
+                .disabled(bindingDeviceID != nil)
             } else {
                 Button {
                     guard let userId = authManager.currentUser?.id else { return }
-                    let bound = bleManager.bindDevice(device, userId: userId)
-                    withAnimation {
-                        boundSuccessMessage = "已成功绑定设备「\(bound.name)」"
-                    }
-                    DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) {
-                        dismiss()
+                    debugDeviceID = device.id
+                    boundSuccessMessage = nil
+                    bindingErrorMessage = nil
+                    bindingDeviceID = device.id
+                    bleManager.connectAndBind(device, userId: userId) { result in
+                        bindingDeviceID = nil
+                        switch result {
+                        case .success(let bound):
+                            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 = "设备已在本机绑定,云端登记将在稍后重试"
+                                }
+                            }
+                        case .failure(let error):
+                            bindingErrorMessage = error.localizedDescription
+                        }
                     }
                 } label: {
-                    Text("绑定设备")
-                        .font(.system(size: 13, weight: .semibold))
-                        .foregroundStyle(Color.spaceBlack)
-                        .padding(.horizontal, 14)
-                        .padding(.vertical, 7)
-                        .background(Color.primary)
-                        .cornerRadius(6)
+                    Group {
+                        if bindingDeviceID == device.id {
+                            ProgressView()
+                                .tint(Color.spaceBlack)
+                        } else {
+                            Text("绑定设备")
+                                .font(.system(size: 13, weight: .semibold))
+                        }
+                    }
+                    .foregroundStyle(Color.spaceBlack)
+                    .frame(minWidth: 72)
+                    .padding(.horizontal, 14)
+                    .padding(.vertical, 7)
+                    .background(Color.primary)
+                    .cornerRadius(6)
                 }
+                .disabled(bindingDeviceID != nil)
             }
         }
         .padding(14)

+ 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

+ 334 - 54
CelestiaTrace/Views/Recording/ActiveRecordingView.swift

@@ -11,24 +11,51 @@ struct ActiveRecordingView: View {
 
     let session: CelestiaSession
     var initialDuration: TimeInterval = 0
+    let recordingSource: RecordingSourceChoice
     var onFinishRecording: (() -> Void)? = nil
 
-    @State private var recordingVM = RecordingViewModel()
+    @State private var recordingVM: RecordingViewModel
     @State private var showNoteSheet = false
     @State private var showCamera = false
     @State private var noteText = ""
     @State private var latestEvent: CelestiaTimelineEvent?
     @State private var latestEventVisible = false
+    @State private var isEndingRecording = false
+    @State private var stopErrorMessage: String?
+    @State private var recordName: String
+    @State private var showRecordNameEditor = false
+    @State private var hasStartedLiveActivity = false
+    @FocusState private var isRecordNameFocused: Bool
+
+    init(
+        session: CelestiaSession,
+        initialDuration: TimeInterval = 0,
+        recordingSource: RecordingSourceChoice = .iPhone,
+        onFinishRecording: (() -> Void)? = nil
+    ) {
+        self.session = session
+        self.initialDuration = initialDuration
+        self.recordingSource = recordingSource
+        self.onFinishRecording = onFinishRecording
+        _recordingVM = State(initialValue: RecordingViewModel(source: recordingSource))
+        _recordName = State(initialValue: session.title)
+    }
 
     var body: some View {
-        ZStack {
+        ZStack(alignment: .top) {
             // Dynamic clean background
             Color.spaceBlack
                 .ignoresSafeArea()
+                .contentShape(Rectangle())
+                .onTapGesture(count: 2) {
+                    endRecording()
+                }
 
             VStack(spacing: 0) {
-                Spacer()
-                    .frame(height: 20)
+                recordNameSection
+                    .padding(.horizontal, 24)
+                    .padding(.top, 16)
+                    .padding(.bottom, 14)
 
                 // Top Header Block (Timecode + Indicator)
                 VStack(spacing: 16) {
@@ -54,15 +81,21 @@ struct ActiveRecordingView: View {
                     .padding(.horizontal, 48)
                     .padding(.bottom, 32)
 
-                // End Recording
-                endRecordingButton
+                // Recording Controls
+                recordingControlButtons
                     .padding(.bottom, 24)
             }
+            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
+            .ignoresSafeArea(.keyboard, edges: .bottom)
         }
+        .ignoresSafeArea(.keyboard, edges: .bottom)
         .toolbar(.hidden, for: .tabBar)
         .toolbar(.hidden, for: .navigationBar)
         .navigationBarHidden(true)
         .navigationBarBackButtonHidden(true)
+        .sheet(isPresented: $showRecordNameEditor) {
+            recordNameEditorSheet
+        }
         .sheet(isPresented: $showNoteSheet) {
             noteInputSheet
         }
@@ -72,12 +105,146 @@ struct ActiveRecordingView: View {
             }
         }
         .onAppear {
+            UIApplication.shared.isIdleTimerDisabled = true
             recordingVM.startRecording(initialDuration: initialDuration)
         }
+        .onChange(of: recordingVM.isRecording) { _, isRecording in
+            guard isRecording, !hasStartedLiveActivity else { return }
+            hasStartedLiveActivity = true
+            RecordingLiveActivityManager.shared.start(
+                sessionID: session.id,
+                title: session.title,
+                sourceName: recordingVM.sourceDisplayName,
+                elapsedSeconds: recordingVM.elapsedTime
+            )
+        }
+        .onChange(of: recordingVM.isPaused) { _, isPaused in
+            guard hasStartedLiveActivity else { return }
+            RecordingLiveActivityManager.shared.update(
+                elapsedSeconds: recordingVM.elapsedTime,
+                isPaused: isPaused
+            )
+        }
+        .onOpenURL { url in
+            handleRecordingURL(url)
+        }
         .onDisappear {
+            UIApplication.shared.isIdleTimerDisabled = false
+            commitRecordName()
             if recordingVM.isRecording {
                 recordingVM.stopRecording()
             }
+            if hasStartedLiveActivity {
+                RecordingLiveActivityManager.shared.end(elapsedSeconds: recordingVM.elapsedTime)
+                hasStartedLiveActivity = false
+            }
+        }
+        .alert("无法结束设备录音", isPresented: Binding(
+            get: { stopErrorMessage != nil },
+            set: { if !$0 { stopErrorMessage = nil } }
+        )) {
+            Button("重试") { endRecording() }
+            Button("继续录音", role: .cancel) { stopErrorMessage = nil }
+        } message: {
+            Text(stopErrorMessage ?? "请确认微光仍在附近并保持连接。")
+        }
+    }
+
+    // MARK: - Record Name
+
+    private var recordNameSection: some View {
+        Button {
+            recordName = session.title
+            showRecordNameEditor = true
+        } label: {
+            VStack(alignment: .leading, spacing: 8) {
+                HStack(spacing: 6) {
+                    Text("记录名称")
+                        .font(.system(size: 10, weight: .semibold, design: .monospaced))
+                        .tracking(1.4)
+
+                    Spacer()
+
+                    Image(systemName: "pencil")
+                        .font(.system(size: 11, weight: .semibold))
+                }
+                .foregroundStyle(Color.secondary)
+
+                Text(session.title)
+                    .font(.system(size: 19, weight: .semibold))
+                    .foregroundStyle(Color.primary)
+                    .lineLimit(1)
+                    .frame(maxWidth: .infinity, alignment: .leading)
+
+                Rectangle()
+                    .fill(Color.primary.opacity(0.16))
+                    .frame(height: 1)
+            }
+            .padding(.horizontal, 14)
+            .padding(.vertical, 12)
+            .background(Color.cardBackground.opacity(0.45))
+            .businessBorder(cornerRadius: 8)
+            .contentShape(Rectangle())
+        }
+        .buttonStyle(.plain)
+    }
+
+    private var recordNameEditorSheet: some View {
+        NavigationStack {
+            ZStack {
+                Color.spaceBlack.ignoresSafeArea()
+
+                VStack(alignment: .leading, spacing: 10) {
+                    Text("记录名称")
+                        .font(.system(size: 10, weight: .semibold, design: .monospaced))
+                        .foregroundStyle(Color.secondary)
+                        .tracking(1.4)
+
+                    TextField("输入记录名称", text: $recordName)
+                        .focused($isRecordNameFocused)
+                        .font(.system(size: 18, weight: .semibold))
+                        .foregroundStyle(Color.primary)
+                        .textInputAutocapitalization(.never)
+                        .autocorrectionDisabled()
+                        .submitLabel(.done)
+                        .padding(.horizontal, 14)
+                        .padding(.vertical, 12)
+                        .background(Color.cardBackground.opacity(0.5))
+                        .businessBorder(cornerRadius: 8)
+                        .onSubmit {
+                            saveRecordNameAndCloseEditor()
+                        }
+
+                    Spacer()
+                }
+                .padding(20)
+            }
+            .navigationTitle("修改记录名称")
+            .navigationBarTitleDisplayMode(.inline)
+            .toolbar {
+                ToolbarItem(placement: .cancellationAction) {
+                    Button("取消") {
+                        recordName = session.title
+                        showRecordNameEditor = false
+                    }
+                    .foregroundStyle(Color.secondary)
+                }
+
+                ToolbarItem(placement: .confirmationAction) {
+                    Button("保存") {
+                        saveRecordNameAndCloseEditor()
+                    }
+                    .disabled(recordName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
+                }
+            }
+        }
+        .presentationDetents([.height(190)])
+        .presentationDragIndicator(.visible)
+        .presentationBackground(Color.spaceBlack)
+        .onAppear {
+            DispatchQueue.main.async {
+                isRecordNameFocused = true
+            }
         }
     }
 
@@ -95,30 +262,42 @@ struct ActiveRecordingView: View {
     // MARK: - Recording Indicator
 
     private var recordingIndicator: some View {
-        HStack(spacing: 8) {
-            TimelineView(.periodic(from: .now, by: 1.0)) { context in
-                let isEven = Int(context.date.timeIntervalSince1970) % 2 == 0
-                Circle()
-                    .fill(recordingVM.isPaused ? Color.secondary : Color.recordingRed)
-                    .frame(width: 8, height: 8)
-                    .opacity(recordingVM.isPaused ? 0.6 : (isEven ? 1.0 : 0.3))
-                    .animation(.easeInOut(duration: 0.5), value: isEven)
+        VStack(spacing: 8) {
+            HStack(spacing: 8) {
+                TimelineView(.periodic(from: .now, by: 1.0)) { context in
+                    let isEven = Int(context.date.timeIntervalSince1970) % 2 == 0
+                    Circle()
+                        .fill(recordingVM.isRecording && !recordingVM.isPaused ? Color.recordingRed : Color.secondary)
+                        .frame(width: 8, height: 8)
+                        .opacity(recordingVM.isRecording && !recordingVM.isPaused ? (isEven ? 1.0 : 0.3) : 0.6)
+                        .animation(.easeInOut(duration: 0.5), value: isEven)
+                }
+                .frame(width: 10, height: 10)
+
+                Text(recordingVM.isPaused ? "已暂停" : recordingVM.statusMessage)
+                    .font(.system(size: 11, weight: .semibold, design: .monospaced))
+                    .foregroundStyle(recordingVM.isRecording && !recordingVM.isPaused ? Color.recordingRed : Color.secondary)
+                    .tracking(1.2)
             }
-            .frame(width: 10, height: 10)
 
-            Text(recordingVM.isPaused ? "已暂停" : "正在录音")
-                .font(.system(size: 11, weight: .semibold, design: .monospaced))
-                .foregroundStyle(recordingVM.isPaused ? Color.secondary : Color.recordingRed)
-                .tracking(2)
+            HStack(spacing: 6) {
+                Image(systemName: recordingSource.systemImage)
+                    .font(.system(size: 11, weight: .medium))
+                Text("设备:\(recordingVM.sourceDisplayName)")
+                    .font(.system(size: 11, weight: .medium))
+            }
+            .foregroundStyle(Color.primary.opacity(0.85))
 
-            // Session title
-            Text("· \(session.title)")
-                .font(.system(size: 11, weight: .regular))
-                .foregroundStyle(Color.secondary.opacity(0.7))
-                .lineLimit(1)
+            if let error = recordingVM.errorMessage {
+                Text(error)
+                    .font(.system(size: 10))
+                    .foregroundStyle(Color.recordingRed)
+                    .multilineTextAlignment(.center)
+                    .lineLimit(2)
+            }
         }
         .padding(.horizontal, 10)
-        .padding(.vertical, 4)
+        .padding(.vertical, 8)
         .businessBorder(cornerRadius: 6)
     }
 
@@ -131,11 +310,6 @@ struct ActiveRecordingView: View {
 
             // Graphical VU Meter + Numeric Display
             HStack(spacing: 10) {
-                Text("音量电平")
-                    .font(.system(size: 9, weight: .semibold, design: .monospaced))
-                    .foregroundStyle(Color.secondary.opacity(0.6))
-                    .tracking(1.5)
-
                 AmplitudeLevelMeterView(amplitude: recordingVM.currentAmplitude)
 
                 Spacer(minLength: 0)
@@ -242,32 +416,64 @@ struct ActiveRecordingView: View {
         .buttonStyle(.plain)
     }
 
-    // MARK: - End Recording Button
+    // MARK: - Recording Controls
 
-    private var endRecordingButton: some View {
-        Button {
-            endRecording()
-        } label: {
-            VStack(spacing: 6) {
-                Text("双击结束录制")
-                    .font(.system(size: 13, weight: .medium))
-                    .foregroundStyle(Color.recordingRed)
-                    .padding(.horizontal, 24)
-                    .padding(.vertical, 10)
-                    .businessBorder(cornerRadius: 8)
-                
-                Text("双击即可完成本次现场录音")
-                    .font(.system(size: 8, weight: .medium, design: .monospaced))
-                    .foregroundStyle(Color.secondary.opacity(0.5))
-                    .tracking(1.5)
+    private var recordingControlButtons: some View {
+        HStack(spacing: 52) {
+            recordingControlButton(
+                icon: recordingVM.isPaused ? "play.fill" : "pause.fill",
+                label: recordingVM.isPaused ? "继续记录" : "暂停",
+                tint: .primary
+            ) {
+                toggleRecordingPause()
             }
-        }
-        .highPriorityGesture(
-            TapGesture(count: 2).onEnded {
+
+            recordingControlButton(
+                icon: "stop.fill",
+                label: isEndingRecording ? "正在结束" : "结束记录",
+                tint: .recordingRed,
+                isProminent: true
+            ) {
                 endRecording()
             }
-        )
+        }
+    }
+
+    private func recordingControlButton(
+        icon: String,
+        label: String,
+        tint: Color,
+        isProminent: Bool = false,
+        action: @escaping () -> Void
+    ) -> some View {
+        Button(action: action) {
+            VStack(spacing: 9) {
+                ZStack {
+                    Circle()
+                        .fill(isProminent ? tint : Color.primary.opacity(0.02))
+                        .frame(width: 64, height: 64)
+
+                    if !isProminent {
+                        Circle()
+                            .stroke(Color.primary.opacity(0.18), lineWidth: 1)
+                            .frame(width: 64, height: 64)
+                    }
+
+                    Image(systemName: icon)
+                        .font(.system(size: 21, weight: .semibold))
+                        .foregroundStyle(isProminent ? Color.white : tint)
+                }
+
+                Text(label)
+                    .font(.system(size: 11, weight: .medium))
+                    .foregroundStyle(tint)
+            }
+            .frame(width: 88)
+        }
         .buttonStyle(.plain)
+        .disabled(isEndingRecording)
+        .opacity(isEndingRecording && !isProminent ? 0.4 : 1)
+        .accessibilityLabel(label)
     }
 
     // MARK: - Note Input Sheet
@@ -355,13 +561,39 @@ struct ActiveRecordingView: View {
         showNoteSheet = false
     }
 
+    private func toggleRecordingPause() {
+        guard !isEndingRecording else { return }
+        HapticManager.trigger(.tapFeedback)
+
+        if recordingVM.isPaused {
+            recordingVM.resumeRecording()
+        } else {
+            recordingVM.pauseRecording()
+        }
+    }
+
     private func endRecording() {
-        recordingVM.stopRecording()
-        
-        let newRecordedURL = recordingVM.outputFileURL
+        guard !isEndingRecording else { return }
+        commitRecordName()
+        isEndingRecording = true
+
+        recordingVM.stopRecording { result in
+            switch result {
+            case .success(let confirmedURL):
+                finalizeRecording(newRecordedURL: confirmedURL ?? recordingVM.outputFileURL)
+            case .failure(let error):
+                isEndingRecording = false
+                stopErrorMessage = error.localizedDescription
+            }
+        }
+    }
+
+    private func finalizeRecording(newRecordedURL: URL?) {
         let existingPath = session.localAudioPath
         let existingURL = AudioPathHelper.resolveURL(for: existingPath)
         let totalRecordedSeconds = recordingVM.elapsedTime
+        RecordingLiveActivityManager.shared.end(elapsedSeconds: totalRecordedSeconds)
+        hasStartedLiveActivity = false
         
         if let newRecordedURL = newRecordedURL {
             Task { @MainActor in
@@ -371,6 +603,7 @@ struct ActiveRecordingView: View {
                 try? modelContext.save()
                 
                 HapticManager.trigger(.recordStop)
+                isEndingRecording = false
                 dismiss()
                 onFinishRecording?()
             }
@@ -383,6 +616,7 @@ struct ActiveRecordingView: View {
                 try? modelContext.save()
             }
             HapticManager.trigger(.recordStop)
+            isEndingRecording = false
             dismiss()
             onFinishRecording?()
         }
@@ -412,6 +646,52 @@ struct ActiveRecordingView: View {
         default: return "事件"
         }
     }
+
+    private func commitRecordName() {
+        let trimmedName = recordName.trimmingCharacters(in: .whitespacesAndNewlines)
+        if trimmedName.isEmpty {
+            recordName = session.title
+            return
+        }
+
+        guard session.title != trimmedName else {
+            if recordName != trimmedName {
+                recordName = trimmedName
+            }
+            return
+        }
+
+        recordName = trimmedName
+        session.title = trimmedName
+        session.isSynced = false
+        session.syncState = .pending
+        try? modelContext.save()
+    }
+
+    private func saveRecordNameAndCloseEditor() {
+        commitRecordName()
+        guard !recordName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
+        isRecordNameFocused = false
+        showRecordNameEditor = false
+    }
+
+    private func handleRecordingURL(_ url: URL) {
+        guard url.scheme == "celestiatrace",
+              url.host == "recording" else { return }
+
+        let components = url.pathComponents.filter { $0 != "/" }
+        guard let sessionID = components.first,
+              sessionID.caseInsensitiveCompare(session.id.uuidString) == .orderedSame else { return }
+
+        switch components.dropFirst().first {
+        case "photo":
+            capturePhoto()
+        case "note":
+            showNoteSheet = true
+        default:
+            break
+        }
+    }
 }
 
 // MARK: - Amplitude Level Meter View

+ 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)
-        }
     }
 }

+ 54 - 14
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))
                 }
@@ -219,10 +219,10 @@ struct SettingsView: View {
                 sectionHeader(icon: "info.circle", title: "关于")
 
                 VStack(spacing: 10) {
-                    infoRow(label: "应用名称", value: "现场记录 (Celestia Trace)")
-                    infoRow(label: "版本信息", value: "0.1.0 (本地持久化单机版)")
+                    infoRow(label: "应用名称", value: "星痕 CelestiaTrace")
+                    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

+ 1 - 1
CelestiaTrace/Views/Splash/SplashView.swift

@@ -52,7 +52,7 @@ struct SplashView: View {
             await playEntrance()
         }
         .accessibilityElement(children: .ignore)
-        .accessibilityLabel("星痕。倾听,然后深刻。纯粹星辰出品。")
+        .accessibilityLabel("星痕 CelestiaTrace。倾听,然后深刻。纯粹星辰出品。")
     }
 
     @MainActor

+ 9 - 0
CelestiaTraceLiveActivity/CelestiaTraceLiveActivityBundle.swift

@@ -0,0 +1,9 @@
+import SwiftUI
+import WidgetKit
+
+@main
+struct CelestiaTraceLiveActivityBundle: WidgetBundle {
+    var body: some Widget {
+        RecordingLiveActivityWidget()
+    }
+}

+ 29 - 0
CelestiaTraceLiveActivity/Info.plist

@@ -0,0 +1,29 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+	<key>CFBundleDevelopmentRegion</key>
+	<string>$(DEVELOPMENT_LANGUAGE)</string>
+	<key>CFBundleDisplayName</key>
+	<string>星痕实时记录</string>
+	<key>CFBundleExecutable</key>
+	<string>$(EXECUTABLE_NAME)</string>
+	<key>CFBundleIdentifier</key>
+	<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
+	<key>CFBundleInfoDictionaryVersion</key>
+	<string>6.0</string>
+	<key>CFBundleName</key>
+	<string>$(PRODUCT_NAME)</string>
+	<key>CFBundlePackageType</key>
+	<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
+	<key>CFBundleShortVersionString</key>
+	<string>$(MARKETING_VERSION)</string>
+	<key>CFBundleVersion</key>
+	<string>$(CURRENT_PROJECT_VERSION)</string>
+	<key>NSExtension</key>
+	<dict>
+		<key>NSExtensionPointIdentifier</key>
+		<string>com.apple.widgetkit-extension</string>
+	</dict>
+</dict>
+</plist>

+ 157 - 0
CelestiaTraceLiveActivity/RecordingLiveActivityWidget.swift

@@ -0,0 +1,157 @@
+import ActivityKit
+import SwiftUI
+import WidgetKit
+
+struct RecordingLiveActivityWidget: Widget {
+    var body: some WidgetConfiguration {
+        ActivityConfiguration(for: RecordingActivityAttributes.self) { context in
+            lockScreenView(context)
+                .activityBackgroundTint(Color.black.opacity(0.92))
+                .activitySystemActionForegroundColor(.white)
+                .widgetURL(recordingURL(context.attributes))
+        } dynamicIsland: { context in
+            DynamicIsland {
+                DynamicIslandExpandedRegion(.leading) {
+                    Label("正在记录", systemImage: "record.circle.fill")
+                        .font(.caption.weight(.semibold))
+                        .foregroundStyle(.red)
+                }
+
+                DynamicIslandExpandedRegion(.trailing) {
+                    elapsedTime(context.state)
+                        .font(.caption.monospacedDigit())
+                        .foregroundStyle(.white)
+                }
+
+                DynamicIslandExpandedRegion(.bottom) {
+                    HStack {
+                        Text(context.attributes.title)
+                            .font(.caption)
+                            .lineLimit(1)
+
+                        Spacer()
+
+                        quickAction(
+                            title: "拍照",
+                            systemImage: "camera.fill",
+                            url: actionURL("photo", context.attributes)
+                        )
+                        quickAction(
+                            title: "文字",
+                            systemImage: "note.text",
+                            url: actionURL("note", context.attributes)
+                        )
+                    }
+                    .foregroundStyle(.white)
+                }
+            } compactLeading: {
+                Image(systemName: "record.circle.fill")
+                    .foregroundStyle(.red)
+            } compactTrailing: {
+                elapsedTime(context.state)
+                    .font(.caption2.monospacedDigit())
+                    .frame(maxWidth: 48)
+            } minimal: {
+                Image(systemName: "record.circle.fill")
+                    .foregroundStyle(.red)
+            }
+            .widgetURL(recordingURL(context.attributes))
+            .keylineTint(.red)
+        }
+    }
+
+    private func lockScreenView(
+        _ context: ActivityViewContext<RecordingActivityAttributes>
+    ) -> some View {
+        VStack(alignment: .leading, spacing: 12) {
+            HStack(spacing: 10) {
+                Image(systemName: context.state.isPaused ? "pause.circle.fill" : "record.circle.fill")
+                    .font(.title2)
+                    .foregroundStyle(context.state.isPaused ? .orange : .red)
+
+                VStack(alignment: .leading, spacing: 2) {
+                    Text(context.attributes.title)
+                        .font(.headline)
+                        .foregroundStyle(.white)
+                        .lineLimit(1)
+                    Text(context.state.isPaused ? "记录已暂停" : "正在记录 · \(context.attributes.sourceName)")
+                        .font(.caption)
+                        .foregroundStyle(.white.opacity(0.65))
+                        .lineLimit(1)
+                }
+
+                Spacer()
+
+                elapsedTime(context.state)
+                    .font(.title3.monospacedDigit().weight(.medium))
+                    .foregroundStyle(.white)
+            }
+
+            HStack(spacing: 10) {
+                quickAction(
+                    title: "拍照记录",
+                    systemImage: "camera.fill",
+                    url: actionURL("photo", context.attributes)
+                )
+                quickAction(
+                    title: "文字记录",
+                    systemImage: "note.text",
+                    url: actionURL("note", context.attributes)
+                )
+            }
+        }
+        .padding(16)
+    }
+
+    @ViewBuilder
+    private func elapsedTime(
+        _ state: RecordingActivityAttributes.ContentState
+    ) -> some View {
+        if state.isPaused {
+            Text(formattedDuration(state.elapsedSeconds))
+        } else {
+            Text(
+                timerInterval: state.timerStartDate...Date.distantFuture,
+                pauseTime: nil,
+                countsDown: false,
+                showsHours: true
+            )
+        }
+    }
+
+    private func quickAction(
+        title: String,
+        systemImage: String,
+        url: URL
+    ) -> some View {
+        Link(destination: url) {
+            Label(title, systemImage: systemImage)
+                .font(.caption.weight(.semibold))
+                .foregroundStyle(.white)
+                .frame(maxWidth: .infinity)
+                .padding(.vertical, 8)
+                .background(.white.opacity(0.12), in: RoundedRectangle(cornerRadius: 8))
+        }
+    }
+
+    private func recordingURL(_ attributes: RecordingActivityAttributes) -> URL {
+        URL(string: "celestiatrace://recording/\(attributes.sessionID.uuidString)")!
+    }
+
+    private func actionURL(
+        _ action: String,
+        _ attributes: RecordingActivityAttributes
+    ) -> URL {
+        URL(string: "celestiatrace://recording/\(attributes.sessionID.uuidString)/\(action)")!
+    }
+
+    private func formattedDuration(_ seconds: TimeInterval) -> String {
+        let totalSeconds = max(0, Int(seconds))
+        return String(
+            format: "%02d:%02d:%02d",
+            totalSeconds / 3600,
+            (totalSeconds % 3600) / 60,
+            totalSeconds % 60
+        )
+    }
+}

+ 25 - 0
Tests/CelestiaTraceTests/BLEFilteringTests.swift

@@ -0,0 +1,25 @@
+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: "YLF20_12345", serviceUUIDs: []))
+        XCTAssertTrue(BLEManager.isSparkAdvertisement(name: "ylf20_abcde", serviceUUIDs: []))
+        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: []))
+    }
+}

+ 52 - 2
generate_project.rb

@@ -42,21 +42,65 @@ app_target.build_configurations.each do |config|
   config.build_settings['TARGETED_DEVICE_FAMILY'] = '1' # 1 for iPhone, 2 for iPad, 1,2 for both
   config.build_settings['SDKROOT'] = 'iphoneos'
   config.build_settings['PRODUCT_BUNDLE_IDENTIFIER'] = 'com.celestia.trace'
-  config.build_settings['GENERATE_INFOPLIST_FILE'] = 'YES'
+  config.build_settings['GENERATE_INFOPLIST_FILE'] = 'NO'
+  config.build_settings['INFOPLIST_FILE'] = 'CelestiaTrace/Resources/Info.plist'
   config.build_settings['INFOPLIST_KEY_UILaunchStoryboardName'] = 'LaunchScreen'
-  config.build_settings['INFOPLIST_KEY_CFBundleDisplayName'] = '星痕'
+  config.build_settings['INFOPLIST_KEY_CFBundleDisplayName'] = '星痕 CelestiaTrace'
   config.build_settings['INFOPLIST_KEY_UISupportedInterfaceOrientations'] = 'UIInterfaceOrientationPortrait'
   config.build_settings['INFOPLIST_KEY_NSMicrophoneUsageDescription'] = '需要麦克风权限以进行会议现场的音轨录音'
   config.build_settings['INFOPLIST_KEY_NSCameraUsageDescription'] = '需要相机权限以在现场录制中拍摄照片记录'
   config.build_settings['INFOPLIST_KEY_NSPhotoLibraryUsageDescription'] = '需要相册权限以选择照片记录'
   config.build_settings['INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription'] = '需要蓝牙权限以扫描和绑定 BLE 录音设备'
   config.build_settings['INFOPLIST_KEY_NSBluetoothPeripheralUsageDescription'] = '需要蓝牙权限以与录音设备传输数据'
+  # Keep active microphone recordings running while the app is backgrounded or the device is locked.
+  config.build_settings['INFOPLIST_KEY_UIBackgroundModes'] = 'audio bluetooth-central'
+  config.build_settings['MARKETING_VERSION'] = '1.0.0'
+  config.build_settings['CURRENT_PROJECT_VERSION'] = '1'
   config.build_settings['ASSETCATALOG_COMPILER_APPICON_NAME'] = 'AppIcon'
   config.build_settings['CODE_SIGN_STYLE'] = 'Automatic'
   # Enable SwiftData & SwiftUI previews support
   config.build_settings['DEVELOPMENT_ASSET_PATHS'] = '""'
 end
 
+# Create the Live Activity widget extension used on the Lock Screen and Dynamic Island.
+live_activity_target = project.new_target(
+  :app_extension,
+  'CelestiaTraceLiveActivity',
+  :ios,
+  '17.0'
+)
+live_activity_target.build_configurations.each do |config|
+  config.build_settings['SWIFT_VERSION'] = '5.9'
+  config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '17.0'
+  config.build_settings['TARGETED_DEVICE_FAMILY'] = '1'
+  config.build_settings['SDKROOT'] = 'iphoneos'
+  config.build_settings['PRODUCT_BUNDLE_IDENTIFIER'] = 'com.celestia.trace.live-activity'
+  config.build_settings['GENERATE_INFOPLIST_FILE'] = 'NO'
+  config.build_settings['INFOPLIST_FILE'] = 'CelestiaTraceLiveActivity/Info.plist'
+  config.build_settings['MARKETING_VERSION'] = '1.0.0'
+  config.build_settings['CURRENT_PROJECT_VERSION'] = '1'
+  config.build_settings['CODE_SIGN_STYLE'] = 'Automatic'
+  config.build_settings['APPLICATION_EXTENSION_API_ONLY'] = 'YES'
+  config.build_settings['SKIP_INSTALL'] = 'YES'
+  config.build_settings['LD_RUNPATH_SEARCH_PATHS'] = '$(inherited) @executable_path/../../Frameworks'
+end
+
+live_activity_group = main_group.new_group(
+  'CelestiaTraceLiveActivity',
+  'CelestiaTraceLiveActivity'
+)
+live_activity_sources = %w[
+  CelestiaTraceLiveActivityBundle.swift
+  RecordingLiveActivityWidget.swift
+].map { |filename| live_activity_group.new_file(filename) }
+live_activity_target.add_file_references(live_activity_sources)
+live_activity_group.new_file('Info.plist')
+
+app_target.add_dependency(live_activity_target)
+embed_extensions_phase = app_target.new_copy_files_build_phase('Embed Foundation Extensions')
+embed_extensions_phase.dst_subfolder_spec = '13'
+embed_extensions_phase.add_file_reference(live_activity_target.product_reference, true)
+
 # Create iOS Test target
 test_target = project.new_target(:unit_test_bundle, 'CelestiaTraceTests', :ios, '17.0')
 test_target.build_configurations.each do |config|
@@ -76,11 +120,17 @@ test_target.add_dependency(app_target)
 add_files_recursively('CelestiaTrace', app_group, app_target, project)
 add_files_recursively('Tests/CelestiaTraceTests', tests_group, test_target, project)
 
+shared_activity_ref = project.files.find do |file|
+  file.path == 'RecordingActivityAttributes.swift'
+end
+live_activity_target.add_file_references([shared_activity_ref]) if shared_activity_ref
+
 # Add the asset catalog (AppIcon and launch-screen color) to the app target.
 assets_ref = app_group.new_file('Resources/Assets.xcassets')
 app_target.resources_build_phase.add_file_reference(assets_ref)
 launch_screen_ref = app_group.new_file('Resources/LaunchScreen.storyboard')
 app_target.resources_build_phase.add_file_reference(launch_screen_ref)
+app_group.new_file('Resources/Info.plist')
 
 # Save project
 project.save