Compare commits

..
Author SHA1 Message Date
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 2a608c8962 File dialogs & artefact helpers — dedicated pickers + host helpers (#6)
- One dedicated method per purpose: selectTargetFile (save .ti1),
  selectExistingTarget (ti1/ti2), selectProfileFile (icc/icm/mpp —
  never ti*, #172), selectSpectrumFile (.sp), selectDatasetFile
  (open-only ti3/txt/cgats/csv, #211), selectCsvSavePath, selectCalFile,
  selectDirectory. No shared generic picker API (#103/#210/#211).
- Ti2Header: TARGET_INSTRUMENT / NUMBER_OF_SETS / NUMBER_OF_PAGES +
  sibling .ti1 detection; NUMBER_OF_FIELDS explicitly not patch count
- TiffPreview: host-side TIFF→PNG thumbnail, 1200px max edge (#58)
- ArtefactFiles: defaultWorkingDirectory, readBase64, appInfo
- 7 new tests; 47/47 green

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-08 19:09:19 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 552227c3af File dialogs & artefact helpers (#6)
- PathSecurity: basename sanitisation (reject / \ .., empty); resolveSafeCwd explicit - Documents - Home - app-data (#59, #60)
- AtomicFileWriter: .tmp + rename/replaceItemAt, parent dirs (#213)
- ArtefactProbe: verify_stage_artefacts, .icm-over-.icc resolution (#69), enumeration incl. .N.tif/_NN.tif/_passN.ti3/CAL_
- FileDialogService: NSOpenPanel/NSSavePanel wrappers (v2 select_* equivalents)
- 11 new tests; 40/40 total green

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-08 19:06:10 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 5ed3ff5428 fetch-argyll: ad-hoc sign + verify; ship real sRGB.gam (#3 follow-up)
- codesign -f -s - every fetched Mach-O, then codesign -dvv verify —
  an unsigned sidecar now fails the script (#165)
- Replace the 8-cusp reference_gamuts stub with the real v0.8.5
  src/assets/sRGB.gam (448 verts / 892 faces) used by the Stage 5
  gamut overlay (#185)

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-08 19:02:49 +01:00
gronod 6b122b2cbc Merge pull request 'Argyll sidecar fetch & binary resolution (#3)' (#35) from feat/3-argyll-resolution into milestone/m1-foundation 2026-09-08 19:01:01 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 716b302374 Argyll sidecar fetch & binary resolution (#3)
- scripts/fetch-argyll.sh: POSIX sh port of fetch-argyll.mjs; queries
  the Gitea release API for the *_macOS_universal_bin.tgz asset,
  extracts Argyll_V*/bin, chmod+x, strips quarantine xattr; honors
  ARGYLL_SERVER_URL / ARGYLL_REPO / ARGYLL_RELEASE_TAG / GITEA_TOKEN.
  Verified end-to-end: 51 universal tools from v3.5.0-ICCery1.8.
- BinaryResolver: settings argyll_binary_dir override (existence-gated)
  → bundled Argyll/<platform>/, macos-universal preferred when instlist
  marker present, else macos-arm64/macos-x86_64; constructed path
  returned even when absent (spawn surfaces process:error). Mock and
  reference-gamut helpers.
- Vendored tracked resources: mocks/{chartread,colprof,profcheck}.mock
  + reference_gamuts/sRGB.gam from ICCery v1, copied as a folder
  reference so the Argyll/ subtree structure survives into the bundle.
- Build phase rsyncs Vendor/Argyll/ → Contents/Resources/Argyll/.
- AppDelegate: killAll via terminateLater so children are signaled
  before teardown (#147/#149).
- 6 resolver tests + fetch script smoke-verified against real release.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-08 19:00:39 +01:00
gronod 20d6bf7fdc Merge pull request 'ProcessManager: spawn / stdin / kill / captured / event bus (#2)' (#34) from feat/2-process-manager into milestone/m1-foundation 2026-09-08 18:52:16 +01:00
18 changed files with 2510 additions and 5 deletions
@@ -0,0 +1,100 @@
import Foundation
/// Resolves Argyll sidecar binaries (docs/04 §0.1 `resolve_binary`).
///
/// Order:
/// 1. Settings `argyll_binary_dir` override only if `<dir>/<name>`
/// exists there.
/// 2. Bundled `<bundle>/Resources/Argyll/<platform>/<name>`.
/// On macOS, `macos-universal` wins whenever it contains the `instlist`
/// marker; otherwise `macos-arm64` / `macos-x86_64` by host arch.
/// 3. If nothing exists the *constructed* bundled path is still returned
/// a missing binary surfaces later as `process:error` on spawn,
/// matching v1 semantics.
public struct BinaryResolver: Sendable {
/// Root that contains the platform dirs `Bundle.resource/Argyll` in
/// the app, a fixture dir in tests.
public let bundledRoot: URL
/// `settings.argyll_binary_dir`, already expanded to a URL.
public let overrideDir: URL?
/// Host architecture directory names, universal preferred.
public let archDirs: [String]
public init(
bundledRoot: URL = AppPaths.bundledArgyllDir,
overrideDir: URL? = nil,
archDirs: [String]? = nil
) {
self.bundledRoot = bundledRoot
self.overrideDir = overrideDir
#if arch(arm64)
let fallback = ["macos-arm64", "macos-aarch64"]
#else
let fallback = ["macos-x86_64"]
#endif
self.archDirs = archDirs ?? ["macos-universal"] + fallback
}
/// Marker used to decide whether `macos-universal` is usable.
public static let markerBinary = "instlist"
/// Resolves a tool name to an absolute URL (never throws see type
/// docs). `name` is the bare tool name, e.g. `"targen"`.
public func resolve(_ name: String) -> URL {
let fm = FileManager.default
if let dir = overrideDir {
let candidate = dir.appendingPathComponent(name)
if fm.fileExists(atPath: candidate.path) {
return candidate
}
}
return bundledRoot
.appendingPathComponent(platformDir(), isDirectory: true)
.appendingPathComponent(name, isDirectory: false)
}
/// The bundled platform directory that resolution will use.
public func platformDir() -> String {
let fm = FileManager.default
let universal = bundledRoot.appendingPathComponent("macos-universal")
if fm.fileExists(
atPath: universal.appendingPathComponent(Self.markerBinary).path
) {
return "macos-universal"
}
for dir in archDirs where dir != "macos-universal" {
if fm.fileExists(
atPath: bundledRoot
.appendingPathComponent(dir)
.appendingPathComponent(Self.markerBinary).path
) {
return dir
}
}
// Nothing present still return the preferred dir so the error
// message points at where the user should drop binaries.
return archDirs.first ?? "macos-universal"
}
/// Bundled mock tool (tracked in git under `Resources/Argyll/mocks/`).
public func mock(_ name: String) -> URL {
bundledRoot
.appendingPathComponent("mocks", isDirectory: true)
.appendingPathComponent("\(name).mock", isDirectory: false)
}
/// Bundled reference gamut (`Resources/Argyll/reference_gamuts/`).
public func referenceGamut(_ name: String) -> URL {
bundledRoot
.appendingPathComponent("reference_gamuts", isDirectory: true)
.appendingPathComponent(name, isDirectory: false)
}
/// Whether the resolved path exists and is executable.
public func exists(_ url: URL) -> Bool {
FileManager.default.isExecutableFile(atPath: url.path)
}
}
@@ -0,0 +1,28 @@
import Foundation
/// Small host-side file helpers (issue #6).
public enum ArtefactFiles {
/// `get_default_working_dir` `resolveSafeCwd(nil)`.
public static func defaultWorkingDirectory() -> URL {
PathSecurity.resolveSafeCwd(nil)
}
/// `read_file_base64` for **text artefacts** the UI needs verbatim
/// (ti1/ti2 previews, CGATS datasets, logs). Binary payloads (TIFF)
/// go through `TiffPreview` instead.
public static func readBase64(_ url: URL) throws -> String {
try Data(contentsOf: url).base64EncodedString()
}
/// `get_app_info` version + build for the About dialog.
public static func appInfo(
bundle: Bundle = .main
) -> (version: String, build: String) {
let info = bundle.infoDictionary ?? [:]
return (
info["CFBundleShortVersionString"] as? String ?? "0.0.0",
info["CFBundleVersion"] as? String ?? "0"
)
}
}
@@ -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
}
}
@@ -0,0 +1,53 @@
import Foundation
/// Parsed header of a `.ti2` chart-layout file (docs/06 §Resume).
/// `parse_ti2_header` reads only CGATS keyword lines the data grid
/// itself belongs to issue #30.
public struct Ti2Header: Sendable, Equatable {
/// `TARGET_INSTRUMENT` (e.g. `i1`, `i1iO`, `CM`).
public var instrument: String?
/// `NUMBER_OF_SETS` the patch count. Note: `NUMBER_OF_FIELDS` is
/// the CGATS column count, *not* the patch count.
public var patchCount: Int?
/// `NUMBER_OF_PAGES`.
public var pageCount: Int?
/// A sibling `<stem>.ti1` exists next to the parsed file.
public var hasSiblingTi1 = false
public static func parse(
_ url: URL,
fileManager: FileManager = .default
) -> Ti2Header {
var header = Ti2Header()
guard let text = try? String(contentsOf: url, encoding: .utf8) else {
return header
}
for rawLine in text.split(whereSeparator: \.isNewline) {
let line = rawLine.trimmingCharacters(in: .whitespaces)
if line.hasPrefix("BEGIN_DATA_FORMAT") || line.hasPrefix("BEGIN_DATA") {
break
}
// CGATS keyword lines: `KEYWORD "value"` or `KEYWORD value`.
guard let space = line.firstIndex(of: " ") else { continue }
let key = String(line[..<space])
let value = String(line[line.index(after: space)...])
.trimmingCharacters(in: .whitespaces)
.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
switch key {
case "TARGET_INSTRUMENT":
header.instrument = value
case "NUMBER_OF_SETS":
header.patchCount = Int(value)
case "NUMBER_OF_PAGES":
header.pageCount = Int(value)
default:
continue
}
}
let stem = url.deletingPathExtension()
header.hasSiblingTi1 = fileManager.fileExists(
atPath: stem.appendingPathExtension("ti1").path
)
return header
}
}
@@ -0,0 +1,38 @@
import Foundation
import ImageIO
import UniformTypeIdentifiers
/// TIFF PNG preview for the Stage 2 gallery (#58): decode on the host
/// side, cap the long edge at 1200 px, emit PNG. Never hand raw TIFF
/// bytes to the UI.
public enum TiffPreview {
public static let maxEdge: Int = 1200
/// Returns PNG data for the first page of a TIFF, or `nil` when the
/// file cannot be decoded.
public static func previewPNG(
tiff url: URL,
maxEdge: Int = Self.maxEdge
) -> Data? {
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else {
return nil
}
let options: [CFString: Any] = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceThumbnailMaxPixelSize: maxEdge,
kCGImageSourceCreateThumbnailWithTransform: true,
]
guard let image = CGImageSourceCreateThumbnailAtIndex(
source, 0, options as CFDictionary
) else { return nil }
let out = NSMutableData()
guard let dest = CGImageDestinationCreateWithData(
out, UTType.png.identifier as CFString, 1, nil
) else { return nil }
CGImageDestinationAddImage(dest, image, nil)
guard CGImageDestinationFinalize(dest) else { return nil }
return out as Data
}
}
+75
View File
@@ -0,0 +1,75 @@
#!/bin/bash
# Mock script for chartread -u
# This script simulates the behaviour of chartread for testing purposes.
# Check for --xy argument or MOCK_XY_TABLE environment variable
IS_XY=0
for arg in "$@"; do
if [ "$arg" = "--xy" ]; then
IS_XY=1
break
fi
done
if [ "$IS_XY" = "1" ] || [ "${MOCK_XY_TABLE}" = "1" ]; then
echo "Place instrument on calibration tile and hit [Space] to calibrate."
read -r _calib
echo "Calibration successful."
echo "Please place sheet 1 of 1 on the table"
echo "hit return to continue, Esc or 'q' to give up"
read -r _sheet1
echo "locate patch A1 with the sight,"
echo "then hit return to continue"
read -r _fid1
echo "locate patch B24 with the sight,"
echo "then hit return to continue"
read -r _fid2
echo "Reading sheet 1..."
sleep 0.5
# Emit mock JSON for strip A
cat << 'EOF'
ROW_COLORS_JSON: {"event": "row_complete", "row_id": "A", "row_index": 0, "total_rows": 2, "patch_count": 3, "patches": [{"id": "1", "loc": "A1", "is_pad": false, "device": [0.0, 50.0, 100.0], "expected": {"XYZ": [18.4210, 20.1234, 15.6789], "Lab": [51.98, -8.45, 12.32]}, "measured": {"XYZ": [18.5120, 20.0451, 15.7100], "Lab": [51.89, -8.31, 12.15]}}, {"id": "2", "loc": "A2", "is_pad": false, "device": [10.0, 60.0, 90.0], "expcted": {"Lab": [60.0, 10.0, -20.0]}, "measured": {"Lab": [60.1, 10.5, -19.5]}}, {"id": "3", "loc": "A3", "is_pad": true, "device": [100.0, 100.0, 100.0]}]}
EOF
# Emit mock JSON for strip B
cat << 'EOF'
ROW_COLORS_JSON: {"event": "row_complete", "row_id": "B", "row_index": 1, "total_rows": 2, "patch_count": 2, "patches": [{"id": "4", "loc": "B1", "is_pad": false, "device": [100.0, 0.0, 0.0], "expected": {"Lab": [40.0, 40.0, 40.0]}, "measured": {"Lab": [38.0, 41.0, 39.0]}}, {"id": "5", "loc": "B2", "is_pad": false, "device": [0.0, 100.0, 0.0], "expcted": {"Lab": [80.0, -50.0, 50.0]}, "measured": {"Lab": [79.0, -49.0, 51.0]}}]}
EOF
echo "Sheet 1 of 1 read OK"
echo "Please remove last sheet from table"
exit 0
fi
# Handheld / strip reader simulation
echo "Place instrument on calibration tile and hit [Space] to calibrate."
# We don't really wait for input, just wait 1 second
sleep 1
echo "Calibration successful."
echo "Hit [Space] to read strip A (or 's' to skip)."
sleep 1
echo "Reading strip A..."
# Emit mock JSON for strip A
cat << 'EOF'
ROW_COLORS_JSON: {"event": "row_complete", "row_id": "A", "row_index": 0, "total_rows": 2, "patch_count": 3, "patches": [{"id": "1", "loc": "A1", "is_pad": false, "device": [0.0, 50.0, 100.0], "expected": {"XYZ": [18.4210, 20.1234, 15.6789], "Lab": [51.98, -8.45, 12.32]}, "measured": {"XYZ": [18.5120, 20.0451, 15.7100], "Lab": [51.89, -8.31, 12.15]}}, {"id": "2", "loc": "A2", "is_pad": false, "device": [10.0, 60.0, 90.0], "expcted": {"Lab": [60.0, 10.0, -20.0]}, "measured": {"Lab": [60.1, 10.5, -19.5]}}, {"id": "3", "loc": "A3", "is_pad": true, "device": [100.0, 100.0, 100.0]}]}
EOF
echo "Hit [Space] to read strip B (or 's' to skip)."
sleep 1
echo "Reading strip B..."
# Emit mock JSON for strip B
cat << 'EOF'
ROW_COLORS_JSON: {"event": "row_complete", "row_id": "B", "row_index": 1, "total_rows": 2, "patch_count": 2, "patches": [{"id": "4", "loc": "B1", "is_pad": false, "device": [100.0, 0.0, 0.0], "expected": {"Lab": [40.0, 40.0, 40.0]}, "measured": {"Lab": [38.0, 41.0, 39.0]}}, {"id": "5", "loc": "B2", "is_pad": false, "device": [0.0, 100.0, 0.0], "expected": {"Lab": [80.0, -50.0, 50.0]}, "measured": {"Lab": [79.0, -49.0, 51.0]}}]}
EOF
echo "Ready to read... done."
exit 0
+20
View File
@@ -0,0 +1,20 @@
#!/bin/bash
# Mock script for colprof
# Simulates colprof execution and outputs progress log
basename="$1"
# Find last argument if -D or other flags are used
for arg in "$@"; do
basename="$arg"
done
echo "colprof: Starting profile calculation for $basename"
sleep 1
echo "Gamut mapping calculation..."
sleep 1
echo "Fitting cLUT grid points..."
sleep 1
echo "Writing ICC profile $basename.icc..."
touch "$basename.icc"
echo "Done."
exit 0
+12
View File
@@ -0,0 +1,12 @@
#!/bin/bash
# Mock script for profcheck
# Simulates real ArgyllCMS profcheck -v -k -s -u output
echo "profcheck: Checking profile accuracy..."
echo "No of test patches = 52"
sleep 1
cat << 'EOF'
{"event": "report", "peak_de2000": 2.41, "avg_de2000": 0.85, "rms": 1.02}
EOF
echo "Profile check complete, errors(CIEDE2000): max. = 2.41, avg. = 0.85, RMS = 1.02"
exit 0
File diff suppressed because it is too large Load Diff
+112
View File
@@ -0,0 +1,112 @@
import AppKit
import UniformTypeIdentifiers
/// Dedicated NSOpenPanel / NSSavePanel wrappers (issue #6) one method
/// per purpose, matching the v1 `select_*` commands (docs/21 §Dialogs).
/// No call site shares a generic picker (#103/#210/#211).
@MainActor
final class FileDialogService {
static let shared = FileDialogService()
private init() {}
// MARK: - selectDirectory
/// `#btnBrowse` working directory for Argyll artefacts.
/// Defaults to Documents (docs/06 §Empty cwd).
func selectDirectory(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: - Dedicated open pickers
/// `selectTargetFile` **save** panel for the new `.ti1` target.
func selectTargetFile(startingAt start: URL? = nil) -> URL? {
let panel = NSSavePanel()
panel.nameFieldStringValue = "target.ti1"
panel.allowedContentTypes = utTypes(["ti1"])
panel.allowsOtherFileTypes = false
panel.directoryURL = start
panel.message = "Choose the .ti1 target file to create"
return run(panel)
}
/// `selectExistingTarget` open `.ti1`/`.ti2` (docs/06 §Resume, #140).
func selectExistingTarget(startingAt start: URL? = nil) -> URL? {
open(extensions: ["ti1", "ti2"], startingAt: start,
message: "Open an existing target (.ti1 or .ti2)")
}
/// `selectProfileFile` `.icc`/`.icm`/`.mpp` only **never** `.ti*`
/// (#172: the profile filter must not accept datasets).
func selectProfileFile(startingAt start: URL? = nil) -> URL? {
open(extensions: ["icc", "icm", "mpp"], startingAt: start,
message: "Choose an ICC/ICM profile or measurement preconditioning file")
}
/// `selectSpectrumFile` `.sp` illuminant spectrum (colprof -i).
func selectSpectrumFile(startingAt start: URL? = nil) -> URL? {
open(extensions: ["sp"], startingAt: start,
message: "Choose a custom illuminant spectrum (.sp)")
}
/// `selectDatasetFile` open a measured dataset (`.ti3`, `.txt`,
/// `.cgats`, `.csv`). Always an *open* dialog, never save (#211).
func selectDatasetFile(startingAt start: URL? = nil) -> URL? {
open(extensions: ["ti3", "txt", "cgats", "csv"], startingAt: start,
message: "Import a measured dataset")
}
/// `selectCsvSavePath` verification-history CSV export.
func selectCsvSavePath(startingAt start: URL? = nil) -> URL? {
let panel = NSSavePanel()
panel.nameFieldStringValue = "verification-history.csv"
panel.allowedContentTypes = utTypes(["csv"])
panel.allowsOtherFileTypes = false
panel.directoryURL = start
return run(panel)
}
/// `selectCalFile` `.cal` calibration curves.
func selectCalFile(startingAt start: URL? = nil) -> URL? {
open(extensions: ["cal"], startingAt: start,
message: "Choose a calibration file (.cal)")
}
// MARK: - Internals (private not a shared public picker API)
private func open(
extensions: [String],
startingAt start: URL?,
message: String?
) -> URL? {
let panel = NSOpenPanel()
panel.canChooseDirectories = false
panel.canChooseFiles = true
panel.allowsMultipleSelection = false
panel.allowedContentTypes = utTypes(extensions)
panel.allowsOtherFileTypes = true
panel.directoryURL = start
if let message { panel.message = message }
return run(panel)
}
private func utTypes(_ extensions: [String]) -> [UTType] {
extensions.compactMap { UTType(filenameExtension: $0) }
}
private func run(_ panel: NSOpenPanel) -> URL? {
panel.runModal() == .OK ? panel.url : nil
}
private func run(_ panel: NSSavePanel) -> URL? {
panel.runModal() == .OK ? panel.url : nil
}
}
+15 -5
View File
@@ -1,4 +1,5 @@
import AppKit import AppKit
import ICCeryCore
import SwiftUI import SwiftUI
@main @main
@@ -19,15 +20,24 @@ struct ICCeryApp: App {
} }
} }
/// AppDelegate: quit when the single window closes, and give later /// AppDelegate: quit when the single window closes, and `killAll` Argyll
/// milestones a hook to `killAll` Argyll children before teardown /// children before teardown (#147/#149). Termination is deferred until
/// (#147/#149 wired once ProcessManager exists in #2). /// `killAll` has signaled every child so `chartread` can park an XY head
/// when the UI already sent `q\n`.
final class AppDelegate: NSObject, NSApplicationDelegate { final class AppDelegate: NSObject, NSApplicationDelegate {
private var terminationRequested = false
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
true true
} }
func applicationWillTerminate(_ notification: Notification) { func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
// Issue #2+: ProcessManager.shared.killAll() guard !terminationRequested else { return .terminateNow }
terminationRequested = true
Task {
await ProcessManager.shared.killAll()
NSApplication.shared.reply(toApplicationShouldTerminate: true)
}
return .terminateLater
} }
} }
@@ -0,0 +1,124 @@
import Testing
import Foundation
import ImageIO
import UniformTypeIdentifiers
@testable import ICCeryCore
private func tempURL(_ name: String) -> URL {
FileManager.default.temporaryDirectory
.appendingPathComponent("iccery-af-\(UUID().uuidString)")
.appendingPathComponent(name)
}
@Suite("Ti2Header")
struct Ti2HeaderTests {
@Test func parsesKeywordsAndSibling() throws {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("iccery-ti2-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
try """
CTI2
TARGET_INSTRUMENT "i1iO"
NUMBER_OF_FIELDS 9
NUMBER_OF_SETS 800
NUMBER_OF_PAGES 3
BEGIN_DATA_FORMAT
SAMPLE_ID RGB_R
END_DATA_FORMAT
""".write(to: dir.appendingPathComponent("job.ti2"), atomically: true, encoding: .utf8)
try "CGATS".write(
to: dir.appendingPathComponent("job.ti1"), atomically: true, encoding: .utf8
)
let h = Ti2Header.parse(dir.appendingPathComponent("job.ti2"))
#expect(h.instrument == "i1iO")
#expect(h.patchCount == 800)
#expect(h.pageCount == 3)
#expect(h.hasSiblingTi1)
}
@Test func missingFileYieldsEmptyHeader() {
let h = Ti2Header.parse(URL(fileURLWithPath: "/nonexistent/x.ti2"))
#expect(h.instrument == nil && h.patchCount == nil && !h.hasSiblingTi1)
}
@Test func numberOfFieldsIsNotPatchCount() throws {
let url = tempURL("t.ti2")
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
)
try "NUMBER_OF_FIELDS 9\nNUMBER_OF_SETS 52\nBEGIN_DATA\n".write(
to: url, atomically: true, encoding: .utf8
)
#expect(Ti2Header.parse(url).patchCount == 52)
}
}
@Suite("TiffPreview")
struct TiffPreviewTests {
/// Builds a real 2000×1000 TIFF in a temp dir via ImageIO.
private func makeTiff(width: Int = 2000, height: Int = 1000) throws -> URL {
let url = tempURL("big.tif")
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
)
let colorSpace = CGColorSpace(name: CGColorSpace.sRGB)!
let ctx = CGContext(
data: nil, width: width, height: height,
bitsPerComponent: 8, bytesPerRow: width * 4,
space: colorSpace,
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
)!
ctx.setFillColor(CGColor(red: 0.5, green: 0.5, blue: 0.5, alpha: 1))
ctx.fill(CGRect(x: 0, y: 0, width: width, height: height))
let image = ctx.makeImage()!
guard let dest = CGImageDestinationCreateWithURL(
url as CFURL, UTType.tiff.identifier as CFString, 1, nil
) else { throw CocoaError(.fileWriteUnknown) }
CGImageDestinationAddImage(dest, image, nil)
guard CGImageDestinationFinalize(dest) else { throw CocoaError(.fileWriteUnknown) }
return url
}
@Test func producesCappedPNG() throws {
let tiff = try makeTiff()
let png = TiffPreview.previewPNG(tiff: tiff)
#expect(png != nil)
// PNG magic
#expect(png!.prefix(8) == Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]))
// Verify the cap by decoding the thumbnail header.
let src = CGImageSourceCreateWithData(png! as CFData, nil)!
let img = CGImageSourceCreateImageAtIndex(src, 0, nil)!
#expect(max(img.width, img.height) <= TiffPreview.maxEdge)
#expect(img.width == 1200)
}
@Test func nonTiffReturnsNil() throws {
let url = tempURL("not-tiff.txt")
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
)
try "hello".write(to: url, atomically: true, encoding: .utf8)
#expect(TiffPreview.previewPNG(tiff: url) == nil)
}
}
@Suite("ArtefactFiles")
struct ArtefactFilesTests {
@Test func base64RoundTrip() throws {
let url = tempURL("a.txt")
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
)
try "hello".write(to: url, atomically: true, encoding: .utf8)
let b64 = try ArtefactFiles.readBase64(url)
#expect(Data(base64Encoded: b64) == Data("hello".utf8))
}
@Test func defaultWorkingDirExists() {
#expect(FileManager.default.fileExists(
atPath: ArtefactFiles.defaultWorkingDirectory().path
))
}
}
@@ -0,0 +1,83 @@
import Testing
import Foundation
@testable import ICCeryCore
@Suite("BinaryResolver")
struct BinaryResolverTests {
private func makeTree(_ body: (URL) throws -> Void) throws -> URL {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("iccery-resolver-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
try body(root)
return root
}
private func touch(_ url: URL, executable: Bool = true) throws {
FileManager.default.createFile(atPath: url.path, contents: Data())
if executable {
try FileManager.default.setAttributes(
[.posixPermissions: 0o755], ofItemAtPath: url.path
)
}
}
@Test func overrideDirWinsWhenFileExists() throws {
let override = try makeTree { root in
try touch(root.appendingPathComponent("targen"))
}
let bundled = try makeTree { _ in }
let r = BinaryResolver(bundledRoot: bundled, overrideDir: override)
#expect(r.resolve("targen") == override.appendingPathComponent("targen"))
}
@Test func overrideFallsThroughWhenMissing() throws {
let override = try makeTree { _ in }
let bundled = try makeTree { root in
let dir = root.appendingPathComponent("macos-universal")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
try touch(dir.appendingPathComponent("instlist"))
}
let r = BinaryResolver(bundledRoot: bundled, overrideDir: override)
#expect(r.resolve("targen").path.contains("macos-universal/targen"))
}
@Test func universalPreferredWhenMarkerPresent() throws {
let bundled = try makeTree { root in
for dir in ["macos-universal", "macos-x86_64"] {
let d = root.appendingPathComponent(dir)
try FileManager.default.createDirectory(at: d, withIntermediateDirectories: true)
try touch(d.appendingPathComponent("instlist"))
}
}
let r = BinaryResolver(bundledRoot: bundled)
#expect(r.platformDir() == "macos-universal")
}
@Test func fallsBackToArchDir() throws {
let bundled = try makeTree { root in
let d = root.appendingPathComponent("macos-x86_64")
try FileManager.default.createDirectory(at: d, withIntermediateDirectories: true)
try touch(d.appendingPathComponent("instlist"))
}
let r = BinaryResolver(
bundledRoot: bundled,
archDirs: ["macos-universal", "macos-x86_64"]
)
#expect(r.platformDir() == "macos-x86_64")
}
@Test func missingEverythingReturnsConstructedPath() throws {
let bundled = try makeTree { _ in }
let r = BinaryResolver(bundledRoot: bundled)
// v1 semantic: path is returned; spawn surfaces the error.
#expect(r.resolve("targen").path.hasSuffix("macos-universal/targen"))
#expect(!r.exists(r.resolve("targen")))
}
@Test func mockAndGamutPaths() throws {
let r = BinaryResolver(bundledRoot: URL(fileURLWithPath: "/x"))
#expect(r.mock("chartread").path == "/x/mocks/chartread.mock")
#expect(r.referenceGamut("sRGB.gam").path == "/x/reference_gamuts/sRGB.gam")
}
}
@@ -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)
}
}
+16
View File
@@ -19,9 +19,25 @@ targets:
- path: Resources - path: Resources
excludes: excludes:
- ICCery.entitlements - ICCery.entitlements
- Argyll
- path: Resources/Argyll
type: folder
dependencies: dependencies:
- package: ICCeryCore - package: ICCeryCore
product: ICCeryCore product: ICCeryCore
postBuildScripts:
- name: Copy Argyll sidecars
script: |
set -e
SRC="${SRCROOT}/Vendor/Argyll"
DEST="${BUILT_PRODUCTS_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Argyll"
if [ -d "$SRC" ]; then
mkdir -p "$DEST"
rsync -a "$SRC/" "$DEST/"
else
echo "note: Vendor/Argyll absent — run scripts/fetch-argyll.sh"
fi
basedOnDependencyAnalysis: false
settings: settings:
base: base:
PRODUCT_BUNDLE_IDENTIFIER: com.gronod.iccery2 PRODUCT_BUNDLE_IDENTIFIER: com.gronod.iccery2
+140
View File
@@ -0,0 +1,140 @@
#!/bin/sh
# scripts/fetch-argyll.sh
#
# Downloads the Gronod ArgyllCMS fork release (macOS universal binaries)
# into Vendor/Argyll/. POSIX sh + curl + tar — no Node dependency.
#
# Env overrides (parity with v1 fetch-argyll.mjs):
# ARGYLL_SERVER_URL default https://git.i3omb.com
# ARGYLL_REPO default gronod/argyllcms
# ARGYLL_RELEASE_TAG default: latest release
# GITEA_TOKEN optional, for private repos
#
# Layout produced (docs/04 §0.6, docs/02 §Sidecar layout):
# Vendor/Argyll/macos-universal/<tools> # marker binary: instlist
# Mocks and reference_gamuts are tracked under Resources/Argyll/ —
# they ship in git, not in the release tarball.
set -eu
SERVER="${ARGYLL_SERVER_URL:-https://git.i3omb.com}"
REPO="${ARGYLL_REPO:-gronod/argyllcms}"
TAG="${ARGYLL_RELEASE_TAG:-}"
SUFFIX="_macOS_universal_bin.tgz"
PLATFORM_DIR="macos-universal"
MARKER="instlist"
ROOT="$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)"
DEST="$ROOT/Vendor/Argyll/$PLATFORM_DIR"
FORCE=0
for arg in "$@"; do
case "$arg" in
--force) FORCE=1 ;;
*) echo "usage: $0 [--force]" >&2; exit 2 ;;
esac
done
if [ "$FORCE" -eq 0 ] && [ -x "$DEST/$MARKER" ]; then
echo "ArgyllCMS binaries already present at $DEST (use --force to re-download)"
exit 0
fi
AUTH_HEADER=""
if [ -n "${GITEA_TOKEN:-}" ]; then
AUTH_HEADER="Authorization: token $GITEA_TOKEN"
fi
api_get() {
if [ -n "$AUTH_HEADER" ]; then
curl -fsSL -H 'Accept: application/json' -H "$AUTH_HEADER" "$1"
else
curl -fsSL -H 'Accept: application/json' "$1"
fi
}
if [ -n "$TAG" ]; then
API_URL="$SERVER/api/v1/repos/$REPO/releases/tags/$TAG"
else
API_URL="$SERVER/api/v1/repos/$REPO/releases/latest"
fi
echo "Fetching release info from $API_URL"
RELEASE_JSON="$(api_get "$API_URL")" || {
echo "error: failed to fetch release info (set GITEA_TOKEN if the repo is private)" >&2
exit 1
}
# Find the macOS universal asset's browser_download_url without jq.
ASSET_URL="$(printf '%s' "$RELEASE_JSON" \
| tr ',' '\n' \
| grep '"browser_download_url"' \
| grep "$SUFFIX" \
| sed -E 's/.*"browser_download_url"[^"]*"([^"]+)".*/\1/' \
| head -n 1)"
if [ -z "$ASSET_URL" ]; then
echo "error: no release asset matching '*$SUFFIX' on $API_URL" >&2
echo "looked-for pattern: Argyll_<tag>_<sha>$SUFFIX" >&2
exit 1
fi
echo "Downloading $ASSET_URL"
TMPDIR_FETCH="$(mktemp -d)"
trap 'rm -rf "$TMPDIR_FETCH"' EXIT
ARCHIVE="$TMPDIR_FETCH/argyll.tgz"
if [ -n "$AUTH_HEADER" ]; then
curl -fSL -o "$ARCHIVE" -H "$AUTH_HEADER" "$ASSET_URL"
else
curl -fSL -o "$ARCHIVE" "$ASSET_URL"
fi
EXTRACT="$TMPDIR_FETCH/extract"
mkdir -p "$EXTRACT"
tar -xzf "$ARCHIVE" -C "$EXTRACT"
# Archive contains Argyll_V*/bin/ (or a bare bin/).
BIN_DIR=""
for d in "$EXTRACT"/Argyll_V*/bin "$EXTRACT"/bin; do
if [ -d "$d" ]; then BIN_DIR="$d"; break; fi
done
if [ -z "$BIN_DIR" ]; then
echo "error: archive has no Argyll_V*/bin or bin/ directory" >&2
exit 1
fi
mkdir -p "$DEST"
cp -R "$BIN_DIR"/. "$DEST"/
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
fi
echo "OK: $(ls "$DEST" | wc -l | tr -d ' ') tools installed to $DEST"