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
|
perf: move heavy work off main actor, throttle progress updates, add signposts
BackupEngine:
- fetchAssets() runs in Task.detached — prevents 100-500ms UI freeze during
PHFetchResult enumeration on large libraries
- Manifest JSON decode + ManifestIndex Set construction run in Task.detached
- pendingAssets filter runs in Task.detached — O(N) array filter off main thread
- Progress updates throttled to 8 Hz via ProgressThrottle — eliminates the
continuous SwiftUI full-tree redraws caused by per-byte upload callbacks
- bytesSoFar captured by value in upload closure — avoids mutable var capture
- reserveCapacity on manifestEntries array
- os_signpost intervals on FetchAssets, NASConnect, ManifestLoad,
BuildPendingQueue, UploadLoop, UploadFile for Instruments profiling
BackupStatusService:
- countSafe() marked nonisolated — runs in Task.detached off main actor,
keeping touch/animation responsive during PHFetchResult batch enumeration
- ManifestIndex JSON decode + Set construction run in Task.detached
- Bootstrap ManifestIndex(nasListing:) construction off main actor
- writeManifest JSON decode/encode/merge off main actor
- os_signpost intervals on Reconcile, ManifestLoad, CountSafe
Model types:
- ManifestIndex, ManifestEntry, PhotoAsset, BackupFilter marked Sendable —
safe to pass across task boundaries without warnings
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 16:42:48 +03:00
|
|
|
|
import Photos
|
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
|
|
|
|
import os.log
|
|
|
|
|
|
|
|
|
|
|
|
private let logger = Logger(subsystem: "com.albert.nasbackup", category: "BackupEngine")
|
perf: move heavy work off main actor, throttle progress updates, add signposts
BackupEngine:
- fetchAssets() runs in Task.detached — prevents 100-500ms UI freeze during
PHFetchResult enumeration on large libraries
- Manifest JSON decode + ManifestIndex Set construction run in Task.detached
- pendingAssets filter runs in Task.detached — O(N) array filter off main thread
- Progress updates throttled to 8 Hz via ProgressThrottle — eliminates the
continuous SwiftUI full-tree redraws caused by per-byte upload callbacks
- bytesSoFar captured by value in upload closure — avoids mutable var capture
- reserveCapacity on manifestEntries array
- os_signpost intervals on FetchAssets, NASConnect, ManifestLoad,
BuildPendingQueue, UploadLoop, UploadFile for Instruments profiling
BackupStatusService:
- countSafe() marked nonisolated — runs in Task.detached off main actor,
keeping touch/animation responsive during PHFetchResult batch enumeration
- ManifestIndex JSON decode + Set construction run in Task.detached
- Bootstrap ManifestIndex(nasListing:) construction off main actor
- writeManifest JSON decode/encode/merge off main actor
- os_signpost intervals on Reconcile, ManifestLoad, CountSafe
Model types:
- ManifestIndex, ManifestEntry, PhotoAsset, BackupFilter marked Sendable —
safe to pass across task boundaries without warnings
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 16:42:48 +03:00
|
|
|
|
private let signposter = OSSignposter(subsystem: "com.albert.nasbackup", category: "BackupEngine")
|
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
|
|
|
|
|
|
|
|
|
|
@MainActor
|
|
|
|
|
|
final class BackupEngine: ObservableObject {
|
|
|
|
|
|
static let shared = BackupEngine()
|
|
|
|
|
|
|
|
|
|
|
|
@Published private(set) var job: BackupJob = BackupJob()
|
perf: eliminate full rescans with local caches; add queue tracking, notifications, SyncView redesign
Cached indexes:
- NASManifestCache: actor-based disk cache for the NAS manifest (kisani_manifest_cache.json)
Avoids NAS network round-trip when cache < 5 min old. Updated after every manifest download.
- LocalPhotoIndex: actor-based disk cache of phone asset metadata (kisani_local_index.json)
Built once on first launch, then updated incrementally via PHPhotoLibraryChangeObserver.
O(1) countSafe via in-memory dict (vs PHFetchResult batch enumeration + CoreData calls).
Fast path reconcile:
When both caches are warm, reconcile() skips NAS connection + PHFetchResult enumeration
entirely and returns in microseconds. Falls through to full NAS path only when stale.
AutoBackupCoordinator.onActive():
Changed force: true → force: false so the 30-second debounce prevents expensive rescans
on rapid app foreground/background cycles. Dashboard appears instantly from cached snapshot.
BackupQueueItem + queue tracking:
BackupEngine publishes queueItems: [BackupQueueItem] with per-file status, progress,
speed, error details. Queue is persisted to disk and reloaded on next launch so SyncView
always has data to show even after restart.
NotificationService:
Centralised local notification sender replacing the inline UNMutableNotificationContent
blocks. Sends for: backup started, completed, failed, paused, NAS offline. Uses category
identifiers so same-type notifications replace each other.
SyncView redesign:
- Removed pill/chip around NAS IP — replaced with plain "Online/Offline/Checking" dot+text
- No live NAS directory listing (was expensive NAS connection on every tab open)
- Shows engine.queueItems grouped into Active / Failed / Completed sections
- Per-file rows with inline progress bar + bytes/speed for uploading items, error text for failed
- Idle state shows archive count from statusService snapshot
GalleryViewModel:
NAS tab and All tab now use NASManifestCache as fallback when statusService.lastManifest
is nil (offline launch). Gallery shows NAS items even when NAS is unreachable.
AppDelegate:
Bootstraps LocalPhotoIndex.loadOrBuild() in background on launch (non-blocking).
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 23:28:29 +03:00
|
|
|
|
@Published private(set) var queueItems: [BackupQueueItem] = []
|
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
|
|
|
|
private let photoService: PhotoLibraryProtocol
|
|
|
|
|
|
private let transferFactory: (NASProtocol) -> any NASTransferProtocol
|
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
|
|
|
|
private var activeTransfer: (any NASTransferProtocol)?
|
|
|
|
|
|
private var isCancelled = false
|
|
|
|
|
|
|
perf: eliminate full rescans with local caches; add queue tracking, notifications, SyncView redesign
Cached indexes:
- NASManifestCache: actor-based disk cache for the NAS manifest (kisani_manifest_cache.json)
Avoids NAS network round-trip when cache < 5 min old. Updated after every manifest download.
- LocalPhotoIndex: actor-based disk cache of phone asset metadata (kisani_local_index.json)
Built once on first launch, then updated incrementally via PHPhotoLibraryChangeObserver.
O(1) countSafe via in-memory dict (vs PHFetchResult batch enumeration + CoreData calls).
Fast path reconcile:
When both caches are warm, reconcile() skips NAS connection + PHFetchResult enumeration
entirely and returns in microseconds. Falls through to full NAS path only when stale.
AutoBackupCoordinator.onActive():
Changed force: true → force: false so the 30-second debounce prevents expensive rescans
on rapid app foreground/background cycles. Dashboard appears instantly from cached snapshot.
BackupQueueItem + queue tracking:
BackupEngine publishes queueItems: [BackupQueueItem] with per-file status, progress,
speed, error details. Queue is persisted to disk and reloaded on next launch so SyncView
always has data to show even after restart.
NotificationService:
Centralised local notification sender replacing the inline UNMutableNotificationContent
blocks. Sends for: backup started, completed, failed, paused, NAS offline. Uses category
identifiers so same-type notifications replace each other.
SyncView redesign:
- Removed pill/chip around NAS IP — replaced with plain "Online/Offline/Checking" dot+text
- No live NAS directory listing (was expensive NAS connection on every tab open)
- Shows engine.queueItems grouped into Active / Failed / Completed sections
- Per-file rows with inline progress bar + bytes/speed for uploading items, error text for failed
- Idle state shows archive count from statusService snapshot
GalleryViewModel:
NAS tab and All tab now use NASManifestCache as fallback when statusService.lastManifest
is nil (offline launch). Gallery shows NAS items even when NAS is unreachable.
AppDelegate:
Bootstraps LocalPhotoIndex.loadOrBuild() in background on launch (non-blocking).
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 23:28:29 +03:00
|
|
|
|
private static let queueCacheURL: URL = {
|
|
|
|
|
|
let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
|
|
|
|
|
|
return caches.appendingPathComponent("kisani_last_queue.json")
|
|
|
|
|
|
}()
|
|
|
|
|
|
|
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
|
|
|
|
private init(
|
|
|
|
|
|
photos: PhotoLibraryProtocol = PhotoLibraryService(),
|
|
|
|
|
|
transferFactory: @escaping (NASProtocol) -> any NASTransferProtocol = { proto in
|
|
|
|
|
|
switch proto {
|
|
|
|
|
|
case .smb: return SMBService()
|
|
|
|
|
|
case .sftp: return SFTPService()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
) {
|
|
|
|
|
|
self.photoService = photos
|
|
|
|
|
|
self.transferFactory = transferFactory
|
perf: eliminate full rescans with local caches; add queue tracking, notifications, SyncView redesign
Cached indexes:
- NASManifestCache: actor-based disk cache for the NAS manifest (kisani_manifest_cache.json)
Avoids NAS network round-trip when cache < 5 min old. Updated after every manifest download.
- LocalPhotoIndex: actor-based disk cache of phone asset metadata (kisani_local_index.json)
Built once on first launch, then updated incrementally via PHPhotoLibraryChangeObserver.
O(1) countSafe via in-memory dict (vs PHFetchResult batch enumeration + CoreData calls).
Fast path reconcile:
When both caches are warm, reconcile() skips NAS connection + PHFetchResult enumeration
entirely and returns in microseconds. Falls through to full NAS path only when stale.
AutoBackupCoordinator.onActive():
Changed force: true → force: false so the 30-second debounce prevents expensive rescans
on rapid app foreground/background cycles. Dashboard appears instantly from cached snapshot.
BackupQueueItem + queue tracking:
BackupEngine publishes queueItems: [BackupQueueItem] with per-file status, progress,
speed, error details. Queue is persisted to disk and reloaded on next launch so SyncView
always has data to show even after restart.
NotificationService:
Centralised local notification sender replacing the inline UNMutableNotificationContent
blocks. Sends for: backup started, completed, failed, paused, NAS offline. Uses category
identifiers so same-type notifications replace each other.
SyncView redesign:
- Removed pill/chip around NAS IP — replaced with plain "Online/Offline/Checking" dot+text
- No live NAS directory listing (was expensive NAS connection on every tab open)
- Shows engine.queueItems grouped into Active / Failed / Completed sections
- Per-file rows with inline progress bar + bytes/speed for uploading items, error text for failed
- Idle state shows archive count from statusService snapshot
GalleryViewModel:
NAS tab and All tab now use NASManifestCache as fallback when statusService.lastManifest
is nil (offline launch). Gallery shows NAS items even when NAS is unreachable.
AppDelegate:
Bootstraps LocalPhotoIndex.loadOrBuild() in background on launch (non-blocking).
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 23:28:29 +03:00
|
|
|
|
// Load last session's queue so SyncView has data to show on next launch.
|
|
|
|
|
|
if let data = try? Data(contentsOf: Self.queueCacheURL),
|
|
|
|
|
|
let items = try? JSONDecoder.kisani.decode([BackupQueueItem].self, from: data) {
|
|
|
|
|
|
self.queueItems = items
|
|
|
|
|
|
}
|
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
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
static func testInstance(
|
|
|
|
|
|
transfer: any NASTransferProtocol,
|
|
|
|
|
|
photos: PhotoLibraryProtocol
|
|
|
|
|
|
) -> BackupEngine {
|
|
|
|
|
|
BackupEngine(photos: photos, transferFactory: { _ in transfer })
|
|
|
|
|
|
}
|
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
|
|
|
|
|
|
|
|
|
|
func run(
|
|
|
|
|
|
connection: NASConnection,
|
|
|
|
|
|
filter: BackupFilter,
|
|
|
|
|
|
triggeredByLAN: Bool = false
|
|
|
|
|
|
) async throws -> BackupResult {
|
|
|
|
|
|
isCancelled = false
|
|
|
|
|
|
let startDate = Date()
|
Add cellular data toggle, Tailscale remote access, fix ToggleRow padding
- ToggleRow: fix cramped layout — proper horizontal + vertical card padding
- ConnectionStore: add allowCellularBackup, useTailscaleWhenRemote, tailscaleHost
- LANMonitor: add isOnCellular (NWPath cellular interface), isTailscaleActive
(detects utun interface with 100.x.x.x CGNAT address), openTailscaleApp()
- BackupEngine: gate backup on cellular setting; resolveHost() switches to
tailscaleHost when not on trusted LAN and Tailscale tunnel is active
- SettingsView: CONNECTIVITY section (cellular toggle + live status),
REMOTE ACCESS section (Tailscale toggle, host field, status dot, Open button)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 11:40:07 +03:00
|
|
|
|
let store = ConnectionStore.shared
|
|
|
|
|
|
let lan = LANMonitor.shared
|
|
|
|
|
|
|
|
|
|
|
|
if !triggeredByLAN && lan.isOnCellular && !store.allowCellularBackup {
|
|
|
|
|
|
throw BackupError.networkUnavailable
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let host = resolveHost(connection: connection, store: store, lan: lan)
|
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
|
|
|
|
|
perf: move heavy work off main actor, throttle progress updates, add signposts
BackupEngine:
- fetchAssets() runs in Task.detached — prevents 100-500ms UI freeze during
PHFetchResult enumeration on large libraries
- Manifest JSON decode + ManifestIndex Set construction run in Task.detached
- pendingAssets filter runs in Task.detached — O(N) array filter off main thread
- Progress updates throttled to 8 Hz via ProgressThrottle — eliminates the
continuous SwiftUI full-tree redraws caused by per-byte upload callbacks
- bytesSoFar captured by value in upload closure — avoids mutable var capture
- reserveCapacity on manifestEntries array
- os_signpost intervals on FetchAssets, NASConnect, ManifestLoad,
BuildPendingQueue, UploadLoop, UploadFile for Instruments profiling
BackupStatusService:
- countSafe() marked nonisolated — runs in Task.detached off main actor,
keeping touch/animation responsive during PHFetchResult batch enumeration
- ManifestIndex JSON decode + Set construction run in Task.detached
- Bootstrap ManifestIndex(nasListing:) construction off main actor
- writeManifest JSON decode/encode/merge off main actor
- os_signpost intervals on Reconcile, ManifestLoad, CountSafe
Model types:
- ManifestIndex, ManifestEntry, PhotoAsset, BackupFilter marked Sendable —
safe to pass across task boundaries without warnings
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 16:42:48 +03:00
|
|
|
|
// ── 1. Fetch assets off the main actor ─────────────────────────────
|
|
|
|
|
|
// fetchAssets() enumerates every PHAsset and builds a [PhotoAsset] array.
|
|
|
|
|
|
// For a library of 10 k+ assets this is 100–500 ms of synchronous work.
|
|
|
|
|
|
// Running it in a detached task keeps the main thread free for animations.
|
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
|
|
|
|
job.prepare()
|
perf: move heavy work off main actor, throttle progress updates, add signposts
BackupEngine:
- fetchAssets() runs in Task.detached — prevents 100-500ms UI freeze during
PHFetchResult enumeration on large libraries
- Manifest JSON decode + ManifestIndex Set construction run in Task.detached
- pendingAssets filter runs in Task.detached — O(N) array filter off main thread
- Progress updates throttled to 8 Hz via ProgressThrottle — eliminates the
continuous SwiftUI full-tree redraws caused by per-byte upload callbacks
- bytesSoFar captured by value in upload closure — avoids mutable var capture
- reserveCapacity on manifestEntries array
- os_signpost intervals on FetchAssets, NASConnect, ManifestLoad,
BuildPendingQueue, UploadLoop, UploadFile for Instruments profiling
BackupStatusService:
- countSafe() marked nonisolated — runs in Task.detached off main actor,
keeping touch/animation responsive during PHFetchResult batch enumeration
- ManifestIndex JSON decode + Set construction run in Task.detached
- Bootstrap ManifestIndex(nasListing:) construction off main actor
- writeManifest JSON decode/encode/merge off main actor
- os_signpost intervals on Reconcile, ManifestLoad, CountSafe
Model types:
- ManifestIndex, ManifestEntry, PhotoAsset, BackupFilter marked Sendable —
safe to pass across task boundaries without warnings
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 16:42:48 +03:00
|
|
|
|
let photoSvc = self.photoService
|
|
|
|
|
|
let spFetch = signposter.beginInterval("FetchAssets")
|
|
|
|
|
|
let assets: [PhotoAsset] = await Task.detached(priority: .userInitiated) {
|
|
|
|
|
|
photoSvc.fetchAssets(filter: filter)
|
|
|
|
|
|
}.value
|
|
|
|
|
|
signposter.endInterval("FetchAssets", spFetch, "\(assets.count) assets")
|
|
|
|
|
|
logger.info("Photo fetch: \(assets.count) assets in library")
|
|
|
|
|
|
|
|
|
|
|
|
try Task.checkCancellation()
|
|
|
|
|
|
|
|
|
|
|
|
// ── 2. Connect to NAS ───────────────────────────────────────────────
|
|
|
|
|
|
// Network I/O suspends the main actor — UI stays live the whole time.
|
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 transfer = transferFactory(connection.nasProtocol)
|
perf: move heavy work off main actor, throttle progress updates, add signposts
BackupEngine:
- fetchAssets() runs in Task.detached — prevents 100-500ms UI freeze during
PHFetchResult enumeration on large libraries
- Manifest JSON decode + ManifestIndex Set construction run in Task.detached
- pendingAssets filter runs in Task.detached — O(N) array filter off main thread
- Progress updates throttled to 8 Hz via ProgressThrottle — eliminates the
continuous SwiftUI full-tree redraws caused by per-byte upload callbacks
- bytesSoFar captured by value in upload closure — avoids mutable var capture
- reserveCapacity on manifestEntries array
- os_signpost intervals on FetchAssets, NASConnect, ManifestLoad,
BuildPendingQueue, UploadLoop, UploadFile for Instruments profiling
BackupStatusService:
- countSafe() marked nonisolated — runs in Task.detached off main actor,
keeping touch/animation responsive during PHFetchResult batch enumeration
- ManifestIndex JSON decode + Set construction run in Task.detached
- Bootstrap ManifestIndex(nasListing:) construction off main actor
- writeManifest JSON decode/encode/merge off main actor
- os_signpost intervals on Reconcile, ManifestLoad, CountSafe
Model types:
- ManifestIndex, ManifestEntry, PhotoAsset, BackupFilter marked Sendable —
safe to pass across task boundaries without warnings
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 16:42:48 +03:00
|
|
|
|
let spConnect = signposter.beginInterval("NASConnect")
|
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
|
|
|
|
try await transfer.connect(
|
perf: move heavy work off main actor, throttle progress updates, add signposts
BackupEngine:
- fetchAssets() runs in Task.detached — prevents 100-500ms UI freeze during
PHFetchResult enumeration on large libraries
- Manifest JSON decode + ManifestIndex Set construction run in Task.detached
- pendingAssets filter runs in Task.detached — O(N) array filter off main thread
- Progress updates throttled to 8 Hz via ProgressThrottle — eliminates the
continuous SwiftUI full-tree redraws caused by per-byte upload callbacks
- bytesSoFar captured by value in upload closure — avoids mutable var capture
- reserveCapacity on manifestEntries array
- os_signpost intervals on FetchAssets, NASConnect, ManifestLoad,
BuildPendingQueue, UploadLoop, UploadFile for Instruments profiling
BackupStatusService:
- countSafe() marked nonisolated — runs in Task.detached off main actor,
keeping touch/animation responsive during PHFetchResult batch enumeration
- ManifestIndex JSON decode + Set construction run in Task.detached
- Bootstrap ManifestIndex(nasListing:) construction off main actor
- writeManifest JSON decode/encode/merge off main actor
- os_signpost intervals on Reconcile, ManifestLoad, CountSafe
Model types:
- ManifestIndex, ManifestEntry, PhotoAsset, BackupFilter marked Sendable —
safe to pass across task boundaries without warnings
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 16:42:48 +03:00
|
|
|
|
to: host, port: connection.port,
|
|
|
|
|
|
username: connection.username, password: connection.password
|
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
|
|
|
|
)
|
perf: move heavy work off main actor, throttle progress updates, add signposts
BackupEngine:
- fetchAssets() runs in Task.detached — prevents 100-500ms UI freeze during
PHFetchResult enumeration on large libraries
- Manifest JSON decode + ManifestIndex Set construction run in Task.detached
- pendingAssets filter runs in Task.detached — O(N) array filter off main thread
- Progress updates throttled to 8 Hz via ProgressThrottle — eliminates the
continuous SwiftUI full-tree redraws caused by per-byte upload callbacks
- bytesSoFar captured by value in upload closure — avoids mutable var capture
- reserveCapacity on manifestEntries array
- os_signpost intervals on FetchAssets, NASConnect, ManifestLoad,
BuildPendingQueue, UploadLoop, UploadFile for Instruments profiling
BackupStatusService:
- countSafe() marked nonisolated — runs in Task.detached off main actor,
keeping touch/animation responsive during PHFetchResult batch enumeration
- ManifestIndex JSON decode + Set construction run in Task.detached
- Bootstrap ManifestIndex(nasListing:) construction off main actor
- writeManifest JSON decode/encode/merge off main actor
- os_signpost intervals on Reconcile, ManifestLoad, CountSafe
Model types:
- ManifestIndex, ManifestEntry, PhotoAsset, BackupFilter marked Sendable —
safe to pass across task boundaries without warnings
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 16:42:48 +03:00
|
|
|
|
signposter.endInterval("NASConnect", spConnect)
|
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
|
|
|
|
activeTransfer = transfer
|
perf: move heavy work off main actor, throttle progress updates, add signposts
BackupEngine:
- fetchAssets() runs in Task.detached — prevents 100-500ms UI freeze during
PHFetchResult enumeration on large libraries
- Manifest JSON decode + ManifestIndex Set construction run in Task.detached
- pendingAssets filter runs in Task.detached — O(N) array filter off main thread
- Progress updates throttled to 8 Hz via ProgressThrottle — eliminates the
continuous SwiftUI full-tree redraws caused by per-byte upload callbacks
- bytesSoFar captured by value in upload closure — avoids mutable var capture
- reserveCapacity on manifestEntries array
- os_signpost intervals on FetchAssets, NASConnect, ManifestLoad,
BuildPendingQueue, UploadLoop, UploadFile for Instruments profiling
BackupStatusService:
- countSafe() marked nonisolated — runs in Task.detached off main actor,
keeping touch/animation responsive during PHFetchResult batch enumeration
- ManifestIndex JSON decode + Set construction run in Task.detached
- Bootstrap ManifestIndex(nasListing:) construction off main actor
- writeManifest JSON decode/encode/merge off main actor
- os_signpost intervals on Reconcile, ManifestLoad, CountSafe
Model types:
- ManifestIndex, ManifestEntry, PhotoAsset, BackupFilter marked Sendable —
safe to pass across task boundaries without warnings
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 16:42:48 +03:00
|
|
|
|
defer { transfer.disconnect(); activeTransfer = nil }
|
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
|
|
|
|
|
perf: move heavy work off main actor, throttle progress updates, add signposts
BackupEngine:
- fetchAssets() runs in Task.detached — prevents 100-500ms UI freeze during
PHFetchResult enumeration on large libraries
- Manifest JSON decode + ManifestIndex Set construction run in Task.detached
- pendingAssets filter runs in Task.detached — O(N) array filter off main thread
- Progress updates throttled to 8 Hz via ProgressThrottle — eliminates the
continuous SwiftUI full-tree redraws caused by per-byte upload callbacks
- bytesSoFar captured by value in upload closure — avoids mutable var capture
- reserveCapacity on manifestEntries array
- os_signpost intervals on FetchAssets, NASConnect, ManifestLoad,
BuildPendingQueue, UploadLoop, UploadFile for Instruments profiling
BackupStatusService:
- countSafe() marked nonisolated — runs in Task.detached off main actor,
keeping touch/animation responsive during PHFetchResult batch enumeration
- ManifestIndex JSON decode + Set construction run in Task.detached
- Bootstrap ManifestIndex(nasListing:) construction off main actor
- writeManifest JSON decode/encode/merge off main actor
- os_signpost intervals on Reconcile, ManifestLoad, CountSafe
Model types:
- ManifestIndex, ManifestEntry, PhotoAsset, BackupFilter marked Sendable —
safe to pass across task boundaries without warnings
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 16:42:48 +03:00
|
|
|
|
try Task.checkCancellation()
|
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
|
|
|
|
|
2026-05-18 00:25:45 +03:00
|
|
|
|
// ── 3. Load manifest ───────────────────────────────────────────────
|
2026-05-17 15:30:52 +03:00
|
|
|
|
let manifestPath = "\(connection.remotePath)/\(BackupManifest.remoteFilename)"
|
perf: move heavy work off main actor, throttle progress updates, add signposts
BackupEngine:
- fetchAssets() runs in Task.detached — prevents 100-500ms UI freeze during
PHFetchResult enumeration on large libraries
- Manifest JSON decode + ManifestIndex Set construction run in Task.detached
- pendingAssets filter runs in Task.detached — O(N) array filter off main thread
- Progress updates throttled to 8 Hz via ProgressThrottle — eliminates the
continuous SwiftUI full-tree redraws caused by per-byte upload callbacks
- bytesSoFar captured by value in upload closure — avoids mutable var capture
- reserveCapacity on manifestEntries array
- os_signpost intervals on FetchAssets, NASConnect, ManifestLoad,
BuildPendingQueue, UploadLoop, UploadFile for Instruments profiling
BackupStatusService:
- countSafe() marked nonisolated — runs in Task.detached off main actor,
keeping touch/animation responsive during PHFetchResult batch enumeration
- ManifestIndex JSON decode + Set construction run in Task.detached
- Bootstrap ManifestIndex(nasListing:) construction off main actor
- writeManifest JSON decode/encode/merge off main actor
- os_signpost intervals on Reconcile, ManifestLoad, CountSafe
Model types:
- ManifestIndex, ManifestEntry, PhotoAsset, BackupFilter marked Sendable —
safe to pass across task boundaries without warnings
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 16:42:48 +03:00
|
|
|
|
let spManifest = signposter.beginInterval("ManifestLoad")
|
2026-05-18 00:11:10 +03:00
|
|
|
|
|
perf: move heavy work off main actor, throttle progress updates, add signposts
BackupEngine:
- fetchAssets() runs in Task.detached — prevents 100-500ms UI freeze during
PHFetchResult enumeration on large libraries
- Manifest JSON decode + ManifestIndex Set construction run in Task.detached
- pendingAssets filter runs in Task.detached — O(N) array filter off main thread
- Progress updates throttled to 8 Hz via ProgressThrottle — eliminates the
continuous SwiftUI full-tree redraws caused by per-byte upload callbacks
- bytesSoFar captured by value in upload closure — avoids mutable var capture
- reserveCapacity on manifestEntries array
- os_signpost intervals on FetchAssets, NASConnect, ManifestLoad,
BuildPendingQueue, UploadLoop, UploadFile for Instruments profiling
BackupStatusService:
- countSafe() marked nonisolated — runs in Task.detached off main actor,
keeping touch/animation responsive during PHFetchResult batch enumeration
- ManifestIndex JSON decode + Set construction run in Task.detached
- Bootstrap ManifestIndex(nasListing:) construction off main actor
- writeManifest JSON decode/encode/merge off main actor
- os_signpost intervals on Reconcile, ManifestLoad, CountSafe
Model types:
- ManifestIndex, ManifestEntry, PhotoAsset, BackupFilter marked Sendable —
safe to pass across task boundaries without warnings
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 16:42:48 +03:00
|
|
|
|
let manifestData = try? await transfer.downloadData(at: manifestPath)
|
2026-05-18 17:24:56 +03:00
|
|
|
|
|
|
|
|
|
|
// Decode NAS manifest off the main actor.
|
|
|
|
|
|
let nasManifest: BackupManifest? = await Task.detached(priority: .userInitiated) {
|
|
|
|
|
|
guard let data = manifestData else { return nil }
|
|
|
|
|
|
return try? JSONDecoder.kisani.decode(BackupManifest.self, from: data)
|
perf: move heavy work off main actor, throttle progress updates, add signposts
BackupEngine:
- fetchAssets() runs in Task.detached — prevents 100-500ms UI freeze during
PHFetchResult enumeration on large libraries
- Manifest JSON decode + ManifestIndex Set construction run in Task.detached
- pendingAssets filter runs in Task.detached — O(N) array filter off main thread
- Progress updates throttled to 8 Hz via ProgressThrottle — eliminates the
continuous SwiftUI full-tree redraws caused by per-byte upload callbacks
- bytesSoFar captured by value in upload closure — avoids mutable var capture
- reserveCapacity on manifestEntries array
- os_signpost intervals on FetchAssets, NASConnect, ManifestLoad,
BuildPendingQueue, UploadLoop, UploadFile for Instruments profiling
BackupStatusService:
- countSafe() marked nonisolated — runs in Task.detached off main actor,
keeping touch/animation responsive during PHFetchResult batch enumeration
- ManifestIndex JSON decode + Set construction run in Task.detached
- Bootstrap ManifestIndex(nasListing:) construction off main actor
- writeManifest JSON decode/encode/merge off main actor
- os_signpost intervals on Reconcile, ManifestLoad, CountSafe
Model types:
- ManifestIndex, ManifestEntry, PhotoAsset, BackupFilter marked Sendable —
safe to pass across task boundaries without warnings
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 16:42:48 +03:00
|
|
|
|
}.value
|
2026-05-18 00:11:10 +03:00
|
|
|
|
|
2026-05-18 18:03:56 +03:00
|
|
|
|
// Use the manifest with more entries as the authoritative base.
|
|
|
|
|
|
// CRITICAL: if the NAS manifest has fewer entries than the local cache (e.g. 1 vs 264),
|
|
|
|
|
|
// the cache preserves accumulated history and must win. Overwriting the cache with a
|
|
|
|
|
|
// smaller NAS manifest destroys history and causes every asset to re-upload.
|
|
|
|
|
|
let cachedManifest = await NASManifestCache.shared.manifest
|
|
|
|
|
|
let cacheCount = cachedManifest?.entries.count ?? 0
|
|
|
|
|
|
let nasCount = nasManifest?.entries.count ?? 0
|
|
|
|
|
|
|
2026-05-18 17:24:56 +03:00
|
|
|
|
let baseManifest: BackupManifest
|
2026-05-18 18:03:56 +03:00
|
|
|
|
if nasCount > 0, nasCount >= cacheCount {
|
|
|
|
|
|
baseManifest = nasManifest!
|
|
|
|
|
|
await NASManifestCache.shared.update(nasManifest!)
|
|
|
|
|
|
logger.info("Manifest from NAS — \(nasCount) entries")
|
|
|
|
|
|
} else if cacheCount > 0 {
|
|
|
|
|
|
baseManifest = cachedManifest!
|
|
|
|
|
|
logger.info("Manifest from cache — \(cacheCount) entries (NAS had \(nasCount))")
|
2026-05-18 17:24:56 +03:00
|
|
|
|
} else {
|
|
|
|
|
|
// Genuinely first backup — no manifest anywhere.
|
|
|
|
|
|
baseManifest = nasManifest ?? BackupManifest()
|
|
|
|
|
|
logger.info("Manifest: starting fresh — \(baseManifest.entries.count) entries")
|
|
|
|
|
|
}
|
|
|
|
|
|
let baseIndex = ManifestIndex(manifest: baseManifest)
|
2026-05-18 00:03:10 +03:00
|
|
|
|
|
2026-05-18 17:24:56 +03:00
|
|
|
|
signposter.endInterval("ManifestLoad", spManifest, "\(baseIndex.totalCount) entries")
|
|
|
|
|
|
logger.info("Manifest base: \(baseIndex.totalCount) entries, isFilenameOnly=\(baseIndex.isFilenameOnly)")
|
2026-05-18 00:11:10 +03:00
|
|
|
|
|
perf: move heavy work off main actor, throttle progress updates, add signposts
BackupEngine:
- fetchAssets() runs in Task.detached — prevents 100-500ms UI freeze during
PHFetchResult enumeration on large libraries
- Manifest JSON decode + ManifestIndex Set construction run in Task.detached
- pendingAssets filter runs in Task.detached — O(N) array filter off main thread
- Progress updates throttled to 8 Hz via ProgressThrottle — eliminates the
continuous SwiftUI full-tree redraws caused by per-byte upload callbacks
- bytesSoFar captured by value in upload closure — avoids mutable var capture
- reserveCapacity on manifestEntries array
- os_signpost intervals on FetchAssets, NASConnect, ManifestLoad,
BuildPendingQueue, UploadLoop, UploadFile for Instruments profiling
BackupStatusService:
- countSafe() marked nonisolated — runs in Task.detached off main actor,
keeping touch/animation responsive during PHFetchResult batch enumeration
- ManifestIndex JSON decode + Set construction run in Task.detached
- Bootstrap ManifestIndex(nasListing:) construction off main actor
- writeManifest JSON decode/encode/merge off main actor
- os_signpost intervals on Reconcile, ManifestLoad, CountSafe
Model types:
- ManifestIndex, ManifestEntry, PhotoAsset, BackupFilter marked Sendable —
safe to pass across task boundaries without warnings
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 16:42:48 +03:00
|
|
|
|
try Task.checkCancellation()
|
|
|
|
|
|
|
|
|
|
|
|
// ── 4. Build pending queue off the main actor ──────────────────────
|
|
|
|
|
|
let spFilter = signposter.beginInterval("BuildPendingQueue")
|
|
|
|
|
|
let pendingAssets: [PhotoAsset] = await Task.detached(priority: .userInitiated) {
|
|
|
|
|
|
assets.filter { asset in
|
2026-05-18 00:25:45 +03:00
|
|
|
|
!baseIndex.matches(localIdentifier: asset.localIdentifier) &&
|
|
|
|
|
|
!(baseIndex.isFilenameOnly && baseIndex.matches(filename: asset.filename)) &&
|
|
|
|
|
|
!baseIndex.matchesUnclaimed(filename: asset.filename)
|
perf: move heavy work off main actor, throttle progress updates, add signposts
BackupEngine:
- fetchAssets() runs in Task.detached — prevents 100-500ms UI freeze during
PHFetchResult enumeration on large libraries
- Manifest JSON decode + ManifestIndex Set construction run in Task.detached
- pendingAssets filter runs in Task.detached — O(N) array filter off main thread
- Progress updates throttled to 8 Hz via ProgressThrottle — eliminates the
continuous SwiftUI full-tree redraws caused by per-byte upload callbacks
- bytesSoFar captured by value in upload closure — avoids mutable var capture
- reserveCapacity on manifestEntries array
- os_signpost intervals on FetchAssets, NASConnect, ManifestLoad,
BuildPendingQueue, UploadLoop, UploadFile for Instruments profiling
BackupStatusService:
- countSafe() marked nonisolated — runs in Task.detached off main actor,
keeping touch/animation responsive during PHFetchResult batch enumeration
- ManifestIndex JSON decode + Set construction run in Task.detached
- Bootstrap ManifestIndex(nasListing:) construction off main actor
- writeManifest JSON decode/encode/merge off main actor
- os_signpost intervals on Reconcile, ManifestLoad, CountSafe
Model types:
- ManifestIndex, ManifestEntry, PhotoAsset, BackupFilter marked Sendable —
safe to pass across task boundaries without warnings
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 16:42:48 +03:00
|
|
|
|
}
|
|
|
|
|
|
}.value
|
|
|
|
|
|
signposter.endInterval("BuildPendingQueue", spFilter, "\(pendingAssets.count) pending")
|
|
|
|
|
|
logger.info("Pending queue: \(pendingAssets.count) of \(assets.count) total")
|
2026-05-17 15:58:54 +03:00
|
|
|
|
|
|
|
|
|
|
guard !pendingAssets.isEmpty else {
|
|
|
|
|
|
job.allAlreadySafe()
|
|
|
|
|
|
BackupStatusService.shared.refresh(force: false)
|
|
|
|
|
|
return BackupResult.empty(date: startDate)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
perf: move heavy work off main actor, throttle progress updates, add signposts
BackupEngine:
- fetchAssets() runs in Task.detached — prevents 100-500ms UI freeze during
PHFetchResult enumeration on large libraries
- Manifest JSON decode + ManifestIndex Set construction run in Task.detached
- pendingAssets filter runs in Task.detached — O(N) array filter off main thread
- Progress updates throttled to 8 Hz via ProgressThrottle — eliminates the
continuous SwiftUI full-tree redraws caused by per-byte upload callbacks
- bytesSoFar captured by value in upload closure — avoids mutable var capture
- reserveCapacity on manifestEntries array
- os_signpost intervals on FetchAssets, NASConnect, ManifestLoad,
BuildPendingQueue, UploadLoop, UploadFile for Instruments profiling
BackupStatusService:
- countSafe() marked nonisolated — runs in Task.detached off main actor,
keeping touch/animation responsive during PHFetchResult batch enumeration
- ManifestIndex JSON decode + Set construction run in Task.detached
- Bootstrap ManifestIndex(nasListing:) construction off main actor
- writeManifest JSON decode/encode/merge off main actor
- os_signpost intervals on Reconcile, ManifestLoad, CountSafe
Model types:
- ManifestIndex, ManifestEntry, PhotoAsset, BackupFilter marked Sendable —
safe to pass across task boundaries without warnings
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 16:42:48 +03:00
|
|
|
|
// ── 5. Upload loop ─────────────────────────────────────────────────
|
2026-05-17 15:58:54 +03:00
|
|
|
|
job.start(totalFiles: pendingAssets.count, totalBytes: 0)
|
perf: eliminate full rescans with local caches; add queue tracking, notifications, SyncView redesign
Cached indexes:
- NASManifestCache: actor-based disk cache for the NAS manifest (kisani_manifest_cache.json)
Avoids NAS network round-trip when cache < 5 min old. Updated after every manifest download.
- LocalPhotoIndex: actor-based disk cache of phone asset metadata (kisani_local_index.json)
Built once on first launch, then updated incrementally via PHPhotoLibraryChangeObserver.
O(1) countSafe via in-memory dict (vs PHFetchResult batch enumeration + CoreData calls).
Fast path reconcile:
When both caches are warm, reconcile() skips NAS connection + PHFetchResult enumeration
entirely and returns in microseconds. Falls through to full NAS path only when stale.
AutoBackupCoordinator.onActive():
Changed force: true → force: false so the 30-second debounce prevents expensive rescans
on rapid app foreground/background cycles. Dashboard appears instantly from cached snapshot.
BackupQueueItem + queue tracking:
BackupEngine publishes queueItems: [BackupQueueItem] with per-file status, progress,
speed, error details. Queue is persisted to disk and reloaded on next launch so SyncView
always has data to show even after restart.
NotificationService:
Centralised local notification sender replacing the inline UNMutableNotificationContent
blocks. Sends for: backup started, completed, failed, paused, NAS offline. Uses category
identifiers so same-type notifications replace each other.
SyncView redesign:
- Removed pill/chip around NAS IP — replaced with plain "Online/Offline/Checking" dot+text
- No live NAS directory listing (was expensive NAS connection on every tab open)
- Shows engine.queueItems grouped into Active / Failed / Completed sections
- Per-file rows with inline progress bar + bytes/speed for uploading items, error text for failed
- Idle state shows archive count from statusService snapshot
GalleryViewModel:
NAS tab and All tab now use NASManifestCache as fallback when statusService.lastManifest
is nil (offline launch). Gallery shows NAS items even when NAS is unreachable.
AppDelegate:
Bootstraps LocalPhotoIndex.loadOrBuild() in background on launch (non-blocking).
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 23:28:29 +03:00
|
|
|
|
NotificationService.notifyBackupStarted(count: pendingAssets.count)
|
|
|
|
|
|
|
|
|
|
|
|
// Build initial queue (all queued) for SyncView
|
|
|
|
|
|
queueItems = pendingAssets.map { asset in
|
|
|
|
|
|
BackupQueueItem(
|
|
|
|
|
|
id: asset.localIdentifier,
|
|
|
|
|
|
filename: asset.filename,
|
|
|
|
|
|
nasPath: "\(connection.remotePath)/\(asset.filename)",
|
|
|
|
|
|
status: .queued, bytesUploaded: 0, totalBytes: 0, speedBytesPerSec: 0,
|
|
|
|
|
|
error: nil, retryCount: 0, queuedAt: Date(), completedAt: nil
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
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
|
|
|
|
|
|
|
|
|
|
var uploaded = 0
|
perf: move heavy work off main actor, throttle progress updates, add signposts
BackupEngine:
- fetchAssets() runs in Task.detached — prevents 100-500ms UI freeze during
PHFetchResult enumeration on large libraries
- Manifest JSON decode + ManifestIndex Set construction run in Task.detached
- pendingAssets filter runs in Task.detached — O(N) array filter off main thread
- Progress updates throttled to 8 Hz via ProgressThrottle — eliminates the
continuous SwiftUI full-tree redraws caused by per-byte upload callbacks
- bytesSoFar captured by value in upload closure — avoids mutable var capture
- reserveCapacity on manifestEntries array
- os_signpost intervals on FetchAssets, NASConnect, ManifestLoad,
BuildPendingQueue, UploadLoop, UploadFile for Instruments profiling
BackupStatusService:
- countSafe() marked nonisolated — runs in Task.detached off main actor,
keeping touch/animation responsive during PHFetchResult batch enumeration
- ManifestIndex JSON decode + Set construction run in Task.detached
- Bootstrap ManifestIndex(nasListing:) construction off main actor
- writeManifest JSON decode/encode/merge off main actor
- os_signpost intervals on Reconcile, ManifestLoad, CountSafe
Model types:
- ManifestIndex, ManifestEntry, PhotoAsset, BackupFilter marked Sendable —
safe to pass across task boundaries without warnings
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 16:42:48 +03:00
|
|
|
|
var skipped = 0
|
|
|
|
|
|
var failed = 0
|
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
|
|
|
|
var totalBytes: Int64 = 0
|
|
|
|
|
|
var speedTracker = SpeedTracker()
|
perf: move heavy work off main actor, throttle progress updates, add signposts
BackupEngine:
- fetchAssets() runs in Task.detached — prevents 100-500ms UI freeze during
PHFetchResult enumeration on large libraries
- Manifest JSON decode + ManifestIndex Set construction run in Task.detached
- pendingAssets filter runs in Task.detached — O(N) array filter off main thread
- Progress updates throttled to 8 Hz via ProgressThrottle — eliminates the
continuous SwiftUI full-tree redraws caused by per-byte upload callbacks
- bytesSoFar captured by value in upload closure — avoids mutable var capture
- reserveCapacity on manifestEntries array
- os_signpost intervals on FetchAssets, NASConnect, ManifestLoad,
BuildPendingQueue, UploadLoop, UploadFile for Instruments profiling
BackupStatusService:
- countSafe() marked nonisolated — runs in Task.detached off main actor,
keeping touch/animation responsive during PHFetchResult batch enumeration
- ManifestIndex JSON decode + Set construction run in Task.detached
- Bootstrap ManifestIndex(nasListing:) construction off main actor
- writeManifest JSON decode/encode/merge off main actor
- os_signpost intervals on Reconcile, ManifestLoad, CountSafe
Model types:
- ManifestIndex, ManifestEntry, PhotoAsset, BackupFilter marked Sendable —
safe to pass across task boundaries without warnings
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 16:42:48 +03:00
|
|
|
|
var throttle = ProgressThrottle(hz: 8)
|
Add live backup status reconciliation with NAS manifest
BackupStatusService (new singleton, ObservableObject, PHPhotoLibraryChangeObserver):
- Loads cached BackupStatusSnapshot instantly from UserDefaults on init
- Full reconcile: fetch phone assets → connect NAS → load/build manifest → compare
→ enforce invariant (alreadySafe ≤ phoneTotal), persist result
- Debounced (30s) for photo library changes; force=true bypasses debounce
- If NAS unreachable: keeps last cached numbers, marks connectionState = .offline
- PHPhotoLibraryChangeObserver triggers refresh on any library change
BackupManifest (new):
- Stored at {remotePath}/.kisani.json on NAS
- Indexed by localIdentifier (PHAsset stable ID); filename fallback for legacy entries
- Built from directory listing if manifest missing (bootstrap for existing backups)
- merged/updated after each backup run via NASTransferProtocol.writeData
BackupStatusSnapshot (new):
- Single source of truth: phoneTotal, alreadySafe, needBackup (derived), nasArchiveTotal,
lastCheckedAt, connectionState
- Invariant enforced in service: alreadySafe = min(safe, phoneTotal)
Protocol / services:
- NASTransferProtocol: adds writeData(_ data: Data, to remotePath: String)
- SMBService: implements writeData via temp file + SMBClient.upload
- SFTPService: implements writeData via SFTP ByteBuffer write
BackupEngine:
- Tracks ManifestEntry per successful upload during backup loop
- After backup: calls BackupStatusService.refreshAfterBackup(entries:connection:)
which applies optimistic UI update then writes manifest + triggers reconcile
BackupView:
- Reads all stats from BackupStatusService.snapshot (not vm/nasFileCount)
- Stats labels: "NAS Archive" / "On iPhone" / "Need Backup" / "Already Safe"
- Live refresh triggers: .task (force), scenePhase.active (force),
nasReachable change (force), remotePath change (force), backup completed (+2s)
- Subtle status row below stats: "Checking…" spinner or "Updated X ago" with
wifi-slash icon when NAS offline; tap refresh button for forced reconcile
- AppMenuView sheet now correctly passes engine EnvironmentObject
BackupViewModel: stripped to auth-only (photosAuthStatus + requestPhotosAccess)
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 12:51:03 +03:00
|
|
|
|
var manifestEntries: [ManifestEntry] = []
|
perf: move heavy work off main actor, throttle progress updates, add signposts
BackupEngine:
- fetchAssets() runs in Task.detached — prevents 100-500ms UI freeze during
PHFetchResult enumeration on large libraries
- Manifest JSON decode + ManifestIndex Set construction run in Task.detached
- pendingAssets filter runs in Task.detached — O(N) array filter off main thread
- Progress updates throttled to 8 Hz via ProgressThrottle — eliminates the
continuous SwiftUI full-tree redraws caused by per-byte upload callbacks
- bytesSoFar captured by value in upload closure — avoids mutable var capture
- reserveCapacity on manifestEntries array
- os_signpost intervals on FetchAssets, NASConnect, ManifestLoad,
BuildPendingQueue, UploadLoop, UploadFile for Instruments profiling
BackupStatusService:
- countSafe() marked nonisolated — runs in Task.detached off main actor,
keeping touch/animation responsive during PHFetchResult batch enumeration
- ManifestIndex JSON decode + Set construction run in Task.detached
- Bootstrap ManifestIndex(nasListing:) construction off main actor
- writeManifest JSON decode/encode/merge off main actor
- os_signpost intervals on Reconcile, ManifestLoad, CountSafe
Model types:
- ManifestIndex, ManifestEntry, PhotoAsset, BackupFilter marked Sendable —
safe to pass across task boundaries without warnings
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 16:42:48 +03:00
|
|
|
|
manifestEntries.reserveCapacity(pendingAssets.count)
|
2026-05-18 00:11:10 +03:00
|
|
|
|
var lastCheckpointAt = 0 // uploaded count at last manifest checkpoint write
|
|
|
|
|
|
let checkpointInterval = 25
|
perf: move heavy work off main actor, throttle progress updates, add signposts
BackupEngine:
- fetchAssets() runs in Task.detached — prevents 100-500ms UI freeze during
PHFetchResult enumeration on large libraries
- Manifest JSON decode + ManifestIndex Set construction run in Task.detached
- pendingAssets filter runs in Task.detached — O(N) array filter off main thread
- Progress updates throttled to 8 Hz via ProgressThrottle — eliminates the
continuous SwiftUI full-tree redraws caused by per-byte upload callbacks
- bytesSoFar captured by value in upload closure — avoids mutable var capture
- reserveCapacity on manifestEntries array
- os_signpost intervals on FetchAssets, NASConnect, ManifestLoad,
BuildPendingQueue, UploadLoop, UploadFile for Instruments profiling
BackupStatusService:
- countSafe() marked nonisolated — runs in Task.detached off main actor,
keeping touch/animation responsive during PHFetchResult batch enumeration
- ManifestIndex JSON decode + Set construction run in Task.detached
- Bootstrap ManifestIndex(nasListing:) construction off main actor
- writeManifest JSON decode/encode/merge off main actor
- os_signpost intervals on Reconcile, ManifestLoad, CountSafe
Model types:
- ManifestIndex, ManifestEntry, PhotoAsset, BackupFilter marked Sendable —
safe to pass across task boundaries without warnings
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 16:42:48 +03:00
|
|
|
|
|
|
|
|
|
|
let spUpload = signposter.beginInterval("UploadLoop", "\(pendingAssets.count) files")
|
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
|
|
|
|
|
2026-05-18 00:03:10 +03:00
|
|
|
|
let maxRetries = 3
|
|
|
|
|
|
|
perf: eliminate full rescans with local caches; add queue tracking, notifications, SyncView redesign
Cached indexes:
- NASManifestCache: actor-based disk cache for the NAS manifest (kisani_manifest_cache.json)
Avoids NAS network round-trip when cache < 5 min old. Updated after every manifest download.
- LocalPhotoIndex: actor-based disk cache of phone asset metadata (kisani_local_index.json)
Built once on first launch, then updated incrementally via PHPhotoLibraryChangeObserver.
O(1) countSafe via in-memory dict (vs PHFetchResult batch enumeration + CoreData calls).
Fast path reconcile:
When both caches are warm, reconcile() skips NAS connection + PHFetchResult enumeration
entirely and returns in microseconds. Falls through to full NAS path only when stale.
AutoBackupCoordinator.onActive():
Changed force: true → force: false so the 30-second debounce prevents expensive rescans
on rapid app foreground/background cycles. Dashboard appears instantly from cached snapshot.
BackupQueueItem + queue tracking:
BackupEngine publishes queueItems: [BackupQueueItem] with per-file status, progress,
speed, error details. Queue is persisted to disk and reloaded on next launch so SyncView
always has data to show even after restart.
NotificationService:
Centralised local notification sender replacing the inline UNMutableNotificationContent
blocks. Sends for: backup started, completed, failed, paused, NAS offline. Uses category
identifiers so same-type notifications replace each other.
SyncView redesign:
- Removed pill/chip around NAS IP — replaced with plain "Online/Offline/Checking" dot+text
- No live NAS directory listing (was expensive NAS connection on every tab open)
- Shows engine.queueItems grouped into Active / Failed / Completed sections
- Per-file rows with inline progress bar + bytes/speed for uploading items, error text for failed
- Idle state shows archive count from statusService snapshot
GalleryViewModel:
NAS tab and All tab now use NASManifestCache as fallback when statusService.lastManifest
is nil (offline launch). Gallery shows NAS items even when NAS is unreachable.
AppDelegate:
Bootstraps LocalPhotoIndex.loadOrBuild() in background on launch (non-blocking).
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 23:28:29 +03:00
|
|
|
|
for (assetIdx, asset) in pendingAssets.enumerated() {
|
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
|
|
|
|
if isCancelled { break }
|
|
|
|
|
|
while case .paused = job.status {
|
|
|
|
|
|
try await Task.sleep(nanoseconds: 500_000_000)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
perf: move heavy work off main actor, throttle progress updates, add signposts
BackupEngine:
- fetchAssets() runs in Task.detached — prevents 100-500ms UI freeze during
PHFetchResult enumeration on large libraries
- Manifest JSON decode + ManifestIndex Set construction run in Task.detached
- pendingAssets filter runs in Task.detached — O(N) array filter off main thread
- Progress updates throttled to 8 Hz via ProgressThrottle — eliminates the
continuous SwiftUI full-tree redraws caused by per-byte upload callbacks
- bytesSoFar captured by value in upload closure — avoids mutable var capture
- reserveCapacity on manifestEntries array
- os_signpost intervals on FetchAssets, NASConnect, ManifestLoad,
BuildPendingQueue, UploadLoop, UploadFile for Instruments profiling
BackupStatusService:
- countSafe() marked nonisolated — runs in Task.detached off main actor,
keeping touch/animation responsive during PHFetchResult batch enumeration
- ManifestIndex JSON decode + Set construction run in Task.detached
- Bootstrap ManifestIndex(nasListing:) construction off main actor
- writeManifest JSON decode/encode/merge off main actor
- os_signpost intervals on Reconcile, ManifestLoad, CountSafe
Model types:
- ManifestIndex, ManifestEntry, PhotoAsset, BackupFilter marked Sendable —
safe to pass across task boundaries without warnings
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 16:42:48 +03:00
|
|
|
|
let remotePath = "\(connection.remotePath)/\(asset.filename)"
|
2026-05-17 15:58:54 +03:00
|
|
|
|
|
perf: eliminate full rescans with local caches; add queue tracking, notifications, SyncView redesign
Cached indexes:
- NASManifestCache: actor-based disk cache for the NAS manifest (kisani_manifest_cache.json)
Avoids NAS network round-trip when cache < 5 min old. Updated after every manifest download.
- LocalPhotoIndex: actor-based disk cache of phone asset metadata (kisani_local_index.json)
Built once on first launch, then updated incrementally via PHPhotoLibraryChangeObserver.
O(1) countSafe via in-memory dict (vs PHFetchResult batch enumeration + CoreData calls).
Fast path reconcile:
When both caches are warm, reconcile() skips NAS connection + PHFetchResult enumeration
entirely and returns in microseconds. Falls through to full NAS path only when stale.
AutoBackupCoordinator.onActive():
Changed force: true → force: false so the 30-second debounce prevents expensive rescans
on rapid app foreground/background cycles. Dashboard appears instantly from cached snapshot.
BackupQueueItem + queue tracking:
BackupEngine publishes queueItems: [BackupQueueItem] with per-file status, progress,
speed, error details. Queue is persisted to disk and reloaded on next launch so SyncView
always has data to show even after restart.
NotificationService:
Centralised local notification sender replacing the inline UNMutableNotificationContent
blocks. Sends for: backup started, completed, failed, paused, NAS offline. Uses category
identifiers so same-type notifications replace each other.
SyncView redesign:
- Removed pill/chip around NAS IP — replaced with plain "Online/Offline/Checking" dot+text
- No live NAS directory listing (was expensive NAS connection on every tab open)
- Shows engine.queueItems grouped into Active / Failed / Completed sections
- Per-file rows with inline progress bar + bytes/speed for uploading items, error text for failed
- Idle state shows archive count from statusService snapshot
GalleryViewModel:
NAS tab and All tab now use NASManifestCache as fallback when statusService.lastManifest
is nil (offline launch). Gallery shows NAS items even when NAS is unreachable.
AppDelegate:
Bootstraps LocalPhotoIndex.loadOrBuild() in background on launch (non-blocking).
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 23:28:29 +03:00
|
|
|
|
if assetIdx < queueItems.count {
|
|
|
|
|
|
queueItems[assetIdx].status = .uploading
|
|
|
|
|
|
}
|
|
|
|
|
|
|
perf: move heavy work off main actor, throttle progress updates, add signposts
BackupEngine:
- fetchAssets() runs in Task.detached — prevents 100-500ms UI freeze during
PHFetchResult enumeration on large libraries
- Manifest JSON decode + ManifestIndex Set construction run in Task.detached
- pendingAssets filter runs in Task.detached — O(N) array filter off main thread
- Progress updates throttled to 8 Hz via ProgressThrottle — eliminates the
continuous SwiftUI full-tree redraws caused by per-byte upload callbacks
- bytesSoFar captured by value in upload closure — avoids mutable var capture
- reserveCapacity on manifestEntries array
- os_signpost intervals on FetchAssets, NASConnect, ManifestLoad,
BuildPendingQueue, UploadLoop, UploadFile for Instruments profiling
BackupStatusService:
- countSafe() marked nonisolated — runs in Task.detached off main actor,
keeping touch/animation responsive during PHFetchResult batch enumeration
- ManifestIndex JSON decode + Set construction run in Task.detached
- Bootstrap ManifestIndex(nasListing:) construction off main actor
- writeManifest JSON decode/encode/merge off main actor
- os_signpost intervals on Reconcile, ManifestLoad, CountSafe
Model types:
- ManifestIndex, ManifestEntry, PhotoAsset, BackupFilter marked Sendable —
safe to pass across task boundaries without warnings
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 16:42:48 +03:00
|
|
|
|
if (try? await transfer.fileExists(at: remotePath)) == true {
|
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
|
|
|
|
job.fileCompleted(skipped: true)
|
|
|
|
|
|
skipped += 1
|
perf: eliminate full rescans with local caches; add queue tracking, notifications, SyncView redesign
Cached indexes:
- NASManifestCache: actor-based disk cache for the NAS manifest (kisani_manifest_cache.json)
Avoids NAS network round-trip when cache < 5 min old. Updated after every manifest download.
- LocalPhotoIndex: actor-based disk cache of phone asset metadata (kisani_local_index.json)
Built once on first launch, then updated incrementally via PHPhotoLibraryChangeObserver.
O(1) countSafe via in-memory dict (vs PHFetchResult batch enumeration + CoreData calls).
Fast path reconcile:
When both caches are warm, reconcile() skips NAS connection + PHFetchResult enumeration
entirely and returns in microseconds. Falls through to full NAS path only when stale.
AutoBackupCoordinator.onActive():
Changed force: true → force: false so the 30-second debounce prevents expensive rescans
on rapid app foreground/background cycles. Dashboard appears instantly from cached snapshot.
BackupQueueItem + queue tracking:
BackupEngine publishes queueItems: [BackupQueueItem] with per-file status, progress,
speed, error details. Queue is persisted to disk and reloaded on next launch so SyncView
always has data to show even after restart.
NotificationService:
Centralised local notification sender replacing the inline UNMutableNotificationContent
blocks. Sends for: backup started, completed, failed, paused, NAS offline. Uses category
identifiers so same-type notifications replace each other.
SyncView redesign:
- Removed pill/chip around NAS IP — replaced with plain "Online/Offline/Checking" dot+text
- No live NAS directory listing (was expensive NAS connection on every tab open)
- Shows engine.queueItems grouped into Active / Failed / Completed sections
- Per-file rows with inline progress bar + bytes/speed for uploading items, error text for failed
- Idle state shows archive count from statusService snapshot
GalleryViewModel:
NAS tab and All tab now use NASManifestCache as fallback when statusService.lastManifest
is nil (offline launch). Gallery shows NAS items even when NAS is unreachable.
AppDelegate:
Bootstraps LocalPhotoIndex.loadOrBuild() in background on launch (non-blocking).
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 23:28:29 +03:00
|
|
|
|
if assetIdx < queueItems.count {
|
|
|
|
|
|
queueItems[assetIdx].status = .skipped
|
|
|
|
|
|
queueItems[assetIdx].completedAt = Date()
|
|
|
|
|
|
}
|
2026-05-18 00:25:45 +03:00
|
|
|
|
// Record in manifest even though we didn't upload — the file is on NAS.
|
|
|
|
|
|
// Without this, skipped files stay "pending" forever and trigger backup loops.
|
|
|
|
|
|
manifestEntries.append(ManifestEntry(
|
|
|
|
|
|
localIdentifier: asset.localIdentifier,
|
|
|
|
|
|
filename: asset.filename,
|
|
|
|
|
|
creationDate: asset.creationDate,
|
|
|
|
|
|
fileSize: 0,
|
|
|
|
|
|
remotePath: remotePath,
|
|
|
|
|
|
uploadedAt: Date()
|
|
|
|
|
|
))
|
perf: move heavy work off main actor, throttle progress updates, add signposts
BackupEngine:
- fetchAssets() runs in Task.detached — prevents 100-500ms UI freeze during
PHFetchResult enumeration on large libraries
- Manifest JSON decode + ManifestIndex Set construction run in Task.detached
- pendingAssets filter runs in Task.detached — O(N) array filter off main thread
- Progress updates throttled to 8 Hz via ProgressThrottle — eliminates the
continuous SwiftUI full-tree redraws caused by per-byte upload callbacks
- bytesSoFar captured by value in upload closure — avoids mutable var capture
- reserveCapacity on manifestEntries array
- os_signpost intervals on FetchAssets, NASConnect, ManifestLoad,
BuildPendingQueue, UploadLoop, UploadFile for Instruments profiling
BackupStatusService:
- countSafe() marked nonisolated — runs in Task.detached off main actor,
keeping touch/animation responsive during PHFetchResult batch enumeration
- ManifestIndex JSON decode + Set construction run in Task.detached
- Bootstrap ManifestIndex(nasListing:) construction off main actor
- writeManifest JSON decode/encode/merge off main actor
- os_signpost intervals on Reconcile, ManifestLoad, CountSafe
Model types:
- ManifestIndex, ManifestEntry, PhotoAsset, BackupFilter marked Sendable —
safe to pass across task boundaries without warnings
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 16:42:48 +03:00
|
|
|
|
logger.debug("Skipped (NAS exists): \(asset.filename, privacy: .public)")
|
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
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-18 00:03:10 +03:00
|
|
|
|
var uploadSucceeded = false
|
|
|
|
|
|
var lastUploadError: Error?
|
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
|
|
|
|
|
2026-05-18 00:03:10 +03:00
|
|
|
|
for attempt in 0..<maxRetries {
|
|
|
|
|
|
if isCancelled { break }
|
perf: eliminate full rescans with local caches; add queue tracking, notifications, SyncView redesign
Cached indexes:
- NASManifestCache: actor-based disk cache for the NAS manifest (kisani_manifest_cache.json)
Avoids NAS network round-trip when cache < 5 min old. Updated after every manifest download.
- LocalPhotoIndex: actor-based disk cache of phone asset metadata (kisani_local_index.json)
Built once on first launch, then updated incrementally via PHPhotoLibraryChangeObserver.
O(1) countSafe via in-memory dict (vs PHFetchResult batch enumeration + CoreData calls).
Fast path reconcile:
When both caches are warm, reconcile() skips NAS connection + PHFetchResult enumeration
entirely and returns in microseconds. Falls through to full NAS path only when stale.
AutoBackupCoordinator.onActive():
Changed force: true → force: false so the 30-second debounce prevents expensive rescans
on rapid app foreground/background cycles. Dashboard appears instantly from cached snapshot.
BackupQueueItem + queue tracking:
BackupEngine publishes queueItems: [BackupQueueItem] with per-file status, progress,
speed, error details. Queue is persisted to disk and reloaded on next launch so SyncView
always has data to show even after restart.
NotificationService:
Centralised local notification sender replacing the inline UNMutableNotificationContent
blocks. Sends for: backup started, completed, failed, paused, NAS offline. Uses category
identifiers so same-type notifications replace each other.
SyncView redesign:
- Removed pill/chip around NAS IP — replaced with plain "Online/Offline/Checking" dot+text
- No live NAS directory listing (was expensive NAS connection on every tab open)
- Shows engine.queueItems grouped into Active / Failed / Completed sections
- Per-file rows with inline progress bar + bytes/speed for uploading items, error text for failed
- Idle state shows archive count from statusService snapshot
GalleryViewModel:
NAS tab and All tab now use NASManifestCache as fallback when statusService.lastManifest
is nil (offline launch). Gallery shows NAS items even when NAS is unreachable.
AppDelegate:
Bootstraps LocalPhotoIndex.loadOrBuild() in background on launch (non-blocking).
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 23:28:29 +03:00
|
|
|
|
|
2026-05-18 00:03:10 +03:00
|
|
|
|
if attempt > 0 {
|
|
|
|
|
|
// Exponential backoff: 2s, 4s before retries 2 and 3
|
|
|
|
|
|
let backoffNs = UInt64(pow(2.0, Double(attempt)) * 1_000_000_000)
|
|
|
|
|
|
try? await Task.sleep(nanoseconds: backoffNs)
|
|
|
|
|
|
if assetIdx < queueItems.count {
|
|
|
|
|
|
queueItems[assetIdx].retryCount = attempt
|
|
|
|
|
|
queueItems[assetIdx].status = .uploading
|
|
|
|
|
|
}
|
|
|
|
|
|
logger.info("Retry \(attempt)/\(maxRetries - 1) for \(asset.filename, privacy: .public)")
|
perf: eliminate full rescans with local caches; add queue tracking, notifications, SyncView redesign
Cached indexes:
- NASManifestCache: actor-based disk cache for the NAS manifest (kisani_manifest_cache.json)
Avoids NAS network round-trip when cache < 5 min old. Updated after every manifest download.
- LocalPhotoIndex: actor-based disk cache of phone asset metadata (kisani_local_index.json)
Built once on first launch, then updated incrementally via PHPhotoLibraryChangeObserver.
O(1) countSafe via in-memory dict (vs PHFetchResult batch enumeration + CoreData calls).
Fast path reconcile:
When both caches are warm, reconcile() skips NAS connection + PHFetchResult enumeration
entirely and returns in microseconds. Falls through to full NAS path only when stale.
AutoBackupCoordinator.onActive():
Changed force: true → force: false so the 30-second debounce prevents expensive rescans
on rapid app foreground/background cycles. Dashboard appears instantly from cached snapshot.
BackupQueueItem + queue tracking:
BackupEngine publishes queueItems: [BackupQueueItem] with per-file status, progress,
speed, error details. Queue is persisted to disk and reloaded on next launch so SyncView
always has data to show even after restart.
NotificationService:
Centralised local notification sender replacing the inline UNMutableNotificationContent
blocks. Sends for: backup started, completed, failed, paused, NAS offline. Uses category
identifiers so same-type notifications replace each other.
SyncView redesign:
- Removed pill/chip around NAS IP — replaced with plain "Online/Offline/Checking" dot+text
- No live NAS directory listing (was expensive NAS connection on every tab open)
- Shows engine.queueItems grouped into Active / Failed / Completed sections
- Per-file rows with inline progress bar + bytes/speed for uploading items, error text for failed
- Idle state shows archive count from statusService snapshot
GalleryViewModel:
NAS tab and All tab now use NASManifestCache as fallback when statusService.lastManifest
is nil (offline launch). Gallery shows NAS items even when NAS is unreachable.
AppDelegate:
Bootstraps LocalPhotoIndex.loadOrBuild() in background on launch (non-blocking).
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 23:28:29 +03:00
|
|
|
|
}
|
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
|
|
|
|
|
2026-05-18 00:03:10 +03:00
|
|
|
|
do {
|
|
|
|
|
|
let spFile = signposter.beginInterval("UploadFile",
|
|
|
|
|
|
"\(asset.filename, privacy: .public)")
|
|
|
|
|
|
let localURL = try await photoService.exportAsset(asset)
|
|
|
|
|
|
defer { try? FileManager.default.removeItem(at: localURL) }
|
|
|
|
|
|
|
|
|
|
|
|
let fileSize = (try? localURL.resourceValues(forKeys: [.fileSizeKey]).fileSize)
|
|
|
|
|
|
.flatMap { Int64($0) } ?? 0
|
|
|
|
|
|
let bytesSoFar = totalBytes
|
|
|
|
|
|
let capturedIdx = assetIdx
|
|
|
|
|
|
|
|
|
|
|
|
if capturedIdx < queueItems.count {
|
|
|
|
|
|
queueItems[capturedIdx].totalBytes = fileSize
|
|
|
|
|
|
queueItems[capturedIdx].bytesUploaded = 0
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try await transfer.upload(localURL: localURL, remotePath: remotePath) { sent, _ in
|
|
|
|
|
|
let speed = speedTracker.update(bytesSent: sent)
|
|
|
|
|
|
guard throttle.shouldUpdate() else { return }
|
|
|
|
|
|
Task { @MainActor [weak self] in
|
|
|
|
|
|
guard let self else { return }
|
|
|
|
|
|
self.job.updateProgress(
|
|
|
|
|
|
fileName: asset.filename,
|
|
|
|
|
|
fileSize: fileSize,
|
|
|
|
|
|
bytesTransferred: bytesSoFar + sent,
|
|
|
|
|
|
speed: speed
|
|
|
|
|
|
)
|
|
|
|
|
|
if capturedIdx < self.queueItems.count {
|
|
|
|
|
|
self.queueItems[capturedIdx].bytesUploaded = sent
|
|
|
|
|
|
self.queueItems[capturedIdx].speedBytesPerSec = speed
|
|
|
|
|
|
}
|
perf: eliminate full rescans with local caches; add queue tracking, notifications, SyncView redesign
Cached indexes:
- NASManifestCache: actor-based disk cache for the NAS manifest (kisani_manifest_cache.json)
Avoids NAS network round-trip when cache < 5 min old. Updated after every manifest download.
- LocalPhotoIndex: actor-based disk cache of phone asset metadata (kisani_local_index.json)
Built once on first launch, then updated incrementally via PHPhotoLibraryChangeObserver.
O(1) countSafe via in-memory dict (vs PHFetchResult batch enumeration + CoreData calls).
Fast path reconcile:
When both caches are warm, reconcile() skips NAS connection + PHFetchResult enumeration
entirely and returns in microseconds. Falls through to full NAS path only when stale.
AutoBackupCoordinator.onActive():
Changed force: true → force: false so the 30-second debounce prevents expensive rescans
on rapid app foreground/background cycles. Dashboard appears instantly from cached snapshot.
BackupQueueItem + queue tracking:
BackupEngine publishes queueItems: [BackupQueueItem] with per-file status, progress,
speed, error details. Queue is persisted to disk and reloaded on next launch so SyncView
always has data to show even after restart.
NotificationService:
Centralised local notification sender replacing the inline UNMutableNotificationContent
blocks. Sends for: backup started, completed, failed, paused, NAS offline. Uses category
identifiers so same-type notifications replace each other.
SyncView redesign:
- Removed pill/chip around NAS IP — replaced with plain "Online/Offline/Checking" dot+text
- No live NAS directory listing (was expensive NAS connection on every tab open)
- Shows engine.queueItems grouped into Active / Failed / Completed sections
- Per-file rows with inline progress bar + bytes/speed for uploading items, error text for failed
- Idle state shows archive count from statusService snapshot
GalleryViewModel:
NAS tab and All tab now use NASManifestCache as fallback when statusService.lastManifest
is nil (offline launch). Gallery shows NAS items even when NAS is unreachable.
AppDelegate:
Bootstraps LocalPhotoIndex.loadOrBuild() in background on launch (non-blocking).
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 23:28:29 +03:00
|
|
|
|
}
|
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
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-18 00:03:10 +03:00
|
|
|
|
signposter.endInterval("UploadFile", spFile, "\(fileSize) bytes")
|
|
|
|
|
|
totalBytes += fileSize
|
|
|
|
|
|
job.fileCompleted(skipped: false)
|
|
|
|
|
|
uploaded += 1
|
|
|
|
|
|
uploadSucceeded = true
|
Add live backup status reconciliation with NAS manifest
BackupStatusService (new singleton, ObservableObject, PHPhotoLibraryChangeObserver):
- Loads cached BackupStatusSnapshot instantly from UserDefaults on init
- Full reconcile: fetch phone assets → connect NAS → load/build manifest → compare
→ enforce invariant (alreadySafe ≤ phoneTotal), persist result
- Debounced (30s) for photo library changes; force=true bypasses debounce
- If NAS unreachable: keeps last cached numbers, marks connectionState = .offline
- PHPhotoLibraryChangeObserver triggers refresh on any library change
BackupManifest (new):
- Stored at {remotePath}/.kisani.json on NAS
- Indexed by localIdentifier (PHAsset stable ID); filename fallback for legacy entries
- Built from directory listing if manifest missing (bootstrap for existing backups)
- merged/updated after each backup run via NASTransferProtocol.writeData
BackupStatusSnapshot (new):
- Single source of truth: phoneTotal, alreadySafe, needBackup (derived), nasArchiveTotal,
lastCheckedAt, connectionState
- Invariant enforced in service: alreadySafe = min(safe, phoneTotal)
Protocol / services:
- NASTransferProtocol: adds writeData(_ data: Data, to remotePath: String)
- SMBService: implements writeData via temp file + SMBClient.upload
- SFTPService: implements writeData via SFTP ByteBuffer write
BackupEngine:
- Tracks ManifestEntry per successful upload during backup loop
- After backup: calls BackupStatusService.refreshAfterBackup(entries:connection:)
which applies optimistic UI update then writes manifest + triggers reconcile
BackupView:
- Reads all stats from BackupStatusService.snapshot (not vm/nasFileCount)
- Stats labels: "NAS Archive" / "On iPhone" / "Need Backup" / "Already Safe"
- Live refresh triggers: .task (force), scenePhase.active (force),
nasReachable change (force), remotePath change (force), backup completed (+2s)
- Subtle status row below stats: "Checking…" spinner or "Updated X ago" with
wifi-slash icon when NAS offline; tap refresh button for forced reconcile
- AppMenuView sheet now correctly passes engine EnvironmentObject
BackupViewModel: stripped to auth-only (photosAuthStatus + requestPhotosAccess)
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 12:51:03 +03:00
|
|
|
|
|
2026-05-18 00:03:10 +03:00
|
|
|
|
if assetIdx < queueItems.count {
|
|
|
|
|
|
queueItems[assetIdx].status = .uploaded
|
|
|
|
|
|
queueItems[assetIdx].bytesUploaded = fileSize
|
|
|
|
|
|
queueItems[assetIdx].completedAt = Date()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
manifestEntries.append(ManifestEntry(
|
|
|
|
|
|
localIdentifier: asset.localIdentifier,
|
|
|
|
|
|
filename: asset.filename,
|
|
|
|
|
|
creationDate: asset.creationDate,
|
|
|
|
|
|
fileSize: fileSize,
|
|
|
|
|
|
remotePath: remotePath,
|
|
|
|
|
|
uploadedAt: Date()
|
|
|
|
|
|
))
|
2026-05-18 00:11:10 +03:00
|
|
|
|
|
|
|
|
|
|
// Checkpoint write every N uploads — protects against interruption.
|
|
|
|
|
|
// Uses the same open connection so no extra connect/disconnect overhead.
|
|
|
|
|
|
if uploaded - lastCheckpointAt >= checkpointInterval {
|
|
|
|
|
|
lastCheckpointAt = uploaded
|
2026-05-18 00:25:45 +03:00
|
|
|
|
var checkpoint = baseManifest
|
2026-05-18 00:11:10 +03:00
|
|
|
|
checkpoint.merge(entries: manifestEntries)
|
|
|
|
|
|
if let encoded = try? JSONEncoder.kisani.encode(checkpoint) {
|
|
|
|
|
|
try? await transfer.writeData(encoded, to: manifestPath)
|
|
|
|
|
|
await NASManifestCache.shared.update(checkpoint)
|
|
|
|
|
|
logger.info("Manifest checkpoint: \(checkpoint.entries.count) total entries")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-18 00:03:10 +03:00
|
|
|
|
} catch {
|
2026-05-18 19:33:26 +03:00
|
|
|
|
// "Object Name Collision" (NTStatus 0xC0000035) means the file is already
|
|
|
|
|
|
// on the NAS. Retrying won't help — treat it as safe immediately.
|
|
|
|
|
|
if isAlreadyExistsError(error) {
|
|
|
|
|
|
job.fileCompleted(skipped: true)
|
|
|
|
|
|
skipped += 1
|
|
|
|
|
|
if assetIdx < queueItems.count {
|
|
|
|
|
|
queueItems[assetIdx].status = .skipped
|
|
|
|
|
|
queueItems[assetIdx].completedAt = Date()
|
|
|
|
|
|
}
|
|
|
|
|
|
manifestEntries.append(ManifestEntry(
|
|
|
|
|
|
localIdentifier: asset.localIdentifier,
|
|
|
|
|
|
filename: asset.filename,
|
|
|
|
|
|
creationDate: asset.creationDate,
|
|
|
|
|
|
fileSize: 0,
|
|
|
|
|
|
remotePath: remotePath,
|
|
|
|
|
|
uploadedAt: Date()
|
|
|
|
|
|
))
|
|
|
|
|
|
logger.info("Collision — file already on NAS, marking safe: \(asset.filename, privacy: .public)")
|
|
|
|
|
|
uploadSucceeded = true
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
2026-05-18 00:03:10 +03:00
|
|
|
|
lastUploadError = error
|
2026-05-18 19:33:26 +03:00
|
|
|
|
logger.warning("Upload attempt \(attempt + 1)/\(maxRetries) failed for \(asset.filename, privacy: .public): \(underlyingDescription(error), privacy: .public)")
|
perf: eliminate full rescans with local caches; add queue tracking, notifications, SyncView redesign
Cached indexes:
- NASManifestCache: actor-based disk cache for the NAS manifest (kisani_manifest_cache.json)
Avoids NAS network round-trip when cache < 5 min old. Updated after every manifest download.
- LocalPhotoIndex: actor-based disk cache of phone asset metadata (kisani_local_index.json)
Built once on first launch, then updated incrementally via PHPhotoLibraryChangeObserver.
O(1) countSafe via in-memory dict (vs PHFetchResult batch enumeration + CoreData calls).
Fast path reconcile:
When both caches are warm, reconcile() skips NAS connection + PHFetchResult enumeration
entirely and returns in microseconds. Falls through to full NAS path only when stale.
AutoBackupCoordinator.onActive():
Changed force: true → force: false so the 30-second debounce prevents expensive rescans
on rapid app foreground/background cycles. Dashboard appears instantly from cached snapshot.
BackupQueueItem + queue tracking:
BackupEngine publishes queueItems: [BackupQueueItem] with per-file status, progress,
speed, error details. Queue is persisted to disk and reloaded on next launch so SyncView
always has data to show even after restart.
NotificationService:
Centralised local notification sender replacing the inline UNMutableNotificationContent
blocks. Sends for: backup started, completed, failed, paused, NAS offline. Uses category
identifiers so same-type notifications replace each other.
SyncView redesign:
- Removed pill/chip around NAS IP — replaced with plain "Online/Offline/Checking" dot+text
- No live NAS directory listing (was expensive NAS connection on every tab open)
- Shows engine.queueItems grouped into Active / Failed / Completed sections
- Per-file rows with inline progress bar + bytes/speed for uploading items, error text for failed
- Idle state shows archive count from statusService snapshot
GalleryViewModel:
NAS tab and All tab now use NASManifestCache as fallback when statusService.lastManifest
is nil (offline launch). Gallery shows NAS items even when NAS is unreachable.
AppDelegate:
Bootstraps LocalPhotoIndex.loadOrBuild() in background on launch (non-blocking).
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 23:28:29 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-18 00:03:10 +03:00
|
|
|
|
if uploadSucceeded { break }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if !uploadSucceeded, let error = lastUploadError {
|
2026-05-18 19:33:26 +03:00
|
|
|
|
// Last-resort check: maybe the file landed on NAS despite the errors
|
|
|
|
|
|
// (e.g. fileExists threw transiently on the pre-check and the upload
|
|
|
|
|
|
// actually succeeded before the connection reset).
|
2026-05-18 18:55:27 +03:00
|
|
|
|
if (try? await transfer.fileExists(at: remotePath)) == true {
|
|
|
|
|
|
job.fileCompleted(skipped: true)
|
|
|
|
|
|
skipped += 1
|
|
|
|
|
|
if assetIdx < queueItems.count {
|
|
|
|
|
|
queueItems[assetIdx].status = .skipped
|
|
|
|
|
|
queueItems[assetIdx].completedAt = Date()
|
|
|
|
|
|
}
|
|
|
|
|
|
manifestEntries.append(ManifestEntry(
|
|
|
|
|
|
localIdentifier: asset.localIdentifier,
|
|
|
|
|
|
filename: asset.filename,
|
|
|
|
|
|
creationDate: asset.creationDate,
|
|
|
|
|
|
fileSize: 0,
|
|
|
|
|
|
remotePath: remotePath,
|
|
|
|
|
|
uploadedAt: Date()
|
|
|
|
|
|
))
|
2026-05-18 19:33:26 +03:00
|
|
|
|
logger.info("Post-retry check — file on NAS, marking safe: \(asset.filename, privacy: .public)")
|
2026-05-18 18:55:27 +03:00
|
|
|
|
} else {
|
2026-05-18 19:33:26 +03:00
|
|
|
|
let reason = underlyingDescription(error)
|
|
|
|
|
|
logger.error("Upload permanently failed for \(asset.filename, privacy: .public): \(reason, privacy: .public)")
|
2026-05-18 18:55:27 +03:00
|
|
|
|
job.fileFailed()
|
|
|
|
|
|
failed += 1
|
|
|
|
|
|
if assetIdx < queueItems.count {
|
|
|
|
|
|
queueItems[assetIdx].status = .failed
|
|
|
|
|
|
queueItems[assetIdx].completedAt = Date()
|
|
|
|
|
|
queueItems[assetIdx].error = BackupQueueItem.UploadError(
|
2026-05-18 19:33:26 +03:00
|
|
|
|
code: underlyingDomain(error),
|
|
|
|
|
|
message: reason,
|
2026-05-18 18:55:27 +03:00
|
|
|
|
timestamp: Date()
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
perf: eliminate full rescans with local caches; add queue tracking, notifications, SyncView redesign
Cached indexes:
- NASManifestCache: actor-based disk cache for the NAS manifest (kisani_manifest_cache.json)
Avoids NAS network round-trip when cache < 5 min old. Updated after every manifest download.
- LocalPhotoIndex: actor-based disk cache of phone asset metadata (kisani_local_index.json)
Built once on first launch, then updated incrementally via PHPhotoLibraryChangeObserver.
O(1) countSafe via in-memory dict (vs PHFetchResult batch enumeration + CoreData calls).
Fast path reconcile:
When both caches are warm, reconcile() skips NAS connection + PHFetchResult enumeration
entirely and returns in microseconds. Falls through to full NAS path only when stale.
AutoBackupCoordinator.onActive():
Changed force: true → force: false so the 30-second debounce prevents expensive rescans
on rapid app foreground/background cycles. Dashboard appears instantly from cached snapshot.
BackupQueueItem + queue tracking:
BackupEngine publishes queueItems: [BackupQueueItem] with per-file status, progress,
speed, error details. Queue is persisted to disk and reloaded on next launch so SyncView
always has data to show even after restart.
NotificationService:
Centralised local notification sender replacing the inline UNMutableNotificationContent
blocks. Sends for: backup started, completed, failed, paused, NAS offline. Uses category
identifiers so same-type notifications replace each other.
SyncView redesign:
- Removed pill/chip around NAS IP — replaced with plain "Online/Offline/Checking" dot+text
- No live NAS directory listing (was expensive NAS connection on every tab open)
- Shows engine.queueItems grouped into Active / Failed / Completed sections
- Per-file rows with inline progress bar + bytes/speed for uploading items, error text for failed
- Idle state shows archive count from statusService snapshot
GalleryViewModel:
NAS tab and All tab now use NASManifestCache as fallback when statusService.lastManifest
is nil (offline launch). Gallery shows NAS items even when NAS is unreachable.
AppDelegate:
Bootstraps LocalPhotoIndex.loadOrBuild() in background on launch (non-blocking).
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 23:28:29 +03:00
|
|
|
|
}
|
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
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
perf: move heavy work off main actor, throttle progress updates, add signposts
BackupEngine:
- fetchAssets() runs in Task.detached — prevents 100-500ms UI freeze during
PHFetchResult enumeration on large libraries
- Manifest JSON decode + ManifestIndex Set construction run in Task.detached
- pendingAssets filter runs in Task.detached — O(N) array filter off main thread
- Progress updates throttled to 8 Hz via ProgressThrottle — eliminates the
continuous SwiftUI full-tree redraws caused by per-byte upload callbacks
- bytesSoFar captured by value in upload closure — avoids mutable var capture
- reserveCapacity on manifestEntries array
- os_signpost intervals on FetchAssets, NASConnect, ManifestLoad,
BuildPendingQueue, UploadLoop, UploadFile for Instruments profiling
BackupStatusService:
- countSafe() marked nonisolated — runs in Task.detached off main actor,
keeping touch/animation responsive during PHFetchResult batch enumeration
- ManifestIndex JSON decode + Set construction run in Task.detached
- Bootstrap ManifestIndex(nasListing:) construction off main actor
- writeManifest JSON decode/encode/merge off main actor
- os_signpost intervals on Reconcile, ManifestLoad, CountSafe
Model types:
- ManifestIndex, ManifestEntry, PhotoAsset, BackupFilter marked Sendable —
safe to pass across task boundaries without warnings
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 16:42:48 +03:00
|
|
|
|
signposter.endInterval("UploadLoop", spUpload, "\(uploaded) uploaded, \(failed) failed")
|
|
|
|
|
|
|
2026-05-18 20:52:40 +03:00
|
|
|
|
// Update local manifest cache immediately with every completed entry
|
|
|
|
|
|
// (uploaded + collision-skipped + fileExists-skipped).
|
|
|
|
|
|
// writeManifest() will persist this to the NAS, but even if that network
|
|
|
|
|
|
// write fails the next fast-path reconcile reads the correct counts here.
|
|
|
|
|
|
// Without this, a failed NAS write leaves the cache at 1 entry and the
|
|
|
|
|
|
// next reconcile computes alreadySafe = 1 instead of the real count.
|
|
|
|
|
|
if !manifestEntries.isEmpty {
|
|
|
|
|
|
var localMerged = baseManifest
|
|
|
|
|
|
localMerged.merge(entries: manifestEntries)
|
|
|
|
|
|
await NASManifestCache.shared.update(localMerged)
|
|
|
|
|
|
logger.info("Local cache updated — \(localMerged.entries.count) total entries")
|
|
|
|
|
|
}
|
2026-05-18 18:03:56 +03:00
|
|
|
|
if uploaded > 0 {
|
|
|
|
|
|
await NASManifestCache.shared.addToDirectoryCount(uploaded)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
let duration = Date().timeIntervalSince(startDate)
|
|
|
|
|
|
let result = BackupResult(
|
|
|
|
|
|
uploadedCount: uploaded,
|
perf: move heavy work off main actor, throttle progress updates, add signposts
BackupEngine:
- fetchAssets() runs in Task.detached — prevents 100-500ms UI freeze during
PHFetchResult enumeration on large libraries
- Manifest JSON decode + ManifestIndex Set construction run in Task.detached
- pendingAssets filter runs in Task.detached — O(N) array filter off main thread
- Progress updates throttled to 8 Hz via ProgressThrottle — eliminates the
continuous SwiftUI full-tree redraws caused by per-byte upload callbacks
- bytesSoFar captured by value in upload closure — avoids mutable var capture
- reserveCapacity on manifestEntries array
- os_signpost intervals on FetchAssets, NASConnect, ManifestLoad,
BuildPendingQueue, UploadLoop, UploadFile for Instruments profiling
BackupStatusService:
- countSafe() marked nonisolated — runs in Task.detached off main actor,
keeping touch/animation responsive during PHFetchResult batch enumeration
- ManifestIndex JSON decode + Set construction run in Task.detached
- Bootstrap ManifestIndex(nasListing:) construction off main actor
- writeManifest JSON decode/encode/merge off main actor
- os_signpost intervals on Reconcile, ManifestLoad, CountSafe
Model types:
- ManifestIndex, ManifestEntry, PhotoAsset, BackupFilter marked Sendable —
safe to pass across task boundaries without warnings
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 16:42:48 +03:00
|
|
|
|
skippedCount: skipped,
|
|
|
|
|
|
failedCount: failed,
|
|
|
|
|
|
duration: duration,
|
|
|
|
|
|
totalBytes: totalBytes,
|
|
|
|
|
|
date: startDate
|
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
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
job.finish(result: result)
|
Add live backup status reconciliation with NAS manifest
BackupStatusService (new singleton, ObservableObject, PHPhotoLibraryChangeObserver):
- Loads cached BackupStatusSnapshot instantly from UserDefaults on init
- Full reconcile: fetch phone assets → connect NAS → load/build manifest → compare
→ enforce invariant (alreadySafe ≤ phoneTotal), persist result
- Debounced (30s) for photo library changes; force=true bypasses debounce
- If NAS unreachable: keeps last cached numbers, marks connectionState = .offline
- PHPhotoLibraryChangeObserver triggers refresh on any library change
BackupManifest (new):
- Stored at {remotePath}/.kisani.json on NAS
- Indexed by localIdentifier (PHAsset stable ID); filename fallback for legacy entries
- Built from directory listing if manifest missing (bootstrap for existing backups)
- merged/updated after each backup run via NASTransferProtocol.writeData
BackupStatusSnapshot (new):
- Single source of truth: phoneTotal, alreadySafe, needBackup (derived), nasArchiveTotal,
lastCheckedAt, connectionState
- Invariant enforced in service: alreadySafe = min(safe, phoneTotal)
Protocol / services:
- NASTransferProtocol: adds writeData(_ data: Data, to remotePath: String)
- SMBService: implements writeData via temp file + SMBClient.upload
- SFTPService: implements writeData via SFTP ByteBuffer write
BackupEngine:
- Tracks ManifestEntry per successful upload during backup loop
- After backup: calls BackupStatusService.refreshAfterBackup(entries:connection:)
which applies optimistic UI update then writes manifest + triggers reconcile
BackupView:
- Reads all stats from BackupStatusService.snapshot (not vm/nasFileCount)
- Stats labels: "NAS Archive" / "On iPhone" / "Need Backup" / "Already Safe"
- Live refresh triggers: .task (force), scenePhase.active (force),
nasReachable change (force), remotePath change (force), backup completed (+2s)
- Subtle status row below stats: "Checking…" spinner or "Updated X ago" with
wifi-slash icon when NAS offline; tap refresh button for forced reconcile
- AppMenuView sheet now correctly passes engine EnvironmentObject
BackupViewModel: stripped to auth-only (photosAuthStatus + requestPhotosAccess)
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 12:51:03 +03:00
|
|
|
|
BackupStatusService.shared.refreshAfterBackup(entries: manifestEntries, connection: connection)
|
|
|
|
|
|
|
Add cellular data toggle, Tailscale remote access, fix ToggleRow padding
- ToggleRow: fix cramped layout — proper horizontal + vertical card padding
- ConnectionStore: add allowCellularBackup, useTailscaleWhenRemote, tailscaleHost
- LANMonitor: add isOnCellular (NWPath cellular interface), isTailscaleActive
(detects utun interface with 100.x.x.x CGNAT address), openTailscaleApp()
- BackupEngine: gate backup on cellular setting; resolveHost() switches to
tailscaleHost when not on trusted LAN and Tailscale tunnel is active
- SettingsView: CONNECTIVITY section (cellular toggle + live status),
REMOTE ACCESS section (Tailscale toggle, host field, status dot, Open button)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 11:40:07 +03:00
|
|
|
|
let entry = BackupHistoryEntry(result: result, nasHost: host, triggeredByLAN: triggeredByLAN)
|
|
|
|
|
|
store.appendHistoryEntry(entry)
|
perf: eliminate full rescans with local caches; add queue tracking, notifications, SyncView redesign
Cached indexes:
- NASManifestCache: actor-based disk cache for the NAS manifest (kisani_manifest_cache.json)
Avoids NAS network round-trip when cache < 5 min old. Updated after every manifest download.
- LocalPhotoIndex: actor-based disk cache of phone asset metadata (kisani_local_index.json)
Built once on first launch, then updated incrementally via PHPhotoLibraryChangeObserver.
O(1) countSafe via in-memory dict (vs PHFetchResult batch enumeration + CoreData calls).
Fast path reconcile:
When both caches are warm, reconcile() skips NAS connection + PHFetchResult enumeration
entirely and returns in microseconds. Falls through to full NAS path only when stale.
AutoBackupCoordinator.onActive():
Changed force: true → force: false so the 30-second debounce prevents expensive rescans
on rapid app foreground/background cycles. Dashboard appears instantly from cached snapshot.
BackupQueueItem + queue tracking:
BackupEngine publishes queueItems: [BackupQueueItem] with per-file status, progress,
speed, error details. Queue is persisted to disk and reloaded on next launch so SyncView
always has data to show even after restart.
NotificationService:
Centralised local notification sender replacing the inline UNMutableNotificationContent
blocks. Sends for: backup started, completed, failed, paused, NAS offline. Uses category
identifiers so same-type notifications replace each other.
SyncView redesign:
- Removed pill/chip around NAS IP — replaced with plain "Online/Offline/Checking" dot+text
- No live NAS directory listing (was expensive NAS connection on every tab open)
- Shows engine.queueItems grouped into Active / Failed / Completed sections
- Per-file rows with inline progress bar + bytes/speed for uploading items, error text for failed
- Idle state shows archive count from statusService snapshot
GalleryViewModel:
NAS tab and All tab now use NASManifestCache as fallback when statusService.lastManifest
is nil (offline launch). Gallery shows NAS items even when NAS is unreachable.
AppDelegate:
Bootstraps LocalPhotoIndex.loadOrBuild() in background on launch (non-blocking).
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 23:28:29 +03:00
|
|
|
|
|
|
|
|
|
|
// Notifications
|
|
|
|
|
|
if result.failedCount > 0 {
|
|
|
|
|
|
NotificationService.notifyBackupFailed(count: result.failedCount)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
NotificationService.notifyBackupCompleted(
|
|
|
|
|
|
uploaded: result.uploadedCount,
|
|
|
|
|
|
total: BackupStatusService.shared.snapshot.phoneTotal
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Persist queue so SyncView shows last session on next launch
|
|
|
|
|
|
let snapshot = queueItems
|
|
|
|
|
|
Task.detached(priority: .utility) { [snapshot, url = Self.queueCacheURL] in
|
|
|
|
|
|
guard let data = try? JSONEncoder.kisani.encode(snapshot) else { return }
|
|
|
|
|
|
try? data.write(to: url, options: .atomic)
|
|
|
|
|
|
}
|
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 result
|
|
|
|
|
|
}
|
|
|
|
|
|
|
perf: move heavy work off main actor, throttle progress updates, add signposts
BackupEngine:
- fetchAssets() runs in Task.detached — prevents 100-500ms UI freeze during
PHFetchResult enumeration on large libraries
- Manifest JSON decode + ManifestIndex Set construction run in Task.detached
- pendingAssets filter runs in Task.detached — O(N) array filter off main thread
- Progress updates throttled to 8 Hz via ProgressThrottle — eliminates the
continuous SwiftUI full-tree redraws caused by per-byte upload callbacks
- bytesSoFar captured by value in upload closure — avoids mutable var capture
- reserveCapacity on manifestEntries array
- os_signpost intervals on FetchAssets, NASConnect, ManifestLoad,
BuildPendingQueue, UploadLoop, UploadFile for Instruments profiling
BackupStatusService:
- countSafe() marked nonisolated — runs in Task.detached off main actor,
keeping touch/animation responsive during PHFetchResult batch enumeration
- ManifestIndex JSON decode + Set construction run in Task.detached
- Bootstrap ManifestIndex(nasListing:) construction off main actor
- writeManifest JSON decode/encode/merge off main actor
- os_signpost intervals on Reconcile, ManifestLoad, CountSafe
Model types:
- ManifestIndex, ManifestEntry, PhotoAsset, BackupFilter marked Sendable —
safe to pass across task boundaries without warnings
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 16:42:48 +03:00
|
|
|
|
// Returns Tailscale host when off trusted LAN and tunnel is active.
|
Add cellular data toggle, Tailscale remote access, fix ToggleRow padding
- ToggleRow: fix cramped layout — proper horizontal + vertical card padding
- ConnectionStore: add allowCellularBackup, useTailscaleWhenRemote, tailscaleHost
- LANMonitor: add isOnCellular (NWPath cellular interface), isTailscaleActive
(detects utun interface with 100.x.x.x CGNAT address), openTailscaleApp()
- BackupEngine: gate backup on cellular setting; resolveHost() switches to
tailscaleHost when not on trusted LAN and Tailscale tunnel is active
- SettingsView: CONNECTIVITY section (cellular toggle + live status),
REMOTE ACCESS section (Tailscale toggle, host field, status dot, Open button)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 11:40:07 +03:00
|
|
|
|
private func resolveHost(connection: NASConnection, store: ConnectionStore, lan: LANMonitor) -> String {
|
|
|
|
|
|
let onTrustedLAN = store.trustedSSIDs.contains(lan.currentSSID ?? "")
|
|
|
|
|
|
guard !onTrustedLAN,
|
|
|
|
|
|
store.useTailscaleWhenRemote,
|
|
|
|
|
|
!store.tailscaleHost.isEmpty,
|
perf: move heavy work off main actor, throttle progress updates, add signposts
BackupEngine:
- fetchAssets() runs in Task.detached — prevents 100-500ms UI freeze during
PHFetchResult enumeration on large libraries
- Manifest JSON decode + ManifestIndex Set construction run in Task.detached
- pendingAssets filter runs in Task.detached — O(N) array filter off main thread
- Progress updates throttled to 8 Hz via ProgressThrottle — eliminates the
continuous SwiftUI full-tree redraws caused by per-byte upload callbacks
- bytesSoFar captured by value in upload closure — avoids mutable var capture
- reserveCapacity on manifestEntries array
- os_signpost intervals on FetchAssets, NASConnect, ManifestLoad,
BuildPendingQueue, UploadLoop, UploadFile for Instruments profiling
BackupStatusService:
- countSafe() marked nonisolated — runs in Task.detached off main actor,
keeping touch/animation responsive during PHFetchResult batch enumeration
- ManifestIndex JSON decode + Set construction run in Task.detached
- Bootstrap ManifestIndex(nasListing:) construction off main actor
- writeManifest JSON decode/encode/merge off main actor
- os_signpost intervals on Reconcile, ManifestLoad, CountSafe
Model types:
- ManifestIndex, ManifestEntry, PhotoAsset, BackupFilter marked Sendable —
safe to pass across task boundaries without warnings
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 16:42:48 +03:00
|
|
|
|
lan.isTailscaleActive else { return connection.host }
|
Add cellular data toggle, Tailscale remote access, fix ToggleRow padding
- ToggleRow: fix cramped layout — proper horizontal + vertical card padding
- ConnectionStore: add allowCellularBackup, useTailscaleWhenRemote, tailscaleHost
- LANMonitor: add isOnCellular (NWPath cellular interface), isTailscaleActive
(detects utun interface with 100.x.x.x CGNAT address), openTailscaleApp()
- BackupEngine: gate backup on cellular setting; resolveHost() switches to
tailscaleHost when not on trusted LAN and Tailscale tunnel is active
- SettingsView: CONNECTIVITY section (cellular toggle + live status),
REMOTE ACCESS section (Tailscale toggle, host field, status dot, Open button)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 11:40:07 +03:00
|
|
|
|
return store.tailscaleHost
|
|
|
|
|
|
}
|
|
|
|
|
|
|
perf: move heavy work off main actor, throttle progress updates, add signposts
BackupEngine:
- fetchAssets() runs in Task.detached — prevents 100-500ms UI freeze during
PHFetchResult enumeration on large libraries
- Manifest JSON decode + ManifestIndex Set construction run in Task.detached
- pendingAssets filter runs in Task.detached — O(N) array filter off main thread
- Progress updates throttled to 8 Hz via ProgressThrottle — eliminates the
continuous SwiftUI full-tree redraws caused by per-byte upload callbacks
- bytesSoFar captured by value in upload closure — avoids mutable var capture
- reserveCapacity on manifestEntries array
- os_signpost intervals on FetchAssets, NASConnect, ManifestLoad,
BuildPendingQueue, UploadLoop, UploadFile for Instruments profiling
BackupStatusService:
- countSafe() marked nonisolated — runs in Task.detached off main actor,
keeping touch/animation responsive during PHFetchResult batch enumeration
- ManifestIndex JSON decode + Set construction run in Task.detached
- Bootstrap ManifestIndex(nasListing:) construction off main actor
- writeManifest JSON decode/encode/merge off main actor
- os_signpost intervals on Reconcile, ManifestLoad, CountSafe
Model types:
- ManifestIndex, ManifestEntry, PhotoAsset, BackupFilter marked Sendable —
safe to pass across task boundaries without warnings
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 16:42:48 +03:00
|
|
|
|
func cancel() { isCancelled = true; job.cancel() }
|
Add cellular data toggle, Tailscale remote access, fix ToggleRow padding
- ToggleRow: fix cramped layout — proper horizontal + vertical card padding
- ConnectionStore: add allowCellularBackup, useTailscaleWhenRemote, tailscaleHost
- LANMonitor: add isOnCellular (NWPath cellular interface), isTailscaleActive
(detects utun interface with 100.x.x.x CGNAT address), openTailscaleApp()
- BackupEngine: gate backup on cellular setting; resolveHost() switches to
tailscaleHost when not on trusted LAN and Tailscale tunnel is active
- SettingsView: CONNECTIVITY section (cellular toggle + live status),
REMOTE ACCESS section (Tailscale toggle, host field, status dot, Open button)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 11:40:07 +03:00
|
|
|
|
func pause() { job.pause() }
|
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
|
|
|
|
func resume() { job.resume() }
|
|
|
|
|
|
|
2026-05-17 15:58:54 +03:00
|
|
|
|
func resolveSuccess() { job.resolveSuccess() }
|
|
|
|
|
|
|
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
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-18 19:33:26 +03:00
|
|
|
|
// MARK: — Error classification helpers
|
|
|
|
|
|
|
|
|
|
|
|
/// True when the error indicates the remote file already exists.
|
|
|
|
|
|
/// SMBClient surfaces this as ErrorResponse with NTStatus.objectNameCollision (0xC0000035),
|
|
|
|
|
|
/// whose localizedDescription is "Object Name Collision".
|
|
|
|
|
|
/// SFTP and POSIX use EEXIST (code 17) or the string "already exists".
|
|
|
|
|
|
private func isAlreadyExistsError(_ error: Error) -> Bool {
|
|
|
|
|
|
let base = unwrapUploadError(error)
|
|
|
|
|
|
let desc = base.localizedDescription.lowercased()
|
|
|
|
|
|
return desc.contains("object name collision") ||
|
|
|
|
|
|
desc.contains("already exist") ||
|
|
|
|
|
|
desc.contains("file exist") ||
|
|
|
|
|
|
(base as NSError).code == 17 // POSIX EEXIST
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Returns the underlying transport error, unwrapping BackupError.uploadFailed if present.
|
|
|
|
|
|
private func unwrapUploadError(_ error: Error) -> Error {
|
|
|
|
|
|
if case let BackupError.uploadFailed(_, underlying) = error { return underlying }
|
|
|
|
|
|
return error
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Human-readable description from the underlying transport error, not the wrapper.
|
|
|
|
|
|
private func underlyingDescription(_ error: Error) -> String {
|
|
|
|
|
|
unwrapUploadError(error).localizedDescription
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// NSError domain from the underlying transport error.
|
|
|
|
|
|
private func underlyingDomain(_ error: Error) -> String {
|
|
|
|
|
|
(unwrapUploadError(error) as NSError).domain
|
|
|
|
|
|
}
|
|
|
|
|
|
|
perf: move heavy work off main actor, throttle progress updates, add signposts
BackupEngine:
- fetchAssets() runs in Task.detached — prevents 100-500ms UI freeze during
PHFetchResult enumeration on large libraries
- Manifest JSON decode + ManifestIndex Set construction run in Task.detached
- pendingAssets filter runs in Task.detached — O(N) array filter off main thread
- Progress updates throttled to 8 Hz via ProgressThrottle — eliminates the
continuous SwiftUI full-tree redraws caused by per-byte upload callbacks
- bytesSoFar captured by value in upload closure — avoids mutable var capture
- reserveCapacity on manifestEntries array
- os_signpost intervals on FetchAssets, NASConnect, ManifestLoad,
BuildPendingQueue, UploadLoop, UploadFile for Instruments profiling
BackupStatusService:
- countSafe() marked nonisolated — runs in Task.detached off main actor,
keeping touch/animation responsive during PHFetchResult batch enumeration
- ManifestIndex JSON decode + Set construction run in Task.detached
- Bootstrap ManifestIndex(nasListing:) construction off main actor
- writeManifest JSON decode/encode/merge off main actor
- os_signpost intervals on Reconcile, ManifestLoad, CountSafe
Model types:
- ManifestIndex, ManifestEntry, PhotoAsset, BackupFilter marked Sendable —
safe to pass across task boundaries without warnings
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 16:42:48 +03:00
|
|
|
|
// MARK: — SpeedTracker
|
|
|
|
|
|
|
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
|
|
|
|
private struct SpeedTracker {
|
|
|
|
|
|
private var lastBytes: Int64 = 0
|
|
|
|
|
|
private var lastTime: Date = Date()
|
|
|
|
|
|
|
|
|
|
|
|
mutating func update(bytesSent: Int64) -> Double {
|
|
|
|
|
|
let now = Date()
|
|
|
|
|
|
let elapsed = now.timeIntervalSince(lastTime)
|
|
|
|
|
|
guard elapsed > 0.1 else { return 0 }
|
|
|
|
|
|
let speed = Double(bytesSent - lastBytes) / elapsed
|
|
|
|
|
|
lastBytes = bytesSent
|
|
|
|
|
|
lastTime = now
|
|
|
|
|
|
return max(0, speed)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
perf: move heavy work off main actor, throttle progress updates, add signposts
BackupEngine:
- fetchAssets() runs in Task.detached — prevents 100-500ms UI freeze during
PHFetchResult enumeration on large libraries
- Manifest JSON decode + ManifestIndex Set construction run in Task.detached
- pendingAssets filter runs in Task.detached — O(N) array filter off main thread
- Progress updates throttled to 8 Hz via ProgressThrottle — eliminates the
continuous SwiftUI full-tree redraws caused by per-byte upload callbacks
- bytesSoFar captured by value in upload closure — avoids mutable var capture
- reserveCapacity on manifestEntries array
- os_signpost intervals on FetchAssets, NASConnect, ManifestLoad,
BuildPendingQueue, UploadLoop, UploadFile for Instruments profiling
BackupStatusService:
- countSafe() marked nonisolated — runs in Task.detached off main actor,
keeping touch/animation responsive during PHFetchResult batch enumeration
- ManifestIndex JSON decode + Set construction run in Task.detached
- Bootstrap ManifestIndex(nasListing:) construction off main actor
- writeManifest JSON decode/encode/merge off main actor
- os_signpost intervals on Reconcile, ManifestLoad, CountSafe
Model types:
- ManifestIndex, ManifestEntry, PhotoAsset, BackupFilter marked Sendable —
safe to pass across task boundaries without warnings
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 16:42:48 +03:00
|
|
|
|
|
|
|
|
|
|
// MARK: — ProgressThrottle
|
|
|
|
|
|
|
|
|
|
|
|
/// Limits the rate at which progress callbacks trigger @MainActor UI updates.
|
|
|
|
|
|
/// Without this, SMB/SFTP stacks fire hundreds of callbacks per second, causing
|
|
|
|
|
|
/// SwiftUI to redraw the full view tree on every tick.
|
|
|
|
|
|
private struct ProgressThrottle {
|
|
|
|
|
|
private var lastUpdate: Date = .distantPast
|
|
|
|
|
|
private let minInterval: TimeInterval
|
|
|
|
|
|
|
|
|
|
|
|
init(hz: Double = 8) { minInterval = 1.0 / max(1, hz) }
|
|
|
|
|
|
|
|
|
|
|
|
mutating func shouldUpdate() -> Bool {
|
|
|
|
|
|
let now = Date()
|
|
|
|
|
|
guard now.timeIntervalSince(lastUpdate) >= minInterval else { return false }
|
|
|
|
|
|
lastUpdate = now
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|