fix: 安全审查 P3 问题修复(10项)

无障碍与用户体验:
- 补全 OnboardingView/ProcessingView/ResultView/WallpaperGuideView 无障碍标注
- ContentView 添加后台切换截屏保护(模糊覆盖层)
- EditorView iPad 右侧面板改为响应式宽度(minWidth:320, maxWidth:420)
- ResultView CelebrationParticles 改用 Task 替代 DispatchQueue 延迟
- DesignSystem accessibility key 从中文改为英文
- EditorView/WallpaperGuideView 生成按钮 accentColor 统一为设计系统
- OnboardingView 第四页添加相册权限预告提示

核心库优化:
- LivePhotoLogger 添加日志安全注释
- CacheManager.makeWorkPaths 添加磁盘空间预检查(<500MB 报错)
- ImageFormatConverter BGRA/ARGB 转换改用 vImagePermuteChannels 加速

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
empty
2026-02-07 20:11:50 +08:00
parent 4bcad4d4b8
commit a49fee4b52
10 changed files with 10193 additions and 10050 deletions

View File

@@ -212,21 +212,25 @@ enum ImageFormatConverter {
bytesPerRow: Int bytesPerRow: Int
) -> [UInt8] { ) -> [UInt8] {
var result = [UInt8](repeating: 0, count: width * height * 4) var result = [UInt8](repeating: 0, count: width * height * 4)
let dstBytesPerRow = width * 4
for y in 0..<height { var srcBuffer = vImage_Buffer(
let srcRow = baseAddress.advanced(by: y * bytesPerRow).assumingMemoryBound(to: UInt8.self) data: baseAddress,
let dstOffset = y * width * 4 height: vImagePixelCount(height),
width: vImagePixelCount(width),
rowBytes: bytesPerRow
)
for x in 0..<width { result.withUnsafeMutableBufferPointer { dstPtr in
let srcIdx = x * 4 var dstBuffer = vImage_Buffer(
let dstIdx = dstOffset + x * 4 data: dstPtr.baseAddress!,
height: vImagePixelCount(height),
// BGRA -> RGBA swap width: vImagePixelCount(width),
result[dstIdx + 0] = srcRow[srcIdx + 2] // R rowBytes: dstBytesPerRow
result[dstIdx + 1] = srcRow[srcIdx + 1] // G )
result[dstIdx + 2] = srcRow[srcIdx + 0] // B // BGRA RGBA: [2,1,0,3]
result[dstIdx + 3] = srcRow[srcIdx + 3] // A let permuteMap: [UInt8] = [2, 1, 0, 3]
} vImagePermuteChannels_ARGB8888(&srcBuffer, &dstBuffer, permuteMap, vImage_Flags(kvImageNoFlags))
} }
return result return result
@@ -239,21 +243,25 @@ enum ImageFormatConverter {
bytesPerRow: Int bytesPerRow: Int
) -> [UInt8] { ) -> [UInt8] {
var result = [UInt8](repeating: 0, count: width * height * 4) var result = [UInt8](repeating: 0, count: width * height * 4)
let dstBytesPerRow = width * 4
for y in 0..<height { var srcBuffer = vImage_Buffer(
let srcRow = baseAddress.advanced(by: y * bytesPerRow).assumingMemoryBound(to: UInt8.self) data: baseAddress,
let dstOffset = y * width * 4 height: vImagePixelCount(height),
width: vImagePixelCount(width),
rowBytes: bytesPerRow
)
for x in 0..<width { result.withUnsafeMutableBufferPointer { dstPtr in
let srcIdx = x * 4 var dstBuffer = vImage_Buffer(
let dstIdx = dstOffset + x * 4 data: dstPtr.baseAddress!,
height: vImagePixelCount(height),
// ARGB -> RGBA swap width: vImagePixelCount(width),
result[dstIdx + 0] = srcRow[srcIdx + 1] // R rowBytes: dstBytesPerRow
result[dstIdx + 1] = srcRow[srcIdx + 2] // G )
result[dstIdx + 2] = srcRow[srcIdx + 3] // B // ARGB RGBA: [1,2,3,0]
result[dstIdx + 3] = srcRow[srcIdx + 0] // A let permuteMap: [UInt8] = [1, 2, 3, 0]
} vImagePermuteChannels_ARGB8888(&srcBuffer, &dstBuffer, permuteMap, vImage_Flags(kvImageNoFlags))
} }
return result return result

View File

@@ -260,6 +260,18 @@ public struct CacheManager: Sendable {
} }
public func makeWorkPaths(workId: UUID) throws -> LivePhotoWorkPaths { public func makeWorkPaths(workId: UUID) throws -> LivePhotoWorkPaths {
// 500MB
let resourceValues = try baseDirectory.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey])
if let availableCapacity = resourceValues.volumeAvailableCapacityForImportantUsage,
availableCapacity < 500_000_000 {
throw AppError(
code: "LPB-001",
stage: .normalize,
message: String(localized: "error.insufficientDiskSpace"),
suggestedActions: [String(localized: "error.clearCache"), String(localized: "error.freeSpace")]
)
}
let workDir = baseDirectory.appendingPathComponent(workId.uuidString, isDirectory: true) let workDir = baseDirectory.appendingPathComponent(workId.uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: workDir, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: workDir, withIntermediateDirectories: true)
return LivePhotoWorkPaths( return LivePhotoWorkPaths(
@@ -291,10 +303,14 @@ public struct LivePhotoLogger: Sendable {
self.logger = os.Logger(subsystem: subsystem, category: category) self.logger = os.Logger(subsystem: subsystem, category: category)
} }
///
/// - Important: 使 .public
public func info(_ message: String) { public func info(_ message: String) {
logger.info("\(message, privacy: .public)") logger.info("\(message, privacy: .public)")
} }
///
/// - Important: 使 .public
public func error(_ message: String) { public func error(_ message: String) {
logger.error("\(message, privacy: .public)") logger.error("\(message, privacy: .public)")
} }

View File

@@ -10,32 +10,58 @@ import LivePhotoCore
struct ContentView: View { struct ContentView: View {
@Environment(AppState.self) private var appState @Environment(AppState.self) private var appState
@Environment(\.scenePhase) private var scenePhase
@AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false @AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false
@State private var showPrivacyOverlay = false
var body: some View { var body: some View {
@Bindable var appState = appState @Bindable var appState = appState
if !hasCompletedOnboarding { ZStack {
OnboardingView(hasCompletedOnboarding: $hasCompletedOnboarding) if !hasCompletedOnboarding {
} else { OnboardingView(hasCompletedOnboarding: $hasCompletedOnboarding)
NavigationStack(path: $appState.navigationPath) { } else {
HomeView() NavigationStack(path: $appState.navigationPath) {
.navigationDestination(for: AppRoute.self) { route in HomeView()
switch route { .navigationDestination(for: AppRoute.self) { route in
case .home: switch route {
HomeView() case .home:
case .editor(let videoURL): HomeView()
EditorView(videoURL: videoURL) case .editor(let videoURL):
case .processing(let videoURL, let exportParams): EditorView(videoURL: videoURL)
ProcessingView(videoURL: videoURL, exportParams: exportParams) case .processing(let videoURL, let exportParams):
case .result(let workflowResult): ProcessingView(videoURL: videoURL, exportParams: exportParams)
ResultView(workflowResult: workflowResult) case .result(let workflowResult):
case .wallpaperGuide(let assetId): ResultView(workflowResult: workflowResult)
WallpaperGuideView(assetId: assetId) case .wallpaperGuide(let assetId):
case .settings: WallpaperGuideView(assetId: assetId)
SettingsView() case .settings:
SettingsView()
}
}
}
}
// Privacy overlay for app switcher
if showPrivacyOverlay {
Color.softBackground
.ignoresSafeArea()
.overlay {
VStack(spacing: DesignTokens.Spacing.lg) {
Image(systemName: "livephoto")
.font(.system(size: 48))
.foregroundStyle(.tint)
Text("Live Photo Studio")
.font(.headline)
.foregroundColor(.textSecondary)
} }
} }
.transition(.opacity)
}
}
.onChange(of: scenePhase) { _, newPhase in
withAnimation(.easeInOut(duration: 0.2)) {
showPrivacyOverlay = newPhase != .active
} }
} }
} }

View File

@@ -447,7 +447,7 @@ struct SoftProgressRing: View {
.animation(DesignTokens.Animation.smooth, value: progress) .animation(DesignTokens.Animation.smooth, value: progress)
} }
.accessibilityElement(children: .ignore) .accessibilityElement(children: .ignore)
.accessibilityLabel(accessibilityLabel ?? String(localized: "进度")) .accessibilityLabel(accessibilityLabel ?? String(localized: "accessibility.progress"))
.accessibilityValue(Text("\(Int(progress * 100))%")) .accessibilityValue(Text("\(Int(progress * 100))%"))
} }
} }
@@ -593,7 +593,7 @@ struct SoftSlider: View {
} }
.frame(height: 28) .frame(height: 28)
.accessibilityElement(children: .ignore) .accessibilityElement(children: .ignore)
.accessibilityLabel(accessibilityLabel.isEmpty ? String(localized: "滑块") : accessibilityLabel) .accessibilityLabel(accessibilityLabel.isEmpty ? String(localized: "accessibility.slider") : accessibilityLabel)
.accessibilityValue(Text(String(format: "%.1f", value))) .accessibilityValue(Text(String(format: "%.1f", value)))
.accessibilityAdjustableAction { direction in .accessibilityAdjustableAction { direction in
guard !isDisabled else { return } guard !isDisabled else { return }

File diff suppressed because it is too large Load Diff

View File

@@ -119,7 +119,7 @@ struct EditorView: View {
} }
.padding(.vertical, DesignTokens.Spacing.lg) .padding(.vertical, DesignTokens.Spacing.lg)
} }
.frame(maxWidth: 360) .frame(minWidth: 320, maxWidth: 420)
} }
.padding(DesignTokens.Spacing.xxl) .padding(DesignTokens.Spacing.xxl)
} }
@@ -593,7 +593,7 @@ struct EditorView: View {
.font(.headline) .font(.headline)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding() .padding()
.background(Color.accentColor) .background(Color.gradientPrimary)
.foregroundColor(.white) .foregroundColor(.white)
.clipShape(RoundedRectangle(cornerRadius: 14)) .clipShape(RoundedRectangle(cornerRadius: 14))
} }
@@ -824,10 +824,10 @@ struct AspectRatioButton: View {
VStack(spacing: 4) { VStack(spacing: 4) {
// //
RoundedRectangle(cornerRadius: 4) RoundedRectangle(cornerRadius: 4)
.stroke(isSelected ? Color.accentColor : Color.textSecondary, lineWidth: 2) .stroke(isSelected ? Color.accentPurple : Color.textSecondary, lineWidth: 2)
.frame(width: iconWidth, height: iconHeight) .frame(width: iconWidth, height: iconHeight)
.background( .background(
isSelected ? Color.accentColor.opacity(0.1) : Color.clear isSelected ? Color.accentPurple.opacity(0.1) : Color.clear
) )
.clipShape(RoundedRectangle(cornerRadius: 4)) .clipShape(RoundedRectangle(cornerRadius: 4))
@@ -838,7 +838,7 @@ struct AspectRatioButton: View {
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.vertical, DesignTokens.Spacing.sm) .padding(.vertical, DesignTokens.Spacing.sm)
.background(isSelected ? Color.accentColor.opacity(0.1) : Color.clear) .background(isSelected ? Color.accentPurple.opacity(0.1) : Color.clear)
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.sm)) .clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.sm))
} }
.buttonStyle(.plain) .buttonStyle(.plain)

