File dialogs — dedicated pickers + host helpers (#6, part 2) #37
@@ -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,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
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,20 @@
|
||||
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).
|
||||
/// 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: - Directory
|
||||
// MARK: - selectDirectory
|
||||
|
||||
/// `#btnBrowse` — working directory for Argyll artefacts.
|
||||
/// Defaults to Documents (docs/06 §Empty cwd).
|
||||
func chooseDirectory(startingAt start: URL? = nil) -> URL? {
|
||||
func selectDirectory(startingAt start: URL? = nil) -> URL? {
|
||||
let panel = NSOpenPanel()
|
||||
panel.canChooseDirectories = true
|
||||
panel.canChooseFiles = false
|
||||
@@ -25,69 +25,83 @@ final class FileDialogService {
|
||||
return run(panel)
|
||||
}
|
||||
|
||||
// MARK: - Open files
|
||||
// MARK: - Dedicated open pickers
|
||||
|
||||
func chooseTI1(startingAt start: URL? = nil) -> URL? {
|
||||
chooseFile(extensions: ["ti1"], startingAt: start)
|
||||
/// `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)
|
||||
}
|
||||
|
||||
func chooseTI2(startingAt start: URL? = nil) -> URL? {
|
||||
chooseFile(extensions: ["ti2"], startingAt: start)
|
||||
/// `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)")
|
||||
}
|
||||
|
||||
/// Stage 1 "Open Existing" — `.ti1` or `.ti2` (docs/06 §Resume).
|
||||
func chooseExistingTarget(startingAt start: URL? = nil) -> URL? {
|
||||
chooseFile(extensions: ["ti1", "ti2"], startingAt: start)
|
||||
/// `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")
|
||||
}
|
||||
|
||||
func chooseTI3(startingAt start: URL? = nil) -> URL? {
|
||||
chooseFile(extensions: ["ti3"], startingAt: start)
|
||||
/// `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)")
|
||||
}
|
||||
|
||||
/// ICC/ICM picker (profiles, preconditioning, calibration `.cal`).
|
||||
func chooseProfile(startingAt start: URL? = nil) -> URL? {
|
||||
chooseFile(extensions: ["icc", "icm"], startingAt: start)
|
||||
/// `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")
|
||||
}
|
||||
|
||||
func chooseCalibration(startingAt start: URL? = nil) -> URL? {
|
||||
chooseFile(extensions: ["cal"], startingAt: start)
|
||||
/// `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)
|
||||
}
|
||||
|
||||
func chooseFile(
|
||||
/// `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? = nil,
|
||||
message: String? = nil
|
||||
startingAt start: URL?,
|
||||
message: String?
|
||||
) -> URL? {
|
||||
let panel = NSOpenPanel()
|
||||
panel.canChooseDirectories = false
|
||||
panel.canChooseFiles = true
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.allowedContentTypes = extensions.compactMap { UTType(filenameExtension: $0) }
|
||||
panel.allowedContentTypes = utTypes(extensions)
|
||||
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)
|
||||
private func utTypes(_ extensions: [String]) -> [UTType] {
|
||||
extensions.compactMap { UTType(filenameExtension: $0) }
|
||||
}
|
||||
|
||||
// MARK: - Internals
|
||||
|
||||
private func run(_ panel: NSOpenPanel) -> URL? {
|
||||
panel.runModal() == .OK ? panel.url : nil
|
||||
}
|
||||
|
||||
@@ -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
|
||||
))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user