Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
500bc0a
feat(ios): offer to trash the original when removing an offline copy
altaywtf Sep 12, 2026
2a31fd0
test(ios): find the remove confirmation by button identifier
altaywtf Sep 12, 2026
0ac6365
test(ios): stop asserting the stock Cancel action in the remove dialog
altaywtf Sep 12, 2026
505019b
test(ios): tap the retry action by identifier
altaywtf Sep 12, 2026
bd3ffb3
fix(ios): keep the remote removal outcome on the queue
altaywtf Sep 12, 2026
735cabe
fix(ios): reconcile file lists after originals are gone and treat mis…
altaywtf Sep 12, 2026
d5f3dca
fix(ios): persist owed originals, gate on fresh preferences, merge re…
altaywtf Sep 12, 2026
31e3988
fix(ios): keep retried originals owed and record the debt before the …
altaywtf Sep 12, 2026
65c2389
fix(ios): reconcile restored original deletions, bind them to the acc…
altaywtf Sep 12, 2026
0ef8d9e
fix(ios): remove a row a kill left behind before retrying its owed or…
altaywtf Sep 12, 2026
068cf97
fix(ios): refuse an original removal whose Trash setting changed sinc…
altaywtf Sep 12, 2026
2f0ce3e
fix(ios): write the debt with the removal, retire replaced queues, an…
altaywtf Sep 12, 2026
297fff8
fix(ios): refresh the Trash setting from the server before each origi…
altaywtf Sep 12, 2026
215f131
fix(ios): bind original removals to the session generation instead of…
altaywtf Sep 13, 2026
da19dfe
fix(ios): send no original removal while the Trash setting is unconfi…
altaywtf Sep 13, 2026
c01e4e9
fix(ios): act on the failures the report showed, keep one debt per fi…
altaywtf Sep 13, 2026
5ec837e
refactor(ios): trim original-removal comments, inline the pending-deb…
altaywtf Sep 13, 2026
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
26 changes: 25 additions & 1 deletion Apps/Shared/Sources/HarnessSeededAPI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,10 @@ import Foundation
nonisolated(unsafe) private static var renameAttempts = 0
nonisolated(unsafe) private static var logoutFailuresRemaining = 0
nonisolated(unsafe) private static var bulkDeleteFailureDelivered = false
/// The seeded root video's original: its first delete fails so the
/// downloads journey proves the remote failure report and retry.
nonisolated(unsafe) private static var offlineOriginalDeleteFailed = false
nonisolated(unsafe) private static var offlineOriginalTrashed = false
nonisolated(unsafe) private static var ambiguousMoveFailureDelivered = false
nonisolated(unsafe) private static var trashDeleteFailureDelivered = false
nonisolated(unsafe) private static var trashListRequests = 0
Expand Down Expand Up @@ -439,6 +443,8 @@ import Foundation
searchContinuationFailed = false
renameAttempts = 0
bulkDeleteFailureDelivered = false
offlineOriginalDeleteFailed = false
offlineOriginalTrashed = false
ambiguousMoveFailureDelivered = false
trashDeleteFailureDelivered = false
trashListRequests = 0
Expand Down Expand Up @@ -1481,6 +1487,22 @@ import Foundation
}