View File

@@ -113,6 +113,23 @@ struct OnboardingView: View {
.foregroundColor(.textSecondary) .foregroundColor(.textSecondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.lineSpacing(4) .lineSpacing(4)
//
if index == 3 {
HStack(spacing: 6) {
Image(systemName: "lock.shield")
.font(.caption)
.foregroundColor(.accentGreen)
Text(String(localized: "onboarding.page4.permissionHint"))
.font(.caption)
.foregroundColor(.textMuted)
.multilineTextAlignment(.center)
}
.padding(.horizontal, DesignTokens.Spacing.lg)
.padding(.vertical, DesignTokens.Spacing.sm)
.background(Color.accentGreen.opacity(0.1))
.clipShape(Capsule())
}
} }
.padding(.horizontal, DesignTokens.Spacing.xxxl) .padding(.horizontal, DesignTokens.Spacing.xxxl)
@@ -134,6 +151,9 @@ struct OnboardingView: View {
.animation(.spring(response: 0.3, dampingFraction: 0.7), value: currentPage) .animation(.spring(response: 0.3, dampingFraction: 0.7), value: currentPage)
} }
} }
.accessibilityElement(children: .ignore)
.accessibilityLabel(String(localized: "accessibility.pageIndicator"))
.accessibilityValue(String(localized: "accessibility.pageOf \(currentPage + 1) \(pages.count)"))
.padding(.bottom, DesignTokens.Spacing.md) .padding(.bottom, DesignTokens.Spacing.md)
// //

