fix: 安全审查 P0-P2 问题修复(26项)

P0 关键修复:
- 移除 exit(0) 强制退出,改为应用语言设置后下次启动生效
- 修复 LivePhotoValidator hasResumed data race,引入线程安全 ResumeOnce
- 修复 addAssetID(toVideo:) continuation 泄漏,添加 writer/reader 启动状态检查
- 修复 OnboardingView "跳过" 按钮未国际化
- 修复 LanguageManager "跟随系统" 硬编码中文
- .gitignore 补全 AI 工具目录

P1 架构与 UI 修复:
- 修复 RealESRGANProcessor actor 隔离违规
- 修复 ODRManager continuation 生命周期保护
- TiledImageProcessor 改为流式拼接,降低内存峰值
- EditorView 硬编码颜色统一为设计系统
- ProcessingView 取消导航竞态修复
- 反馈诊断包添加知情同意提示

P2 代码质量与合规:
- EditorView/WallpaperGuideView 硬编码间距圆角统一为设计令牌
- PrivacyPolicyView 设计系统颜色统一
- HomeView 重复 onChange 合并
- PHAuthorizationStatus 改为英文技术术语
- Analytics 日志 assetId 脱敏
- 隐私政策补充 localIdentifier 存储说明
- 清理孤立的 subscription 翻译 key
- 脚本硬编码绝对路径改为相对路径
- DesignSystem SoftSlider 类型不匹配编译错误修复

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
empty
2026-02-07 20:04:41 +08:00
parent e08cfc981e
commit 4bcad4d4b8
19 changed files with 640 additions and 1396 deletions

13
.gitignore vendored
View File

@@ -114,3 +114,16 @@ to-live-photo/to-live-photo/build/
# PyTorch models (use Core ML instead)
*.pth
.serena/
# AI coding tools
.agent/
.agents/
.claude/
.codex/
.cursor/
.gemini/
.kiro/
.opencode/
.qoder/
.trae/
.windsurf/

View File