fileActionsLock.lock()
if fileID == 412 {
defer { fileActionsLock.unlock() }
if offlineOriginalDeleteFailed {
offlineOriginalTrashed = true
return (200, #"{"status":"OK"}"#)
}
offlineOriginalDeleteFailed = true
return (
503,
fixtureError(
statusCode: 503,
type: "HARNESS_TRANSIENT_ORIGINAL_DELETE_FAILURE",
message: "The first delete of the seeded root video fails for retry proof"
)
)
}
if fileID == 410 {
harnessFolderDeleted = true
if trashEnabled { trashFolders[410] = ActionFolder(name: harnessFolderName, parentID: 0) }
Expand Down Expand Up @@ -1859,6 +1881,7 @@ import Foundation
let parentSort = inheritsDefault ? "null" : jsonString(sortBy)
let folderName = harnessFolderName
let folderDeleted = harnessFolderDeleted
let rootVideoTrashed = offlineOriginalTrashed
fileActionsLock.unlock()
let mutableFolderRows = mutableFolders.map { id, folder in
folderObject(id: id, name: folder.name, parentID: folder.parentID)
Expand Down Expand Up @@ -1905,6 +1928,7 @@ import Foundation
""",
] + mutableFolderRows
if folderDeleted { rows.removeFirst() }
if rootVideoTrashed { rows.removeAll { $0.contains(#""id": 412,"#) } }
// Only the two name orders are modelled; the journey proves the
// round trip, not the server's comparator.
if sortBy == "NAME_DESC" { rows.reverse() }
Expand All @@ -1924,7 +1948,7 @@ import Foundation
"files": [
\(rows.joined(separator: ",\n"))
],
"total": \(8 + mutableFolders.count - (folderDeleted ? 1 : 0))
"total": \(8 + mutableFolders.count - (folderDeleted ? 1 : 0) - (rootVideoTrashed ? 1 : 0))
}
"""
}
Expand Down
175 changes: 165 additions & 10 deletions Apps/iOS/Sources/OfflineDownloads.swift
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,14 @@ struct PutioOfflineStore: Sendable {
let version: Int
var items: [PutioOfflineItem]
var concurrencyLimit: Int
/// Originals the user asked put.io to take whose answer is still owed.
var pendingOriginals: [PutioOfflineRemovalTarget]?
}

struct Loaded {
var items: [PutioOfflineItem]
var concurrencyLimit: Int
var pendingOriginals: [PutioOfflineRemovalTarget]
}

let directory: URL
Expand All @@ -170,28 +178,34 @@ struct PutioOfflineStore: Sendable {

/// A document that fails to decode is set aside as `queue.corrupt.json`
/// rather than silently replaced, so stored assets can still be recovered.
func load() -> (items: [PutioOfflineItem], concurrencyLimit: Int) {
guard let data = try? Data(contentsOf: fileURL) else {
return ([], PutioOfflineQueue.defaultConcurrencyLimit)
}
func load() -> Loaded {
let empty = Loaded(
items: [], concurrencyLimit: PutioOfflineQueue.defaultConcurrencyLimit, pendingOriginals: [])
guard let data = try? Data(contentsOf: fileURL) else { return empty }
guard let document = try? JSONDecoder().decode(Document.self, from: data),
document.version == Self.version
else {
try? FileManager.default.removeItem(at: corruptURL)
try? FileManager.default.moveItem(at: fileURL, to: corruptURL)
return ([], PutioOfflineQueue.defaultConcurrencyLimit)
return empty
}
let limit =
PutioOfflineQueue.concurrencyLimits.contains(document.concurrencyLimit)
? document.concurrencyLimit : PutioOfflineQueue.defaultConcurrencyLimit
return (document.items, limit)
return Loaded(
items: document.items, concurrencyLimit: limit,
pendingOriginals: document.pendingOriginals ?? [])
}

private var corruptURL: URL { directory.appending(path: "queue.corrupt.json") }

func save(items: [PutioOfflineItem], concurrencyLimit: Int) {
func save(
items: [PutioOfflineItem], concurrencyLimit: Int,
pendingOriginals: [PutioOfflineRemovalTarget] = []
) {
let document = Document(
version: Self.version, items: items, concurrencyLimit: concurrencyLimit)
version: Self.version, items: items, concurrencyLimit: concurrencyLimit,
pendingOriginals: pendingOriginals.isEmpty ? nil : pendingOriginals)
guard let data = try? JSONEncoder().encode(document) else { return }
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
try? data.write(to: fileURL, options: .atomic)
Expand Down Expand Up @@ -268,6 +282,7 @@ typealias PutioOfflineConversionStart = @MainActor @Sendable (PutioFileID) async
typealias PutioOfflineConversionStatus =
@MainActor @Sendable (PutioFileID) async throws -> PutioVideoConversionStatus
typealias PutioOfflinePositionReport = @MainActor @Sendable (PutioFileID, Int) async throws -> Void
typealias PutioOfflineOriginalDelete = @MainActor @Sendable (PutioFileID) async throws -> Void

@MainActor
@Observable
Expand All @@ -281,20 +296,33 @@ final class PutioOfflineQueue {
private(set) var concurrencyLimit: Int
private(set) var storedBytes: Int64 = 0
private(set) var availableBytes: Int64 = 0
/// Unresolved remote failures, merged across requests; outlives the screen.
private(set) var originalFailure: PutioOfflineOriginalOutcome?
/// Originals put.io has not confirmed yet; persisted so a kill still retries.
private(set) var pendingOriginals: [PutioOfflineRemovalTarget] = []

@ObservationIgnored private let store: PutioOfflineStore
@ObservationIgnored private let engine: any PutioOfflineDownloadEngine
@ObservationIgnored private let resolve: PutioOfflineResolve
@ObservationIgnored private let startConversion: PutioOfflineConversionStart
@ObservationIgnored private let conversionStatus: PutioOfflineConversionStatus
@ObservationIgnored private let reportPosition: PutioOfflinePositionReport
@ObservationIgnored private let deleteOriginal: PutioOfflineOriginalDelete
/// The account's Trash setting as the server has it now, nil when unknown.
@ObservationIgnored private let trashSetting: @MainActor () async -> Bool?
@ObservationIgnored private let conversionPollInterval: Duration
@ObservationIgnored private let sleep: @Sendable (Duration) async throws -> Void
@ObservationIgnored private let availableStorage: @MainActor () -> Int64
@ObservationIgnored private let fileManager: FileManager
@ObservationIgnored private let readTracks: PutioOfflineTrackReader
@ObservationIgnored private let isPlayable: PutioOfflinePlayabilityCheck
@ObservationIgnored private let notifyCompletion: @MainActor (PutioOfflineItem) -> Void
@ObservationIgnored private let notifyOriginalsDeleted: @MainActor ([PutioFileID]) -> Void
/// Bumped by a purge so in-flight original requests stop writing.
@ObservationIgnored private var originalsGeneration = 0
/// False once the owning shell's session ended; originals paths then neither
/// start work nor write.
@ObservationIgnored private let isLive: @MainActor () -> Bool
/// A worker is identified by its token so a cancelled worker's cleanup
/// never clears a successor that pause-then-resume already started.
private struct Worker {
Expand Down Expand Up @@ -331,13 +359,17 @@ final class PutioOfflineQueue {
await PutioOfflineQueue.storedTracks(at: $0)
},
notifyCompletion: @escaping @MainActor (PutioOfflineItem) -> Void = { _ in },
notifyOriginalsDeleted: @escaping @MainActor ([PutioFileID]) -> Void = { _ in },
isPlayable: @escaping PutioOfflinePlayabilityCheck = {
await PutioOfflineQueue.assetIsPlayable(at: $0)
},
resolve: @escaping PutioOfflineResolve,
startConversion: @escaping PutioOfflineConversionStart,
conversionStatus: @escaping PutioOfflineConversionStatus,
reportPosition: @escaping PutioOfflinePositionReport
reportPosition: @escaping PutioOfflinePositionReport,
deleteOriginal: @escaping PutioOfflineOriginalDelete,
trashSetting: @escaping @MainActor () async -> Bool? = { nil },
isLive: @escaping @MainActor () -> Bool = { true }
) {
self.store = store
self.engine = engine
Expand All @@ -347,14 +379,19 @@ final class PutioOfflineQueue {
self.fileManager = fileManager
self.readTracks = readTracks
self.notifyCompletion = notifyCompletion
self.notifyOriginalsDeleted = notifyOriginalsDeleted
self.isPlayable = isPlayable
self.resolve = resolve
self.startConversion = startConversion
self.conversionStatus = conversionStatus
self.reportPosition = reportPosition
self.deleteOriginal = deleteOriginal
self.trashSetting = trashSetting
self.isLive = isLive
let loaded = store.load()
items = loaded.items
concurrencyLimit = loaded.concurrencyLimit
pendingOriginals = loaded.pendingOriginals
// Queues written before the sidecar existed are its only record.
store.recordPackages(at: items.compactMap(\.localPath))
engine.onProgress = { [weak self] id, progress in self?.engineProgressed(id, progress) }
Expand Down Expand Up @@ -384,6 +421,10 @@ final class PutioOfflineQueue {
func restore() async {
guard !restored else { return }
restored = true
if !pendingOriginals.isEmpty {
let owed = pendingOriginals
Task { _ = await deleteOriginals(owed) }
}
let alive = Set(await engine.restoreTasks())
for index in items.indices {
switch items[index].stage {
Expand Down Expand Up @@ -588,6 +629,115 @@ final class PutioOfflineQueue {
schedule()
}

/// Removes the local copies, then asks put.io for each original in turn.
/// The local removal never depends on the remote result.
func removeDeletingOriginals(fileIDs: [PutioFileID], movesToTrash: Bool) async
-> PutioOfflineOriginalOutcome
{
guard isLive() else { return PutioOfflineOriginalOutcome() }
let targets = items.filter { fileIDs.contains($0.id) }.map {
PutioOfflineRemovalTarget(id: $0.id, name: $0.name, movesToTrash: movesToTrash)
}
// Debt and removal land in one document write; a kill records both or neither.
adoptPendingOriginals(targets)
remove(fileIDs: fileIDs)
return await deleteOriginals(targets)
}

/// Asks put.io for originals whose local copies are already gone; also the
/// retry path. Failures merge into `originalFailure` per file so concurrent
/// requests keep each other's retry targets. A purge or session end during
/// the request returns what was answered so far and writes nothing.
func deleteOriginals(_ targets: [PutioOfflineRemovalTarget]) async
-> PutioOfflineOriginalOutcome
{
var outcome = PutioOfflineOriginalOutcome()
let generation = originalsGeneration
guard isCurrent(generation) else { return outcome }
if adoptPendingOriginals(targets) { persist() }
let current = await trashSetting()
guard isCurrent(generation) else { return outcome }
for target in targets {
if current == nil {
outcome.failures.append(.init(target: target, reason: .transient))
} else if let current, current != target.movesToTrash {
outcome.failures.append(.init(target: target, reason: .trashSettingChanged))
} else {
do {
try await deleteOriginal(target.id)
outcome.deleted.append(target)
} catch PutioRuntimeError.notFound {
// Already gone, possibly from a delete whose response was lost.
outcome.deleted.append(target)
} catch {
outcome.failures.append(.init(target: target, reason: .init(error)))
}
}
guard isCurrent(generation) else { return outcome }
if outcome.failures.last?.target != target {
pendingOriginals.removeAll { $0.id == target.id }
persist()
}
}
if !outcome.deleted.isEmpty { notifyOriginalsDeleted(outcome.deleted.map(\.id)) }
var merged = originalFailure ?? PutioOfflineOriginalOutcome()
merged.deleted = []
let touched = Set(targets.map(\.id))
merged.failures.removeAll { touched.contains($0.target.id) }
merged.failures.append(contentsOf: outcome.failures)
originalFailure = merged.failures.isEmpty ? nil : merged
return outcome
}

private func isCurrent(_ generation: Int) -> Bool {
generation == originalsGeneration && isLive()
}

/// One debt per file: a newer confirmation for the same original replaces
/// the older name and promised mode. Returns whether anything changed.
@discardableResult
private func adoptPendingOriginals(_ targets: [PutioOfflineRemovalTarget]) -> Bool {
var changed = false
for target in targets where !pendingOriginals.contains(target) {
pendingOriginals.removeAll { $0.id == target.id }
pendingOriginals.append(target)
changed = true
}
return changed
}

/// Clears the shown failures for a retry. Retryable originals stay pending;
/// ones no retry can resolve are given up. Failures that merged in after
/// the report was shown stay for the next one.
func takeFailedOriginalsForRetry(shown: PutioOfflineOriginalOutcome? = nil)
-> [PutioOfflineRemovalTarget]
{
guard let report = originalFailure else { return [] }
let handled = shown ?? report
let retryable = handled.retryableTargets
let abandoned = Set(handled.failedTargets.map(\.id)).subtracting(retryable.map(\.id))
settleOriginalFailures(handled.failedTargets.map(\.id), givingUp: abandoned)
return retryable
}

/// Acknowledging the shown failures gives those originals up; failures that
/// merged in after the report was shown stay for the next one.
func dismissOriginalFailure(shown: PutioOfflineOriginalOutcome? = nil) {
guard let report = originalFailure else { return }
let acknowledged = (shown ?? report).failedTargets.map(\.id)
settleOriginalFailures(acknowledged, givingUp: Set(acknowledged))
}

private func settleOriginalFailures(_ handled: [PutioFileID], givingUp: Set<PutioFileID>) {
let handled = Set(handled)
var remaining = originalFailure ?? PutioOfflineOriginalOutcome()
remaining.failures.removeAll { handled.contains($0.target.id) }
originalFailure = remaining.failures.isEmpty ? nil : remaining
guard !givingUp.isEmpty else { return }
pendingOriginals.removeAll { givingUp.contains($0.id) }
persist()
}

/// Ends every download and deletes the account's whole offline directory
/// plus every tracked package, including media a quarantined queue file no
/// longer references.
Expand All @@ -602,6 +752,10 @@ final class PutioOfflineQueue {
suspended.removeAll()
resumeWhenFree.removeAll()
items.removeAll()
// The account-wide operation already covered the originals.
originalsGeneration &+= 1
pendingOriginals.removeAll()
originalFailure = nil
let survivors = store.loadPackages().filter { relativePath in
let url = Self.localURL(for: relativePath)
try? fileManager.removeItem(at: url)
Expand Down Expand Up @@ -964,7 +1118,8 @@ final class PutioOfflineQueue {
}

private func persist() {
store.save(items: items, concurrencyLimit: concurrencyLimit)
store.save(
items: items, concurrencyLimit: concurrencyLimit, pendingOriginals: pendingOriginals)
}

private func recomputeStorage() {
Expand Down
Loading