View File

@@ -138,6 +138,8 @@ struct ProcessingView: View {
.animation(.spring(response: 0.3), value: currentStageIndex) .animation(.spring(response: 0.3), value: currentStageIndex)
} }
} }
.accessibilityElement(children: .ignore)
.accessibilityLabel(String(localized: "accessibility.processingStage"))
.padding(.top, DesignTokens.Spacing.md) .padding(.top, DesignTokens.Spacing.md)
} }
} }

View File

@@ -26,6 +26,7 @@ struct ResultView: View {
// //
if isSuccess && celebrationParticles { if isSuccess && celebrationParticles {
CelebrationParticles() CelebrationParticles()
.accessibilityHidden(true)
} }
VStack(spacing: DesignTokens.Spacing.xxxl) { VStack(spacing: DesignTokens.Spacing.xxxl) {
@@ -223,14 +224,14 @@ struct CelebrationParticles: View {
.position(particle.position) .position(particle.position)
.opacity(particle.opacity) .opacity(particle.opacity)
} }
.onAppear { .task {
viewSize = geometry.size viewSize = geometry.size
generateParticles(in: geometry.size) await generateParticles(in: geometry.size)
} }
} }
} }
private func generateParticles(in size: CGSize) { private func generateParticles(in size: CGSize) async {
let colors: [Color] = [.accentPurple, .accentPink, .accentGreen, .accentCyan, .accentOrange] let colors: [Color] = [.accentPurple, .accentPink, .accentGreen, .accentCyan, .accentOrange]
// 使 // 使
@@ -241,25 +242,29 @@ struct CelebrationParticles: View {
let flyDistance = size.height * 0.4 // 40% let flyDistance = size.height * 0.4 // 40%
for i in 0..<30 { for i in 0..<30 {
let delay = Double(i) * 0.03 let delayNanoseconds = UInt64(Double(i) * 0.03 * 1_000_000_000)
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { do {
let particle = Particle( try await Task.sleep(nanoseconds: delayNanoseconds)
id: UUID(), } catch {
position: CGPoint(x: CGFloat.random(in: minX...maxX), y: startY), return // Task was cancelled (view destroyed)
color: colors.randomElement()!, }
size: CGFloat.random(in: 6...12),
opacity: 1.0
)
particles.append(particle)
// let particle = Particle(
withAnimation(.easeOut(duration: Double.random(in: 1.5...2.5))) { id: UUID(),
if let index = particles.firstIndex(where: { $0.id == particle.id }) { position: CGPoint(x: CGFloat.random(in: minX...maxX), y: startY),
particles[index].position.y -= CGFloat.random(in: flyDistance...(flyDistance * 1.5)) color: colors.randomElement()!,
particles[index].position.x += CGFloat.random(in: -50...50) size: CGFloat.random(in: 6...12),
particles[index].opacity = 0 opacity: 1.0
} )
particles.append(particle)
//
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: flyDistance...(flyDistance * 1.5))
particles[index].position.x += CGFloat.random(in: -50...50)
particles[index].opacity = 0
} }
} }
} }

