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:
@@ -212,21 +212,25 @@ enum ImageFormatConverter {
|
||||
bytesPerRow: Int
|
||||
) -> [UInt8] {
|
||||
var result = [UInt8](repeating: 0, count: width * height * 4)
|
||||
let dstBytesPerRow = width * 4
|
||||
|
||||
for y in 0..<height {
|
||||
let srcRow = baseAddress.advanced(by: y * bytesPerRow).assumingMemoryBound(to: UInt8.self)
|
||||
let dstOffset = y * width * 4
|
||||
var srcBuffer = vImage_Buffer(
|
||||
data: baseAddress,
|
||||
height: vImagePixelCount(height),
|
||||
width: vImagePixelCount(width),
|
||||
rowBytes: bytesPerRow
|
||||
)
|
||||
|
||||
for x in 0..<width {
|
||||
let srcIdx = x * 4
|
||||
let dstIdx = dstOffset + x * 4
|
||||
|
||||
// BGRA -> RGBA swap
|
||||
result[dstIdx + 0] = srcRow[srcIdx + 2] // R
|
||||
result[dstIdx + 1] = srcRow[srcIdx + 1] // G
|
||||
result[dstIdx + 2] = srcRow[srcIdx + 0] // B
|
||||
result[dstIdx + 3] = srcRow[srcIdx + 3] // A
|
||||
}
|
||||
result.withUnsafeMutableBufferPointer { dstPtr in
|
||||
var dstBuffer = vImage_Buffer(
|
||||
data: dstPtr.baseAddress!,
|
||||
height: vImagePixelCount(height),
|
||||
width: vImagePixelCount(width),
|
||||
rowBytes: dstBytesPerRow
|
||||
)
|
||||
// BGRA → RGBA: 通道重排 [2,1,0,3]
|
||||
let permuteMap: [UInt8] = [2, 1, 0, 3]
|
||||
vImagePermuteChannels_ARGB8888(&srcBuffer, &dstBuffer, permuteMap, vImage_Flags(kvImageNoFlags))
|
||||
}
|
||||
|
||||
return result
|
||||
@@ -239,21 +243,25 @@ enum ImageFormatConverter {
|
||||
bytesPerRow: Int
|
||||
) -> [UInt8] {
|
||||
var result = [UInt8](repeating: 0, count: width * height * 4)
|
||||
let dstBytesPerRow = width * 4
|
||||
|
||||
for y in 0..<height {
|
||||
let srcRow = baseAddress.advanced(by: y * bytesPerRow).assumingMemoryBound(to: UInt8.self)
|
||||
let dstOffset = y * width * 4
|
||||
var srcBuffer = vImage_Buffer(
|
||||
data: baseAddress,
|
||||
height: vImagePixelCount(height),
|
||||
width: vImagePixelCount(width),
|
||||
rowBytes: bytesPerRow
|
||||
)
|
||||
|
||||
for x in 0..<width {
|
||||
let srcIdx = x * 4
|
||||
let dstIdx = dstOffset + x * 4
|
||||
|
||||
// ARGB -> RGBA swap
|
||||
result[dstIdx + 0] = srcRow[srcIdx + 1] // R
|
||||
result[dstIdx + 1] = srcRow[srcIdx + 2] // G
|
||||
result[dstIdx + 2] = srcRow[srcIdx + 3] // B
|
||||
result[dstIdx + 3] = srcRow[srcIdx + 0] // A
|
||||
}
|
||||
result.withUnsafeMutableBufferPointer { dstPtr in
|
||||
var dstBuffer = vImage_Buffer(
|
||||
data: dstPtr.baseAddress!,
|
||||
height: vImagePixelCount(height),
|
||||
width: vImagePixelCount(width),
|
||||
rowBytes: dstBytesPerRow
|
||||
)
|
||||
// ARGB → RGBA: 通道重排 [1,2,3,0]
|
||||
let permuteMap: [UInt8] = [1, 2, 3, 0]
|
||||
vImagePermuteChannels_ARGB8888(&srcBuffer, &dstBuffer, permuteMap, vImage_Flags(kvImageNoFlags))
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
@@ -260,6 +260,18 @@ public struct CacheManager: Sendable {
|
||||
}
|
||||
|
||||
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)
|
||||
try FileManager.default.createDirectory(at: workDir, withIntermediateDirectories: true)
|
||||
return LivePhotoWorkPaths(
|
||||
@@ -291,10 +303,14 @@ public struct LivePhotoLogger: Sendable {
|
||||
self.logger = os.Logger(subsystem: subsystem, category: category)
|
||||
}
|
||||
|
||||
/// 记录信息级别日志
|
||||
/// - Important: 日志使用 .public 隐私级别,不要在消息中包含用户输入内容、个人信息或文件路径中的用户名
|
||||
public func info(_ message: String) {
|
||||
logger.info("\(message, privacy: .public)")
|
||||
}
|
||||
|
||||
/// 记录错误级别日志
|
||||
/// - Important: 日志使用 .public 隐私级别,不要在消息中包含用户输入内容、个人信息或文件路径中的用户名
|
||||
public func error(_ message: String) {
|
||||
logger.error("\(message, privacy: .public)")
|
||||
}
|
||||
|
||||
@@ -10,32 +10,58 @@ import LivePhotoCore
|
||||
|
||||
struct ContentView: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false
|
||||
@State private var showPrivacyOverlay = false
|
||||
|
||||
var body: some View {
|
||||
@Bindable var appState = appState
|
||||
|
||||
if !hasCompletedOnboarding {
|
||||
OnboardingView(hasCompletedOnboarding: $hasCompletedOnboarding)
|
||||
} else {
|
||||
NavigationStack(path: $appState.navigationPath) {
|
||||
HomeView()
|
||||
.navigationDestination(for: AppRoute.self) { route in
|
||||
switch route {
|
||||
case .home:
|
||||
HomeView()
|
||||
case .editor(let videoURL):
|
||||
EditorView(videoURL: videoURL)
|
||||
case .processing(let videoURL, let exportParams):
|
||||
ProcessingView(videoURL: videoURL, exportParams: exportParams)
|
||||
case .result(let workflowResult):
|
||||
ResultView(workflowResult: workflowResult)
|
||||
case .wallpaperGuide(let assetId):
|
||||
WallpaperGuideView(assetId: assetId)
|
||||
case .settings:
|
||||
SettingsView()
|
||||
ZStack {
|
||||
if !hasCompletedOnboarding {
|
||||
OnboardingView(hasCompletedOnboarding: $hasCompletedOnboarding)
|
||||
} else {
|
||||
NavigationStack(path: $appState.navigationPath) {
|
||||
HomeView()
|
||||
.navigationDestination(for: AppRoute.self) { route in
|
||||
switch route {
|
||||
case .home:
|
||||
HomeView()
|
||||
case .editor(let videoURL):
|
||||
EditorView(videoURL: videoURL)
|
||||
case .processing(let videoURL, let exportParams):
|
||||
ProcessingView(videoURL: videoURL, exportParams: exportParams)
|
||||
case .result(let workflowResult):
|
||||
ResultView(workflowResult: workflowResult)
|
||||
case .wallpaperGuide(let assetId):
|
||||
WallpaperGuideView(assetId: assetId)
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -447,7 +447,7 @@ struct SoftProgressRing: View {
|
||||
.animation(DesignTokens.Animation.smooth, value: progress)
|
||||
}
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(accessibilityLabel ?? String(localized: "进度"))
|
||||
.accessibilityLabel(accessibilityLabel ?? String(localized: "accessibility.progress"))
|
||||
.accessibilityValue(Text("\(Int(progress * 100))%"))
|
||||
}
|
||||
}
|
||||
@@ -593,7 +593,7 @@ struct SoftSlider: View {
|
||||
}
|
||||
.frame(height: 28)
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(accessibilityLabel.isEmpty ? String(localized: "滑块") : accessibilityLabel)
|
||||
.accessibilityLabel(accessibilityLabel.isEmpty ? String(localized: "accessibility.slider") : accessibilityLabel)
|
||||
.accessibilityValue(Text(String(format: "%.1f", value)))
|
||||
.accessibilityAdjustableAction { direction in
|
||||
guard !isDisabled else { return }
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -119,7 +119,7 @@ struct EditorView: View {
|
||||
}
|
||||
.padding(.vertical, DesignTokens.Spacing.lg)
|
||||
}
|
||||
.frame(maxWidth: 360)
|
||||
.frame(minWidth: 320, maxWidth: 420)
|
||||
}
|
||||
.padding(DesignTokens.Spacing.xxl)
|
||||
}
|
||||
@@ -593,7 +593,7 @@ struct EditorView: View {
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color.accentColor)
|
||||
.background(Color.gradientPrimary)
|
||||
.foregroundColor(.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
@@ -824,10 +824,10 @@ struct AspectRatioButton: View {
|
||||
VStack(spacing: 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)
|
||||
.background(
|
||||
isSelected ? Color.accentColor.opacity(0.1) : Color.clear
|
||||
isSelected ? Color.accentPurple.opacity(0.1) : Color.clear
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||
|
||||
@@ -838,7 +838,7 @@ struct AspectRatioButton: View {
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.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))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
@@ -113,6 +113,23 @@ struct OnboardingView: View {
|
||||
.foregroundColor(.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.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)
|
||||
|
||||
@@ -134,6 +151,9 @@ struct OnboardingView: View {
|
||||
.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)
|
||||
|
||||
// 主按钮
|
||||
|
||||
@@ -138,6 +138,8 @@ struct ProcessingView: View {
|
||||
.animation(.spring(response: 0.3), value: currentStageIndex)
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(String(localized: "accessibility.processingStage"))
|
||||
.padding(.top, DesignTokens.Spacing.md)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ struct ResultView: View {
|
||||
// 成功时的庆祝粒子效果
|
||||
if isSuccess && celebrationParticles {
|
||||
CelebrationParticles()
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
|
||||
VStack(spacing: DesignTokens.Spacing.xxxl) {
|
||||
@@ -223,14 +224,14 @@ struct CelebrationParticles: View {
|
||||
.position(particle.position)
|
||||
.opacity(particle.opacity)
|
||||
}
|
||||
.onAppear {
|
||||
.task {
|
||||
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]
|
||||
|
||||
// 使用实际屏幕尺寸计算粒子位置
|
||||
@@ -241,25 +242,29 @@ struct CelebrationParticles: View {
|
||||
let flyDistance = size.height * 0.4 // 飞行距离为屏幕高度的 40%
|
||||
|
||||
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) {
|
||||
let particle = Particle(
|
||||
id: UUID(),
|
||||
position: CGPoint(x: CGFloat.random(in: minX...maxX), y: startY),
|
||||
color: colors.randomElement()!,
|
||||
size: CGFloat.random(in: 6...12),
|
||||
opacity: 1.0
|
||||
)
|
||||
particles.append(particle)
|
||||
do {
|
||||
try await Task.sleep(nanoseconds: delayNanoseconds)
|
||||
} catch {
|
||||
return // Task was cancelled (view destroyed)
|
||||
}
|
||||
|
||||
// 动画粒子向上飘动
|
||||
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
|
||||
}
|
||||
let particle = Particle(
|
||||
id: UUID(),
|
||||
position: CGPoint(x: CGFloat.random(in: minX...maxX), y: startY),
|
||||
color: colors.randomElement()!,
|
||||
size: CGFloat.random(in: 6...12),
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,7 +225,7 @@ struct WallpaperGuideView: View {
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color.accentColor)
|
||||
.background(Color.gradientPrimary)
|
||||
.foregroundColor(.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.md))
|
||||
}
|
||||
@@ -250,7 +250,7 @@ struct StepRow: View {
|
||||
VStack(spacing: 0) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color.accentColor)
|
||||
.fill(Color.accentPurple)
|
||||
.frame(width: 32, height: 32)
|
||||
Text("\(number)")
|
||||
.font(.subheadline)
|
||||
@@ -260,7 +260,7 @@ struct StepRow: View {
|
||||
|
||||
if !isLast {
|
||||
Rectangle()
|
||||
.fill(Color.accentColor.opacity(0.3))
|
||||
.fill(Color.accentPurple.opacity(0.3))
|
||||
.frame(width: 2)
|
||||
.frame(maxHeight: .infinity)
|
||||
}
|
||||
@@ -275,7 +275,7 @@ struct StepRow: View {
|
||||
.font(.subheadline)
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
|
||||
|
||||
Text(description)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
@@ -283,6 +283,7 @@ struct StepRow: View {
|
||||
}
|
||||
.padding(.bottom, isLast ? 0 : 16)
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -312,6 +313,7 @@ struct FAQRow: View {
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color.softElevated)
|
||||
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.md))
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user