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: 2 additions & 0 deletions App/Batch/BatchQueue.swift
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,8 @@ final class BatchQueue {
let listing = try await ArchiveEngine.shared.list(job.url, password: password)
var archive = Archive(listing: listing, url: job.url)
archive.source?.password = password
archive = await DiskImageAliasLoader.prepare(archive, password: password)
if Task.isCancelled { throw EngineError(.cancelled) }
// Listing acquires any required split-folder grant. Capture before extraction,
// not afterward: replaced archives must never be mistaken for the extracted input.
source = ArchiveAftercare.Source.capture(job.url)
Expand Down
76 changes: 62 additions & 14 deletions App/Browser/BrowserModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,7 @@ final class BrowserModel {
guard let self, let target else { return }
let old = target.archive
let nextOperations = target.edits.operations
let preservesBytes = nextOperations == previousOperations && !target.edits.isSaving && !target.edits.changedOnDisk
let common = zip(previousOperations, nextOperations).prefix { pair in pair.0 == pair.1 }.count
let reversedRenames = previousOperations.dropFirst(common).reversed().compactMap { operation -> EditOperation? in
if case let .rename(from, to) = operation { return .rename(from: to, to: from) }
Expand All @@ -390,8 +391,10 @@ final class BrowserModel {
Location(levels: location.levels, folder: location.levels.last === target ? EditSession.map(path: location.folder, through: moves) : location.folder)
}
if let url = old.source?.url {
var updated = Archive(listing: target.edits.pending, url: url, name: old.name, nameEncoding: old.source?.nameEncoding)
var updated = Archive(listing: target.edits.pending, url: url, name: old.name,
nameEncoding: old.source?.nameEncoding, aliasDropTargets: old.aliasDropTargets)
updated.source?.password = target.password
if preservesBytes { updated.record(old.checkup) }
target.archive = updated
if target.edits.isSaving { target.savedArchive = updated }
} else {
Expand Down Expand Up @@ -496,11 +499,15 @@ final class BrowserModel {
var archive = Archive(listing: target.edits.base, url: url,
name: target.edits.name, nameEncoding: target.archive.source?.nameEncoding)
archive.source?.password = target.password
// `reload` already notified onChange, which carried aliases from the previous bytes.
// Publish the fresh, unclassified listing before another suspension so cancellation can
// never retain an old install destination for a file that changed on disk.
target.archive = archive
target.savedArchive = archive
target.search = SearchIndex(total: archive.index.visible.count + archive.index.helpers.count)
target.indexed = 0
startIndexing(target)
guard !isClosed, level === target else { return false }
folder = EditSession.nearestExistingParent(folderBefore, in: target.edits.base)
selection = Set(selectionBefore.filter { target.archive.entry(at: $0) != nil })
history = historyBefore.map { location in
Expand All @@ -511,6 +518,20 @@ final class BrowserModel {
}
refresh()
runSearch()
let freshID = archive.id
archive = await DiskImageAliasLoader.prepare(archive, password: target.password)
guard !Task.isCancelled, !isClosed, level === target, target.archive.id == freshID else { return false }
if archive.id != freshID {
target.archive = archive
target.savedArchive = archive
target.search = SearchIndex(total: archive.index.visible.count + archive.index.helpers.count)
target.indexed = 0
startIndexing(target)
selection = selection.filter { archive.entry(at: $0) != nil }
refresh()
runSearch()
}
if levels.first === target { loadInstallerCanvas() }
editingWindow?.isDocumentEdited = levels.contains { $0.edits.isEdited }
return true
}
Expand Down Expand Up @@ -571,19 +592,26 @@ final class BrowserModel {
}

private func adoptArchiveURL(_ url: URL, on target: Level) {
let encoding = target.archive.source?.nameEncoding
let previous = target.archive
let encoding = previous.source?.nameEncoding
let listing = target.edits.isEdited ? target.edits.pending : target.edits.base
var archive = Archive(listing: listing, url: url, name: url.lastPathComponent, nameEncoding: encoding)
let aliasTargets = target.archive.aliasDropTargets
var archive = Archive(listing: listing, url: url, name: url.lastPathComponent,
nameEncoding: encoding, aliasDropTargets: aliasTargets)
archive.source?.password = target.password
if !target.edits.isSaving { archive.record(previous.checkup) }
target.archive = archive
if !target.edits.isEdited {
target.savedArchive = archive
} else {
var saved = Archive(listing: target.edits.base, url: url, name: url.lastPathComponent, nameEncoding: encoding)
var saved = Archive(listing: target.edits.base, url: url, name: url.lastPathComponent,
nameEncoding: encoding, aliasDropTargets: aliasTargets)
saved.source?.password = target.password
if !target.edits.isSaving { saved.record(target.savedArchive.checkup) }
target.savedArchive = saved
}
updateRepresentedURL()
if levels.first === target { loadInstallerCanvas() }
if level === target { editingWindow?.title = url.lastPathComponent }
}

Expand All @@ -610,18 +638,32 @@ final class BrowserModel {
}

/// Disk images are checked against their checksums in the background; browsing doesn't wait.
func verifyDiskImage() {
guard archive.format == .dmg, let url = archive.source?.url else { return }
Task { [weak self] in
@discardableResult
func verifyDiskImage(using verify: @escaping (URL) async throws -> Void = { try await ArchiveEngine.shared.verify($0) }) -> Task<Void, Never>? {
let target = level
guard target.archive.format == .dmg, let url = target.archive.source?.url else { return nil }
let archiveID = target.archive.id
let identity = target.edits.identity
return Task { [weak self, weak target] in
var damage: String?
do {
try await ArchiveEngine.shared.verify(url)
try await verify(url)
} catch {
let error = EngineError(error)
if error.failure == .cancelled || error.failure == .passwordRequired { return }
damage = error.message
}
self?.archive.record([Checkup.diskImageFinding(damaged: damage)])
guard !Task.isCancelled, let self, !self.isClosed, let target,
self.levels.contains(where: { $0 === target }), target.archive.source?.url == url,
target.archive.format == .dmg, !target.edits.changedOnDisk else { return }
if let identity {
guard target.edits.identity == identity, (try? FileIdentity(of: url)) == identity else { return }
} else {
guard target.archive.id == archiveID else { return }
}
let finding = Checkup.diskImageFinding(damaged: damage)
target.archive.record([finding])
target.savedArchive.record([finding])
}
}

Expand Down Expand Up @@ -1146,7 +1188,7 @@ final class BrowserModel {
let (listing, password) = try await passwords.retrying(entry.name) { password in
try await ArchiveEngine.shared.list(url, password: password)
}
let inner = await Self.makeArchive(listing, url: url, name: entry.name)
let inner = await Self.makeArchive(listing, url: url, name: entry.name, password: password)
guard !Task.isCancelled else { return }
let level = Level(archive: inner, password: password, entryPath: entry.path)
connectEdits(level)
Expand All @@ -1164,8 +1206,10 @@ final class BrowserModel {
}

@concurrent
private static func makeArchive(_ listing: ArchiveListing, url: URL, name: String) async -> Archive {
Archive(listing: listing, url: url, name: name)
private static func makeArchive(_ listing: ArchiveListing, url: URL, name: String, password: String? = nil) async -> Archive {
var archive = Archive(listing: listing, url: url, name: name)
archive.source?.password = password
return await DiskImageAliasLoader.prepare(archive, password: password)
}

private func report(_ error: any Error, step: NextStep, count: Int, name: String? = nil) {
Expand All @@ -1187,11 +1231,14 @@ final class BrowserModel {

func readNames(as encoding: NameEncoding?) {
guard let source = archive.source, let listing = source.listing else { return }
var reread = Archive(listing: listing, url: source.url, name: archive.name, nameEncoding: encoding)
let aliasTargets = archive.aliasDropTargets
var reread = Archive(listing: listing, url: source.url, name: archive.name,
nameEncoding: encoding, aliasDropTargets: aliasTargets)
reread.record(archive.checkup.filter { finding in !reread.checkup.contains { $0.title == finding.title } })
let level = level
reread.source?.password = level.password
level.savedArchive = Archive(listing: level.edits.base, url: source.url, name: archive.name, nameEncoding: encoding)
level.savedArchive = Archive(listing: level.edits.base, url: source.url, name: archive.name,
nameEncoding: encoding, aliasDropTargets: aliasTargets)
level.savedArchive.source?.password = level.password
level.edits.displayedPaths = [:]
for (shown, stored) in reread.source?.storedPaths ?? [:] {
Expand All @@ -1205,6 +1252,7 @@ final class BrowserModel {
selection = []
if !folder.isEmpty, reread.entry(at: folder) == nil { folder = "" } else { refresh() }
runSearch()
if levels.first === level { loadInstallerCanvas() }
}

// MARK: - Next steps
Expand Down
70 changes: 70 additions & 0 deletions App/Browser/InstallerCanvas.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,76 @@ import AppKit
import ImageIO
import SwiftUI

/// Finder aliases have a regular data fork, unlike the symlinks recognized in the listing. Read
/// only their cached bookmark properties: resolving an alias could access the author's volume or
/// a network share. Unrecognized files keep their ordinary content semantics.
enum DiskImageAliasLoader {
static let maxAliasBytes: Int64 = 64 << 10
static let maxAliases = 32

/// Classification is rebuilt before the model receives it, so aliases are consistently dimmed,
/// excluded from Extract All and offered as install destinations. Unrecognized archives stay unchanged.
static func prepare(_ archive: Archive, password: String?) async -> Archive {
let targets = await load(in: archive, password: password)
guard !targets.isEmpty, !Task.isCancelled, let source = archive.source, let listing = source.listing else { return archive }
var result = Archive(listing: listing, url: source.url, name: archive.name, nameEncoding: source.nameEncoding,
aliasDropTargets: archive.aliasDropTargets + targets)
result.source = source
// Preserve checks that finished after listing (for example the disk-image checksum), and
// add only the newly learned layout classification rather than generating duplicate cards.
result.checkup = archive.checkup + result.checkup.filter { finding in
!archive.checkup.contains { $0.title == finding.title && $0.detail == finding.detail && $0.severity == finding.severity }
}
return result
}

static func candidates(in archive: Archive) -> [String: Int64] {
guard archive.isDiskImage, let listing = archive.source?.listing else { return [:] }
let regular = Set(listing.entries.filter { !$0.isFolder && !$0.isSymlink && !PathContainment.escapes($0.path) }
.map { PathContainment.containedPath(for: $0.path) })
let entries = archive.children(of: archive.volumeRoot).filter {
!$0.isFolder && DiskImageLayout.applicationsNames.contains($0.name)
&& $0.size > 0 && $0.size <= maxAliasBytes && regular.contains($0.path)
&& archive.source?.storedPaths[$0.path]?.count == 1
}.sorted {
if ($0.name == "Applications") != ($1.name == "Applications") { return $0.name == "Applications" }
return $0.path < $1.path
}
return Dictionary(uniqueKeysWithValues: entries.prefix(maxAliases).map { ($0.path, maxAliasBytes) })
}

static func load(in archive: Archive, password: String?) async -> [DiskImageLayout.DropTarget] {
guard !Task.isCancelled else { return [] }
let wanted = candidates(in: archive)
guard !wanted.isEmpty, let source = archive.source,
let files = try? await ArchiveFileReader.read(wanted, from: archive, source: source, password: password),
!Task.isCancelled else { return [] }
return files.keys.sorted().compactMap { path in
guard let data = files[path], let folder = cachedFolder(in: data) else { return nil }
return DiskImageLayout.DropTarget(path: path, folder: folder)
}
}

/// Converts a Finder alias-file wrapper to bookmark data without resolving its destination.
/// Foundation reads cached path/directory properties even when that destination no longer exists.
static func cachedFolder(in data: Data) -> String? {
guard data.count >= 16, data.count <= maxAliasBytes,
data.prefix(4) == Data("book".utf8), data[8..<12] == Data("mark".utf8) else { return nil }
let directory = FileManager.default.temporaryDirectory.appending(path: "StowAlias-\(UUID().uuidString)", directoryHint: .isDirectory)
defer { try? FileManager.default.removeItem(at: directory) }
do {
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false)
let file = directory.appending(path: "alias")
try data.write(to: file, options: .atomic)
let bookmark = try URL.bookmarkData(withContentsOf: file)
guard bookmark.count <= maxAliasBytes,
let values = URL.resourceValues(forKeys: [.pathKey, .isDirectoryKey], fromBookmarkData: bookmark),
values.isDirectory == true, let path = values.path, DiskImageLayout.isSafeDropFolder(path) else { return nil }
return path
} catch { return nil }
}
}

// MARK: - Model

/// A disk image's own window, ready to draw (#344): the author's background, the apps' own icons
Expand Down
2 changes: 2 additions & 0 deletions App/Integration/SystemActions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ final class SystemActions {
let listing = try await ArchiveEngine.shared.list(url, password: password)
var archive = await Self.makeArchive(listing, url: url)
archive.source?.password = password
archive = await DiskImageAliasLoader.prepare(archive, password: password)
if Task.isCancelled { throw EngineError(.cancelled) }
source = ArchiveAftercare.Source.capture(url)
return try await Extraction.run(archive, selection: nil, to: folder, onProgress: report)
}
Expand Down
3 changes: 3 additions & 0 deletions App/Model/Archive.swift
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,9 @@ nonisolated struct Archive: Identifiable, Sendable {
/// Stored paths of the AppleDouble files a Mac packed (`__MACOSX/…/._name`, `._name`).
var appleDoublePaths: Set<String> = []
var dropTargets: [DiskImageLayout.DropTarget] = []
/// Finder bookmark destinations verified from archive-contained bytes. Reused when only
/// the archive's name/encoding changes; an on-disk reload reads the metadata again.
var aliasDropTargets: [DiskImageLayout.DropTarget] = []
/// Hidden files that dress the volume's window — its `.DS_Store` and what sits in the hidden
/// folders beside it — with their listed sizes, by contained path. Read for the installer
/// canvas (#344); never browsed or extracted with the contents.
Expand Down
6 changes: 4 additions & 2 deletions App/Model/Checkup.swift
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,8 @@ nonisolated extension Archive {
/// Maps an engine listing into the browser's model: names are shown where they will actually be
/// written, folders are synthesized from file paths, and the listing-time Checkup runs. Names
/// stored in an older encoding are read with `nameEncoding` (nil: whichever suits each name).
init(listing: ArchiveListing, url: URL, name: String? = nil, nameEncoding: NameEncoding? = nil) {
init(listing: ArchiveListing, url: URL, name: String? = nil, nameEncoding: NameEncoding? = nil,
aliasDropTargets: [DiskImageLayout.DropTarget] = []) {
let values = try? url.resourceValues(forKeys: [.contentModificationDateKey, .fileSizeKey])
let fallbackDate = values?.contentModificationDate ?? .now
var scan = Checkup.ListingScan(archiveSize: listing.physicalSize ?? Int64(values?.fileSize ?? 0))
Expand All @@ -241,7 +242,7 @@ nonisolated extension Archive {
let layout = format == .dmg || format == .iso
? DiskImageLayout.layout(of: listing.entries.map {
(PathContainment.containedPath(for: $0.path), $0.isFolder, $0.isSymlink, $0.linkTarget)
})
}, aliasDropTargets: aliasDropTargets)
: DiskImageLayout.Layout()

for item in listing.entries {
Expand Down Expand Up @@ -327,6 +328,7 @@ nonisolated extension Archive {
installerExtras = layout.extras
self.appleDoublePaths = appleDoublePaths
dropTargets = layout.dropTargets
self.aliasDropTargets = aliasDropTargets
self.presentationFiles = presentationFiles
}

Expand Down
Loading
Loading