Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion BitMatch-iPad/Views/ModularContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,7 @@ struct SettingsSheetView: View {
}

Section {
Text("Every destination is checked against your card before BitMatch calls it verified. This sets how thoroughly that check runs.")
Text("This controls how BitMatch checks a copy. Quick checks file sizes without verifying contents.")
.font(.footnote)
.foregroundColor(.secondary)
} header: {
Expand Down
7 changes: 4 additions & 3 deletions BitMatch-iPadTests/QueueAccessoryPresentationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -96,12 +96,13 @@ struct QueueAccessoryPresentationTests {
#expect(afterRun.tone == .warning)
}

@Test func progressAboveOneIsClamped() {
@Test func runningProgressAboveOneCannotClaimACompleteQueue() {
let result = QueueAccessoryPresentation.make(queue: queue([
row("A001", state: .copying(progress: 140), progress: 1.4)
]))
#expect(result.detail == "1 of 1 · 100%")
#expect(result.progressFraction == 1)
#expect(result.detail == "1 of 1 · 99%")
#expect(result.progressFraction == 0.99)
#expect(result.tone == .progress)
}

@Test func sameRecordRealarmsAfterLeavingAndReenteringAProblemState() {
Expand Down
4 changes: 4 additions & 0 deletions BitMatch/Core/Services/CameraCardDetectionService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,10 @@ final class CameraCardDetectionService: ObservableObject {
// detection started before this mount.
unmountedPaths.remove(event.volume.path)
volumeGenerations[event.volume.path, default: 0] &+= 1
// A missed unmount event can leave the previous card at this
// path. Retire its row before the new detection; path-based
// deduplication must not keep the old camera label.
await removeDetectedCamera(at: event.volume)
startDetection(for: event.volume)

case .unmounted:
Expand Down
9 changes: 9 additions & 0 deletions BitMatch/Core/Services/MacCameraAutoSourceController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,15 @@ final class MacCameraAutoSourceController: ObservableObject {
guard let shared, shared.reportSettings.enableAutoCameraDetection,
volumeAccess?.needsDriveAccess != true,
!shared.isOperationInProgress, !shared.showsOutcomeSummary else { return }
// The executor can become terminal before its journal record settles.
// Finish is not safe to display yet, but a new card must not silently
// replace the source until the operator starts a new transfer.
if !shared.lastOperationWasCompare {
switch shared.operationState {
case .completed, .failed, .cancelled: return
default: break
}
}
let shouldSelect = AutomaticSourceSelectionPolicy.shouldSelect(
automaticSelectionEnabled: shared.reportSettings.autoPopulateSource,
hasExistingSource: shared.sourceURL != nil,
Expand Down
215 changes: 178 additions & 37 deletions BitMatch/Core/Services/VolumeMonitorService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import BitMatchEngine

#if os(macOS)
import DiskArbitration
import Darwin
#endif

/// Finds camera cards and backup drives as they mount. Published state and
Expand All @@ -30,6 +31,55 @@ final class VolumeMonitorService: ObservableObject {
#if os(macOS)
private var volumesDispatchSource: DispatchSourceFileSystemObject?
#endif
/// A path may be reused by a different card when the unmount and mount
/// happen between directory notifications. Keep the mounted filesystem's
/// best available identity, rather than the path, beside each row.
private var publishedMountIdentities: [String: String] = [:]
private var pendingMountIdentities: [String: String] = [:]
private var pendingAnalysisRequests: [String: UInt64] = [:]
private var nextAnalysisRequest: UInt64 = 0

struct MountedVolume: Sendable {
let url: URL
let identity: String?

var path: String { url.standardizedFileURL.resolvingSymlinksInPath().path }
}

struct Reconciliation: Sendable {
let stalePaths: Set<String>
let toAnalyze: [MountedVolume]
}

/// Pure decision used by the live monitor and deterministic event-gap
/// tests. A pending scan does not make an old published identity current.
nonisolated static func reconciliation(
mounted: [MountedVolume], published: [String: String],
pending: [String: String], knownPaths: Set<String>, forceAnalysis: Bool = false
) -> Reconciliation {
let current = Dictionary(mounted.compactMap { volume in
volume.identity.map { (volume.path, $0) }
}, uniquingKeysWith: { _, newer in newer })
let stale = Set(knownPaths.union(published.keys).filter { path in
guard let previous = published[path] else { return current[path] == nil }
return current[path] != previous
})
let toAnalyze = mounted.filter { volume in
guard let identity = volume.identity else { return false }
return (forceAnalysis || !knownPaths.contains(volume.path) || stale.contains(volume.path))
&& (forceAnalysis || pending[volume.path] != identity)
}
return Reconciliation(stalePaths: stale, toAnalyze: toAnalyze)
}

nonisolated static func acceptsAnalysis(_ analyzed: MountedVolume, mounted: [MountedVolume]) -> Bool {
guard let identity = analyzed.identity else { return false }
return mounted.contains { $0.path == analyzed.path && $0.identity == identity }
}

nonisolated static func acceptsAnalysisRequest(_ request: UInt64, current: UInt64?) -> Bool {
current == request
}

struct DetectedVolume: Identifiable, Equatable, Sendable {
let id = UUID()
Expand Down Expand Up @@ -228,68 +278,113 @@ final class VolumeMonitorService: ObservableObject {
vlog("⏭️ Skipping system volume: \(volumePath)")
return
}
analyzeInBackground([volumePath], facts: facts)
analyzeInBackground(
[MountedVolume(url: volumePath, identity: Self.mountIdentity(for: volumePath))],
facts: facts, forceAnalysis: true
)
}
#endif

// MARK: - Volume Analysis

/// Reads each volume off the main actor and adds what it finds.
private func analyzeInBackground(_ volumes: [URL], facts: DiskFacts? = nil) {
private func analyzeInBackground(
_ volumes: [MountedVolume], facts: DiskFacts? = nil, forceAnalysis: Bool = false
) {
#if os(macOS)
let fresh = volumes.compactMap { volume -> (MountedVolume, UInt64)? in
guard let identity = volume.identity else { return nil }
guard forceAnalysis || pendingMountIdentities[volume.path] != identity else { return nil }
nextAnalysisRequest &+= 1
pendingMountIdentities[volume.path] = identity
pendingAnalysisRequests[volume.path] = nextAnalysisRequest
return (volume, nextAnalysisRequest)
}
Task.detached(priority: .utility) { [weak self] in
for volumeURL in volumes {
for (volume, request) in fresh {
let volumeURL = volume.url
Self.vlog("🔬 Analyzing volume at: \(volumeURL.path)")
let connected = VolumeAnalysis.connectedVolume(at: volumeURL, facts: facts)
if let connected { await self?.updateConnectedVolume(connected) }
if let detected = VolumeAnalysis.analyzeVolume(at: volumeURL, facts: facts, cameraInfo: connected?.cameraName) {
let detected = VolumeAnalysis.analyzeVolume(at: volumeURL, facts: facts, cameraInfo: connected?.cameraName)
if let detected {
Self.vlog("✅ Volume detected as \(detected.type): \(detected.displayName)")
await self?.addDetectedVolume(detected)
} else {
Self.vlog("❌ Volume not recognized as camera card or backup drive")
}
await self?.publishAnalysis(connected: connected, detected: detected, for: volume, request: request)
}
}
#else
vlog("⚠️ Volume analysis not available on iOS")
#endif
}

private func checkForVolumeChanges() {
private func checkForVolumeChanges(forceAnalysis: Bool = false) {
vlog("🔍 Checking for volume changes...")
guard let currentVolumes = Self.mountedVolumes() else { return }
let currentVolumePaths = Set(currentVolumes.map { $0.standardizedFileURL.resolvingSymlinksInPath().path })

connectedVolumes.removeAll { !currentVolumePaths.contains($0.url.path) }

// Remove cards and drives that are gone.
for card in availableCameraCards where !currentVolumePaths.contains(card.url.path) {
vlog("📤 Camera card removed: \(card.displayName)")
availableCameraCards.removeAll { $0.id == card.id }
}
for drive in availableBackupDrives where !currentVolumePaths.contains(drive.url.path) {
vlog("📤 Backup drive removed: \(drive.displayName)")
availableBackupDrives.removeAll { $0.id == drive.id }
}

// Analyze only volumes not already known (read here, on the main
// actor, where the lists change).
// A volume counts as known once either list has it, so a card the
// drive list hides is not analyzed again on every change.
let mounted = currentVolumes.map { MountedVolume(url: $0, identity: Self.mountIdentity(for: $0)) }
let currentIdentities = Dictionary(mounted.compactMap { volume in
volume.identity.map { (volume.path, $0) }
}, uniquingKeysWith: { _, newer in newer })
pendingMountIdentities = pendingMountIdentities.filter { currentIdentities[$0.key] == $0.value }
pendingAnalysisRequests = pendingAnalysisRequests.filter { pendingMountIdentities[$0.key] != nil }
let knownPaths = Set(connectedVolumes.map { $0.url.path })
.union((availableCameraCards + availableBackupDrives).map { $0.url.path })
let newVolumes = currentVolumes.filter { !knownPaths.contains($0.path) }
for volumeURL in newVolumes {
vlog("🔍 New volume detected: \(volumeURL.lastPathComponent)")
let plan = Self.reconciliation(
mounted: mounted, published: publishedMountIdentities,
pending: pendingMountIdentities, knownPaths: knownPaths,
forceAnalysis: forceAnalysis
)
if !plan.stalePaths.isEmpty {
connectedVolumes.removeAll { plan.stalePaths.contains($0.url.path) }
availableCameraCards.removeAll { plan.stalePaths.contains($0.url.path) }
availableBackupDrives.removeAll { plan.stalePaths.contains($0.url.path) }
for path in plan.stalePaths { publishedMountIdentities.removeValue(forKey: path) }
}
analyzeInBackground(newVolumes)
for volume in plan.toAnalyze {
vlog("🔍 New or replaced volume detected: \(volume.url.lastPathComponent)")
}
analyzeInBackground(plan.toAnalyze, forceAnalysis: forceAnalysis)
}

private func scanExistingVolumes() {
vlog("🔍 Scanning existing volumes...")
guard let volumes = Self.mountedVolumes() else { return }
vlog("📂 Found \(volumes.count) volumes: \(volumes.map { $0.lastPathComponent })")
analyzeInBackground(volumes)
checkForVolumeChanges()
}

/// UUID plus the mounted filesystem's device number catches ordinary
/// replacements at the same path. The device number is only a best-effort
/// discovery fallback when macOS provides no UUID; it can be reused after
/// a fast remount and never authorizes a transfer by itself.
nonisolated static func mountIdentity(for url: URL) -> String? {
#if os(macOS)
var current = url
current.removeAllCachedResourceValues()
guard let mountRoot = (try? current.resourceValues(forKeys: [.volumeURLKey]))?.allValues[.volumeURLKey] as? URL,
mountRoot.standardizedFileURL.resolvingSymlinksInPath().path
== current.standardizedFileURL.resolvingSymlinksInPath().path else { return nil }
return filesystemIdentity(for: current)
#else
return nil
#endif
}

/// A selected source may be a folder inside a card, not the mount root.
nonisolated static func selectionIdentity(for url: URL) -> String? {
filesystemIdentity(for: url)
}

private nonisolated static func filesystemIdentity(for url: URL) -> String? {
#if os(macOS)
var current = url
current.removeAllCachedResourceValues()
var info = stat()
guard stat(current.path, &info) == 0 else { return nil }
let uuid = (try? current.resourceValues(forKeys: [.volumeUUIDStringKey]))?.volumeUUIDString ?? ""
return "\(UInt64(info.st_dev)):\(uuid)"
#else
return nil
#endif
}

private nonisolated static func mountedVolumes() -> [URL]? {
Expand All @@ -306,8 +401,32 @@ final class VolumeMonitorService: ObservableObject {

// MARK: - Volume Management

private func publishAnalysis(
connected: ConnectedDrivesPresentation.Volume?, detected: DetectedVolume?,
for mount: MountedVolume, request: UInt64
) {
defer {
if Self.acceptsAnalysisRequest(request, current: pendingAnalysisRequests[mount.path]) {
pendingMountIdentities.removeValue(forKey: mount.path)
pendingAnalysisRequests.removeValue(forKey: mount.path)
}
}
guard Self.acceptsAnalysisRequest(request, current: pendingAnalysisRequests[mount.path]),
let identity = mount.identity,
let live = Self.mountedVolumes()?.map({ MountedVolume(url: $0, identity: Self.mountIdentity(for: $0)) }),
Self.acceptsAnalysis(mount, mounted: live) else { return }
if publishedMountIdentities[mount.path] != nil,
publishedMountIdentities[mount.path] != identity {
connectedVolumes.removeAll { $0.url.path == mount.path }
availableCameraCards.removeAll { $0.url.path == mount.path }
availableBackupDrives.removeAll { $0.url.path == mount.path }
}
if let connected { updateConnectedVolume(connected) }
if let detected { addDetectedVolume(detected) }
if connected != nil || detected != nil { publishedMountIdentities[mount.path] = identity }
}

private func updateConnectedVolume(_ volume: ConnectedDrivesPresentation.Volume) {
guard Self.mountedVolumes()?.contains(where: { $0.standardizedFileURL.resolvingSymlinksInPath() == volume.url }) == true else { return }
if let index = connectedVolumes.firstIndex(where: { $0.url == volume.url }) {
connectedVolumes[index] = volume
} else {
Expand All @@ -318,12 +437,16 @@ final class VolumeMonitorService: ObservableObject {
private func addDetectedVolume(_ volume: DetectedVolume) {
switch volume.type {
case .cameraCard:
if !availableCameraCards.contains(where: { $0.devicePath == volume.devicePath }) {
if let index = availableCameraCards.firstIndex(where: { $0.url.path == volume.url.path }) {
availableCameraCards[index] = volume
} else if !availableCameraCards.contains(where: { $0.devicePath == volume.devicePath }) {
availableCameraCards.append(volume)
vlog("📷 Camera card detected: \(volume.displayName) (\(volume.capacityFormatted))")
}
case .backupDrive:
if !availableBackupDrives.contains(where: { $0.devicePath == volume.devicePath }) {
if let index = availableBackupDrives.firstIndex(where: { $0.url.path == volume.url.path }) {
availableBackupDrives[index] = volume
} else if !availableBackupDrives.contains(where: { $0.devicePath == volume.devicePath }) {
availableBackupDrives.append(volume)
vlog("💾 Backup drive detected: \(volume.displayName) (\(volume.capacityFormatted))")
}
Expand All @@ -339,12 +462,30 @@ final class VolumeMonitorService: ObservableObject {
// MARK: - Public Methods

func refreshVolumes() {
scanExistingVolumes()
// The user just granted/recovered access or asked to rescan. A known
// mount may have been classified only partially while access was
// missing, so re-analyze even when its identity did not change.
checkForVolumeChanges(forceAnalysis: true)
}

func forceAnalyzeVolume(at url: URL) {
vlog("🔧 Force analyzing volume: \(url)")
analyzeInBackground([url])
analyzeInBackground(
[MountedVolume(url: url, identity: Self.mountIdentity(for: url))], forceAnalysis: true
)
}

/// Auto-queue may only act on a row while its published mount is still
/// the mounted filesystem, even if a change notification is in flight.
func isCurrentDiscoveredVolume(_ row: ConnectedDrivesPresentation.Row) -> Bool {
var current = row.url
current.removeAllCachedResourceValues()
let path = current.standardizedFileURL.resolvingSymlinksInPath().path
guard let published = publishedMountIdentities[path], let rowID = row.volumeID,
let liveID = (try? current.resourceValues(forKeys: [.volumeUUIDStringKey]))?.volumeUUIDString else {
return false
}
return rowID == liveID && Self.mountIdentity(for: current) == published
}
}

Expand Down
Loading
Loading