View File

@@ -225,7 +225,7 @@ struct WallpaperGuideView: View {
.font(.headline) .font(.headline)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding() .padding()
.background(Color.accentColor) .background(Color.gradientPrimary)
.foregroundColor(.white) .foregroundColor(.white)
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.md)) .clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.md))
} }
@@ -250,7 +250,7 @@ struct StepRow: View {
VStack(spacing: 0) { VStack(spacing: 0) {
ZStack { ZStack {
Circle() Circle()
.fill(Color.accentColor) .fill(Color.accentPurple)
.frame(width: 32, height: 32) .frame(width: 32, height: 32)
Text("\(number)") Text("\(number)")
.font(.subheadline) .font(.subheadline)
@@ -260,7 +260,7 @@ struct StepRow: View {
if !isLast { if !isLast {
Rectangle() Rectangle()
.fill(Color.accentColor.opacity(0.3)) .fill(Color.accentPurple.opacity(0.3))
.frame(width: 2) .frame(width: 2)
.frame(maxHeight: .infinity) .frame(maxHeight: .infinity)
} }
@@ -275,7 +275,7 @@ struct StepRow: View {
.font(.subheadline) .font(.subheadline)
.fontWeight(.semibold) .fontWeight(.semibold)
} }
Text(description) Text(description)
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
@@ -283,6 +283,7 @@ struct StepRow: View {
} }
.padding(.bottom, isLast ? 0 : 16) .padding(.bottom, isLast ? 0 : 16)
} }
.accessibilityElement(children: .combine)
} }
} }
@@ -312,6 +313,7 @@ struct FAQRow: View {
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.background(Color.softElevated) .background(Color.softElevated)
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.md)) .clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.md))
.accessibilityElement(children: .combine)
} }
} }