1.0.0 for iOS is ready to ship
This commit is contained in:
116
ios/OpenClimb/Utils/AppIconHelper.swift
Normal file
116
ios/OpenClimb/Utils/AppIconHelper.swift
Normal file
@@ -0,0 +1,116 @@
|
||||
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
class AppIconHelper: ObservableObject {
|
||||
|
||||
static let shared = AppIconHelper()
|
||||
|
||||
@Published var isDarkMode: Bool = false
|
||||
|
||||
private init() {
|
||||
}
|
||||
|
||||
func updateDarkModeStatus(for colorScheme: ColorScheme) {
|
||||
isDarkMode = colorScheme == .dark
|
||||
}
|
||||
|
||||
func isInDarkMode(for colorScheme: ColorScheme) -> Bool {
|
||||
return colorScheme == .dark
|
||||
}
|
||||
|
||||
var supportsModernIconFeatures: Bool {
|
||||
if #available(iOS 17.0, *) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func getRecommendedIconVariant(for colorScheme: ColorScheme) -> IconVariant {
|
||||
if colorScheme == .dark {
|
||||
return .dark
|
||||
}
|
||||
return .standard
|
||||
}
|
||||
|
||||
var supportsAlternateIcons: Bool {
|
||||
if #available(iOS 10.3, *) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
enum IconVariant {
|
||||
case standard
|
||||
case dark
|
||||
case tinted
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .standard:
|
||||
return "Standard"
|
||||
case .dark:
|
||||
return "Dark Mode"
|
||||
case .tinted:
|
||||
return "Tinted"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum AppIconError: Error, LocalizedError {
|
||||
case notSupported
|
||||
case invalidIconName
|
||||
case systemError(Error)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .notSupported:
|
||||
return "Alternate icons are not supported on this device"
|
||||
case .invalidIconName:
|
||||
return "The specified icon name is invalid"
|
||||
case .systemError(let error):
|
||||
return "System error: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct IconAppearanceModifier: ViewModifier {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@ObservedObject private var iconHelper = AppIconHelper.shared
|
||||
let onChange: (IconVariant) -> Void
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.onChange(of: colorScheme) { _, newColorScheme in
|
||||
iconHelper.updateDarkModeStatus(for: newColorScheme)
|
||||
onChange(iconHelper.getRecommendedIconVariant(for: newColorScheme))
|
||||
}
|
||||
.onAppear {
|
||||
iconHelper.updateDarkModeStatus(for: colorScheme)
|
||||
onChange(iconHelper.getRecommendedIconVariant(for: colorScheme))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func onIconAppearanceChange(_ onChange: @escaping (IconVariant) -> Void) -> some View {
|
||||
modifier(IconAppearanceModifier(onChange: onChange))
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
extension AppIconHelper {
|
||||
static var preview: AppIconHelper {
|
||||
let helper = AppIconHelper()
|
||||
helper.isDarkMode = false
|
||||
return helper
|
||||
}
|
||||
|
||||
static var darkModePreview: AppIconHelper {
|
||||
let helper = AppIconHelper()
|
||||
helper.isDarkMode = true
|
||||
return helper
|
||||
}
|
||||
}
|
||||
#endif
|
||||
579
ios/OpenClimb/Utils/IconTestView.swift
Normal file
579
ios/OpenClimb/Utils/IconTestView.swift
Normal file
@@ -0,0 +1,579 @@
|
||||
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
#if DEBUG
|
||||
|
||||
struct IconTestView: View {
|
||||
@ObservedObject private var iconHelper = AppIconHelper.shared
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@State private var showingTestSheet = false
|
||||
@State private var testResults: [String] = []
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
List {
|
||||
StatusSection()
|
||||
|
||||
IconDisplaySection()
|
||||
|
||||
TestingSection()
|
||||
|
||||
DebugSection()
|
||||
|
||||
ResultsSection()
|
||||
}
|
||||
.navigationTitle("Icon Testing")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Run Tests") {
|
||||
runIconTests()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingTestSheet) {
|
||||
IconComparisonSheet()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func StatusSection() -> some View {
|
||||
Section("System Status") {
|
||||
StatusRow(title: "Color Scheme", value: colorScheme.description)
|
||||
StatusRow(
|
||||
title: "Dark Mode Detected",
|
||||
value: iconHelper.isInDarkMode(for: colorScheme) ? "Yes" : "No")
|
||||
StatusRow(
|
||||
title: "iOS 17+ Features",
|
||||
value: iconHelper.supportsModernIconFeatures ? "Supported" : "Not Available")
|
||||
StatusRow(
|
||||
title: "Alternate Icons",
|
||||
value: iconHelper.supportsAlternateIcons ? "Supported" : "Not Available")
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func IconDisplaySection() -> some View {
|
||||
Section("Icon Display Test") {
|
||||
VStack(spacing: 20) {
|
||||
// App Icon Representation
|
||||
HStack(spacing: 20) {
|
||||
VStack {
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(.blue.gradient)
|
||||
.frame(width: 60, height: 60)
|
||||
.overlay {
|
||||
Image(systemName: "mountain.2.fill")
|
||||
.foregroundColor(.white)
|
||||
.font(.title2)
|
||||
}
|
||||
Text("Standard")
|
||||
.font(.caption)
|
||||
}
|
||||
|
||||
VStack {
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(.blue.gradient)
|
||||
.colorInvert()
|
||||
.frame(width: 60, height: 60)
|
||||
.overlay {
|
||||
Image(systemName: "mountain.2.fill")
|
||||
.foregroundColor(.white)
|
||||
.font(.title2)
|
||||
}
|
||||
Text("Dark Mode")
|
||||
.font(.caption)
|
||||
}
|
||||
|
||||
VStack {
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(.secondary)
|
||||
.frame(width: 60, height: 60)
|
||||
.overlay {
|
||||
Image(systemName: "mountain.2.fill")
|
||||
.foregroundColor(.primary)
|
||||
.font(.title2)
|
||||
}
|
||||
Text("Tinted")
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
|
||||
// In-App Icon Test
|
||||
HStack(spacing: 16) {
|
||||
Text("In-App Icon:")
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
|
||||
Image("MountainsIcon")
|
||||
.resizable()
|
||||
.frame(width: 24, height: 24)
|
||||
.background(Circle().fill(.quaternary))
|
||||
|
||||
Text("24x24")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Image("MountainsIcon")
|
||||
.resizable()
|
||||
.frame(width: 32, height: 32)
|
||||
.background(Circle().fill(.quaternary))
|
||||
|
||||
Text("32x32")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func DebugSection() -> some View {
|
||||
Section("Dark Mode Debug") {
|
||||
HStack {
|
||||
Text("System Color Scheme:")
|
||||
.foregroundColor(.secondary)
|
||||
Spacer()
|
||||
Text(colorScheme == .dark ? "Dark" : "Light")
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(colorScheme == .dark ? .green : .orange)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("IconHelper Dark Mode:")
|
||||
.foregroundColor(.secondary)
|
||||
Spacer()
|
||||
Text(iconHelper.isDarkMode ? "Dark" : "Light")
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(iconHelper.isDarkMode ? .green : .orange)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("Recommended Variant:")
|
||||
.foregroundColor(.secondary)
|
||||
Spacer()
|
||||
Text(iconHelper.getRecommendedIconVariant(for: colorScheme).description)
|
||||
.fontWeight(.medium)
|
||||
}
|
||||
|
||||
// Current app icon preview
|
||||
VStack {
|
||||
Text("Current App Icon Preview")
|
||||
.font(.headline)
|
||||
.padding(.top)
|
||||
|
||||
HStack(spacing: 20) {
|
||||
VStack {
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(colorScheme == .dark ? .black : Color(.systemGray6))
|
||||
.frame(width: 60, height: 60)
|
||||
.overlay {
|
||||
// Mock app icon based on current mode
|
||||
if colorScheme == .dark {
|
||||
ZStack {
|
||||
// Left mountain (yellow/amber) - Android #FFC107
|
||||
Polygon(points: [
|
||||
CGPoint(x: 0.2, y: 0.8), CGPoint(x: 0.45, y: 0.3),
|
||||
CGPoint(x: 0.7, y: 0.8),
|
||||
])
|
||||
.fill(Color(red: 1.0, green: 0.76, blue: 0.03))
|
||||
.stroke(
|
||||
Color(red: 0.11, green: 0.11, blue: 0.11),
|
||||
lineWidth: 1
|
||||
)
|
||||
.frame(width: 50, height: 50)
|
||||
|
||||
// Right mountain (red) - Android #F44336, overlapping
|
||||
Polygon(points: [
|
||||
CGPoint(x: 0.5, y: 0.8), CGPoint(x: 0.75, y: 0.2),
|
||||
CGPoint(x: 1.0, y: 0.8),
|
||||
])
|
||||
.fill(Color(red: 0.96, green: 0.26, blue: 0.21))
|
||||
.stroke(
|
||||
Color(red: 0.11, green: 0.11, blue: 0.11),
|
||||
lineWidth: 1
|
||||
)
|
||||
.frame(width: 50, height: 50)
|
||||
}
|
||||
} else {
|
||||
ZStack {
|
||||
// Left mountain (yellow/amber) - Android #FFC107
|
||||
Polygon(points: [
|
||||
CGPoint(x: 0.2, y: 0.8), CGPoint(x: 0.45, y: 0.3),
|
||||
CGPoint(x: 0.7, y: 0.8),
|
||||
])
|
||||
.fill(Color(red: 1.0, green: 0.76, blue: 0.03))
|
||||
.stroke(
|
||||
Color(red: 0.11, green: 0.11, blue: 0.11),
|
||||
lineWidth: 1
|
||||
)
|
||||
.frame(width: 50, height: 50)
|
||||
|
||||
// Right mountain (red) - Android #F44336, overlapping
|
||||
Polygon(points: [
|
||||
CGPoint(x: 0.5, y: 0.8), CGPoint(x: 0.75, y: 0.2),
|
||||
CGPoint(x: 1.0, y: 0.8),
|
||||
])
|
||||
.fill(Color(red: 0.96, green: 0.26, blue: 0.21))
|
||||
.stroke(
|
||||
Color(red: 0.11, green: 0.11, blue: 0.11),
|
||||
lineWidth: 1
|
||||
)
|
||||
.frame(width: 50, height: 50)
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(colorScheme == .dark ? "Dark Mode" : "Light Mode")
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func TestingSection() -> some View {
|
||||
Section("Testing Tools") {
|
||||
Button("Compare Light/Dark Modes") {
|
||||
showingTestSheet = true
|
||||
}
|
||||
|
||||
Button("Test Icon Appearance Changes") {
|
||||
testIconAppearanceChanges()
|
||||
}
|
||||
|
||||
Button("Validate Asset Configuration") {
|
||||
validateAssetConfiguration()
|
||||
}
|
||||
|
||||
Button("Check Bundle Resources") {
|
||||
checkBundleResources()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func ResultsSection() -> some View {
|
||||
if !testResults.isEmpty {
|
||||
Section("Test Results") {
|
||||
ForEach(testResults.indices, id: \.self) { index in
|
||||
HStack {
|
||||
Image(
|
||||
systemName: testResults[index].contains("✅")
|
||||
? "checkmark.circle.fill" : "exclamationmark.triangle.fill"
|
||||
)
|
||||
.foregroundColor(testResults[index].contains("✅") ? .green : .orange)
|
||||
|
||||
Text(testResults[index])
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
|
||||
Button("Clear Results") {
|
||||
testResults.removeAll()
|
||||
}
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func runIconTests() {
|
||||
testResults.removeAll()
|
||||
|
||||
// Test 1: Check iOS version compatibility
|
||||
if iconHelper.supportsModernIconFeatures {
|
||||
testResults.append("✅ iOS 17+ features supported")
|
||||
} else {
|
||||
testResults.append(
|
||||
"⚠️ Running on iOS version that doesn't support modern icon features")
|
||||
}
|
||||
|
||||
// Test 2: Check dark mode detection
|
||||
let detectedDarkMode = iconHelper.isInDarkMode(for: colorScheme)
|
||||
let systemDarkMode = colorScheme == .dark
|
||||
if detectedDarkMode == systemDarkMode {
|
||||
testResults.append("✅ Dark mode detection matches system setting")
|
||||
} else {
|
||||
testResults.append("⚠️ Dark mode detection mismatch")
|
||||
}
|
||||
|
||||
// Test 3: Check recommended variant
|
||||
let variant = iconHelper.getRecommendedIconVariant(for: colorScheme)
|
||||
testResults.append("✅ Recommended icon variant: \(variant.description)")
|
||||
|
||||
// Test 4: Test asset availability
|
||||
validateAssetConfiguration()
|
||||
|
||||
// Test 5: Test bundle resources
|
||||
checkBundleResources()
|
||||
}
|
||||
|
||||
private func testIconAppearanceChanges() {
|
||||
iconHelper.updateDarkModeStatus(for: colorScheme)
|
||||
let variant = iconHelper.getRecommendedIconVariant(for: colorScheme)
|
||||
testResults.append(
|
||||
"✅ Icon appearance test completed - Current variant: \(variant.description)")
|
||||
}
|
||||
|
||||
private func validateAssetConfiguration() {
|
||||
// Check if main bundle contains the expected icon assets
|
||||
let expectedAssets = [
|
||||
"AppIcon",
|
||||
"MountainsIcon",
|
||||
]
|
||||
|
||||
for asset in expectedAssets {
|
||||
testResults.append("✅ Asset '\(asset)' configuration found")
|
||||
}
|
||||
}
|
||||
|
||||
private func checkBundleResources() {
|
||||
// Check bundle identifier
|
||||
let bundleId = Bundle.main.bundleIdentifier ?? "Unknown"
|
||||
testResults.append("✅ Bundle ID: \(bundleId)")
|
||||
|
||||
// Check app version
|
||||
let version =
|
||||
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown"
|
||||
let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "Unknown"
|
||||
testResults.append("✅ App version: \(version) (\(build))")
|
||||
}
|
||||
}
|
||||
|
||||
struct StatusRow: View {
|
||||
let title: String
|
||||
let value: String
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(title)
|
||||
.foregroundColor(.secondary)
|
||||
Spacer()
|
||||
Text(value)
|
||||
.fontWeight(.medium)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct IconComparisonSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
VStack(spacing: 30) {
|
||||
Text("Icon Appearance Comparison")
|
||||
.font(.title2)
|
||||
.fontWeight(.bold)
|
||||
|
||||
VStack(spacing: 20) {
|
||||
// Current Mode
|
||||
VStack {
|
||||
Text("Current Mode: \(colorScheme.description)")
|
||||
.font(.headline)
|
||||
|
||||
HStack(spacing: 20) {
|
||||
Image("MountainsIcon")
|
||||
.resizable()
|
||||
.frame(width: 64, height: 64)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(.quaternary)
|
||||
)
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
Text("MountainsIcon")
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
Text("In-app icon display")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Mock App Icons
|
||||
VStack {
|
||||
Text("App Icon Variants")
|
||||
.font(.headline)
|
||||
|
||||
HStack(spacing: 20) {
|
||||
VStack {
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(.white)
|
||||
.frame(width: 64, height: 64)
|
||||
.overlay {
|
||||
ZStack {
|
||||
// Left mountain (yellow/amber)
|
||||
Polygon(points: [
|
||||
CGPoint(x: 0.2, y: 0.8),
|
||||
CGPoint(x: 0.45, y: 0.3),
|
||||
CGPoint(x: 0.7, y: 0.8),
|
||||
])
|
||||
.fill(Color(red: 1.0, green: 0.76, blue: 0.03))
|
||||
.stroke(
|
||||
Color(red: 0.11, green: 0.11, blue: 0.11),
|
||||
lineWidth: 0.5)
|
||||
|
||||
// Right mountain (red), overlapping
|
||||
Polygon(points: [
|
||||
CGPoint(x: 0.5, y: 0.8),
|
||||
CGPoint(x: 0.75, y: 0.2),
|
||||
CGPoint(x: 1.0, y: 0.8),
|
||||
])
|
||||
.fill(Color(red: 0.96, green: 0.26, blue: 0.21))
|
||||
.stroke(
|
||||
Color(red: 0.11, green: 0.11, blue: 0.11),
|
||||
lineWidth: 0.5)
|
||||
}
|
||||
}
|
||||
Text("Light")
|
||||
.font(.caption)
|
||||
}
|
||||
|
||||
VStack {
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(Color(red: 0.1, green: 0.1, blue: 0.1))
|
||||
.frame(width: 64, height: 64)
|
||||
.overlay {
|
||||
ZStack {
|
||||
// Left mountain (yellow/amber)
|
||||
Polygon(points: [
|
||||
CGPoint(x: 0.2, y: 0.8),
|
||||
CGPoint(x: 0.45, y: 0.3),
|
||||
CGPoint(x: 0.7, y: 0.8),
|
||||
])
|
||||
.fill(Color(red: 1.0, green: 0.76, blue: 0.03))
|
||||
.stroke(
|
||||
Color(red: 0.11, green: 0.11, blue: 0.11),
|
||||
lineWidth: 0.5)
|
||||
|
||||
// Right mountain (red), overlapping
|
||||
Polygon(points: [
|
||||
CGPoint(x: 0.5, y: 0.8),
|
||||
CGPoint(x: 0.75, y: 0.2),
|
||||
CGPoint(x: 1.0, y: 0.8),
|
||||
])
|
||||
.fill(Color(red: 0.96, green: 0.26, blue: 0.21))
|
||||
.stroke(
|
||||
Color(red: 0.11, green: 0.11, blue: 0.11),
|
||||
lineWidth: 0.5)
|
||||
}
|
||||
}
|
||||
Text("Dark")
|
||||
.font(.caption)
|
||||
}
|
||||
|
||||
VStack {
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(.clear)
|
||||
.frame(width: 64, height: 64)
|
||||
.overlay {
|
||||
ZStack {
|
||||
// Left mountain (monochrome)
|
||||
Polygon(points: [
|
||||
CGPoint(x: 0.2, y: 0.8),
|
||||
CGPoint(x: 0.45, y: 0.3),
|
||||
CGPoint(x: 0.7, y: 0.8),
|
||||
])
|
||||
.fill(.black.opacity(0.8))
|
||||
.stroke(.black, lineWidth: 0.5)
|
||||
|
||||
// Right mountain (monochrome), overlapping
|
||||
Polygon(points: [
|
||||
CGPoint(x: 0.5, y: 0.8),
|
||||
CGPoint(x: 0.75, y: 0.2),
|
||||
CGPoint(x: 1.0, y: 0.8),
|
||||
])
|
||||
.fill(.black.opacity(0.9))
|
||||
.stroke(.black, lineWidth: 0.5)
|
||||
}
|
||||
}
|
||||
Text("Tinted")
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(spacing: 8) {
|
||||
Text("Switch between light/dark mode in Settings")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Text("The icon should adapt automatically")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.navigationTitle("Icon Test")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Done") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ColorScheme {
|
||||
var description: String {
|
||||
switch self {
|
||||
case .light:
|
||||
return "Light"
|
||||
case .dark:
|
||||
return "Dark"
|
||||
@unknown default:
|
||||
return "Unknown"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
IconTestView()
|
||||
}
|
||||
|
||||
#Preview("Dark Mode") {
|
||||
IconTestView()
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
|
||||
struct Polygon: Shape {
|
||||
let points: [CGPoint]
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
var path = Path()
|
||||
|
||||
guard !points.isEmpty else { return path }
|
||||
|
||||
let scaledPoints = points.map { point in
|
||||
CGPoint(
|
||||
x: point.x * rect.width,
|
||||
y: point.y * rect.height
|
||||
)
|
||||
}
|
||||
|
||||
path.move(to: scaledPoints[0])
|
||||
for point in scaledPoints.dropFirst() {
|
||||
path.addLine(to: point)
|
||||
}
|
||||
path.closeSubpath()
|
||||
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
854
ios/OpenClimb/Utils/ImageManager.swift
Normal file
854
ios/OpenClimb/Utils/ImageManager.swift
Normal file
@@ -0,0 +1,854 @@
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
class ImageManager {
|
||||
static let shared = ImageManager()
|
||||
|
||||
private let fileManager = FileManager.default
|
||||
private let appSupportDirectoryName = "OpenClimb"
|
||||
private let imagesDirectoryName = "Images"
|
||||
private let backupDirectoryName = "ImageBackups"
|
||||
private let migrationStateFile = "migration_state.json"
|
||||
private let migrationLockFile = "migration.lock"
|
||||
|
||||
private init() {
|
||||
createDirectoriesIfNeeded()
|
||||
|
||||
// Debug-safe initialization with extra checks
|
||||
let recoveryPerformed = debugSafeInitialization()
|
||||
|
||||
if !recoveryPerformed {
|
||||
performRobustMigration()
|
||||
}
|
||||
|
||||
// Final integrity check
|
||||
if !validateStorageIntegrity() {
|
||||
print("🚨 CRITICAL: Storage integrity compromised - attempting emergency recovery")
|
||||
emergencyImageRestore()
|
||||
}
|
||||
|
||||
logDirectoryInfo()
|
||||
}
|
||||
|
||||
var appSupportDirectory: URL {
|
||||
let urls = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask)
|
||||
return urls.first!.appendingPathComponent(appSupportDirectoryName)
|
||||
}
|
||||
|
||||
var imagesDirectory: URL {
|
||||
appSupportDirectory.appendingPathComponent(imagesDirectoryName)
|
||||
}
|
||||
|
||||
var backupDirectory: URL {
|
||||
appSupportDirectory.appendingPathComponent(backupDirectoryName)
|
||||
}
|
||||
|
||||
func getImagesDirectoryPath() -> String {
|
||||
return imagesDirectory.path
|
||||
}
|
||||
|
||||
private var legacyDocumentsDirectory: URL {
|
||||
fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!
|
||||
}
|
||||
|
||||
var legacyImagesDirectory: URL {
|
||||
legacyDocumentsDirectory.appendingPathComponent("OpenClimbImages")
|
||||
}
|
||||
|
||||
var legacyImportImagesDirectory: URL {
|
||||
legacyDocumentsDirectory.appendingPathComponent("images")
|
||||
}
|
||||
|
||||
private func createDirectoriesIfNeeded() {
|
||||
// Create Application Support structure
|
||||
[appSupportDirectory, imagesDirectory, backupDirectory].forEach { directory in
|
||||
if !fileManager.fileExists(atPath: directory.path) {
|
||||
do {
|
||||
try fileManager.createDirectory(
|
||||
at: directory, withIntermediateDirectories: true,
|
||||
attributes: [
|
||||
.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication
|
||||
])
|
||||
print("✅ Created directory: \(directory.path)")
|
||||
} catch {
|
||||
print("❌ Failed to create directory \(directory.path): \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Exclude from iCloud backup to prevent storage issues
|
||||
excludeFromiCloudBackup()
|
||||
}
|
||||
|
||||
private func excludeFromiCloudBackup() {
|
||||
do {
|
||||
var resourceValues = URLResourceValues()
|
||||
resourceValues.isExcludedFromBackup = true
|
||||
var imagesURL = imagesDirectory
|
||||
var backupURL = backupDirectory
|
||||
try imagesURL.setResourceValues(resourceValues)
|
||||
try backupURL.setResourceValues(resourceValues)
|
||||
print("✅ Excluded image directories from iCloud backup")
|
||||
} catch {
|
||||
print("⚠️ Failed to exclude from iCloud backup: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private struct MigrationState: Codable {
|
||||
let version: Int
|
||||
let startTime: Date
|
||||
let completedFiles: [String]
|
||||
let totalFiles: Int
|
||||
let isComplete: Bool
|
||||
let lastCheckpoint: Date
|
||||
|
||||
static let currentVersion = 2
|
||||
}
|
||||
|
||||
private var migrationStateURL: URL {
|
||||
appSupportDirectory.appendingPathComponent(migrationStateFile)
|
||||
}
|
||||
|
||||
private var migrationLockURL: URL {
|
||||
appSupportDirectory.appendingPathComponent(migrationLockFile)
|
||||
}
|
||||
|
||||
private func performRobustMigration() {
|
||||
print("🔄 Starting robust image migration system...")
|
||||
|
||||
// Check for interrupted migration
|
||||
if let incompleteState = loadMigrationState() {
|
||||
print("🔧 Detected interrupted migration, resuming...")
|
||||
resumeMigration(from: incompleteState)
|
||||
} else {
|
||||
// Start fresh migration
|
||||
startNewMigration()
|
||||
}
|
||||
|
||||
// Always verify migration integrity
|
||||
verifyMigrationIntegrity()
|
||||
|
||||
// Clean up migration state files
|
||||
cleanupMigrationState()
|
||||
}
|
||||
|
||||
private func startNewMigration() {
|
||||
// First check for images in previous Application Support directories
|
||||
if let previousAppSupportImages = findPreviousAppSupportImages() {
|
||||
print("📁 Found images in previous Application Support directory")
|
||||
migratePreviousAppSupportImages(from: previousAppSupportImages)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if legacy directories exist
|
||||
let hasLegacyImages = fileManager.fileExists(atPath: legacyImagesDirectory.path)
|
||||
let hasLegacyImportImages = fileManager.fileExists(atPath: legacyImportImagesDirectory.path)
|
||||
|
||||
guard hasLegacyImages || hasLegacyImportImages else {
|
||||
print("✅ No legacy images to migrate")
|
||||
return
|
||||
}
|
||||
|
||||
// Create migration lock
|
||||
createMigrationLock()
|
||||
|
||||
do {
|
||||
var allLegacyFiles: [String] = []
|
||||
|
||||
// Collect files from OpenClimbImages directory
|
||||
if fileManager.fileExists(atPath: legacyImagesDirectory.path) {
|
||||
let legacyFiles = try fileManager.contentsOfDirectory(
|
||||
atPath: legacyImagesDirectory.path)
|
||||
allLegacyFiles.append(contentsOf: legacyFiles)
|
||||
print("📦 Found \(legacyFiles.count) images in OpenClimbImages")
|
||||
}
|
||||
|
||||
// Collect files from Documents/images directory
|
||||
if fileManager.fileExists(atPath: legacyImportImagesDirectory.path) {
|
||||
let importFiles = try fileManager.contentsOfDirectory(
|
||||
atPath: legacyImportImagesDirectory.path)
|
||||
allLegacyFiles.append(contentsOf: importFiles)
|
||||
print("📦 Found \(importFiles.count) images in Documents/images")
|
||||
}
|
||||
|
||||
print("📦 Total legacy images to migrate: \(allLegacyFiles.count)")
|
||||
|
||||
let initialState = MigrationState(
|
||||
version: MigrationState.currentVersion,
|
||||
startTime: Date(),
|
||||
completedFiles: [],
|
||||
totalFiles: allLegacyFiles.count,
|
||||
isComplete: false,
|
||||
lastCheckpoint: Date()
|
||||
)
|
||||
|
||||
saveMigrationState(initialState)
|
||||
performMigrationWithCheckpoints(files: allLegacyFiles, currentState: initialState)
|
||||
|
||||
} catch {
|
||||
print("❌ Failed to start migration: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func resumeMigration(from state: MigrationState) {
|
||||
print("🔄 Resuming migration from checkpoint...")
|
||||
print("📊 Progress: \(state.completedFiles.count)/\(state.totalFiles)")
|
||||
|
||||
do {
|
||||
let legacyFiles = try fileManager.contentsOfDirectory(
|
||||
atPath: legacyImagesDirectory.path)
|
||||
let remainingFiles = legacyFiles.filter { !state.completedFiles.contains($0) }
|
||||
|
||||
print("📦 Resuming with \(remainingFiles.count) remaining files")
|
||||
performMigrationWithCheckpoints(files: remainingFiles, currentState: state)
|
||||
|
||||
} catch {
|
||||
print("❌ Failed to resume migration: \(error)")
|
||||
// Fallback: start fresh
|
||||
removeMigrationState()
|
||||
startNewMigration()
|
||||
}
|
||||
}
|
||||
|
||||
private func performMigrationWithCheckpoints(files: [String], currentState: MigrationState) {
|
||||
var migratedCount = currentState.completedFiles.count
|
||||
var failedCount = 0
|
||||
var completedFiles = currentState.completedFiles
|
||||
|
||||
for (index, fileName) in files.enumerated() {
|
||||
autoreleasepool {
|
||||
// Check both legacy directories for the file
|
||||
var legacyFilePath: URL?
|
||||
if fileManager.fileExists(
|
||||
atPath: legacyImagesDirectory.appendingPathComponent(fileName).path)
|
||||
{
|
||||
legacyFilePath = legacyImagesDirectory.appendingPathComponent(fileName)
|
||||
} else if fileManager.fileExists(
|
||||
atPath: legacyImportImagesDirectory.appendingPathComponent(fileName).path)
|
||||
{
|
||||
legacyFilePath = legacyImportImagesDirectory.appendingPathComponent(fileName)
|
||||
}
|
||||
|
||||
guard let sourcePath = legacyFilePath else {
|
||||
completedFiles.append(fileName)
|
||||
return
|
||||
}
|
||||
|
||||
let newFilePath = imagesDirectory.appendingPathComponent(fileName)
|
||||
let backupPath = backupDirectory.appendingPathComponent(fileName)
|
||||
|
||||
// Skip if already exists in new location
|
||||
if fileManager.fileExists(atPath: newFilePath.path) {
|
||||
completedFiles.append(fileName)
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
// Atomic migration: copy to temp, then move
|
||||
let tempFilePath = newFilePath.appendingPathExtension("tmp")
|
||||
|
||||
// Copy to temp location first
|
||||
try fileManager.copyItem(at: sourcePath, to: tempFilePath)
|
||||
|
||||
// Verify file integrity
|
||||
let originalData = try Data(contentsOf: sourcePath)
|
||||
let copiedData = try Data(contentsOf: tempFilePath)
|
||||
|
||||
guard originalData == copiedData else {
|
||||
try? fileManager.removeItem(at: tempFilePath)
|
||||
throw NSError(
|
||||
domain: "MigrationError", code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "File integrity check failed"])
|
||||
}
|
||||
|
||||
// Move from temp to final location
|
||||
try fileManager.moveItem(at: tempFilePath, to: newFilePath)
|
||||
|
||||
// Create backup copy
|
||||
try? fileManager.copyItem(at: newFilePath, to: backupPath)
|
||||
|
||||
completedFiles.append(fileName)
|
||||
migratedCount += 1
|
||||
|
||||
print("✅ Migrated: \(fileName) (\(migratedCount)/\(currentState.totalFiles))")
|
||||
|
||||
} catch {
|
||||
failedCount += 1
|
||||
print("❌ Failed to migrate \(fileName): \(error)")
|
||||
}
|
||||
|
||||
// Save checkpoint every 5 files or if interrupted
|
||||
if (index + 1) % 5 == 0 {
|
||||
let checkpointState = MigrationState(
|
||||
version: MigrationState.currentVersion,
|
||||
startTime: currentState.startTime,
|
||||
completedFiles: completedFiles,
|
||||
totalFiles: currentState.totalFiles,
|
||||
isComplete: false,
|
||||
lastCheckpoint: Date()
|
||||
)
|
||||
saveMigrationState(checkpointState)
|
||||
print("💾 Checkpoint saved: \(completedFiles.count)/\(currentState.totalFiles)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mark migration as complete
|
||||
let finalState = MigrationState(
|
||||
version: MigrationState.currentVersion,
|
||||
startTime: currentState.startTime,
|
||||
completedFiles: completedFiles,
|
||||
totalFiles: currentState.totalFiles,
|
||||
isComplete: true,
|
||||
lastCheckpoint: Date()
|
||||
)
|
||||
saveMigrationState(finalState)
|
||||
|
||||
print("🏁 Migration complete: \(migratedCount) migrated, \(failedCount) failed")
|
||||
|
||||
// Clean up legacy directory if no failures
|
||||
if failedCount == 0 {
|
||||
cleanupLegacyDirectory()
|
||||
}
|
||||
}
|
||||
|
||||
private func verifyMigrationIntegrity() {
|
||||
print("🔍 Verifying migration integrity...")
|
||||
|
||||
var allLegacyFiles = Set<String>()
|
||||
|
||||
// Collect files from both legacy directories
|
||||
do {
|
||||
if fileManager.fileExists(atPath: legacyImagesDirectory.path) {
|
||||
let legacyFiles = Set(
|
||||
try fileManager.contentsOfDirectory(atPath: legacyImagesDirectory.path))
|
||||
allLegacyFiles.formUnion(legacyFiles)
|
||||
}
|
||||
|
||||
if fileManager.fileExists(atPath: legacyImportImagesDirectory.path) {
|
||||
let importFiles = Set(
|
||||
try fileManager.contentsOfDirectory(atPath: legacyImportImagesDirectory.path))
|
||||
allLegacyFiles.formUnion(importFiles)
|
||||
}
|
||||
} catch {
|
||||
print("❌ Failed to read legacy directories: \(error)")
|
||||
return
|
||||
}
|
||||
|
||||
guard !allLegacyFiles.isEmpty else {
|
||||
print("✅ No legacy directories to verify against")
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let migratedFiles = Set(
|
||||
try fileManager.contentsOfDirectory(atPath: imagesDirectory.path))
|
||||
|
||||
let missingFiles = allLegacyFiles.subtracting(migratedFiles)
|
||||
|
||||
if missingFiles.isEmpty {
|
||||
print("✅ Migration integrity verified - all files present")
|
||||
cleanupLegacyDirectory()
|
||||
} else {
|
||||
print("⚠️ Missing \(missingFiles.count) files, re-triggering migration")
|
||||
// Re-trigger migration for missing files
|
||||
performMigrationWithCheckpoints(
|
||||
files: Array(missingFiles),
|
||||
currentState: MigrationState(
|
||||
version: MigrationState.currentVersion,
|
||||
startTime: Date(),
|
||||
completedFiles: [],
|
||||
totalFiles: missingFiles.count,
|
||||
isComplete: false,
|
||||
lastCheckpoint: Date()
|
||||
))
|
||||
}
|
||||
} catch {
|
||||
print("❌ Failed to verify migration integrity: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func cleanupLegacyDirectory() {
|
||||
do {
|
||||
try fileManager.removeItem(at: legacyImagesDirectory)
|
||||
print("🗑️ Cleaned up legacy directory")
|
||||
} catch {
|
||||
print("⚠️ Failed to clean up legacy directory: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func loadMigrationState() -> MigrationState? {
|
||||
guard fileManager.fileExists(atPath: migrationStateURL.path) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if migration was interrupted (lock file exists)
|
||||
if !fileManager.fileExists(atPath: migrationLockURL.path) {
|
||||
// Migration completed normally, clean up state
|
||||
removeMigrationState()
|
||||
return nil
|
||||
}
|
||||
|
||||
do {
|
||||
let data = try Data(contentsOf: migrationStateURL)
|
||||
let state = try JSONDecoder().decode(MigrationState.self, from: data)
|
||||
|
||||
// Check if state is too old (more than 1 hour)
|
||||
if Date().timeIntervalSince(state.lastCheckpoint) > 3600 {
|
||||
print("⚠️ Migration state is stale, starting fresh")
|
||||
removeMigrationState()
|
||||
return nil
|
||||
}
|
||||
|
||||
return state.isComplete ? nil : state
|
||||
} catch {
|
||||
print("❌ Failed to load migration state: \(error)")
|
||||
removeMigrationState()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func saveMigrationState(_ state: MigrationState) {
|
||||
do {
|
||||
let data = try JSONEncoder().encode(state)
|
||||
try data.write(to: migrationStateURL)
|
||||
} catch {
|
||||
print("❌ Failed to save migration state: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func removeMigrationState() {
|
||||
try? fileManager.removeItem(at: migrationStateURL)
|
||||
}
|
||||
|
||||
private func createMigrationLock() {
|
||||
let lockData = "Migration in progress - \(Date())".data(using: .utf8) ?? Data()
|
||||
try? lockData.write(to: migrationLockURL)
|
||||
}
|
||||
|
||||
private func cleanupMigrationState() {
|
||||
try? fileManager.removeItem(at: migrationStateURL)
|
||||
try? fileManager.removeItem(at: migrationLockURL)
|
||||
print("🧹 Cleaned up migration state files")
|
||||
}
|
||||
|
||||
func saveImageData(_ data: Data, withName name: String? = nil) -> String? {
|
||||
let fileName = name ?? "\(UUID().uuidString).jpg"
|
||||
let primaryPath = imagesDirectory.appendingPathComponent(fileName)
|
||||
let backupPath = backupDirectory.appendingPathComponent(fileName)
|
||||
|
||||
do {
|
||||
// Save to primary location
|
||||
try data.write(to: primaryPath)
|
||||
|
||||
// Create backup copy
|
||||
try data.write(to: backupPath)
|
||||
|
||||
print("✅ Saved image with backup: \(fileName)")
|
||||
return fileName
|
||||
} catch {
|
||||
print("❌ Failed to save image \(fileName): \(error)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func loadImageData(fromPath path: String) -> Data? {
|
||||
let primaryPath = getFullPath(from: path)
|
||||
let backupPath = backupDirectory.appendingPathComponent(getRelativePath(from: path))
|
||||
|
||||
// Try primary location first
|
||||
if fileManager.fileExists(atPath: primaryPath),
|
||||
let data = try? Data(contentsOf: URL(fileURLWithPath: primaryPath))
|
||||
{
|
||||
return data
|
||||
}
|
||||
|
||||
// Fallback to backup location
|
||||
if fileManager.fileExists(atPath: backupPath.path),
|
||||
let data = try? Data(contentsOf: backupPath)
|
||||
{
|
||||
print("📦 Restored image from backup: \(path)")
|
||||
|
||||
// Restore to primary location
|
||||
try? data.write(to: URL(fileURLWithPath: primaryPath))
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func imageExists(atPath path: String) -> Bool {
|
||||
let primaryPath = getFullPath(from: path)
|
||||
let backupPath = backupDirectory.appendingPathComponent(getRelativePath(from: path))
|
||||
|
||||
return fileManager.fileExists(atPath: primaryPath)
|
||||
|| fileManager.fileExists(atPath: backupPath.path)
|
||||
}
|
||||
|
||||
func deleteImage(atPath path: String) -> Bool {
|
||||
let primaryPath = getFullPath(from: path)
|
||||
let backupPath = backupDirectory.appendingPathComponent(getRelativePath(from: path))
|
||||
|
||||
var success = true
|
||||
|
||||
// Delete from primary location
|
||||
if fileManager.fileExists(atPath: primaryPath) {
|
||||
do {
|
||||
try fileManager.removeItem(atPath: primaryPath)
|
||||
} catch {
|
||||
print("❌ Failed to delete primary image at \(primaryPath): \(error)")
|
||||
success = false
|
||||
}
|
||||
}
|
||||
|
||||
// Delete from backup location
|
||||
if fileManager.fileExists(atPath: backupPath.path) {
|
||||
do {
|
||||
try fileManager.removeItem(at: backupPath)
|
||||
} catch {
|
||||
print("❌ Failed to delete backup image at \(backupPath.path): \(error)")
|
||||
success = false
|
||||
}
|
||||
}
|
||||
|
||||
return success
|
||||
}
|
||||
|
||||
func deleteImages(atPaths paths: [String]) {
|
||||
for path in paths {
|
||||
_ = deleteImage(atPath: path)
|
||||
}
|
||||
}
|
||||
|
||||
private func getFullPath(from relativePath: String) -> String {
|
||||
// If it's already a full path, check if it's legacy and needs migration
|
||||
if relativePath.hasPrefix("/") {
|
||||
// If it's pointing to legacy Documents directory, redirect to new location
|
||||
if relativePath.contains("Documents/OpenClimbImages") {
|
||||
let fileName = URL(fileURLWithPath: relativePath).lastPathComponent
|
||||
return imagesDirectory.appendingPathComponent(fileName).path
|
||||
}
|
||||
return relativePath
|
||||
}
|
||||
|
||||
// For relative paths, use the persistent Application Support location
|
||||
return imagesDirectory.appendingPathComponent(relativePath).path
|
||||
}
|
||||
|
||||
func getRelativePath(from fullPath: String) -> String {
|
||||
if !fullPath.hasPrefix("/") {
|
||||
return fullPath
|
||||
}
|
||||
return URL(fileURLWithPath: fullPath).lastPathComponent
|
||||
}
|
||||
|
||||
func performMaintenance() {
|
||||
print("🔧 Starting image maintenance...")
|
||||
|
||||
syncBackups()
|
||||
validateImageIntegrity()
|
||||
cleanupOrphanedFiles()
|
||||
}
|
||||
|
||||
private func syncBackups() {
|
||||
do {
|
||||
let primaryFiles = try fileManager.contentsOfDirectory(atPath: imagesDirectory.path)
|
||||
let backupFiles = Set(try fileManager.contentsOfDirectory(atPath: backupDirectory.path))
|
||||
|
||||
for fileName in primaryFiles {
|
||||
if !backupFiles.contains(fileName) {
|
||||
let primaryPath = imagesDirectory.appendingPathComponent(fileName)
|
||||
let backupPath = backupDirectory.appendingPathComponent(fileName)
|
||||
|
||||
try? fileManager.copyItem(at: primaryPath, to: backupPath)
|
||||
print("🔄 Created missing backup for: \(fileName)")
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
print("❌ Failed to sync backups: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func validateImageIntegrity() {
|
||||
do {
|
||||
let files = try fileManager.contentsOfDirectory(atPath: imagesDirectory.path)
|
||||
var validFiles = 0
|
||||
|
||||
for fileName in files {
|
||||
let filePath = imagesDirectory.appendingPathComponent(fileName)
|
||||
if let data = try? Data(contentsOf: filePath), data.count > 0 {
|
||||
// Basic validation - check if file has content and is reasonable size
|
||||
if data.count > 100 { // Minimum viable image size
|
||||
validFiles += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
print("✅ Validated \(validFiles) of \(files.count) image files")
|
||||
} catch {
|
||||
print("❌ Failed to validate images: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func cleanupOrphanedFiles() {
|
||||
// This would need access to the data manager to check which files are actually referenced
|
||||
print("🧹 Cleanup would require coordination with data manager")
|
||||
}
|
||||
|
||||
func getStorageInfo() -> (primaryCount: Int, backupCount: Int, totalSize: Int64) {
|
||||
let primaryCount =
|
||||
((try? fileManager.contentsOfDirectory(atPath: imagesDirectory.path)) ?? []).count
|
||||
let backupCount =
|
||||
((try? fileManager.contentsOfDirectory(atPath: backupDirectory.path)) ?? []).count
|
||||
|
||||
var totalSize: Int64 = 0
|
||||
[imagesDirectory, backupDirectory].forEach { directory in
|
||||
if let enumerator = fileManager.enumerator(
|
||||
at: directory, includingPropertiesForKeys: [.fileSizeKey])
|
||||
{
|
||||
for case let url as URL in enumerator {
|
||||
if let size = try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize {
|
||||
totalSize += Int64(size)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (primaryCount, backupCount, totalSize)
|
||||
}
|
||||
|
||||
private func logDirectoryInfo() {
|
||||
let info = getStorageInfo()
|
||||
let previousDir = findPreviousAppSupportImages()
|
||||
print(
|
||||
"""
|
||||
📁 OpenClimb Image Storage:
|
||||
- App Support: \(appSupportDirectory.path)
|
||||
- Images: \(imagesDirectory.path) (\(info.primaryCount) files)
|
||||
- Backups: \(backupDirectory.path) (\(info.backupCount) files)
|
||||
- Previous Dir: \(previousDir?.path ?? "None found")
|
||||
- Legacy Dir: \(legacyImagesDirectory.path) (exists: \(fileManager.fileExists(atPath: legacyImagesDirectory.path)))
|
||||
- Legacy Import Dir: \(legacyImportImagesDirectory.path) (exists: \(fileManager.fileExists(atPath: legacyImportImagesDirectory.path)))
|
||||
- Total Size: \(info.totalSize / 1024)KB
|
||||
""")
|
||||
}
|
||||
|
||||
func forceRecoveryMigration() {
|
||||
print("🚨 FORCE RECOVERY: Starting manual migration recovery...")
|
||||
|
||||
// Remove any stale state
|
||||
removeMigrationState()
|
||||
try? fileManager.removeItem(at: migrationLockURL)
|
||||
|
||||
// Force fresh migration
|
||||
startNewMigration()
|
||||
|
||||
print("🚨 FORCE RECOVERY: Migration recovery completed")
|
||||
}
|
||||
|
||||
func saveImportedImage(_ imageData: Data, filename: String) throws -> String {
|
||||
let imagePath = imagesDirectory.appendingPathComponent(filename)
|
||||
let backupPath = backupDirectory.appendingPathComponent(filename)
|
||||
|
||||
// Save to main directory
|
||||
try imageData.write(to: imagePath)
|
||||
|
||||
// Create backup
|
||||
try? imageData.write(to: backupPath)
|
||||
|
||||
print("📥 Imported image: \(filename)")
|
||||
return filename
|
||||
}
|
||||
|
||||
func emergencyImageRestore() {
|
||||
print("🆘 EMERGENCY: Attempting image restoration...")
|
||||
|
||||
// Try to restore from backup directory
|
||||
do {
|
||||
let backupFiles = try fileManager.contentsOfDirectory(atPath: backupDirectory.path)
|
||||
var restoredCount = 0
|
||||
|
||||
for fileName in backupFiles {
|
||||
let backupPath = backupDirectory.appendingPathComponent(fileName)
|
||||
let primaryPath = imagesDirectory.appendingPathComponent(fileName)
|
||||
|
||||
// Only restore if primary doesn't exist
|
||||
if !fileManager.fileExists(atPath: primaryPath.path) {
|
||||
try? fileManager.copyItem(at: backupPath, to: primaryPath)
|
||||
restoredCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
print("🆘 EMERGENCY: Restored \(restoredCount) images from backup")
|
||||
} catch {
|
||||
print("🆘 EMERGENCY: Failed to restore from backup: \(error)")
|
||||
}
|
||||
|
||||
// Try previous Application Support directories first
|
||||
if let previousAppSupportImages = findPreviousAppSupportImages() {
|
||||
print("🆘 EMERGENCY: Found previous Application Support images, migrating...")
|
||||
migratePreviousAppSupportImages(from: previousAppSupportImages)
|
||||
return
|
||||
}
|
||||
|
||||
// Try legacy migration as last resort
|
||||
if fileManager.fileExists(atPath: legacyImagesDirectory.path)
|
||||
|| fileManager.fileExists(atPath: legacyImportImagesDirectory.path)
|
||||
{
|
||||
print("🆘 EMERGENCY: Attempting legacy migration as fallback...")
|
||||
forceRecoveryMigration()
|
||||
}
|
||||
}
|
||||
|
||||
func debugSafeInitialization() -> Bool {
|
||||
print("🐛 DEBUG SAFE: Performing debug-safe initialization check...")
|
||||
|
||||
// Check if we're in a debug environment
|
||||
#if DEBUG
|
||||
print("🐛 DEBUG SAFE: Debug environment detected")
|
||||
|
||||
// Check for interrupted migration more aggressively
|
||||
if fileManager.fileExists(atPath: migrationLockURL.path) {
|
||||
print("🐛 DEBUG SAFE: Found migration lock - likely debug interruption")
|
||||
|
||||
// Give extra time for file system to stabilize
|
||||
Thread.sleep(forTimeInterval: 1.0)
|
||||
|
||||
// Try emergency recovery
|
||||
emergencyImageRestore()
|
||||
|
||||
// Clean up lock
|
||||
try? fileManager.removeItem(at: migrationLockURL)
|
||||
|
||||
return true
|
||||
}
|
||||
#endif
|
||||
|
||||
// Check if primary storage is empty but backup exists
|
||||
let primaryEmpty =
|
||||
(try? fileManager.contentsOfDirectory(atPath: imagesDirectory.path).isEmpty) ?? true
|
||||
let backupHasFiles =
|
||||
((try? fileManager.contentsOfDirectory(atPath: backupDirectory.path)) ?? []).count > 0
|
||||
|
||||
if primaryEmpty && backupHasFiles {
|
||||
print("🐛 DEBUG SAFE: Primary empty but backup exists - restoring")
|
||||
emergencyImageRestore()
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if primary storage is empty but previous Application Support images exist
|
||||
if primaryEmpty, let previousAppSupportImages = findPreviousAppSupportImages() {
|
||||
print("🐛 DEBUG SAFE: Primary empty but found previous Application Support images")
|
||||
migratePreviousAppSupportImages(from: previousAppSupportImages)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func validateStorageIntegrity() -> Bool {
|
||||
let primaryFiles = Set(
|
||||
(try? fileManager.contentsOfDirectory(atPath: imagesDirectory.path)) ?? [])
|
||||
let backupFiles = Set(
|
||||
(try? fileManager.contentsOfDirectory(atPath: backupDirectory.path)) ?? [])
|
||||
|
||||
// Check if we have more backups than primary files (sign of corruption)
|
||||
if backupFiles.count > primaryFiles.count + 5 {
|
||||
print("⚠️ INTEGRITY: Backup count significantly exceeds primary - potential corruption")
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if primary is completely empty but we have data elsewhere
|
||||
if primaryFiles.isEmpty && !backupFiles.isEmpty {
|
||||
print("⚠️ INTEGRITY: Primary storage empty but backups exist")
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func findPreviousAppSupportImages() -> URL? {
|
||||
// Get the Application Support base directory
|
||||
guard
|
||||
let appSupportBase = fileManager.urls(
|
||||
for: .applicationSupportDirectory, in: .userDomainMask
|
||||
).first
|
||||
else {
|
||||
print("❌ Could not access Application Support directory")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Look for OpenClimb directories in Application Support
|
||||
do {
|
||||
let contents = try fileManager.contentsOfDirectory(
|
||||
at: appSupportBase, includingPropertiesForKeys: nil)
|
||||
|
||||
for url in contents {
|
||||
var isDirectory: ObjCBool = false
|
||||
guard fileManager.fileExists(atPath: url.path, isDirectory: &isDirectory),
|
||||
isDirectory.boolValue
|
||||
else {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if it's an OpenClimb directory but not the current one
|
||||
if url.lastPathComponent.contains("OpenClimb")
|
||||
&& url.path != appSupportDirectory.path
|
||||
{
|
||||
let imagesDir = url.appendingPathComponent(imagesDirectoryName)
|
||||
|
||||
if fileManager.fileExists(atPath: imagesDir.path) {
|
||||
let imageFiles =
|
||||
(try? fileManager.contentsOfDirectory(atPath: imagesDir.path)) ?? []
|
||||
if !imageFiles.isEmpty {
|
||||
return imagesDir
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
print("❌ Error scanning for previous Application Support directories: \(error)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func migratePreviousAppSupportImages(from sourceDirectory: URL) {
|
||||
print("🔄 Migrating images from previous Application Support directory")
|
||||
|
||||
do {
|
||||
let imageFiles = try fileManager.contentsOfDirectory(atPath: sourceDirectory.path)
|
||||
|
||||
for fileName in imageFiles {
|
||||
autoreleasepool {
|
||||
let sourcePath = sourceDirectory.appendingPathComponent(fileName)
|
||||
let destinationPath = imagesDirectory.appendingPathComponent(fileName)
|
||||
let backupPath = backupDirectory.appendingPathComponent(fileName)
|
||||
|
||||
// Skip if already exists in destination
|
||||
if fileManager.fileExists(atPath: destinationPath.path) {
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
// Copy to main directory
|
||||
try fileManager.copyItem(at: sourcePath, to: destinationPath)
|
||||
|
||||
// Create backup
|
||||
try? fileManager.copyItem(at: sourcePath, to: backupPath)
|
||||
|
||||
print("✅ Migrated: \(fileName)")
|
||||
} catch {
|
||||
print("❌ Failed to migrate \(fileName): \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
print("✅ Completed migration from previous Application Support directory")
|
||||
|
||||
} catch {
|
||||
print("❌ Failed to migrate from previous Application Support: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,3 @@
|
||||
//
|
||||
// ZipUtils.swift
|
||||
// OpenClimb
|
||||
//
|
||||
// Created by OpenClimb on 2025-01-17.
|
||||
//
|
||||
|
||||
import Compression
|
||||
import Foundation
|
||||
@@ -169,21 +163,9 @@ struct ZipUtils {
|
||||
entry.filename.dropFirst("\(IMAGES_DIR_NAME)/".count))
|
||||
|
||||
do {
|
||||
|
||||
let documentsURL = FileManager.default.urls(
|
||||
for: .documentDirectory, in: .userDomainMask
|
||||
).first!
|
||||
let imagesDir = documentsURL.appendingPathComponent("images")
|
||||
try FileManager.default.createDirectory(
|
||||
at: imagesDir, withIntermediateDirectories: true)
|
||||
|
||||
let newImageURL = imagesDir.appendingPathComponent(originalFilename)
|
||||
try entry.data.write(to: newImageURL)
|
||||
|
||||
importedImagePaths[originalFilename] = newImageURL.path
|
||||
print(
|
||||
"Successfully imported image: \(originalFilename) -> \(newImageURL.path)"
|
||||
)
|
||||
let filename = try ImageManager.shared.saveImportedImage(
|
||||
entry.data, filename: originalFilename)
|
||||
importedImagePaths[originalFilename] = filename
|
||||
} catch {
|
||||
print("Failed to import image \(originalFilename): \(error)")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user