@@ -155,11 +155,13 @@ public actor ODRManager {
private func checkODRAvailability() async -> Bool {
// Use conditionallyBeginAccessingResources to check without triggering download
let request = NSBundleResourceRequest(tags: [Self.modelTag])
return await withCheckedContinuation { continuation in
request.conditionallyBeginAccessingResources { available in
request.conditionallyBeginAccessingResources { [request] available in
// Capture request explicitly to prevent ARC from releasing it
// before the callback fires
_ = request
if available {
// Model is already downloaded via ODR
self.logger.debug("ODR model is available locally")
}
continuation.resume(returning: available)

View File

@@ -112,7 +112,10 @@ actor RealESRGANProcessor {
logger.info("Running inference on \(width)x\(height) image...")
// Run inference synchronously (MLModel prediction is thread-safe)
// Capture actor-isolated state before entering non-isolated closure
let localModel = model
// Run inference on background queue (MLModel prediction is thread-safe)
let output: [UInt8] = try await withCheckedThrowingContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async {
do {
@@ -123,22 +126,15 @@ actor RealESRGANProcessor {
)
// Run inference synchronously
let prediction = try model.prediction(from: inputProvider)
let prediction = try localModel.prediction(from: inputProvider)
// Extract output from model
// The model outputs to "activation_out" as either MultiArray or Image
let rgbaData: [UInt8]
if let outputValue = prediction.featureValue(for: "activation_out") {
if let multiArray = outputValue.multiArrayValue {
// Output is MLMultiArray with shape [C, H, W]
self.logger.info("Output is MultiArray: \(multiArray.shape)")
rgbaData = try self.multiArrayToRGBA(multiArray)
rgbaData = try Self.multiArrayToRGBA(multiArray)
} else if let outputBuffer = outputValue.imageBufferValue {
// Output is CVPixelBuffer (image)
let outWidth = CVPixelBufferGetWidth(outputBuffer)
let outHeight = CVPixelBufferGetHeight(outputBuffer)
self.logger.info("Output is Image: \(outWidth)x\(outHeight)")
rgbaData = try ImageFormatConverter.pixelBufferToRGBAData(outputBuffer)
} else {
continuation.resume(throwing: AIEnhanceError.inferenceError(
@@ -162,13 +158,14 @@ actor RealESRGANProcessor {
}
}
logger.info("Inference completed, output size: \(output.count) bytes")
return output
}
/// Convert MLMultiArray [C, H, W] to RGBA byte array
/// - Parameter multiArray: Output from model with shape [3, H, W] (RGB channels)
/// - Returns: RGBA byte array with shape [H * W * 4]
private func multiArrayToRGBA(_ multiArray: MLMultiArray) throws -> [UInt8] {
private static func multiArrayToRGBA(_ multiArray: MLMultiArray) throws -> [UInt8] {
let shape = multiArray.shape.map { $0.intValue }
// Expect shape [3, H, W] for RGB
@@ -178,12 +175,9 @@ actor RealESRGANProcessor {
)
}
let channels = shape[0]
let height = shape[1]
let width = shape[2]
logger.info("Converting MultiArray \(channels)x\(height)x\(width) to RGBA")
// Output array: RGBA format
var rgbaData = [UInt8](repeating: 255, count: width * height * 4)

View File

@@ -63,17 +63,38 @@ struct TiledImageProcessor {
logger.info("Extracted \(tiles.count) tiles")
progress?(0.1)
// Step 2: Process each tile
var processedTiles: [(tile: ImageTile, output: [UInt8])] = []
// Step 2: Pre-allocate output buffers for streaming stitching
let outputWidth = originalWidth * config.modelScale
let outputHeight = originalHeight * config.modelScale
var outputBuffer = [Float](repeating: 0, count: outputWidth * outputHeight * 3)
var weightBuffer = [Float](repeating: 0, count: outputWidth * outputHeight)
// Step 3: Process each tile and blend immediately (streaming)
let tileProgressBase = 0.1
let tileProgressRange = 0.7
let tileProgressRange = 0.75
for (index, tile) in tiles.enumerated() {
try Task.checkCancellation()
let pixelBuffer = try ImageFormatConverter.cgImageToPixelBuffer(tile.image)
let outputData = try await processor.processImage(pixelBuffer)
processedTiles.append((tile, outputData))
// Blend tile into output immediately no accumulation
let weights = createBlendingWeights(
tileWidth: min(config.outputTileSize, outputWidth - tile.outputOriginX),
tileHeight: min(config.outputTileSize, outputHeight - tile.outputOriginY)
)
blendTileIntoOutput(
data: outputData,
weights: weights,
atX: tile.outputOriginX,
atY: tile.outputOriginY,
outputWidth: outputWidth,
outputHeight: outputHeight,
outputBuffer: &outputBuffer,
weightBuffer: &weightBuffer
)
// outputData and weights are released here
let tileProgress = tileProgressBase + tileProgressRange * Double(index + 1) / Double(tiles.count)
progress?(tileProgress)
@@ -82,19 +103,14 @@ struct TiledImageProcessor {
await Task.yield()
}
progress?(0.85)
progress?(0.9)
// Step 3: Stitch tiles with blending
let outputWidth = originalWidth * config.modelScale
let outputHeight = originalHeight * config.modelScale
let stitchedImage = try stitchTiles(
processedTiles,
outputWidth: outputWidth,
outputHeight: outputHeight
)
// Step 4: Normalize and create final image
normalizeByWeights(&outputBuffer, weights: weightBuffer, width: outputWidth, height: outputHeight)
let stitchedImage = try createCGImage(from: outputBuffer, width: outputWidth, height: outputHeight)
progress?(0.95)
// Step 4: Cap at max dimension if needed
// Step 5: Cap at max dimension if needed
let finalImage = try capToMaxDimension(stitchedImage, maxDimension: 4320)
progress?(1.0)
@@ -196,45 +212,6 @@ struct TiledImageProcessor {
// MARK: - Tile Stitching
/// Stitch processed tiles with weighted blending
private func stitchTiles(
_ tiles: [(tile: ImageTile, output: [UInt8])],
outputWidth: Int,
outputHeight: Int
) throws -> CGImage {
// Create output buffers
var outputBuffer = [Float](repeating: 0, count: outputWidth * outputHeight * 3)
var weightBuffer = [Float](repeating: 0, count: outputWidth * outputHeight)
let outputTileSize = config.outputTileSize // 2048
for (tile, data) in tiles {
// Create blending weights for this tile
let weights = createBlendingWeights(
tileWidth: min(outputTileSize, outputWidth - tile.outputOriginX),
tileHeight: min(outputTileSize, outputHeight - tile.outputOriginY)
)
// Blend tile into output
blendTileIntoOutput(
data: data,
weights: weights,
atX: tile.outputOriginX,
atY: tile.outputOriginY,
outputWidth: outputWidth,
outputHeight: outputHeight,
outputBuffer: &outputBuffer,
weightBuffer: &weightBuffer
)
}
// Normalize by accumulated weights
normalizeByWeights(&outputBuffer, weights: weightBuffer, width: outputWidth, height: outputHeight)
// Convert to CGImage
return try createCGImage(from: outputBuffer, width: outputWidth, height: outputHeight)
}
/// Create blending weights with linear falloff at edges
private func createBlendingWeights(tileWidth: Int, tileHeight: Int) -> [Float] {
let overlap = config.outputOverlap // 256

View File

@@ -347,6 +347,21 @@ public actor AlbumWriter {
}
}
/// 线 continuation resume
private final class ResumeOnce: @unchecked Sendable {
private var _consumed = false
private let lock = NSLock()
/// true false
func tryConsume() -> Bool {
lock.lock()
defer { lock.unlock() }
if _consumed { return false }
_consumed = true
return true
}
}
public actor LivePhotoValidator {
public init() {}
@@ -378,16 +393,13 @@ public actor LivePhotoValidator {
public func requestLivePhoto(photoURL: URL, pairedVideoURL: URL) async -> PHLivePhoto? {
await withCheckedContinuation { continuation in
var hasResumed = false
let resumeOnce = ResumeOnce()
let requestID = PHLivePhoto.request(
withResourceFileURLs: [pairedVideoURL, photoURL],
placeholderImage: nil,
targetSize: .zero,
contentMode: .aspectFit
) { livePhoto, info in
// resume
guard !hasResumed else { return }
//
if let isDegraded = info[PHLivePhotoInfoIsDegradedKey] as? Bool, isDegraded {
return
@@ -398,8 +410,9 @@ public actor LivePhotoValidator {
#if DEBUG
print("[LivePhotoValidator] requestLivePhoto error: \(error.localizedDescription)")
#endif
hasResumed = true
continuation.resume(returning: nil)
if resumeOnce.tryConsume() {
continuation.resume(returning: nil)
}
return
}
@@ -407,23 +420,24 @@ public actor LivePhotoValidator {
#if DEBUG
print("[LivePhotoValidator] requestLivePhoto cancelled")
#endif
hasResumed = true
continuation.resume(returning: nil)
if resumeOnce.tryConsume() {
continuation.resume(returning: nil)
}
return
}
hasResumed = true
continuation.resume(returning: livePhoto)
if resumeOnce.tryConsume() {
continuation.resume(returning: livePhoto)
}
}
//
DispatchQueue.main.asyncAfter(deadline: .now() + 10) {
guard !hasResumed else { return }
guard resumeOnce.tryConsume() else { return }
#if DEBUG
print("[LivePhotoValidator] requestLivePhoto timeout, requestID: \(requestID)")
#endif
PHLivePhoto.cancelRequest(withRequestID: requestID)
hasResumed = true
continuation.resume(returning: nil)
}
}
@@ -966,6 +980,23 @@ public actor LivePhotoBuilder {
assetWriter.startWriting()
videoReader.startReading()
metadataReader.startReading()
// writer/reader continuation resume
guard assetWriter.status == .writing else {
continuation.resume(throwing: AppError(code: "LPB-301", stage: .writeVideoMetadata, message: "视频处理失败", underlyingErrorDescription: assetWriter.error?.localizedDescription ?? "Writer 启动失败", suggestedActions: ["重试"]))
return
}
guard videoReader.status == .reading else {
assetWriter.cancelWriting()
continuation.resume(throwing: AppError(code: "LPB-301", stage: .writeVideoMetadata, message: "视频处理失败", underlyingErrorDescription: videoReader.error?.localizedDescription ?? "VideoReader 启动失败", suggestedActions: ["重试"]))
return
}
guard metadataReader.status == .reading else {
assetWriter.cancelWriting()
continuation.resume(throwing: AppError(code: "LPB-301", stage: .writeVideoMetadata, message: "视频处理失败", underlyingErrorDescription: metadataReader.error?.localizedDescription ?? "MetadataReader 启动失败", suggestedActions: ["重试"]))
return
}
assetWriter.startSession(atSourceTime: .zero)
var currentFrameCount = 0

View File

@@ -1,10 +1,12 @@
#!/bin/bash
# 多语言快速截图脚本
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# 语言参数
LANGUAGE=${1:-zh-Hans}
SCREENSHOT_DIR="/Users/yuanjiantsui/projects/to-live-photo/app-store-screenshots/$LANGUAGE/6.7inch"
SCREENSHOT_DIR="$SCRIPT_DIR/app-store-screenshots/$LANGUAGE/6.7inch"
mkdir -p "$SCREENSHOT_DIR"
COUNTER=1

View File

@@ -1,7 +1,8 @@
#!/bin/bash
# 将 6.9" 截图缩放为 6.5" 截图
SOURCE_DIR="/Users/yuanjiantsui/projects/to-live-photo/app-store-screenshots"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SOURCE_DIR="$SCRIPT_DIR/app-store-screenshots"
LANGUAGES=("ja" "zh-Hans" "zh-Hant" "en" "es" "ar" "fr" "ko")
echo "📐 开始缩放截图: 6.9\" (1320x2868) → 6.5\" (1284x2778)"

View File

@@ -76,6 +76,7 @@ final class AppState {
self.currentWorkId = nil
self.currentProcessingTask = nil
self.processingProgress = nil
self.pop()
}
}
} else {
@@ -84,6 +85,7 @@ final class AppState {
currentWorkId = nil
currentProcessingTask = nil
processingProgress = nil
pop()
}
Analytics.shared.log(.buildLivePhotoCancel)
@@ -142,7 +144,7 @@ final class AppState {
state.currentExportParams = nil
}
Analytics.shared.log(.buildLivePhotoSuccess)
Analytics.shared.log(.saveAlbumSuccess, parameters: ["assetId": result.savedAssetId])
Analytics.shared.log(.saveAlbumSuccess, parameters: ["assetId": String(result.savedAssetId.prefix(8)) + "..."])
return result
} catch is CancellationError {
//

View File

@@ -530,19 +530,25 @@ struct SoftSlider: View {
let gradient: LinearGradient
let accessibilityLabel: String
let step: Double
let onEditingChanged: ((Bool) -> Void)?
let isDisabled: Bool
init(
value: Binding<Double>,
in range: ClosedRange<Double>,
step: Double = 0.1,
gradient: LinearGradient = Color.gradientPrimary,
accessibilityLabel: String = ""
accessibilityLabel: String = "",
isDisabled: Bool = false,
onEditingChanged: ((Bool) -> Void)? = nil
) {
self._value = value
self.range = range
self.step = step
self.gradient = gradient
self.accessibilityLabel = accessibilityLabel
self.isDisabled = isDisabled
self.onEditingChanged = onEditingChanged
}
var body: some View {
@@ -560,7 +566,7 @@ struct SoftSlider: View {
//
Capsule()
.fill(gradient)
.fill(isDisabled ? LinearGradient(colors: [Color.softPressed], startPoint: .leading, endPoint: .trailing) : gradient)
.frame(width: max(0, thumbX), height: 8)
//
@@ -572,18 +578,25 @@ struct SoftSlider: View {
.gesture(
DragGesture()
.onChanged { gesture in
guard !isDisabled else { return }
let newProgress = gesture.location.x / width
let clampedProgress = max(0, min(1, newProgress))
value = range.lowerBound + (range.upperBound - range.lowerBound) * clampedProgress
onEditingChanged?(true)
}
.onEnded { _ in
onEditingChanged?(false)
}
)
}
.opacity(isDisabled ? 0.5 : 1.0)
}
.frame(height: 28)
.accessibilityElement(children: .ignore)
.accessibilityLabel(accessibilityLabel.isEmpty ? String(localized: "滑块") : accessibilityLabel)
.accessibilityValue(Text(String(format: "%.1f", value)))
.accessibilityAdjustableAction { direction in
guard !isDisabled else { return }
switch direction {
case .increment:
value = min(range.upperBound, value + step)

View File

@@ -20,7 +20,7 @@ final class LanguageManager {
var displayName: String {
switch self {
case .system: return "跟随系统"
case .system: return String(localized: "settings.language.system")
case .zhHans: return "简体中文"
case .zhHant: return "繁體中文"
case .en: return "English"

File diff suppressed because it is too large Load Diff

View File

@@ -241,9 +241,9 @@ struct EditorView: View {
.font(.caption)
.foregroundColor(.textSecondary)
}
.padding(16)
.padding(DesignTokens.Spacing.lg)
.background(Color.softElevated)
.clipShape(RoundedRectangle(cornerRadius: 12))
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.md))
}
// MARK: -
@@ -289,9 +289,9 @@ struct EditorView: View {
}
}
}
.padding(16)
.padding(DesignTokens.Spacing.lg)
.background(Color.softElevated)
.clipShape(RoundedRectangle(cornerRadius: 12))
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.md))
}
// MARK: -
@@ -310,18 +310,25 @@ struct EditorView: View {
.foregroundStyle(.tint)
}
Slider(value: $trimEnd, in: 1.0...max(1.0, min(1.5, videoDuration))) { _ in
updateKeyFrameTime()
}
.disabled(videoDuration < 1.0)
SoftSlider(
value: $trimEnd,
in: 1.0...max(1.0, min(1.5, videoDuration)),
step: 0.1,
gradient: Color.gradientPrimary,
accessibilityLabel: String(localized: "editor.videoDuration"),
isDisabled: videoDuration < 1.0,
onEditingChanged: { _ in
updateKeyFrameTime()
}
)
Text(String(localized: "editor.durationHint"))
.font(.caption)
.foregroundColor(.textSecondary)
}
.padding(16)
.padding(DesignTokens.Spacing.lg)
.background(Color.softElevated)
.clipShape(RoundedRectangle(cornerRadius: 12))
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.md))
}
// MARK: -
@@ -340,19 +347,26 @@ struct EditorView: View {
.foregroundStyle(.tint)
}
Slider(value: $keyFrameTime, in: trimStart...max(trimStart + 0.1, trimEnd)) { editing in
if !editing {
extractCoverFrame()
SoftSlider(
value: $keyFrameTime,
in: trimStart...max(trimStart + 0.1, trimEnd),
step: 0.05,
gradient: Color.gradientCyan,
accessibilityLabel: String(localized: "editor.keyFrameTime"),
onEditingChanged: { editing in
if !editing {
extractCoverFrame()
}
}
}
)
Text(String(localized: "editor.keyFrameHint"))
.font(.caption)
.foregroundColor(.textSecondary)
}
.padding(16)
.padding(DesignTokens.Spacing.lg)
.background(Color.softElevated)
.clipShape(RoundedRectangle(cornerRadius: 12))
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.md))
}
// MARK: - AI
@@ -362,7 +376,7 @@ struct EditorView: View {
Toggle(isOn: $aiEnhanceEnabled) {
HStack {
Image(systemName: "wand.and.stars.inverse")
.foregroundStyle(.purple)
.foregroundStyle(Color.accentPurple)
VStack(alignment: .leading, spacing: 2) {
Text(String(localized: "editor.aiEnhance"))
.font(.headline)
@@ -372,7 +386,7 @@ struct EditorView: View {
}
}
}
.tint(.purple)
.tint(Color.accentPurple)
.disabled(!AIEnhancer.isAvailable() || aiModelDownloading)
.onChange(of: aiEnhanceEnabled) { _, newValue in
if newValue {
@@ -392,8 +406,8 @@ struct EditorView: View {
}
ProgressView(value: aiModelDownloadProgress)
.tint(.purple)
.tint(Color.accentPurple)
Text(String(format: "%.0f%%", aiModelDownloadProgress * 100))
.font(.caption2)
.foregroundColor(.textSecondary)
@@ -414,21 +428,21 @@ struct EditorView: View {
}
HStack(spacing: 4) {
Image(systemName: "sparkles")
.foregroundStyle(.purple)
.foregroundStyle(Color.accentPurple)
.font(.caption)
Text(String(localized: "editor.aiResolutionBoost"))
.font(.caption)
}
HStack(spacing: 4) {
Image(systemName: "clock")
.foregroundStyle(.purple)
.foregroundStyle(Color.accentPurple)
.font(.caption)
Text(String(localized: "editor.aiProcessingTime"))
.font(.caption)
}
HStack(spacing: 4) {
Image(systemName: "cpu")
.foregroundStyle(.purple)
.foregroundStyle(Color.accentPurple)
.font(.caption)
Text(String(localized: "editor.aiLocalProcessing"))
.font(.caption)
@@ -450,9 +464,9 @@ struct EditorView: View {
.padding(.top, 4)
}
}
.padding(16)
.background(Color.purple.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 12))
.padding(DesignTokens.Spacing.lg)
.background(Color.accentPurple.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.md))
.task {
//
aiModelNeedsDownload = await AIEnhancer.needsDownload()
@@ -513,9 +527,9 @@ struct EditorView: View {
.padding(.leading, 4)
}
}
.padding(16)
.padding(DesignTokens.Spacing.lg)
.background(Color.softElevated)
.clipShape(RoundedRectangle(cornerRadius: 12))
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.md))
}
// MARK: -
@@ -561,9 +575,9 @@ struct EditorView: View {
}
}
}
.padding(16)
.background(Color.yellow.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 12))
.padding(DesignTokens.Spacing.lg)
.background(Color.accentOrange.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.md))
}
// MARK: -

View File

@@ -72,11 +72,11 @@ struct HomeView: View {
VStack(spacing: DesignTokens.Spacing.sm) {
Text(String(localized: "home.title"))
.font(.system(size: DesignTokens.FontSize.xxl, weight: .bold))
.font(.title2.bold())
.foregroundColor(.textPrimary)
Text(String(localized: "home.subtitle"))
.font(.system(size: DesignTokens.FontSize.base))
.font(.subheadline)
.foregroundColor(.textSecondary)
.multilineTextAlignment(.center)
}
@@ -91,7 +91,7 @@ struct HomeView: View {
Image(systemName: "video.badge.plus")
.font(.system(size: 18, weight: .semibold))
Text(String(localized: "home.selectVideo"))
.font(.system(size: DesignTokens.FontSize.base, weight: .semibold))
.font(.subheadline.weight(.semibold))
}
.foregroundColor(.white)
.frame(maxWidth: .infinity)
@@ -102,9 +102,6 @@ struct HomeView: View {
}
.buttonStyle(HomeButtonStyle())
.disabled(isLoading)
.onChange(of: selectedItem) { _, _ in
Analytics.shared.log(.homeImportVideoClick)
}
//
if isLoading {
@@ -112,7 +109,7 @@ struct HomeView: View {
ProgressView()
.tint(.accentPurple)
Text(String(localized: "home.loading"))
.font(.system(size: DesignTokens.FontSize.sm))
.font(.footnote)
.foregroundColor(.textSecondary)
}
.padding(.top, DesignTokens.Spacing.sm)
@@ -124,7 +121,7 @@ struct HomeView: View {
Image(systemName: "exclamationmark.circle.fill")
.foregroundColor(.accentOrange)
Text(errorMessage)
.font(.system(size: DesignTokens.FontSize.sm))
.font(.footnote)
.foregroundColor(.accentOrange)
}
.padding(.top, DesignTokens.Spacing.sm)
@@ -150,7 +147,7 @@ struct HomeView: View {
}
Text(String(localized: "home.quickStart"))
.font(.system(size: DesignTokens.FontSize.lg, weight: .semibold))
.font(.headline)
.foregroundColor(.textPrimary)
Spacer()
@@ -166,7 +163,7 @@ struct HomeView: View {
HStack {
Spacer()
Text(String(localized: "home.emptyHint"))
.font(.system(size: DesignTokens.FontSize.xs))
.font(.caption2)
.foregroundColor(.textMuted)
Spacer()
}
@@ -191,13 +188,13 @@ struct HomeView: View {
}
Text(String(localized: "home.recentWorks"))
.font(.system(size: DesignTokens.FontSize.lg, weight: .semibold))
.font(.headline)
.foregroundColor(.textPrimary)
Spacer()
Text(String(localized: "home.worksCount \(recentWorks.recentWorks.count)"))
.font(.system(size: DesignTokens.FontSize.sm))
.font(.footnote)
.foregroundColor(.textMuted)
}
.padding(.horizontal, DesignTokens.Spacing.xs)
@@ -219,6 +216,7 @@ struct HomeView: View {
private func handleSelectedItem(_ item: PhotosPickerItem?) async {
guard let item else { return }
Analytics.shared.log(.homeImportVideoClick)
isLoading = true
errorMessage = nil
@@ -254,12 +252,12 @@ struct QuickStartStep: View {
.frame(width: 24, height: 24)
Text("\(number)")
.font(.system(size: DesignTokens.FontSize.xs, weight: .bold))
.font(.caption2.bold())
.foregroundColor(.white)
}
Text(text)
.font(.system(size: DesignTokens.FontSize.sm))
.font(.footnote)
.foregroundColor(.textSecondary)
Spacer()
@@ -336,11 +334,11 @@ struct RecentWorkCard: View {
//
VStack(alignment: .leading, spacing: 2) {
Text(work.aspectRatioDisplayName)
.font(.system(size: DesignTokens.FontSize.sm, weight: .medium))
.font(.footnote.weight(.medium))
.foregroundColor(.textPrimary)
Text(work.createdAt.formatted(.relative(presentation: .named)))
.font(.system(size: DesignTokens.FontSize.xs))
.font(.caption2)
.foregroundColor(.textMuted)
}
}

View File

@@ -105,11 +105,11 @@ struct OnboardingView: View {
//
VStack(spacing: DesignTokens.Spacing.lg) {
Text(page.title)
.font(.system(size: DesignTokens.FontSize.xxxl, weight: .bold))
.font(.title.bold())
.foregroundColor(.textPrimary)
Text(page.description)
.font(.system(size: DesignTokens.FontSize.base))
.font(.subheadline)
.foregroundColor(.textSecondary)
.multilineTextAlignment(.center)
.lineSpacing(4)
@@ -148,7 +148,7 @@ struct OnboardingView: View {
} label: {
HStack(spacing: DesignTokens.Spacing.sm) {
Text(currentPage < pages.count - 1 ? String(localized: "onboarding.nextStep") : String(localized: "onboarding.getStarted"))
.font(.system(size: DesignTokens.FontSize.base, weight: .semibold))
.font(.subheadline.weight(.semibold))
Image(systemName: currentPage < pages.count - 1 ? "arrow.right" : "checkmark")
.font(.system(size: 14, weight: .semibold))
@@ -168,8 +168,8 @@ struct OnboardingView: View {
Button {
completeOnboarding()
} label: {
Text("跳过")
.font(.system(size: DesignTokens.FontSize.sm, weight: .medium))
Text(String(localized: "onboarding.skip"))
.font(.footnote.weight(.medium))
.foregroundColor(.textMuted)
}
.padding(.top, DesignTokens.Spacing.sm)

View File

@@ -13,7 +13,7 @@ struct PrivacyPolicyView: View {
VStack(alignment: .leading, spacing: 20) {
Text("privacy.lastUpdated")
.font(.caption)
.foregroundStyle(.secondary)
.foregroundStyle(Color.textSecondary)
Group {
sectionHeader(String(localized: "privacy.overview.title"))
@@ -24,15 +24,6 @@ struct PrivacyPolicyView: View {
bulletPoint(String(localized: "privacy.localMode.item1.title"), String(localized: "privacy.localMode.item1.desc"))
bulletPoint(String(localized: "privacy.localMode.item2.title"), String(localized: "privacy.localMode.item2.desc"))
sectionHeader(String(localized: "privacy.cloudMode.title"))
bulletPoint(String(localized: "privacy.cloudMode.item1.title"), String(localized: "privacy.cloudMode.item1.desc"))
bulletPoint(String(localized: "privacy.cloudMode.item2.title"), String(localized: "privacy.cloudMode.item2.desc"))
bulletPoint(String(localized: "privacy.cloudMode.item3.title"), String(localized: "privacy.cloudMode.item3.desc"))
Text("privacy.cloudMode.warning")
.font(.caption)
.foregroundStyle(.orange)
.padding(.vertical, 4)
sectionHeader(String(localized: "privacy.permissions.title"))
bulletPoint(String(localized: "privacy.permissions.item1.title"), String(localized: "privacy.permissions.item1.desc"))
bulletPoint(String(localized: "privacy.permissions.item2.title"), String(localized: "privacy.permissions.item2.desc"))
@@ -45,6 +36,7 @@ struct PrivacyPolicyView: View {
sectionHeader(String(localized: "privacy.storage.title"))
bulletPoint(String(localized: "privacy.storage.item1.title"), String(localized: "privacy.storage.item1.desc"))
bulletPoint(String(localized: "privacy.storage.item2.title"), String(localized: "privacy.storage.item2.desc"))
bulletPoint(String(localized: "privacy.storage.item3.title"), String(localized: "privacy.storage.item3.desc"))
sectionHeader(String(localized: "privacy.thirdParty.title"))
bulletPoint(String(localized: "privacy.thirdParty.item1.title"), String(localized: "privacy.thirdParty.item1.desc"))
@@ -57,7 +49,7 @@ struct PrivacyPolicyView: View {
sectionHeader(String(localized: "privacy.contact.title"))
Text("privacy.contact.intro")
Text("privacy.contact.email")
.foregroundStyle(.blue)
.foregroundStyle(Color.accentPurple)
}
.padding(.horizontal)
}
@@ -76,12 +68,12 @@ struct PrivacyPolicyView: View {
private func bulletPoint(_ title: String, _ description: String) -> some View {
HStack(alignment: .top, spacing: 8) {
Text("")
.foregroundStyle(.secondary)
.foregroundStyle(Color.textSecondary)
VStack(alignment: .leading, spacing: 2) {
Text(title)
.fontWeight(.medium)
Text(description)
.foregroundStyle(.secondary)
.foregroundStyle(Color.textSecondary)
}
}
}
@@ -93,7 +85,7 @@ struct TermsOfServiceView: View {
VStack(alignment: .leading, spacing: 20) {
Text("terms.lastUpdated")
.font(.caption)
.foregroundStyle(.secondary)
.foregroundStyle(Color.textSecondary)
Group {
sectionHeader(String(localized: "terms.acceptance.title"))
@@ -103,21 +95,11 @@ struct TermsOfServiceView: View {
Text("terms.service.desc1")
Text("terms.service.desc2")
sectionHeader(String(localized: "terms.subscription.title"))
bulletPoint(String(localized: "terms.subscription.item1.title"), String(localized: "terms.subscription.item1.desc"))
bulletPoint(String(localized: "terms.subscription.item2.title"), String(localized: "terms.subscription.item2.desc"))
bulletPoint(String(localized: "terms.subscription.item3.title"), String(localized: "terms.subscription.item3.desc"))
bulletPoint(String(localized: "terms.subscription.item4.title"), String(localized: "terms.subscription.item4.desc"))
sectionHeader(String(localized: "terms.limits.title"))
bulletPoint(String(localized: "terms.limits.item1.title"), String(localized: "terms.limits.item1.desc"))
bulletPoint(String(localized: "terms.limits.item2.title"), String(localized: "terms.limits.item2.desc"))
bulletPoint(String(localized: "terms.limits.item3.title"), String(localized: "terms.limits.item3.desc"))
sectionHeader(String(localized: "terms.cloud.title"))
bulletPoint(String(localized: "terms.cloud.item1.title"), String(localized: "terms.cloud.item1.desc"))
bulletPoint(String(localized: "terms.cloud.item2.title"), String(localized: "terms.cloud.item2.desc"))
sectionHeader(String(localized: "terms.disclaimer.title"))
bulletPoint(String(localized: "terms.disclaimer.item1.title"), String(localized: "terms.disclaimer.item1.desc"))
bulletPoint(String(localized: "terms.disclaimer.item2.title"), String(localized: "terms.disclaimer.item2.desc"))
@@ -131,7 +113,7 @@ struct TermsOfServiceView: View {
sectionHeader(String(localized: "terms.contact.title"))
Text("terms.contact.email")
.foregroundStyle(.blue)
.foregroundStyle(Color.accentPurple)
}
.padding(.horizontal)
}
@@ -150,12 +132,12 @@ struct TermsOfServiceView: View {
private func bulletPoint(_ title: String, _ description: String) -> some View {
HStack(alignment: .top, spacing: 8) {
Text("")
.foregroundStyle(.secondary)
.foregroundStyle(Color.textSecondary)
VStack(alignment: .leading, spacing: 2) {
Text(title)
.fontWeight(.medium)
Text(description)
.foregroundStyle(.secondary)
.foregroundStyle(Color.textSecondary)
}
}
}

View File

@@ -43,7 +43,6 @@ struct ProcessingView: View {
ToolbarItem(placement: .navigationBarLeading) {
Button(String(localized: "processing.cancel")) {
appState.cancelProcessing()
appState.pop()
}
.foregroundColor(.accentPurple)
}
@@ -78,7 +77,7 @@ struct ProcessingView: View {
}
Text(String(localized: "processing.cancelling"))
.font(.system(size: DesignTokens.FontSize.lg, weight: .semibold))
.font(.headline)
.foregroundColor(.textSecondary)
}
} else {
@@ -109,7 +108,7 @@ struct ProcessingView: View {
if let progress = appState.processingProgress {
Text(String(format: "%.0f%%", progress.fraction * 100))
.font(.system(size: DesignTokens.FontSize.lg, weight: .bold))
.font(.headline.bold())
.foregroundColor(.textPrimary)
.contentTransition(.numericText())
}
@@ -119,13 +118,13 @@ struct ProcessingView: View {
//
VStack(spacing: DesignTokens.Spacing.md) {
Text(stageText)
.font(.system(size: DesignTokens.FontSize.lg, weight: .semibold))
.font(.headline)
.foregroundColor(.textPrimary)
.contentTransition(.numericText())
.animation(.easeInOut(duration: 0.3), value: stageText)
Text(stageDescription)
.font(.system(size: DesignTokens.FontSize.sm))
.font(.footnote)
.foregroundColor(.textSecondary)
.multilineTextAlignment(.center)
}
@@ -162,11 +161,11 @@ struct ProcessingView: View {
if let error = appState.processingError {
VStack(spacing: DesignTokens.Spacing.md) {
Text(String(localized: "processing.failed"))
.font(.system(size: DesignTokens.FontSize.xl, weight: .bold))
.font(.title3.bold())
.foregroundColor(.textPrimary)
Text(error.message)
.font(.system(size: DesignTokens.FontSize.base))
.font(.subheadline)
.foregroundColor(.textSecondary)
.multilineTextAlignment(.center)
@@ -177,7 +176,7 @@ struct ProcessingView: View {
Image(systemName: "lightbulb.fill")
.foregroundColor(.accentOrange)
Text(String(localized: "processing.suggestions"))
.font(.system(size: DesignTokens.FontSize.sm, weight: .semibold))
.font(.footnote.weight(.semibold))
.foregroundColor(.textPrimary)
}
@@ -187,7 +186,7 @@ struct ProcessingView: View {
.fill(Color.accentOrange)
.frame(width: 6, height: 6)
Text(action)
.font(.system(size: DesignTokens.FontSize.sm))
.font(.footnote)
.foregroundColor(.textSecondary)
}
}

View File

@@ -100,12 +100,12 @@ struct ResultView: View {
private var resultInfo: some View {
VStack(spacing: DesignTokens.Spacing.lg) {
Text(isSuccess ? String(localized: "result.saved") : String(localized: "result.saveFailed"))
.font(.system(size: DesignTokens.FontSize.xxl, weight: .bold))
.font(.title2.bold())
.foregroundColor(.textPrimary)
if isSuccess {
Text(String(localized: "result.savedDescription"))
.font(.system(size: DesignTokens.FontSize.base))
.font(.subheadline)
.foregroundColor(.textSecondary)
.multilineTextAlignment(.center)
@@ -122,7 +122,7 @@ struct ResultView: View {
.padding(.top, DesignTokens.Spacing.sm)
} else {
Text(String(localized: "result.failedDescription"))
.font(.system(size: DesignTokens.FontSize.base))
.font(.subheadline)
.foregroundColor(.textSecondary)
}
}
@@ -144,7 +144,7 @@ struct ResultView: View {
appState.popToRoot()
}
} else {
SoftPrimaryButton("返回重试", icon: "arrow.counterclockwise", gradient: Color.gradientWarm) {
SoftPrimaryButton(String(localized: "result.backToRetry"), icon: "arrow.counterclockwise", gradient: Color.gradientWarm) {
appState.pop()
}
@@ -199,7 +199,7 @@ struct ValidationBadge: View {
Image(systemName: icon)
.font(.system(size: 12))
Text(text)
.font(.system(size: DesignTokens.FontSize.xs, weight: .medium))
.font(.caption2.weight(.medium))
}
.foregroundColor(color)
.padding(.horizontal, DesignTokens.Spacing.md)
@@ -212,6 +212,7 @@ struct ValidationBadge: View {
// MARK: -
struct CelebrationParticles: View {
@State private var particles: [Particle] = []
@State private var viewSize: CGSize = .zero
var body: some View {
GeometryReader { geometry in
@@ -222,22 +223,30 @@ struct CelebrationParticles: View {
.position(particle.position)
.opacity(particle.opacity)
}
}
.onAppear {
generateParticles()
.onAppear {
viewSize = geometry.size
generateParticles(in: geometry.size)
}
}
}
private func generateParticles() {
private func generateParticles(in size: CGSize) {
let colors: [Color] = [.accentPurple, .accentPink, .accentGreen, .accentCyan, .accentOrange]
// 使
let horizontalPadding: CGFloat = 50
let minX = horizontalPadding
let maxX = max(horizontalPadding + 100, size.width - horizontalPadding)
let startY = size.height * 0.5 //
let flyDistance = size.height * 0.4 // 40%
for i in 0..<30 {
let delay = Double(i) * 0.03
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
let particle = Particle(
id: UUID(),
position: CGPoint(x: CGFloat.random(in: 50...350), y: 400),
position: CGPoint(x: CGFloat.random(in: minX...maxX), y: startY),
color: colors.randomElement()!,
size: CGFloat.random(in: 6...12),
opacity: 1.0
@@ -247,7 +256,7 @@ struct CelebrationParticles: View {
//
withAnimation(.easeOut(duration: Double.random(in: 1.5...2.5))) {
if let index = particles.firstIndex(where: { $0.id == particle.id }) {
particles[index].position.y -= CGFloat.random(in: 300...500)
particles[index].position.y -= CGFloat.random(in: flyDistance...(flyDistance * 1.5))
particles[index].position.x += CGFloat.random(in: -50...50)
particles[index].opacity = 0
}

View File

@@ -16,6 +16,7 @@ struct SettingsView: View {
@State private var feedbackPackageURL: URL?
@State private var showingShareSheet = false
@State private var showingLanguageChangeAlert = false
@State private var showingFeedbackConfirmAlert = false
@State private var pendingLanguage: LanguageManager.Language?
var body: some View {
@@ -93,7 +94,7 @@ struct SettingsView: View {
//
Section {
Button {
exportFeedbackPackage()
showingFeedbackConfirmAlert = true
} label: {
Label(String(localized: "settings.exportDiagnostics"), systemImage: "doc.text")
}
@@ -165,15 +166,22 @@ struct SettingsView: View {
Button(String(localized: "common.cancel"), role: .cancel) {
pendingLanguage = nil
}
Button(String(localized: "settings.restartNow"), role: .none) {
Button(String(localized: "settings.applyAndRestart"), role: .none) {
if let newLanguage = pendingLanguage {
LanguageManager.shared.current = newLanguage
}
exit(0)
}
} message: {
Text(String(localized: "settings.languageChangeAlertMessage"))
}
.alert(String(localized: "settings.feedbackConfirmTitle"), isPresented: $showingFeedbackConfirmAlert) {
Button(String(localized: "common.cancel"), role: .cancel) {}
Button(String(localized: "settings.feedbackConfirmExport"), role: .none) {
exportFeedbackPackage()
}
} message: {
Text(String(localized: "settings.feedbackConfirmMessage"))
}
.sheet(isPresented: $showingShareSheet) {
if let url = feedbackPackageURL {
ShareSheet(activityItems: [url])
@@ -407,12 +415,12 @@ struct SettingsView: View {
extension PHAuthorizationStatus: @retroactive CustomStringConvertible {
public var description: String {
switch self {
case .notDetermined: return "未确定"
case .restricted: return "受限"
case .denied: return "已拒绝"
case .authorized: return "已授权"
case .limited: return "部分授权"
@unknown default: return "未知"
case .notDetermined: return "notDetermined"
case .restricted: return "restricted"
case .denied: return "denied"
case .authorized: return "authorized"
case .limited: return "limited"
@unknown default: return "unknown"
}
}
}

View File

@@ -18,7 +18,7 @@ struct WallpaperGuideView: View {
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 24) {
VStack(alignment: .leading, spacing: DesignTokens.Spacing.xxl) {
headerSection
quickActionSection
@@ -29,8 +29,8 @@ struct WallpaperGuideView: View {
doneButton
}
.padding(.horizontal, 20)
.padding(.vertical, 16)
.padding(.horizontal, DesignTokens.Spacing.xl)
.padding(.vertical, DesignTokens.Spacing.lg)
}
.navigationTitle(String(localized: "wallpaper.title"))
.navigationBarTitleDisplayMode(.inline)
@@ -95,17 +95,11 @@ struct WallpaperGuideView: View {
Image(systemName: "arrow.up.right.square")
.font(.title3)
}
.padding(16)
.padding(DesignTokens.Spacing.lg)
.frame(maxWidth: .infinity)
.background(
LinearGradient(
colors: [Color.blue, Color.purple],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.background(Color.gradientPrimary)
.foregroundColor(.white)
.clipShape(RoundedRectangle(cornerRadius: 14))
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.md))
}
}
@@ -172,7 +166,7 @@ struct WallpaperGuideView: View {
}
.padding(12)
.background(Color.softElevated)
.clipShape(RoundedRectangle(cornerRadius: 12))
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.md))
}
}
@@ -233,7 +227,7 @@ struct WallpaperGuideView: View {
.padding()
.background(Color.accentColor)
.foregroundColor(.white)
.clipShape(RoundedRectangle(cornerRadius: 14))
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.md))
}
Text(String(localized: "wallpaper.canAlwaysCreate"))
@@ -317,7 +311,7 @@ struct FAQRow: View {
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.softElevated)
.clipShape(RoundedRectangle(cornerRadius: 12))
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.md))
}
}