File dialogs & artefact helpers (#6) #36
@@ -0,0 +1,98 @@
|
||||
import Foundation
|
||||
|
||||
/// Result of `verify_stage_artefacts(cwd, basename)` (docs/06).
|
||||
public struct StageArtefacts: Sendable, Equatable {
|
||||
/// `<basename>.ti1` exists (Stage 1 done → unlocks Stage 2).
|
||||
public var stage1Complete = false
|
||||
/// `<basename>.ti2` exists (Stage 2 done → with ti1, unlocks Stage 3).
|
||||
public var stage2Complete = false
|
||||
/// `<basename>.ti3` exists (Stage 3 done → unlocks Stage 4).
|
||||
public var stage3Complete = false
|
||||
/// `.icc`/`.icm` exists (Stage 4 done → with ti3, unlocks Stage 5).
|
||||
public var stage4Complete = false
|
||||
/// Absolute path of the profile file when present.
|
||||
public var profilePath: URL?
|
||||
}
|
||||
|
||||
/// Filesystem probing for wizard artefacts (docs/02 §Working directory,
|
||||
/// docs/06 §Stages). All artefacts live next to each other in `cwd`.
|
||||
public enum ArtefactProbe {
|
||||
|
||||
/// `verify_stage_artefacts` — the gating truth source.
|
||||
public static func verify(
|
||||
basename: String,
|
||||
cwd: URL,
|
||||
fileManager: FileManager = .default
|
||||
) -> StageArtefacts {
|
||||
var out = StageArtefacts()
|
||||
out.stage1Complete = exists(artefact(basename, "ti1", cwd), fm: fileManager)
|
||||
out.stage2Complete = exists(artefact(basename, "ti2", cwd), fm: fileManager)
|
||||
out.stage3Complete = exists(artefact(basename, "ti3", cwd), fm: fileManager)
|
||||
if let profile = resolveProfile(basename: basename, cwd: cwd, fileManager: fileManager) {
|
||||
out.stage4Complete = true
|
||||
out.profilePath = profile
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/// `<cwd>/<basename>.<ext>` — the canonical artefact URL.
|
||||
public static func artefact(_ basename: String, _ ext: String, _ cwd: URL) -> URL {
|
||||
cwd.appendingPathComponent("\(basename).\(ext)", isDirectory: false)
|
||||
}
|
||||
|
||||
/// Profile extension resolution (#69): existing `.icm` wins over
|
||||
/// `.icc`; when neither exists the macOS default is `.icc`.
|
||||
/// (`profcheck`/`iccgamut` swap extension when the requested path is
|
||||
/// missing.)
|
||||
public static func resolveProfile(
|
||||
basename: String,
|
||||
cwd: URL,
|
||||
fileManager: FileManager = .default
|
||||
) -> URL? {
|
||||
let icm = artefact(basename, "icm", cwd)
|
||||
if exists(icm, fm: fileManager) { return icm }
|
||||
let icc = artefact(basename, "icc", cwd)
|
||||
if exists(icc, fm: fileManager) { return icc }
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Default extension for a *new* profile on macOS (#69).
|
||||
public static let defaultProfileExtension = "icc"
|
||||
|
||||
/// Every artefact path for a basename: `.ti1 .ti2 .tif .N.tif
|
||||
/// .ti3 _passN.ti3 .icc .icm .gam` plus the `CAL_<basename>` namespace.
|
||||
/// Multi-page TIFFs match `<basename>.tif`, `<basename>.1.tif` … and
|
||||
/// `<basename>_NN.tif` (manifest naming).
|
||||
public static func existingArtefacts(
|
||||
basename: String,
|
||||
cwd: URL,
|
||||
fileManager: FileManager = .default
|
||||
) -> [URL] {
|
||||
guard let entries = try? fileManager.contentsOfDirectory(
|
||||
at: cwd,
|
||||
includingPropertiesForKeys: nil,
|
||||
options: [.skipsHiddenFiles]
|
||||
) else { return [] }
|
||||
|
||||
let prefixes = [basename + ".", "CAL_" + basename + "."]
|
||||
let suffixes: Set<String> = ["ti1", "ti2", "tif", "ti3", "icc", "icm", "gam", "cal"]
|
||||
let passPrefix = basename + "_pass"
|
||||
let tifStemPrefix = basename + "_"
|
||||
let calPrefix = "CAL_" + basename
|
||||
|
||||
return entries.filter { url in
|
||||
let name = url.lastPathComponent
|
||||
let ext = url.pathExtension.lowercased()
|
||||
guard suffixes.contains(ext) else { return false }
|
||||
if prefixes.contains(where: { name.hasPrefix($0) }) { return true }
|
||||
if name.hasPrefix(passPrefix), ext == "ti3" { return true }
|
||||
if name.hasPrefix(tifStemPrefix), ext == "tif" { return true }
|
||||
if name.hasPrefix(calPrefix) { return true }
|
||||
return false
|
||||
}.sorted { $0.lastPathComponent < $1.lastPathComponent }
|
||||
}
|
||||
|
||||
private static func exists(_ url: URL, fm: FileManager) -> Bool {
|
||||
fm.fileExists(atPath: url.path)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import Foundation
|
||||
|
||||
/// Atomic `.tmp`-then-rename file writes — the convention used by
|
||||
/// settings.json, verification_history.json and wizard_state.json
|
||||
/// (docs/02 §Persistence, #213).
|
||||
public enum AtomicFileWriter {
|
||||
|
||||
/// Writes `data` to `url` atomically: sibling `<name>.tmp`, then a
|
||||
/// rename (which is atomic on APFS/HFS+). Parent dirs are created.
|
||||
public static func write(_ data: Data, to url: URL) throws {
|
||||
let fm = FileManager.default
|
||||
let dir = url.deletingLastPathComponent()
|
||||
try fm.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
|
||||
let tmp = url.appendingPathExtension("tmp")
|
||||
do {
|
||||
try data.write(to: tmp, options: [])
|
||||
// replaceItemAt handles same-volume atomic swap and removes
|
||||
// the destination cleanly; fall back to remove+move.
|
||||
if fm.fileExists(atPath: url.path) {
|
||||
_ = try fm.replaceItemAt(url, withItemAt: tmp)
|
||||
} else {
|
||||
try fm.moveItem(at: tmp, to: url)
|
||||
}
|
||||
} catch {
|
||||
try? fm.removeItem(at: tmp)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
public static func write(_ text: String, to url: URL) throws {
|
||||
try write(Data(text.utf8), to: url)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import Foundation
|
||||
|
||||
/// Basename sanitisation and safe working-directory resolution
|
||||
/// (docs/02 §Working directory, docs/06 §Empty cwd).
|
||||
public enum PathSecurity {
|
||||
|
||||
public enum Error: Swift.Error, Equatable, Sendable {
|
||||
case invalidBasename(String)
|
||||
}
|
||||
|
||||
/// Basenames must not contain `/`, `\`, or `..` and must be
|
||||
/// non-empty. Never invent a default basename (#60).
|
||||
public static func isValidBasename(_ name: String) -> Bool {
|
||||
guard !name.isEmpty else { return false }
|
||||
return !name.contains("/") && !name.contains("\\") && !name.contains("..")
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public static func sanitizeBasename(_ name: String) throws -> String {
|
||||
guard isValidBasename(name) else {
|
||||
throw Error.invalidBasename(name)
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
/// `resolve_safe_cwd` (docs/04 §0.2): explicit real directory →
|
||||
/// Documents → Home → app-data. Never returns an empty/nil cwd.
|
||||
public static func resolveSafeCwd(
|
||||
_ explicit: URL?,
|
||||
fileManager: FileManager = .default
|
||||
) -> URL {
|
||||
if let explicit,
|
||||
fileManager.fileExists(atPath: explicit.path, isDirectory: nil) {
|
||||
return explicit
|
||||
}
|
||||
let candidates: [URL?] = [
|
||||
fileManager.urls(for: .documentDirectory, in: .userDomainMask).first,
|
||||
fileManager.homeDirectoryForCurrentUser,
|
||||
AppPaths.appDataDir,
|
||||
]
|
||||
for candidate in candidates {
|
||||
guard let url = candidate else { continue }
|
||||
if !fileManager.fileExists(atPath: url.path) {
|
||||
try? fileManager.createDirectory(at: url, withIntermediateDirectories: true)
|
||||
}
|
||||
if fileManager.fileExists(atPath: url.path, isDirectory: nil) {
|
||||
return url
|
||||
}
|
||||
}
|
||||
// Last resort: app-data, created unconditionally.
|
||||
try? fileManager.createDirectory(at: AppPaths.appDataDir, withIntermediateDirectories: true)
|
||||
return AppPaths.appDataDir
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,98 @@
|
||||
import AppKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
/// NSOpenPanel / NSSavePanel wrappers (issue #6). All CGATS/ICC file
|
||||
/// picking in the app goes through this service — the v1 equivalent of
|
||||
/// the `select_*` Tauri commands (docs/21 §Dialogs).
|
||||
@MainActor
|
||||
final class FileDialogService {
|
||||
|
||||
static let shared = FileDialogService()
|
||||
private init() {}
|
||||
|
||||
// MARK: - Directory
|
||||
|
||||
/// `#btnBrowse` — working directory for Argyll artefacts.
|
||||
/// Defaults to Documents (docs/06 §Empty cwd).
|
||||
func chooseDirectory(startingAt start: URL? = nil) -> URL? {
|
||||
let panel = NSOpenPanel()
|
||||
panel.canChooseDirectories = true
|
||||
panel.canChooseFiles = false
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.directoryURL = start
|
||||
?? FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first
|
||||
panel.prompt = "Choose"
|
||||
return run(panel)
|
||||
}
|
||||
|
||||
// MARK: - Open files
|
||||
|
||||
func chooseTI1(startingAt start: URL? = nil) -> URL? {
|
||||
chooseFile(extensions: ["ti1"], startingAt: start)
|
||||
}
|
||||
|
||||
func chooseTI2(startingAt start: URL? = nil) -> URL? {
|
||||
chooseFile(extensions: ["ti2"], startingAt: start)
|
||||
}
|
||||
|
||||
/// Stage 1 "Open Existing" — `.ti1` or `.ti2` (docs/06 §Resume).
|
||||
func chooseExistingTarget(startingAt start: URL? = nil) -> URL? {
|
||||
chooseFile(extensions: ["ti1", "ti2"], startingAt: start)
|
||||
}
|
||||
|
||||
func chooseTI3(startingAt start: URL? = nil) -> URL? {
|
||||
chooseFile(extensions: ["ti3"], startingAt: start)
|
||||
}
|
||||
|
||||
/// ICC/ICM picker (profiles, preconditioning, calibration `.cal`).
|
||||
func chooseProfile(startingAt start: URL? = nil) -> URL? {
|
||||
chooseFile(extensions: ["icc", "icm"], startingAt: start)
|
||||
}
|
||||
|
||||
func chooseCalibration(startingAt start: URL? = nil) -> URL? {
|
||||
chooseFile(extensions: ["cal"], startingAt: start)
|
||||
}
|
||||
|
||||
func chooseFile(
|
||||
extensions: [String],
|
||||
startingAt start: URL? = nil,
|
||||
message: String? = nil
|
||||
) -> URL? {
|
||||
let panel = NSOpenPanel()
|
||||
panel.canChooseDirectories = false
|
||||
panel.canChooseFiles = true
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.allowedContentTypes = extensions.compactMap { UTType(filenameExtension: $0) }
|
||||
panel.allowsOtherFileTypes = true
|
||||
panel.directoryURL = start
|
||||
if let message { panel.message = message }
|
||||
return run(panel)
|
||||
}
|
||||
|
||||
// MARK: - Save
|
||||
|
||||
func saveFile(
|
||||
defaultName: String,
|
||||
extensions: [String],
|
||||
startingAt start: URL? = nil,
|
||||
message: String? = nil
|
||||
) -> URL? {
|
||||
let panel = NSSavePanel()
|
||||
panel.nameFieldStringValue = defaultName
|
||||
panel.allowedContentTypes = extensions.compactMap { UTType(filenameExtension: $0) }
|
||||
panel.allowsOtherFileTypes = true
|
||||
panel.directoryURL = start
|
||||
if let message { panel.message = message }
|
||||
return run(panel)
|
||||
}
|
||||
|
||||
// MARK: - Internals
|
||||
|
||||
private func run(_ panel: NSOpenPanel) -> URL? {
|
||||
panel.runModal() == .OK ? panel.url : nil
|
||||
}
|
||||
|
||||
private func run(_ panel: NSSavePanel) -> URL? {
|
||||
panel.runModal() == .OK ? panel.url : nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import ICCeryCore
|
||||
|
||||
private func tempDir(_ name: String = UUID().uuidString) throws -> URL {
|
||||
let url = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("iccery-files-\(name)")
|
||||
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
|
||||
return url
|
||||
}
|
||||
|
||||
private func touch(_ url: URL, _ contents: String = "x") throws {
|
||||
try contents.write(to: url, atomically: true, encoding: .utf8)
|
||||
}
|
||||
|
||||
@Suite("PathSecurity")
|
||||
struct PathSecurityTests {
|
||||
@Test func rejectsTraversalAndSeparators() {
|
||||
for bad in ["a/b", "a\\b", "..", "a/../b", "", "..x"] {
|
||||
#expect(!PathSecurity.isValidBasename(bad))
|
||||
#expect(throws: PathSecurity.Error.self) {
|
||||
try PathSecurity.sanitizeBasename(bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test func acceptsNormalNames() {
|
||||
for good in ["target", "My Target 01", "écheneau-ümläut", "a.b"] {
|
||||
#expect(PathSecurity.isValidBasename(good))
|
||||
}
|
||||
}
|
||||
|
||||
@Test func resolveSafeCwdPrefersExplicit() throws {
|
||||
let dir = try tempDir()
|
||||
#expect(PathSecurity.resolveSafeCwd(dir) == dir)
|
||||
}
|
||||
|
||||
@Test func resolveSafeCwdNeverReturnsNil() {
|
||||
let missing = URL(fileURLWithPath: "/nonexistent-\(UUID().uuidString)")
|
||||
let resolved = PathSecurity.resolveSafeCwd(missing)
|
||||
#expect(FileManager.default.fileExists(atPath: resolved.path))
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("AtomicFileWriter")
|
||||
struct AtomicFileWriterTests {
|
||||
@Test func writesAndLeavesNoTmp() throws {
|
||||
let dir = try tempDir()
|
||||
let url = dir.appendingPathComponent("state.json")
|
||||
try AtomicFileWriter.write(Data("{\"a\":1}".utf8), to: url)
|
||||
#expect(try String(contentsOf: url, encoding: .utf8) == "{\"a\":1}")
|
||||
#expect(!FileManager.default.fileExists(atPath: url.appendingPathExtension("tmp").path))
|
||||
}
|
||||
|
||||
@Test func overwritesExistingAtomically() throws {
|
||||
let dir = try tempDir()
|
||||
let url = dir.appendingPathComponent("f.txt")
|
||||
try AtomicFileWriter.write("one", to: url)
|
||||
try AtomicFileWriter.write("two-longer", to: url)
|
||||
#expect(try String(contentsOf: url, encoding: .utf8) == "two-longer")
|
||||
}
|
||||
|
||||
@Test func createsParentDirs() throws {
|
||||
let dir = try tempDir()
|
||||
let url = dir.appendingPathComponent("a/b/c/deep.json")
|
||||
try AtomicFileWriter.write("{}", to: url)
|
||||
#expect(FileManager.default.fileExists(atPath: url.path))
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("ArtefactProbe")
|
||||
struct ArtefactProbeTests {
|
||||
@Test func verifyProgression() throws {
|
||||
let dir = try tempDir()
|
||||
var v = ArtefactProbe.verify(basename: "t", cwd: dir)
|
||||
#expect(v == StageArtefacts())
|
||||
|
||||
try touch(dir.appendingPathComponent("t.ti1"))
|
||||
v = ArtefactProbe.verify(basename: "t", cwd: dir)
|
||||
#expect(v.stage1Complete && !v.stage2Complete && !v.stage3Complete)
|
||||
|
||||
try touch(dir.appendingPathComponent("t.ti2"))
|
||||
try touch(dir.appendingPathComponent("t.ti3"))
|
||||
v = ArtefactProbe.verify(basename: "t", cwd: dir)
|
||||
#expect(v.stage2Complete && v.stage3Complete && !v.stage4Complete)
|
||||
|
||||
try touch(dir.appendingPathComponent("t.icc"))
|
||||
v = ArtefactProbe.verify(basename: "t", cwd: dir)
|
||||
#expect(v.stage4Complete && v.profilePath?.pathExtension == "icc")
|
||||
}
|
||||
|
||||
@Test func icmWinsOverIcc() throws {
|
||||
let dir = try tempDir()
|
||||
try touch(dir.appendingPathComponent("p.icc"))
|
||||
try touch(dir.appendingPathComponent("p.icm"))
|
||||
let profile = ArtefactProbe.resolveProfile(basename: "p", cwd: dir)
|
||||
#expect(profile?.pathExtension == "icm")
|
||||
}
|
||||
|
||||
@Test func enumeratesPassesPagesAndCAL() throws {
|
||||
let dir = try tempDir()
|
||||
for name in [
|
||||
"t.ti1", "t.ti2", "t.tif", "t.2.tif", "t_03.tif",
|
||||
"t.ti3", "t_pass1.ti3", "t_pass2.ti3",
|
||||
"t.icc", "t.gam",
|
||||
"CAL_t.ti1", "CAL_t.cal",
|
||||
// must NOT match:
|
||||
"other.ti1", "t.txt", "CAL_other.ti1",
|
||||
] { try touch(dir.appendingPathComponent(name)) }
|
||||
|
||||
let names = ArtefactProbe.existingArtefacts(basename: "t", cwd: dir)
|
||||
.map(\.lastPathComponent)
|
||||
for expected in [
|
||||
"t.ti1", "t.ti2", "t.tif", "t.2.tif", "t_03.tif",
|
||||
"t.ti3", "t_pass1.ti3", "t_pass2.ti3",
|
||||
"t.icc", "t.gam", "CAL_t.ti1", "CAL_t.cal",
|
||||
] {
|
||||
#expect(names.contains(expected), "missing \(expected)")
|
||||
}
|
||||
#expect(!names.contains("other.ti1"))
|
||||
#expect(!names.contains("t.txt"))
|
||||
#expect(!names.contains("CAL_other.ti1"))
|
||||
}
|
||||
|
||||
@Test func emptyDirReturnsEmpty() throws {
|
||||
let dir = try tempDir()
|
||||
#expect(ArtefactProbe.existingArtefacts(basename: "x", cwd: dir).isEmpty)
|
||||
}
|
||||
}
|
||||
@@ -110,6 +110,28 @@ find "$DEST" -type f -exec chmod 0755 {} +
|
||||
# Downloads carry com.apple.quarantine; the app cannot spawn quarantined tools.
|
||||
xattr -dr com.apple.quarantine "$DEST" 2>/dev/null || true
|
||||
|
||||
# Ad-hoc sign every Mach-O (#165: unsigned arm64 → "Killed: 9"), then
|
||||
# verify — an unsigned sidecar fails the script.
|
||||
for f in "$DEST"/*; do
|
||||
[ -f "$f" ] || continue
|
||||
if file -b "$f" | grep -q 'Mach-O'; then
|
||||
codesign -f -s - "$f" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
UNSIGNED=""
|
||||
for f in "$DEST"/*; do
|
||||
[ -f "$f" ] || continue
|
||||
if file -b "$f" | grep -q 'Mach-O'; then
|
||||
if ! codesign -dvv "$f" >/dev/null 2>&1; then
|
||||
UNSIGNED="$UNSIGNED $f"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
if [ -n "$UNSIGNED" ]; then
|
||||
echo "error: unsigned binaries remain:$UNSIGNED" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -x "$DEST/$MARKER" ]; then
|
||||
echo "error: marker binary $MARKER missing after extraction" >&2
|
||||
exit 1
|
||||
|
||||
Reference in New Issue
Block a user