Bläddra i källkod

Update splash experience and ignore generated files

bob.yuxinyang 1 månad sedan
förälder
incheckning
eb2d5c7fed

+ 15 - 0
.gitignore

@@ -1,9 +1,24 @@
 .DS_Store
+
+# Swift Package Manager
 .build/
 .swiftpm/
+
+# Xcode build products
+build/
+build_*/
 build_derived_data/
 DerivedData/
+*.ipa
+*.dSYM
+*.dSYM.zip
+
+# Xcode user-specific data
 *.xcuserstate
 xcuserdata/
 *.xccheckout
 *.xcscmblueprint
+
+# Generated review/export artifacts
+outputs/
+*.inspect.ndjson

+ 36 - 2
CelestiaTrace.xcodeproj/project.pbxproj

@@ -49,6 +49,10 @@
 		F3CB51CC262E0F4929EB129B /* RemoteAuthServiceStub.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E13AE9CB7E8C9E3BACFC679 /* RemoteAuthServiceStub.swift */; };
 		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 */
@@ -92,6 +96,10 @@
 		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>"; };
+		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>"; };
 		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>"; };
@@ -216,6 +224,7 @@
 		7B90B8D292E18F60A02CDFE2 /* Views */ = {
 			isa = PBXGroup;
 			children = (
+				A9B8C7D6E5F4000000000005 /* Splash */,
 				B7A7A2A0082F059CCD6BE791 /* Settings */,
 				31A0E1032B1847F49A6241BD /* Home */,
 				BC1D87D6D1785EA7D6AA6AF7 /* Recording */,
@@ -278,6 +287,7 @@
 		B5CB52A86415BBB0B9DCE932 /* CelestiaTrace */ = {
 			isa = PBXGroup;
 			children = (
+				A9B8C7D6E5F4000000000006 /* Resources */,
 				CC066F5A1023EDB077FDD88E /* ViewModels */,
 				DA204128E169127A0B8C4B30 /* App */,
 				2626EC2897F60B86D833D875 /* Models */,
@@ -350,9 +360,27 @@
 			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 = (
+				A1B2C3D4E5F6012345678902 /* AudioPathHelper.swift */,
 				AEED3C75367F66D6D6CE745D /* SilenceDetector.swift */,
 				DB09EA3EE9CFFADB34B5B43A /* RecordingEnvironmentDetector.swift */,
 				977EB01CFA491A1573D7CF22 /* RealAudioRecorder.swift */,
@@ -438,6 +466,8 @@
 			isa = PBXResourcesBuildPhase;
 			buildActionMask = 2147483647;
 			files = (
+				A9B8C7D6E5F4000000000003 /* Assets.xcassets in Resources */,
+				A9B8C7D6E5F4000000000007 /* LaunchScreen.storyboard in Resources */,
 			);
 			runOnlyForDeploymentPostprocessing = 0;
 		};
@@ -462,6 +492,7 @@
 			isa = PBXSourcesBuildPhase;
 			buildActionMask = 2147483647;
 			files = (
+				A9B8C7D6E5F4000000000001 /* SplashView.swift in Sources */,
 				BE12EF8734ABF645B2B28BBB /* PlaybackViewModel.swift in Sources */,
 				96791550D01A4FD10EEB9BA3 /* RecordingViewModel.swift in Sources */,
 				E78FC729614A4C93C97D564D /* ColorTheme.swift in Sources */,
@@ -501,6 +532,7 @@
 				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 */,
 			);
 			runOnlyForDeploymentPostprocessing = 0;
@@ -588,12 +620,13 @@
 				DEVELOPMENT_ASSET_PATHS = "\"\"";
 				DEVELOPMENT_TEAM = 5ERVQEJ497;
 				GENERATE_INFOPLIST_FILE = YES;
+				INFOPLIST_KEY_CFBundleDisplayName = "星痕";
 				INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "需要蓝牙权限以扫描和绑定 BLE 录音设备";
 				INFOPLIST_KEY_NSBluetoothPeripheralUsageDescription = "需要蓝牙权限以与录音设备传输数据";
 				INFOPLIST_KEY_NSCameraUsageDescription = "需要相机权限以在现场录制中拍摄照片记录";
 				INFOPLIST_KEY_NSMicrophoneUsageDescription = "需要麦克风权限以进行会议现场的音轨录音";
 				INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "需要相册权限以选择照片记录";
-				INFOPLIST_KEY_UILaunchScreen_Generation = YES;
+				INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
 				INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
 				IPHONEOS_DEPLOYMENT_TARGET = 17.0;
 				LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
@@ -614,12 +647,13 @@
 				DEVELOPMENT_ASSET_PATHS = "\"\"";
 				DEVELOPMENT_TEAM = 5ERVQEJ497;
 				GENERATE_INFOPLIST_FILE = YES;
+				INFOPLIST_KEY_CFBundleDisplayName = "星痕";
 				INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "需要蓝牙权限以扫描和绑定 BLE 录音设备";
 				INFOPLIST_KEY_NSBluetoothPeripheralUsageDescription = "需要蓝牙权限以与录音设备传输数据";
 				INFOPLIST_KEY_NSCameraUsageDescription = "需要相机权限以在现场录制中拍摄照片记录";
 				INFOPLIST_KEY_NSMicrophoneUsageDescription = "需要麦克风权限以进行会议现场的音轨录音";
 				INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "需要相册权限以选择照片记录";
-				INFOPLIST_KEY_UILaunchScreen_Generation = YES;
+				INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
 				INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
 				IPHONEOS_DEPLOYMENT_TARGET = 17.0;
 				LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";

+ 2 - 2
CelestiaTrace/App/CelestiaTraceApp.swift

@@ -15,10 +15,10 @@ struct CelestiaTraceApp: App {
             fatalError("Failed to initialize SwiftData Container: \(error)")
         }
     }
-    
+
     var body: some Scene {
         WindowGroup {
-            ContentView()
+            LaunchRootView()
         }
         .modelContainer(container)
     }

+ 20 - 0
CelestiaTrace/Resources/Assets.xcassets/AccentColor.colorset/Contents.json

@@ -0,0 +1,20 @@
+{
+  "colors" : [
+    {
+      "color" : {
+        "color-space" : "srgb",
+        "components" : {
+          "alpha" : "1.000",
+          "blue" : "0.384",
+          "green" : "0.345",
+          "red" : "0.306"
+        }
+      },
+      "idiom" : "universal"
+    }
+  ],
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}

BIN
CelestiaTrace/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png


+ 14 - 0
CelestiaTrace/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json

@@ -0,0 +1,14 @@
+{
+  "images" : [
+    {
+      "filename" : "AppIcon.png",
+      "idiom" : "universal",
+      "platform" : "ios",
+      "size" : "1024x1024"
+    }
+  ],
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}

+ 6 - 0
CelestiaTrace/Resources/Assets.xcassets/Contents.json

@@ -0,0 +1,6 @@
+{
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}

+ 20 - 0
CelestiaTrace/Resources/Assets.xcassets/LaunchBackground.colorset/Contents.json

@@ -0,0 +1,20 @@
+{
+  "colors" : [
+    {
+      "color" : {
+        "color-space" : "srgb",
+        "components" : {
+          "alpha" : "1.000",
+          "blue" : "0.969",
+          "green" : "0.961",
+          "red" : "0.961"
+        }
+      },
+      "idiom" : "universal"
+    }
+  ],
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}

+ 21 - 0
CelestiaTrace/Resources/Assets.xcassets/SplashArtwork.imageset/Contents.json

@@ -0,0 +1,21 @@
+{
+  "images" : [
+    {
+      "filename" : "SplashArtwork.png",
+      "idiom" : "universal",
+      "scale" : "1x"
+    },
+    {
+      "idiom" : "universal",
+      "scale" : "2x"
+    },
+    {
+      "idiom" : "universal",
+      "scale" : "3x"
+    }
+  ],
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}

BIN
CelestiaTrace/Resources/Assets.xcassets/SplashArtwork.imageset/SplashArtwork.png


+ 44 - 0
CelestiaTrace/Resources/LaunchScreen.storyboard

@@ -0,0 +1,44 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="26100" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="XH-view-controller">
+    <device id="retina6_12" orientation="portrait" appearance="light"/>
+    <dependencies>
+        <deployment identifier="iOS"/>
+        <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="26100"/>
+        <capability name="Named colors" minToolsVersion="9.0"/>
+        <capability name="Safe area layout guides" minToolsVersion="9.0"/>
+        <capability name="System colors in document resources" minToolsVersion="11.0"/>
+    </dependencies>
+    <scenes>
+        <scene sceneID="XH-scene">
+            <objects>
+                <viewController id="XH-view-controller" sceneMemberID="viewController">
+                    <view key="view" contentMode="scaleToFill" id="XH-root-view">
+                        <rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
+                        <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
+                        <subviews>
+                            <imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFill" image="SplashArtwork" translatesAutoresizingMaskIntoConstraints="NO" id="XH-artwork">
+                                <rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
+                            </imageView>
+                        </subviews>
+                        <viewLayoutGuide key="safeArea" id="XH-safe-area"/>
+                        <color key="backgroundColor" name="LaunchBackground"/>
+                        <constraints>
+                            <constraint firstItem="XH-artwork" firstAttribute="leading" secondItem="XH-root-view" secondAttribute="leading" id="XH-leading"/>
+                            <constraint firstAttribute="trailing" secondItem="XH-artwork" secondAttribute="trailing" id="XH-trailing"/>
+                            <constraint firstItem="XH-artwork" firstAttribute="top" secondItem="XH-root-view" secondAttribute="top" id="XH-top"/>
+                            <constraint firstAttribute="bottom" secondItem="XH-artwork" secondAttribute="bottom" id="XH-bottom"/>
+                        </constraints>
+                    </view>
+                </viewController>
+                <placeholder placeholderIdentifier="IBFirstResponder" id="XH-first-responder" userLabel="First Responder" sceneMemberID="firstResponder"/>
+            </objects>
+            <point key="canvasLocation" x="132" y="112"/>
+        </scene>
+    </scenes>
+    <resources>
+        <image name="SplashArtwork" width="853" height="1844"/>
+        <namedColor name="LaunchBackground">
+            <color red="0.9607843137" green="0.9607843137" blue="0.9686274510" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
+        </namedColor>
+    </resources>
+</document>

+ 37 - 0
CelestiaTrace/Services/Audio/AudioPathHelper.swift

@@ -0,0 +1,37 @@
+import Foundation
+
+/// Utility helper to resolve dynamic iOS App Sandbox document paths for audio files
+/// and convert between absolute and relative paths.
+public struct AudioPathHelper {
+    /// Resolves an audio file path (which could be nil, a relative filename, or an outdated absolute sandbox path from a prior app launch)
+    /// to a valid, existing URL in the current app container's Documents directory.
+    public static func resolveURL(for path: String?) -> URL? {
+        guard let path = path, !path.isEmpty else { return nil }
+        
+        let fm = FileManager.default
+        
+        // 1. Direct check: If path exists as-is, return it
+        if fm.fileExists(atPath: path) {
+            return URL(fileURLWithPath: path)
+        }
+        
+        // 2. Dynamic Sandbox check: Extract filename and search in current Documents directory
+        let filename = (path as NSString).lastPathComponent
+        guard !filename.isEmpty else { return nil }
+        
+        if let documentsURL = fm.urls(for: .documentDirectory, in: .userDomainMask).first {
+            let candidateURL = documentsURL.appendingPathComponent(filename)
+            if fm.fileExists(atPath: candidateURL.path) {
+                return candidateURL
+            }
+        }
+        
+        return nil
+    }
+    
+    /// Converts an absolute or relative file path into a relative filename (e.g. "merged_123.m4a") suitable for persistent storage.
+    public static func relativePath(from pathOrURL: String?) -> String? {
+        guard let pathOrURL = pathOrURL, !pathOrURL.isEmpty else { return nil }
+        return (pathOrURL as NSString).lastPathComponent
+    }
+}

+ 102 - 1
CelestiaTrace/Services/Audio/RealAudioRecorder.swift

@@ -71,7 +71,7 @@ final class RealAudioRecorder: AudioRecorderProtocol {
             // Configure AAC recorder settings
             let settings: [String: Any] = [
                 AVFormatIDKey: Int(kAudioFormatMPEG4AAC),
-                AVSampleRateKey: 12000.0,
+                AVSampleRateKey: 44100.0,
                 AVNumberOfChannelsKey: 1,
                 AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue
             ]
@@ -209,3 +209,104 @@ final class RealAudioRecorder: AudioRecorderProtocol {
         audioRecorder?.stop()
     }
 }
+
+// MARK: - AudioMerger
+
+/// Utility struct to merge existing audio recording file with newly recorded audio segment.
+struct AudioMerger {
+    /// Merges an existing audio file and a newly recorded audio segment into a single unified .m4a file.
+    /// Uses modern async/await AVFoundation APIs to ensure tracks and durations are fully loaded
+    /// before building the composition, preventing empty-track or indefinite-duration bugs.
+    /// - Parameters:
+    ///   - firstURL: The original audio file URL (can be nil or non-existent).
+    ///   - secondURL: The newly recorded audio segment URL.
+    /// - Returns: The resulting merged (or fallback) file URL.
+    @MainActor
+    static func mergeAudioFiles(firstURL: URL?, secondURL: URL) async -> URL {
+        let resolvedFirst = AudioPathHelper.resolveURL(for: firstURL?.path)
+        let resolvedSecond = AudioPathHelper.resolveURL(for: secondURL.path) ?? secondURL
+        
+        guard let validFirstURL = resolvedFirst else {
+            return resolvedSecond
+        }
+        
+        guard FileManager.default.fileExists(atPath: resolvedSecond.path) else {
+            return validFirstURL
+        }
+        
+        let composition = AVMutableComposition()
+        guard let compositionTrack = composition.addMutableTrack(
+            withMediaType: .audio,
+            preferredTrackID: kCMPersistentTrackID_Invalid
+        ) else {
+            print("[AudioMerger] Failed to create composition audio track")
+            return resolvedSecond
+        }
+        
+        let asset1 = AVURLAsset(url: validFirstURL)
+        let asset2 = AVURLAsset(url: resolvedSecond)
+        
+        do {
+            // Load durations and tracks asynchronously to ensure they are ready
+            let duration1 = try await asset1.load(.duration)
+            let duration2 = try await asset2.load(.duration)
+            let tracks1 = try await asset1.loadTracks(withMediaType: .audio)
+            let tracks2 = try await asset2.loadTracks(withMediaType: .audio)
+            
+            if let track1 = tracks1.first {
+                try compositionTrack.insertTimeRange(
+                    CMTimeRange(start: .zero, duration: duration1),
+                    of: track1,
+                    at: .zero
+                )
+                print("[AudioMerger] Inserted first track: \(CMTimeGetSeconds(duration1))s")
+            } else {
+                print("[AudioMerger] Warning: No audio track found in first asset")
+            }
+            
+            // Insert second track right after the first
+            let insertionPoint = compositionTrack.timeRange.duration
+            if let track2 = tracks2.first {
+                try compositionTrack.insertTimeRange(
+                    CMTimeRange(start: .zero, duration: duration2),
+                    of: track2,
+                    at: insertionPoint
+                )
+                print("[AudioMerger] Inserted second track: \(CMTimeGetSeconds(duration2))s at offset \(CMTimeGetSeconds(insertionPoint))s")
+            } else {
+                print("[AudioMerger] Warning: No audio track found in second asset")
+            }
+        } catch {
+            print("[AudioMerger] Error loading/inserting tracks: \(error.localizedDescription)")
+            return resolvedSecond
+        }
+        
+        // Export merged composition
+        let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
+        let mergedURL = documents.appendingPathComponent("merged_\(UUID().uuidString).m4a")
+        
+        guard let exportSession = AVAssetExportSession(
+            asset: composition,
+            presetName: AVAssetExportPresetAppleM4A
+        ) else {
+            print("[AudioMerger] Failed to create AVAssetExportSession")
+            return resolvedSecond
+        }
+        
+        exportSession.outputURL = mergedURL
+        exportSession.outputFileType = .m4a
+        
+        await exportSession.export()
+        
+        switch exportSession.status {
+        case .completed:
+            let mergedDuration = CMTimeGetSeconds(AVURLAsset(url: mergedURL).duration)
+            print("[AudioMerger] Successfully merged audio to: \(mergedURL.lastPathComponent) (\(mergedDuration)s)")
+            return mergedURL
+        default:
+            print("[AudioMerger] Audio export failed: \(String(describing: exportSession.error))")
+            return resolvedSecond
+        }
+    }
+}
+

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

@@ -28,9 +28,8 @@ public final class SilenceDetector: Sendable {
         thresholdDB: Float = -40.0,
         minDuration: TimeInterval = 2.0
     ) async -> [SilenceRange] {
-        let fm = FileManager.default
         // Fallback to mock data if the file doesn't exist locally (for simulator and mock prototyping)
-        guard !audioURL.path.isEmpty && fm.fileExists(atPath: audioURL.path) else {
+        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),
@@ -39,7 +38,7 @@ public final class SilenceDetector: Sendable {
         }
         
         let task = Task.detached(priority: .userInitiated) { () -> [SilenceRange] in
-            guard let audioFile = try? AVAudioFile(forReading: audioURL) else {
+            guard let audioFile = try? AVAudioFile(forReading: validURL) else {
                 return []
             }
             

+ 213 - 17
CelestiaTrace/Services/Auth/RemoteAuthServiceStub.swift

@@ -1,47 +1,243 @@
 import Foundation
 
-/// Remote server adapter placeholder implementing `AuthServiceProtocol`.
-/// This stub demonstrates how `CelestiaTrace` can switch to a real remote backend server in the future.
+/// 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://api.celestiatrace.com/v1")!, session: URLSession = .shared) {
+    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) {
-        // TODO: Replace stub with URLSession POST request to `/auth/login`
-        // let endpoint = baseURL.appendingPathComponent("auth/login")
-        // var urlReq = URLRequest(url: endpoint)
-        // urlReq.httpMethod = "POST"
-        // ...
+        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)
         
-        completion(.failure(.networkError("未配置实际远程服务器地址")))
+        performRequest(urlReq, completion: completion)
     }
     
     func register(request: RegisterRequest, completion: @escaping (AuthResult<UserAccount>) -> Void) {
-        // TODO: Replace stub with URLSession POST request to `/auth/register`
-        completion(.failure(.networkError("未配置实际远程服务器地址")))
+        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) {
-        // TODO: Replace stub with URLSession PUT request to `/user/profile`
-        completion(.failure(.networkError("未配置实际远程服务器地址")))
+        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) {
-        // TODO: Replace stub with URLSession POST request to `/user/change-password`
-        completion(.failure(.networkError("未配置实际远程服务器地址")))
+        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) {
-        // TODO: Call remote `/auth/logout` endpoint if needed
+        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 nil
+        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()
     }
 }

+ 8 - 10
CelestiaTrace/ViewModels/PlaybackViewModel.swift

@@ -86,15 +86,13 @@ final class PlaybackViewModel: NSObject, AVAudioPlayerDelegate {
         audioPlayer?.stop()
         audioPlayer = nil
 
-        let urlToPlay: URL?
-        if let path = path, !path.isEmpty, FileManager.default.fileExists(atPath: path) {
-            urlToPlay = URL(fileURLWithPath: path)
-        } else {
-            let targetDuration = durationMs > 0 ? durationMs / 1000.0 : 30.0
-            urlToPlay = generateFallbackAudioFile(durationSeconds: targetDuration)
+        guard let targetURL = AudioPathHelper.resolveURL(for: path) else {
+            print("[PlaybackViewModel] No valid audio file found for path: \(path ?? "nil")")
+            if durationMs > 0 {
+                self.totalDurationMs = durationMs
+            }
+            return
         }
-
-        guard let targetURL = urlToPlay else { return }
         self.activeAudioURL = targetURL
 
         setupAudioSession()
@@ -218,8 +216,8 @@ final class PlaybackViewModel: NSObject, AVAudioPlayerDelegate {
 
     /// Runs silence detection asynchronously.
     func analyzeSilence(audioURL: URL?) {
-        let url = audioURL ?? activeAudioURL
-        guard let url = url else {
+        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)

+ 6 - 2
CelestiaTrace/ViewModels/RecordingViewModel.swift

@@ -12,6 +12,7 @@ final class RecordingViewModel {
     // MARK: - Published State
 
     private(set) var isRecording: Bool = false
+    private(set) var initialDuration: TimeInterval = 0
     private(set) var elapsedTime: TimeInterval = 0
     private(set) var currentAmplitude: Float = 0
     private(set) var waveformSamples: [Float] = []
@@ -58,7 +59,10 @@ final class RecordingViewModel {
     // MARK: - Recording Controls
 
     /// Starts recording and begins polling the recorder for state updates.
-    func startRecording() {
+    /// - Parameter initialDuration: Prior recorded duration in seconds when continuing a session.
+    func startRecording(initialDuration: TimeInterval = 0) {
+        self.initialDuration = initialDuration
+        self.elapsedTime = initialDuration
         recorder.startRecording()
         isRecording = true
         startSyncTimer()
@@ -148,7 +152,7 @@ final class RecordingViewModel {
     /// Pulls current values from the ObservableObject recorder into @Observable properties.
     private func syncState() {
         isRecording = recorder.isRecording
-        elapsedTime = recorder.elapsedTime
+        elapsedTime = initialDuration + recorder.elapsedTime
         currentAmplitude = recorder.currentAmplitude
         waveformSamples = recorder.waveformSamples
         isPaused = recorder.isPaused

+ 6 - 3
CelestiaTrace/Views/Detail/SessionDetailView.swift

@@ -84,16 +84,19 @@ struct SessionDetailView: View {
             Text(activeWarningMsg + "\n\n另外请手动确认:\n1. 手机侧边静音开关已拨至红色(静音状态)\n2. 已关闭可能在此期间响起的系统闹钟")
         }
         .fullScreenCover(isPresented: $navigateToRecording) {
-            ActiveRecordingView(session: session) {
+            ActiveRecordingView(
+                session: session,
+                initialDuration: Double(session.durationMs) / 1000.0
+            ) {
                 playbackVM.totalDurationMs = Double(session.durationMs)
                 playbackVM.preparePlayer(path: session.localAudioPath, durationMs: Double(session.durationMs))
-                playbackVM.analyzeSilence(audioURL: session.localAudioPath.map { URL(fileURLWithPath: $0) })
+                playbackVM.analyzeSilence(audioURL: AudioPathHelper.resolveURL(for: session.localAudioPath))
             }
         }
         .onAppear {
             playbackVM.totalDurationMs = Double(session.durationMs)
             playbackVM.preparePlayer(path: session.localAudioPath, durationMs: Double(session.durationMs))
-            playbackVM.analyzeSilence(audioURL: session.localAudioPath.map { URL(fileURLWithPath: $0) })
+            playbackVM.analyzeSilence(audioURL: AudioPathHelper.resolveURL(for: session.localAudioPath))
         }
     }
 

+ 30 - 8
CelestiaTrace/Views/Recording/ActiveRecordingView.swift

@@ -10,6 +10,7 @@ struct ActiveRecordingView: View {
     @Environment(\.dismiss) private var dismiss
 
     let session: CelestiaSession
+    var initialDuration: TimeInterval = 0
     var onFinishRecording: (() -> Void)? = nil
 
     @State private var recordingVM = RecordingViewModel()
@@ -71,7 +72,7 @@ struct ActiveRecordingView: View {
             }
         }
         .onAppear {
-            recordingVM.startRecording()
+            recordingVM.startRecording(initialDuration: initialDuration)
         }
         .onDisappear {
             if recordingVM.isRecording {
@@ -356,14 +357,35 @@ struct ActiveRecordingView: View {
 
     private func endRecording() {
         recordingVM.stopRecording()
-        if let fileURL = recordingVM.outputFileURL {
-            session.localAudioPath = fileURL.path
+        
+        let newRecordedURL = recordingVM.outputFileURL
+        let existingPath = session.localAudioPath
+        let existingURL = AudioPathHelper.resolveURL(for: existingPath)
+        let totalRecordedSeconds = recordingVM.elapsedTime
+        
+        if let newRecordedURL = newRecordedURL {
+            Task { @MainActor in
+                let mergedURL = await AudioMerger.mergeAudioFiles(firstURL: existingURL, secondURL: newRecordedURL)
+                session.localAudioPath = AudioPathHelper.relativePath(from: mergedURL.path)
+                session.endTime = session.startTime.addingTimeInterval(totalRecordedSeconds)
+                try? modelContext.save()
+                
+                HapticManager.trigger(.recordStop)
+                dismiss()
+                onFinishRecording?()
+            }
+        } else {
+            if existingURL != nil {
+                session.endTime = session.startTime.addingTimeInterval(totalRecordedSeconds)
+                try? modelContext.save()
+            } else {
+                session.endTime = Date()
+                try? modelContext.save()
+            }
+            HapticManager.trigger(.recordStop)
+            dismiss()
+            onFinishRecording?()
         }
-        session.endTime = Date()
-        try? modelContext.save()
-        HapticManager.trigger(.recordStop)
-        dismiss()
-        onFinishRecording?()
     }
 
     private func showLatestEvent(_ event: CelestiaTimelineEvent) {

+ 83 - 0
CelestiaTrace/Views/Splash/SplashView.swift

@@ -0,0 +1,83 @@
+import SwiftUI
+
+/// Keeps the static system launch screen visually continuous, then presents
+/// the selected 星痕 brand artwork before revealing the app.
+struct LaunchRootView: View {
+    @State private var isShowingSplash = true
+
+    var body: some View {
+        ZStack {
+            ContentView()
+
+            if isShowingSplash {
+                SplashView {
+                    withAnimation(.easeInOut(duration: 0.45)) {
+                        isShowingSplash = false
+                    }
+                }
+                .transition(.opacity)
+                .zIndex(1)
+            }
+        }
+    }
+}
+
+struct SplashView: View {
+    @Environment(\.accessibilityReduceMotion) private var reduceMotion
+
+    @State private var artworkOpacity = 0.0
+    @State private var artworkScale = 1.008
+    @State private var hasStarted = false
+
+    let onFinished: () -> Void
+
+    var body: some View {
+        ZStack {
+            Color("LaunchBackground")
+                .ignoresSafeArea()
+
+            Image("SplashArtwork")
+                .resizable()
+                .scaledToFill()
+                .frame(maxWidth: .infinity, maxHeight: .infinity)
+                .clipped()
+                .opacity(artworkOpacity)
+                .scaleEffect(artworkScale)
+                .ignoresSafeArea()
+        }
+        .statusBarHidden(true)
+        .task {
+            guard !hasStarted else { return }
+            hasStarted = true
+            await playEntrance()
+        }
+        .accessibilityElement(children: .ignore)
+        .accessibilityLabel("星痕。倾听,然后深刻。纯粹星辰出品。")
+    }
+
+    @MainActor
+    private func playEntrance() async {
+        if reduceMotion {
+            artworkOpacity = 1
+            artworkScale = 1
+            try? await Task.sleep(for: .seconds(1.2))
+            onFinished()
+            return
+        }
+
+        withAnimation(.easeOut(duration: 0.65)) {
+            artworkOpacity = 1
+            artworkScale = 1
+        }
+
+        let holdDuration: Duration = ProcessInfo.processInfo.environment["XINGHEN_SPLASH_QA"] == "1"
+            ? .seconds(30)
+            : .seconds(2.05)
+        try? await Task.sleep(for: holdDuration)
+        onFinished()
+    }
+}
+
+#Preview("星痕开屏") {
+    SplashView(onFinished: {})
+}

+ 37 - 0
design-qa.md

@@ -0,0 +1,37 @@
+# 星痕开屏 Design QA
+
+## Evidence
+
+- Source visual truth: `/Users/yuxin/.codex/generated_images/019f8988-0af7-7e33-89f1-5eba7fec1326/exec-d1b043c8-9d80-4f04-8292-131b141ff3d7.png`
+- Implementation screenshot: `/Users/yuxin/local/code/celestia-trace/ios-celestia-trace/build_splash/splash-iphone17pro.png`
+- Side-by-side comparison: `/Users/yuxin/local/code/celestia-trace/ios-celestia-trace/build_splash/splash-comparison.png`
+- App icon system-mask check: `/Users/yuxin/local/code/celestia-trace/ios-celestia-trace/build_splash/home-icon.png`
+- Source pixels: 853 × 1844.
+- Implementation pixels: 1206 × 2622, captured on iPhone 17 Pro at 402 × 874 points and 3× density.
+- Normalized comparison: both sides scaled to 942 × 2048 pixels; density differences were normalized before judging.
+- State: light appearance, splash artwork fully visible, no transition in progress.
+
+## Findings
+
+- No actionable P0, P1, or P2 differences.
+- Fonts and typography: app-owned typography is preserved directly from the selected visual asset, including weight, tracking, hierarchy, and copy.
+- Spacing and layout rhythm: the orbit, waveform, gold origin point, title, slogan, and parent-brand signature keep the source proportions. The Dynamic Island is system-owned device chrome and is intentionally not recreated in the source artwork.
+- Colors and visual tokens: warm white, graphite, slate gray, and muted gold match the selected direction. The native launch storyboard uses the same artwork and background color to prevent a cold-start flash.
+- Image quality and asset fidelity: the selected raster source is used directly rather than approximated with code-native drawing. It remains clean at the tested device size without visible masking seams or compression artifacts.
+- Copy and content: “星痕”, “倾听,然后深刻。”, and “纯粹星辰出品” are present and unchanged.
+- App icon: the 1024 × 1024 opaque RGB master compiles into the AppIcon set, receives the iOS system mask correctly, and displays with the app name “星痕”.
+
+## Focused Region Review
+
+A separate crop was not needed: the full-view comparison renders the title, slogan, fine waveform, orbit endpoints, and footer clearly enough to assess all fidelity-critical details. The icon was additionally checked on the simulator home screen at its real system size.
+
+## Comparison History
+
+- Initial pass: no P0/P1/P2 mismatches found. The implementation uses the selected source artwork directly, so no visual correction loop was required.
+- Build verification: Debug iOS Simulator build succeeded; the asset catalog, AppIcon, LaunchScreen storyboard, and SwiftUI splash transition all compiled successfully.
+
+## Follow-up Polish
+
+- P3: the icon’s finest gray waveform lines are intentionally subtle and may be faint against bright wallpapers. The gold origin point and main orbit remain recognizable, so this is acceptable for the selected quiet brand direction.
+
+final result: passed

+ 8 - 1
generate_project.rb

@@ -43,7 +43,8 @@ app_target.build_configurations.each do |config|
   config.build_settings['SDKROOT'] = 'iphoneos'
   config.build_settings['PRODUCT_BUNDLE_IDENTIFIER'] = 'com.celestia.trace'
   config.build_settings['GENERATE_INFOPLIST_FILE'] = 'YES'
-  config.build_settings['INFOPLIST_KEY_UILaunchScreen_Generation'] = 'YES'
+  config.build_settings['INFOPLIST_KEY_UILaunchStoryboardName'] = 'LaunchScreen'
+  config.build_settings['INFOPLIST_KEY_CFBundleDisplayName'] = '星痕'
   config.build_settings['INFOPLIST_KEY_UISupportedInterfaceOrientations'] = 'UIInterfaceOrientationPortrait'
   config.build_settings['INFOPLIST_KEY_NSMicrophoneUsageDescription'] = '需要麦克风权限以进行会议现场的音轨录音'
   config.build_settings['INFOPLIST_KEY_NSCameraUsageDescription'] = '需要相机权限以在现场录制中拍摄照片记录'
@@ -75,6 +76,12 @@ 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)
 
+# 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)
+
 # Save project
 project.save
 puts "Successfully created CelestiaTrace.xcodeproj"