Initial scaffold — NASBackup iOS app
Full project scaffold: XcodeGen project.yml, Core models/protocols/errors,
SMBService (kishikawakatsumi/SMBClient), SFTPService (Citadel 0.9.2),
PhotoLibraryService, LANMonitor, BackupEngine, BackgroundTaskManager,
all feature UIs (Login, Connect, Browse, Backup, History, Settings),
Shared components and theme. Builds clean with zero errors.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 17:05:04 +03:00
|
|
|
import Foundation
|
|
|
|
|
import Photos
|
|
|
|
|
|
|
|
|
|
final class PhotoLibraryService: PhotoLibraryProtocol {
|
|
|
|
|
var authorizationStatus: PHAuthorizationStatus {
|
|
|
|
|
PHPhotoLibrary.authorizationStatus(for: .readWrite)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func requestAuthorization() async -> PHAuthorizationStatus {
|
|
|
|
|
await PHPhotoLibrary.requestAuthorization(for: .readWrite)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func fetchAssets(filter: BackupFilter) -> [PhotoAsset] {
|
|
|
|
|
let options = PHFetchOptions()
|
|
|
|
|
options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: true)]
|
|
|
|
|
|
|
|
|
|
var subpredicates: [NSPredicate] = []
|
|
|
|
|
|
|
|
|
|
var mediaTypes: [PHAssetMediaType] = []
|
|
|
|
|
if filter.includePhotos { mediaTypes.append(.image) }
|
|
|
|
|
if filter.includeVideos { mediaTypes.append(.video) }
|
|
|
|
|
|
|
|
|
|
if !mediaTypes.isEmpty {
|
|
|
|
|
let typePredicate = NSPredicate(
|
|
|
|
|
format: "mediaType IN %@",
|
|
|
|
|
mediaTypes.map(\.rawValue)
|
|
|
|
|
)
|
|
|
|
|
subpredicates.append(typePredicate)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if !filter.includeScreenshots {
|
|
|
|
|
subpredicates.append(NSPredicate(
|
|
|
|
|
format: "NOT ((mediaSubtype & %d) != 0)",
|
|
|
|
|
PHAssetMediaSubtype.photoScreenshot.rawValue
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if subpredicates.count > 1 {
|
|
|
|
|
options.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: subpredicates)
|
|
|
|
|
} else {
|
|
|
|
|
options.predicate = subpredicates.first
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let result = PHAsset.fetchAssets(with: options)
|
|
|
|
|
var assets: [PhotoAsset] = []
|
|
|
|
|
result.enumerateObjects { asset, _, _ in
|
feat: Kisani rebrand, step-based onboarding flow, dark mode fixes
- Rename app to Kisani in all UI text and Face ID prompt
- Replace flat 2-state RootView with 5-step flow: ConnectView →
LoginView → FolderSetupView → PhotoPermissionView → MainTabView
- Remove NavigationStack from LoginView (was causing nested-stack crash);
drive navigation via ConnectionStore.isSessionActive
- Add FolderSetupView and PhotoPermissionView onboarding screens with
step indicator (2/3, 3/3), spring entrance animations, and UGreen
connection chip
- Fix dark mode button invisibility: PrimaryButtonStyle and PillButtonStyle
now use AppTheme.inkInverse; GhostButtonStyle border uses ink.opacity(0.25)
- Add AppTheme.inkInverse adaptive token (dark bg ink on dark, white on light)
- Add ConnectionStore.isSessionActive (non-persisted) and
onboardingPhotoShown (UserDefaults-persisted)
- Add os_log logging in LoginViewModel at auth start/success/failure
- Remove dead Help button from ConnectView
- UGreenCompatibilityBadge, KeychainStore, SavedConnections, extensions,
PrivacyInfo.xcprivacy, unit test target and test stubs
- AppTheme: full UIColor dynamic provider tokens for dark/light adaptive color
- AppearanceMode enum + segmented picker in SettingsView
- BackupView: enlarged ring, removed options card and linear progress bar
- HistoryView: duration and bytes formatting, improved typography hierarchy
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 16:20:53 +03:00
|
|
|
autoreleasepool {
|
|
|
|
|
let isScreenshot = asset.mediaSubtypes.contains(.photoScreenshot)
|
|
|
|
|
let resources = PHAssetResource.assetResources(for: asset)
|
|
|
|
|
let isRAW = resources.contains { $0.type == .alternatePhoto }
|
|
|
|
|
if isRAW && !filter.includeRAW { return }
|
Initial scaffold — NASBackup iOS app
Full project scaffold: XcodeGen project.yml, Core models/protocols/errors,
SMBService (kishikawakatsumi/SMBClient), SFTPService (Citadel 0.9.2),
PhotoLibraryService, LANMonitor, BackupEngine, BackgroundTaskManager,
all feature UIs (Login, Connect, Browse, Backup, History, Settings),
Shared components and theme. Builds clean with zero errors.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 17:05:04 +03:00
|
|
|
|
feat: Kisani rebrand, step-based onboarding flow, dark mode fixes
- Rename app to Kisani in all UI text and Face ID prompt
- Replace flat 2-state RootView with 5-step flow: ConnectView →
LoginView → FolderSetupView → PhotoPermissionView → MainTabView
- Remove NavigationStack from LoginView (was causing nested-stack crash);
drive navigation via ConnectionStore.isSessionActive
- Add FolderSetupView and PhotoPermissionView onboarding screens with
step indicator (2/3, 3/3), spring entrance animations, and UGreen
connection chip
- Fix dark mode button invisibility: PrimaryButtonStyle and PillButtonStyle
now use AppTheme.inkInverse; GhostButtonStyle border uses ink.opacity(0.25)
- Add AppTheme.inkInverse adaptive token (dark bg ink on dark, white on light)
- Add ConnectionStore.isSessionActive (non-persisted) and
onboardingPhotoShown (UserDefaults-persisted)
- Add os_log logging in LoginViewModel at auth start/success/failure
- Remove dead Help button from ConnectView
- UGreenCompatibilityBadge, KeychainStore, SavedConnections, extensions,
PrivacyInfo.xcprivacy, unit test target and test stubs
- AppTheme: full UIColor dynamic provider tokens for dark/light adaptive color
- AppearanceMode enum + segmented picker in SettingsView
- BackupView: enlarged ring, removed options card and linear progress bar
- HistoryView: duration and bytes formatting, improved typography hierarchy
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 16:20:53 +03:00
|
|
|
let filename = resources.first?.originalFilename ?? "\(asset.localIdentifier).jpg"
|
Initial scaffold — NASBackup iOS app
Full project scaffold: XcodeGen project.yml, Core models/protocols/errors,
SMBService (kishikawakatsumi/SMBClient), SFTPService (Citadel 0.9.2),
PhotoLibraryService, LANMonitor, BackupEngine, BackgroundTaskManager,
all feature UIs (Login, Connect, Browse, Backup, History, Settings),
Shared components and theme. Builds clean with zero errors.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 17:05:04 +03:00
|
|
|
|
feat: Kisani rebrand, step-based onboarding flow, dark mode fixes
- Rename app to Kisani in all UI text and Face ID prompt
- Replace flat 2-state RootView with 5-step flow: ConnectView →
LoginView → FolderSetupView → PhotoPermissionView → MainTabView
- Remove NavigationStack from LoginView (was causing nested-stack crash);
drive navigation via ConnectionStore.isSessionActive
- Add FolderSetupView and PhotoPermissionView onboarding screens with
step indicator (2/3, 3/3), spring entrance animations, and UGreen
connection chip
- Fix dark mode button invisibility: PrimaryButtonStyle and PillButtonStyle
now use AppTheme.inkInverse; GhostButtonStyle border uses ink.opacity(0.25)
- Add AppTheme.inkInverse adaptive token (dark bg ink on dark, white on light)
- Add ConnectionStore.isSessionActive (non-persisted) and
onboardingPhotoShown (UserDefaults-persisted)
- Add os_log logging in LoginViewModel at auth start/success/failure
- Remove dead Help button from ConnectView
- UGreenCompatibilityBadge, KeychainStore, SavedConnections, extensions,
PrivacyInfo.xcprivacy, unit test target and test stubs
- AppTheme: full UIColor dynamic provider tokens for dark/light adaptive color
- AppearanceMode enum + segmented picker in SettingsView
- BackupView: enlarged ring, removed options card and linear progress bar
- HistoryView: duration and bytes formatting, improved typography hierarchy
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 16:20:53 +03:00
|
|
|
assets.append(PhotoAsset(
|
|
|
|
|
localIdentifier: asset.localIdentifier,
|
|
|
|
|
filename: filename,
|
|
|
|
|
creationDate: asset.creationDate,
|
|
|
|
|
mediaType: asset.mediaType,
|
|
|
|
|
pixelWidth: asset.pixelWidth,
|
|
|
|
|
pixelHeight: asset.pixelHeight,
|
|
|
|
|
duration: asset.duration,
|
|
|
|
|
isScreenshot: isScreenshot,
|
|
|
|
|
isRAW: isRAW
|
|
|
|
|
))
|
|
|
|
|
}
|
Initial scaffold — NASBackup iOS app
Full project scaffold: XcodeGen project.yml, Core models/protocols/errors,
SMBService (kishikawakatsumi/SMBClient), SFTPService (Citadel 0.9.2),
PhotoLibraryService, LANMonitor, BackupEngine, BackgroundTaskManager,
all feature UIs (Login, Connect, Browse, Backup, History, Settings),
Shared components and theme. Builds clean with zero errors.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 17:05:04 +03:00
|
|
|
}
|
|
|
|
|
return assets
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func exportAsset(_ asset: PhotoAsset) async throws -> URL {
|
|
|
|
|
let fetchResult = PHAsset.fetchAssets(
|
|
|
|
|
withLocalIdentifiers: [asset.localIdentifier],
|
|
|
|
|
options: nil
|
|
|
|
|
)
|
|
|
|
|
guard let phAsset = fetchResult.firstObject else {
|
|
|
|
|
throw BackupError.uploadFailed(asset.filename, underlying: NSError(domain: "PhotoExport", code: -1))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let tmpURL = FileManager.default.temporaryDirectory
|
|
|
|
|
.appendingPathComponent(UUID().uuidString)
|
|
|
|
|
.appendingPathExtension((asset.filename as NSString).pathExtension)
|
|
|
|
|
|
|
|
|
|
return try await withCheckedThrowingContinuation { continuation in
|
|
|
|
|
if phAsset.mediaType == .video {
|
|
|
|
|
let options = PHVideoRequestOptions()
|
|
|
|
|
options.isNetworkAccessAllowed = true
|
|
|
|
|
PHImageManager.default().requestAVAsset(forVideo: phAsset, options: options) { avAsset, _, _ in
|
|
|
|
|
guard let urlAsset = avAsset as? AVURLAsset else {
|
|
|
|
|
continuation.resume(throwing: BackupError.uploadFailed(asset.filename, underlying: NSError(domain: "AVExport", code: -1)))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
continuation.resume(returning: urlAsset.url)
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
let options = PHImageRequestOptions()
|
|
|
|
|
options.isNetworkAccessAllowed = true
|
|
|
|
|
options.version = .current
|
|
|
|
|
options.deliveryMode = .highQualityFormat
|
|
|
|
|
|
|
|
|
|
PHImageManager.default().requestImageDataAndOrientation(for: phAsset, options: options) { data, _, _, _ in
|
|
|
|
|
guard let data else {
|
|
|
|
|
continuation.resume(throwing: BackupError.uploadFailed(asset.filename, underlying: NSError(domain: "PhotoExport", code: -2)))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
do {
|
|
|
|
|
try data.write(to: tmpURL)
|
|
|
|
|
continuation.resume(returning: tmpURL)
|
|
|
|
|
} catch {
|
|
|
|
|
continuation.resume(throwing: BackupError.uploadFailed(asset.filename, underlying: error))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|