Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d056c26bf | ||
|
|
c4f037535a | ||
|
|
191bcb0280 | ||
|
|
9c625ee21f | ||
|
|
40cc4ad701 | ||
|
|
658d9813bd | ||
|
|
368ae56cf3 | ||
|
|
bd539df009 |
@@ -17,16 +17,9 @@ jobs:
|
||||
runs-on: macos-12
|
||||
env:
|
||||
DERIVED: build/DerivedData-test
|
||||
# Tag name on tag pushes, branch name otherwise — scripts/version.sh
|
||||
# only honours values matching 'v[0-9]*'.
|
||||
RELEASE_TAG: ${{ github.ref_name }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# Full history + tags: scripts/version.sh needs `git describe` and
|
||||
# `git rev-list --count`; the default depth-1 clone has neither.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Assert Xcode 14+ toolchain
|
||||
run: |
|
||||
@@ -58,10 +51,6 @@ jobs:
|
||||
- name: Build for testing (host arch)
|
||||
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
|
||||
run: |
|
||||
# Stamp the test build with the same version triple packaging uses
|
||||
# so CI-built apps are traceable (and mis-tags fail early) (#189).
|
||||
eval "$(scripts/version.sh)"
|
||||
echo "version=$MARKETING_VERSION build=$CURRENT_PROJECT_VERSION tag=$ICCERY_RELEASE_TAG"
|
||||
xcodebuild build-for-testing \
|
||||
-scheme ICCery \
|
||||
-destination 'platform=macOS' \
|
||||
@@ -70,10 +59,7 @@ jobs:
|
||||
ARCHS="$(uname -m)" \
|
||||
ONLY_ACTIVE_ARCH=NO \
|
||||
CODE_SIGNING_ALLOWED=YES \
|
||||
CODE_SIGN_IDENTITY='-' \
|
||||
MARKETING_VERSION="$MARKETING_VERSION" \
|
||||
CURRENT_PROJECT_VERSION="$CURRENT_PROJECT_VERSION" \
|
||||
ICCERY_RELEASE_TAG="$ICCERY_RELEASE_TAG"
|
||||
CODE_SIGN_IDENTITY='-'
|
||||
|
||||
# Xcode embeds the shared ICCeryCore package framework into the app
|
||||
# and the test bundle without signing it. Ad-hoc hosts still require
|
||||
@@ -205,10 +191,6 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# Full history + tags: scripts/version.sh needs `git describe` and
|
||||
# `git rev-list --count`; the default depth-1 clone has neither.
|
||||
fetch-depth: 0
|
||||
|
||||
# scripts/package-release.sh runs `xcodegen generate` and dmgbuild;
|
||||
# see build-and-test for why brew is not used on macOS 12 (#109).
|
||||
@@ -220,7 +202,6 @@ jobs:
|
||||
- name: Package release
|
||||
run: scripts/package-release.sh
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.ref_name }}
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
CODESIGN_IDENTITY: ${{ secrets.CODESIGN_IDENTITY }}
|
||||
DEVELOPMENT_TEAM: ${{ secrets.DEVELOPMENT_TEAM }}
|
||||
|
||||
@@ -1,250 +0,0 @@
|
||||
# GitHub Actions twin of .gitea/workflows/macos.yml.
|
||||
# Deltas from the Gitea file (everything else is the same jobs/steps):
|
||||
# - runs-on macos-14: github.com retired macos-12. Do not use
|
||||
# macos-latest — in 2026 that is macos-26-arm64, where a 1280×800
|
||||
# window hangs off the virtual display and XCTest marks sidebar
|
||||
# controls (x ≈ -116) as not hittable (run 34864198118).
|
||||
# - actions/upload-artifact@v4: v3 is shut down on github.com. Gitea act_runner
|
||||
# still uses v3.
|
||||
# - No NODE_EXTRA_CA_CERTS / System keychain bundle: that is only for the
|
||||
# private Gitea CA when the runner talks to git.i3omb.com.
|
||||
# - Tag DMGs go to a GitHub Release via `gh` instead of
|
||||
# scripts/attach-release-asset.sh (Gitea /api/v1).
|
||||
name: macOS CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
tags:
|
||||
- 'v*'
|
||||
pull_request:
|
||||
branches:
|
||||
- develop
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-and-test:
|
||||
runs-on: macos-14
|
||||
env:
|
||||
DERIVED: build/DerivedData-test
|
||||
# Tag name on tag pushes, branch name otherwise — scripts/version.sh
|
||||
# only honours values matching 'v[0-9]*'.
|
||||
RELEASE_TAG: ${{ github.ref_name }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# Full history + tags: scripts/version.sh needs `git describe` and
|
||||
# `git rev-list --count`; the default depth-1 clone has neither.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Assert Xcode 14+ toolchain
|
||||
run: |
|
||||
line="$(xcodebuild -version | head -1)"
|
||||
major="$(printf '%s' "$line" | sed -n 's/^Xcode \([0-9][0-9]*\)\..*/\1/p')"
|
||||
if [ -z "$major" ] || [ "$major" -lt 14 ]; then
|
||||
echo "Unexpected Xcode version: $line" >&2; exit 1
|
||||
fi
|
||||
echo "$line"
|
||||
|
||||
# Tag pushes whose name contains "prerelease" skip the test build and both
|
||||
# test legs: they exist to package a build already validated elsewhere.
|
||||
# The job still succeeds quickly so `package`'s `needs:` stays satisfied.
|
||||
# Homebrew's xcodegen formula requires Xcode 15.3, which cannot be
|
||||
# installed on macOS 12 (#109). The script installs a pinned
|
||||
# prebuilt release instead. dmgbuild is not installed here — the
|
||||
# test job does not package (#95).
|
||||
- name: Ensure host tools
|
||||
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
|
||||
run: scripts/ensure-host-tools.sh
|
||||
|
||||
- name: Generate Xcode project
|
||||
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
|
||||
run: xcodegen generate --spec project.yml
|
||||
|
||||
# Tests only ever run on the runner's own architecture; build
|
||||
# just that slice. Packaging (scripts/package-release.sh) still
|
||||
# produces the universal Release binary.
|
||||
- name: Build for testing (host arch)
|
||||
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
|
||||
run: |
|
||||
# Stamp the test build with the same version triple packaging uses
|
||||
# so CI-built apps are traceable (and mis-tags fail early) (#189).
|
||||
eval "$(scripts/version.sh)"
|
||||
echo "version=$MARKETING_VERSION build=$CURRENT_PROJECT_VERSION tag=$ICCERY_RELEASE_TAG"
|
||||
xcodebuild build-for-testing \
|
||||
-scheme ICCery \
|
||||
-destination 'platform=macOS' \
|
||||
-derivedDataPath "$DERIVED" \
|
||||
-configuration Debug \
|
||||
ARCHS="$(uname -m)" \
|
||||
ONLY_ACTIVE_ARCH=NO \
|
||||
CODE_SIGNING_ALLOWED=YES \
|
||||
CODE_SIGN_IDENTITY='-' \
|
||||
MARKETING_VERSION="$MARKETING_VERSION" \
|
||||
CURRENT_PROJECT_VERSION="$CURRENT_PROJECT_VERSION" \
|
||||
ICCERY_RELEASE_TAG="$ICCERY_RELEASE_TAG"
|
||||
|
||||
# Xcode embeds the shared ICCeryCore package framework into the app
|
||||
# and the test bundle without signing it. Ad-hoc hosts still require
|
||||
# every loaded dylib to carry a cdhash — dyld killed the test host at
|
||||
# launch (run 31992) — so sign every embedded copy once the build is
|
||||
# done (embed steps run after any build script phase) (#119).
|
||||
- name: Sign package product frameworks
|
||||
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
|
||||
run: |
|
||||
find "$DERIVED/Build/Products/Debug" -depth -name '*_PackageProduct.framework' -print0 \
|
||||
| while IFS= read -r -d '' fw; do
|
||||
echo "signing $fw"
|
||||
codesign --force --sign - --timestamp=none "$fw"
|
||||
done
|
||||
|
||||
- name: Test unit (ICCeryCoreTests)
|
||||
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
|
||||
run: |
|
||||
XCTESTRUN="$(find "$DERIVED" -name 'ICCery*.xctestrun' | head -n 1)"
|
||||
if [ -z "$XCTESTRUN" ] || [ ! -f "$XCTESTRUN" ]; then
|
||||
echo "error: no xctestrun produced by build-for-testing" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "xctestrun: $XCTESTRUN"
|
||||
xcodebuild test-without-building \
|
||||
-xctestrun "$XCTESTRUN" \
|
||||
-only-testing:ICCeryCoreTests \
|
||||
-destination 'platform=macOS' \
|
||||
-derivedDataPath "$DERIVED"
|
||||
|
||||
# UI tests need macOS Automation / Accessibility permission on the
|
||||
# runner. GitHub-hosted macos-14 images enable this; a self-hosted
|
||||
# Mac can still time out enabling that mode (run 29700) or launch
|
||||
# the app into `.runningBackground` (run 29804). Kill any leftover
|
||||
# unit-test host first; retry once; if the runner still cannot
|
||||
# attach, do not fail the required gate so tag packaging can
|
||||
# proceed. Real XCTest assertion failures still fail the job.
|
||||
- name: Test UI (ICCeryUITests)
|
||||
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
|
||||
run: |
|
||||
set -o pipefail
|
||||
XCTESTRUN="$(find "$DERIVED" -name 'ICCery*.xctestrun' | head -n 1)"
|
||||
LOG="$DERIVED/ui-test.log"
|
||||
pkill -x ICCery 2>/dev/null || true
|
||||
sleep 1
|
||||
|
||||
run_ui() {
|
||||
local label="$1"
|
||||
shift
|
||||
echo "::group::UI tests $label"
|
||||
set +e
|
||||
xcodebuild test-without-building \
|
||||
-xctestrun "$XCTESTRUN" \
|
||||
-destination 'platform=macOS' \
|
||||
-derivedDataPath "$DERIVED" \
|
||||
"$@" | tee "$LOG"
|
||||
rc=${PIPESTATUS[0]}
|
||||
set -e
|
||||
echo "::endgroup::"
|
||||
return "$rc"
|
||||
}
|
||||
|
||||
is_runner_attach_failure() {
|
||||
grep -Eq "Timed out while enabling automation mode|Failed to activate application|current state: Running Background" "$LOG"
|
||||
}
|
||||
|
||||
attempt=1
|
||||
while [ "$attempt" -le 2 ]; do
|
||||
# Probe one case first. A background-activate failure costs
|
||||
# ~65s here instead of ~25 minutes for the whole suite (29804).
|
||||
if ! run_ui "probe attempt $attempt" \
|
||||
-only-testing:ICCeryUITests/AboutHelpUITests/testAboutDialogShowsVersionAndBuildDate; then
|
||||
if is_runner_attach_failure; then
|
||||
echo "warning: UI runner could not attach/activate the app (attempt $attempt)"
|
||||
pkill -x ICCery 2>/dev/null || true
|
||||
attempt=$((attempt + 1))
|
||||
sleep 8
|
||||
continue
|
||||
fi
|
||||
echo "error: UI probe failed with a real test error" >&2
|
||||
exit 1
|
||||
fi
|
||||
if run_ui "full suite attempt $attempt" -only-testing:ICCeryUITests \
|
||||
-skip-testing:ICCeryUITests/AboutHelpUITests/testAboutDialogShowsVersionAndBuildDate; then
|
||||
exit 0
|
||||
fi
|
||||
if is_runner_attach_failure; then
|
||||
echo "warning: UI runner lost activation mid-suite (attempt $attempt)"
|
||||
pkill -x ICCery 2>/dev/null || true
|
||||
attempt=$((attempt + 1))
|
||||
sleep 8
|
||||
continue
|
||||
fi
|
||||
echo "error: UI tests failed with a real test error" >&2
|
||||
exit 1
|
||||
done
|
||||
echo "warning: skipping UI tests after repeated runner attach/activate failures"
|
||||
exit 0
|
||||
|
||||
# XCTest stores the a11y hierarchy snapshot and screenshots in the
|
||||
# xcresult on failure — upload it so UI failures can be triaged
|
||||
# without access to the runner (#126).
|
||||
- name: Upload UI test xcresult
|
||||
if: "failure() && !(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ui-test-xcresult
|
||||
path: build/DerivedData-test/Logs/Test
|
||||
|
||||
package:
|
||||
needs: build-and-test
|
||||
runs-on: macos-14
|
||||
if: github.ref == 'refs/heads/develop' || startsWith(github.ref, 'refs/tags/v')
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# Full history + tags: scripts/version.sh needs `git describe` and
|
||||
# `git rev-list --count`; the default depth-1 clone has neither.
|
||||
fetch-depth: 0
|
||||
|
||||
# scripts/package-release.sh runs `xcodegen generate` and dmgbuild;
|
||||
# see build-and-test for why brew is not used on macOS 12 (#109).
|
||||
# INSTALL_DMGBUILD isolates dmgbuild in build/.venv-dmgbuild so
|
||||
# the test job never pip-installs it (#95).
|
||||
- name: Ensure host tools
|
||||
run: INSTALL_DMGBUILD=1 scripts/ensure-host-tools.sh
|
||||
|
||||
- name: Package release
|
||||
run: scripts/package-release.sh
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.ref_name }}
|
||||
CODESIGN_IDENTITY: ${{ secrets.CODESIGN_IDENTITY }}
|
||||
DEVELOPMENT_TEAM: ${{ secrets.DEVELOPMENT_TEAM }}
|
||||
NOTARIZE_APPLE_ID: ${{ secrets.NOTARIZE_APPLE_ID }}
|
||||
NOTARIZE_PASSWORD: ${{ secrets.NOTARIZE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
|
||||
- name: Upload DMG artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: iccery-dmg
|
||||
path: ICCery-*.dmg
|
||||
|
||||
- name: Attach DMG to GitHub release
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -eu
|
||||
TAG="$GITHUB_REF_NAME"
|
||||
case "$TAG" in
|
||||
*prerelease*) PRE_FLAG=--prerelease ;;
|
||||
*) PRE_FLAG= ;;
|
||||
esac
|
||||
if ! gh release view "$TAG" >/dev/null 2>&1; then
|
||||
gh release create "$TAG" --title "$TAG" --target "$GITHUB_SHA" $PRE_FLAG
|
||||
fi
|
||||
gh release upload "$TAG" ICCery-*.dmg --clobber
|
||||
@@ -18,9 +18,6 @@ Vendor/Argyll/
|
||||
# XcodeGen output (regenerate with `make gen`)
|
||||
ICCery.xcodeproj/
|
||||
|
||||
# Local-only developer helpers (never committed)
|
||||
/set-version.sh
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
|
||||
@@ -39,38 +39,10 @@ Empty cwd illegal (#59). Atomic writes = `.tmp` + rename (#213).
|
||||
User-supplied strings via SwiftUI `Text` only (#114).
|
||||
TIFF never rendered directly — host-side PNG preview (#58).
|
||||
|
||||
## Versioning
|
||||
`scripts/version.sh` is the single source: tag/describe → `ICCERY_RELEASE_TAG`
|
||||
(About shows `tag (marketing)`), `MARKETING_VERSION` = strict `X.Y.Z`,
|
||||
`CURRENT_PROJECT_VERSION` = `git rev-list --count HEAD` (#189).
|
||||
`v*` tag builds hard-fail if tag's X.Y.Z ≠ `project.yml` MARKETING_VERSION —
|
||||
bump `project.yml` on `develop` before tagging. CI needs `fetch-depth: 0`.
|
||||
|
||||
## Branching
|
||||
`develop` ← `milestone/mN-<name>` ← `feat/<issue#>-<slug>`.
|
||||
PRs via Gitea MCP. Every issue/PR: `Project/ICCery-v2` + `Feature/*` or `Bug/*` + `Priority/*`.
|
||||
|
||||
## Issue ticket style
|
||||
- Title: `[Kind/Priority] short description` — e.g. `[Bug/Critical] …`, `[Feature/Medium] …`.
|
||||
- Labels: `Kind/Bug` or `Kind/Feature` (also `Kind/Testing` for test work),
|
||||
one `Bug/<area>` or `Feature/<area>` (Architecture/Backend/UI/DevOps),
|
||||
one `Priority/*`, plus `Project/ICCery-v2`. Set the milestone when the work
|
||||
belongs to an active `mN` milestone.
|
||||
- Bug bodies: `## Summary` → `## Root Cause Analysis` (file:line evidence;
|
||||
note checked-and-dismissed hypotheses) → `## Proposed Fix` (options or
|
||||
deterministic plan) → `## Acceptance Criteria` (checkbox list) →
|
||||
`## Dependencies` → `## References`.
|
||||
- Feature bodies: same skeleton minus Root Cause; lead with Summary and a
|
||||
concrete implementation plan.
|
||||
- Dependencies/blockers must **always** be recorded via the gitea MCP
|
||||
`issue_write` methods (`add_dependency`, `block_issue`; reads via
|
||||
`issue_read` `list_dependencies` / `list_blocks` — see "Gitea issue
|
||||
dependencies"), not just mentioned in the body. This is
|
||||
mandatory when issues share a milestone with an implementation order:
|
||||
wire up `add_dependency` (blocked-by) and `block_issue` (blocks) links so
|
||||
the order is machine-readable. The `## Dependencies` body section may
|
||||
still summarise them for readability, but the MCP links are authoritative.
|
||||
|
||||
## Verify
|
||||
```
|
||||
xcodebuild test -scheme ICCery -destination 'platform=macOS' ARCHS="$(uname -m)"
|
||||
@@ -82,14 +54,3 @@ Universal (`ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO`) is still required for rel
|
||||
2-arg `(PMPrintSession, CFStringRef) -> OSStatus`. Never pass integer `1`.
|
||||
Modes: `AP_ApplicationColorMatching` then `ApplicationColorMatching`.
|
||||
`lp` path and Quartz/`ICCeryPrintKit` path use **different** ColorSync dictionaries. Never mix.
|
||||
|
||||
## Gitea issue dependencies
|
||||
Use the `gitea` MCP (custom build with blocking support — verified working):
|
||||
|
||||
- `issue_write` methods:
|
||||
- `add_dependency` — `blocking_issue` blocks `issue_number`.
|
||||
- `remove_dependency` — removes `blocking_issue` from `issue_number`'s blockers.
|
||||
- `block_issue` / `unblock_issue` — `issue_number` blocks/unblocks `blocked_issue`.
|
||||
- `issue_read` methods: `list_dependencies` (issues blocking N),
|
||||
`list_blocks` (issues N blocks).
|
||||
- All issue numbers are *display numbers*, not db ids.
|
||||
|
||||
+3
-4
@@ -1,6 +1,6 @@
|
||||
# BUILD-PLAN.md — ICCery v2 Mac
|
||||
|
||||
Spec snapshot: `docs/`. Source of tickets: Gitea milestones M1–M6 + M10 (id 32) + M11 (id 34) + Later.
|
||||
Spec snapshot: `docs/`. Source of tickets: Gitea milestones M1–M6 + M10 (id 32) + Later.
|
||||
|
||||
## Sprint rule
|
||||
Do not start milestone N+1 implementation until milestone N **CI/mock gate** is green.
|
||||
@@ -20,12 +20,11 @@ Hardware gates block *release of that sprint*, not filing, and not starting codi
|
||||
| M8 | Deduplicate & consolidate | 79–86 | (already shipped on `develop`) | N/A |
|
||||
| M9 | macOS 12 / Xcode 14.2 retarget | (milestone/m9-monterey, PR #145) | XCTest + ObservableObject + macos-12 CI | N/A |
|
||||
| M10 | Studio workflow | 146–149 | Media library + spot-read + gamut compare + project file unit/UI smoke | Real printer+paper+.cal; live spot-read; two `.gam`; reopen `.icceryproj` |
|
||||
| M11 | Printer settings completeness & dialog binding | 183, 180, 181, 186 | Quality/media/paper caps parse + `LpArgs` emit + picker UI smoke + panel capture-return | Real Epson: 7 `EPIJ_Qual` options; real Canon Pro9500: English media labels; dialog bound to non-default queue with paper/quality/media/orientation preselected; captured settings reach `lp` argv |
|
||||
| Later | Quartz / TargetPrint | 16 | `ICCeryPrintKit` standalone + seam test | 1:1 on paper vs TIFF |
|
||||
|
||||
M8–M11 are merged to `develop`; M11 shipped via PR #199 (hardware gate passed on real Epson XP-55 / Canon Pro9500 II).
|
||||
M8 and M9 merged to `develop` via PR #104 / #145; M10 starts from `800c980`.
|
||||
|
||||
Issue **16 is not an M3, M6, M10, or M11 exit gate.**
|
||||
Issue **16 is not an M3, M6, or M10 exit gate.**
|
||||
|
||||
## Branch taxonomy
|
||||
|
||||
|
||||
@@ -20,14 +20,8 @@ public enum ArtefactFiles {
|
||||
bundle: Bundle = .main
|
||||
) -> (version: String, build: String, buildDate: String) {
|
||||
let info = bundle.infoDictionary ?? [:]
|
||||
let short = info["CFBundleShortVersionString"] as? String ?? "0.0.0"
|
||||
let version = info["CFBundleShortVersionString"] as? String ?? "0.0.0"
|
||||
let build = info["CFBundleVersion"] as? String ?? "0"
|
||||
var tag = ""
|
||||
if let url = bundle.url(forResource: "ICCeryReleaseTag", withExtension: nil),
|
||||
let raw = try? String(contentsOf: url, encoding: .utf8) {
|
||||
tag = raw
|
||||
}
|
||||
let version = displayVersion(shortVersion: short, releaseTag: tag)
|
||||
|
||||
let url = bundle.executableURL ?? bundle.bundleURL
|
||||
let buildDate: String
|
||||
@@ -43,15 +37,4 @@ public enum ArtefactFiles {
|
||||
|
||||
return (version, build, buildDate)
|
||||
}
|
||||
|
||||
/// About "Version:" string from the release tag + marketing version
|
||||
/// (#189): `v2.0.0-pre2 (2.0.0)`; dedupes to `v2.0.0` on an exact
|
||||
/// release tag; falls back to the short version when untagged.
|
||||
static func displayVersion(shortVersion: String, releaseTag: String) -> String {
|
||||
let tag = releaseTag.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if tag.isEmpty || tag == shortVersion || tag == "v\(shortVersion)" {
|
||||
return tag.isEmpty ? shortVersion : tag
|
||||
}
|
||||
return "\(tag) (\(shortVersion))"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,23 +46,4 @@ public enum ColorMatchingAttempts {
|
||||
"AP_ColorMatchingMode",
|
||||
"AP.ColorMatchingMode",
|
||||
]
|
||||
|
||||
/// The Quartz/`NSPrintOperation` colour-matching vocabulary
|
||||
/// (docs/14 §7). With the `lp` path removed there is a single
|
||||
/// spool path and it carries **both** dictionaries — these keys
|
||||
/// are written alongside the AP_* pair (#201 D2).
|
||||
///
|
||||
/// `PMColorMatchingMode=APCustomColorMatching` plus an empty
|
||||
/// `PMCustomColorMatchingProfile` tell Quartz the application
|
||||
/// supplies device colour; the `com.apple.print.PrintSettings.*`
|
||||
/// legacy spelling covers drivers that read the flattened
|
||||
/// dictionary.
|
||||
public static let quartzModeKey = "PMColorMatchingMode"
|
||||
public static let quartzCustomMatching = "APCustomColorMatching"
|
||||
public static let quartzProfileKey = "PMCustomColorMatchingProfile"
|
||||
public static let quartzLegacyModeKey =
|
||||
"com.apple.print.PrintSettings.PMColorMatchingMode"
|
||||
/// The nested sub-dictionary inside `NSPrintInfo.dictionary()` the
|
||||
/// colour keys are mirrored into.
|
||||
public static let quartzNestedDictKey = "com.apple.print.printSettings"
|
||||
}
|
||||
|
||||
@@ -25,9 +25,8 @@ public enum CupsOptionsFilter {
|
||||
"EPIJ_OSColMat", "ColorCorrection", "StpColorCorrection",
|
||||
"EpsonColorMode", "ColorModel",
|
||||
// Quality
|
||||
"Resolution", "cupsPrintQuality", "Quality", "EPIJ_Qual",
|
||||
"EPIJ_Quality", "CNIJQuality", "CNIJPrintQuality",
|
||||
"PrintQuality", "StpQuality", "OutputMode",
|
||||
"Resolution", "cupsPrintQuality", "Quality", "EPIJ_Quality",
|
||||
"CNIJQuality", "StpQuality", "OutputMode",
|
||||
// Duplex
|
||||
"Duplex", "sides",
|
||||
]
|
||||
|
||||
@@ -174,13 +174,8 @@ public enum CupsParsers {
|
||||
|
||||
/// PPD `*<key> <id>/<Human Label>:` lines → `id → label` map.
|
||||
/// Language-qualified forms (`*en_US.<key> id/Label:`) also match.
|
||||
/// Precedence is deterministic, not positional (#181): unqualified
|
||||
/// `*Key` > `en_US.` > `en.` > first-qualified-seen, so a trailing
|
||||
/// locale block (Canon `th.CNIJMediaType`) can never overwrite the
|
||||
/// base English labels — and a qualified-only id still gets its
|
||||
/// first-seen qualified label (R9).
|
||||
public static func ppdChoiceLabels(_ ppd: String, key: String) -> [String: String] {
|
||||
var hits: [String: [(qualifier: String?, label: String)]] = [:]
|
||||
var map: [String: String] = [:]
|
||||
for rawLine in ppd.split(separator: "\n") {
|
||||
var line = rawLine.trimmingCharacters(in: .whitespaces)
|
||||
guard line.hasPrefix("*"), !line.hasPrefix("**") else { continue }
|
||||
@@ -188,9 +183,7 @@ public enum CupsParsers {
|
||||
// Optional locale qualifier: `en_US.InputSlot` → `InputSlot`.
|
||||
// Only strip when the part before the first `.` looks like
|
||||
// a locale (short `xx`/`xx_YY`); real keys containing dots
|
||||
// are left alone. The qualifier is recorded for precedence
|
||||
// rather than dropped (#181).
|
||||
var qualifier: String?
|
||||
// are left alone.
|
||||
if let dot = line.firstIndex(of: ".") {
|
||||
let prefix = line[..<dot]
|
||||
let looksLikeLocale = (2...5).contains(prefix.count)
|
||||
@@ -198,7 +191,6 @@ public enum CupsParsers {
|
||||
&& (prefix.count == 2 || prefix.contains("_"))
|
||||
let candidate = line[line.index(after: dot)...]
|
||||
if looksLikeLocale && candidate.hasPrefix(key) {
|
||||
qualifier = prefix.lowercased()
|
||||
line = String(candidate)
|
||||
}
|
||||
}
|
||||
@@ -209,50 +201,15 @@ public enum CupsParsers {
|
||||
rest = String(rest[..<colon])
|
||||
// `<id>/<Human label>` — human label after the last `/`.
|
||||
guard let slash = rest.firstIndex(of: "/") else { continue }
|
||||
let id = ppdUnescape(String(rest[..<slash])
|
||||
.trimmingCharacters(in: .whitespaces))
|
||||
let human = ppdUnescape(String(rest[rest.index(after: slash)...])
|
||||
.trimmingCharacters(in: .whitespaces))
|
||||
guard !id.isEmpty else { continue }
|
||||
hits[id, default: []].append(
|
||||
(qualifier, human.isEmpty ? id : human))
|
||||
}
|
||||
var map: [String: String] = [:]
|
||||
for (id, candidates) in hits {
|
||||
// First occurrence wins inside each qualifier class, in
|
||||
// file order — same as the old sequential behaviour.
|
||||
map[id] = candidates.first { $0.qualifier == nil }?.label
|
||||
?? candidates.first { $0.qualifier == "en_us" }?.label
|
||||
?? candidates.first { $0.qualifier == "en" }?.label
|
||||
?? candidates[0].label
|
||||
let id = String(rest[..<slash])
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
let human = String(rest[rest.index(after: slash)...])
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
if !id.isEmpty { map[id] = human.isEmpty ? id : human }
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
/// PPD `<XX>` hex escapes → the literal byte (`<2F>` → `/`,
|
||||
/// `<20>` → space). Anything that is not `<` + two hex digits +
|
||||
/// `>` passes through untouched (#181).
|
||||
private static func ppdUnescape(_ text: String) -> String {
|
||||
var result = ""
|
||||
var index = text.startIndex
|
||||
while index < text.endIndex {
|
||||
guard text[index] == "<",
|
||||
let hexEnd = text.index(
|
||||
index, offsetBy: 3, limitedBy: text.endIndex),
|
||||
hexEnd < text.endIndex, text[hexEnd] == ">",
|
||||
let byte = UInt8(
|
||||
text[text.index(after: index)..<hexEnd], radix: 16)
|
||||
else {
|
||||
result.append(text[index])
|
||||
index = text.index(after: index)
|
||||
continue
|
||||
}
|
||||
result.append(Character(UnicodeScalar(byte)))
|
||||
index = text.index(after: hexEnd)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// MARK: - Detection (docs/11)
|
||||
|
||||
/// Media-type option key in preference order — used both to read a
|
||||
@@ -266,61 +223,13 @@ public enum CupsParsers {
|
||||
}
|
||||
|
||||
/// Media type from a captured `key=value key=value` options string.
|
||||
/// Prefers `MediaType` (docs/11 §tests), then the remaining roster
|
||||
/// keys in detection order — `CNIJMediaType`, `EPIJ_Medi`,
|
||||
/// `StpMediaType` (#186 capture-return).
|
||||
/// Prefers `MediaType`, then `EPIJ_Medi` (docs/11 §tests).
|
||||
public static func extractMediaType(fromOptionsString options: String) -> String? {
|
||||
let pairs = lpoptions(options)
|
||||
if let v = pairs.first(where: { $0.key == "MediaType" })?.value {
|
||||
return v
|
||||
}
|
||||
for key in mediaTypeKeys where key != "MediaType" {
|
||||
if let v = pairs.first(where: { $0.key == key })?.value {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Print-quality option key in preference order — vendor-first,
|
||||
/// weakest last (#183). `OutputMode`/`Resolution` sit last: on some
|
||||
/// drivers they are colour-mode keys, not quality (#180).
|
||||
/// `CNIJPrintMode2`/`CNIJPQualitySlider` are deferred (#16).
|
||||
public static let qualityKeys = [
|
||||
"EPIJ_Qual", "CNIJPrintQuality", "CNIJQuality",
|
||||
"cupsPrintQuality", "PrintQuality", "Quality", "StpQuality",
|
||||
"EPIJ_Quality", "OutputMode", "Resolution",
|
||||
]
|
||||
|
||||
public static func detectQualityKey(optionKeys: Set<String>) -> String? {
|
||||
qualityKeys.first { optionKeys.contains($0) }
|
||||
}
|
||||
|
||||
/// A single value from a captured `key=value key=value` options
|
||||
/// string — case-insensitive key match (#183 capture-return).
|
||||
public static func extractOption(
|
||||
named key: String,
|
||||
fromOptionsString options: String
|
||||
) -> String? {
|
||||
lpoptions(options).first {
|
||||
$0.key.caseInsensitiveCompare(key) == .orderedSame
|
||||
}?.value
|
||||
}
|
||||
|
||||
/// Print-quality token from a captured options string — the queue's
|
||||
/// quality key is detected from the roster before extracting (#183).
|
||||
public static func extractQuality(fromOptionsString options: String) -> String? {
|
||||
let pairs = lpoptions(options)
|
||||
guard let key = detectQualityKey(
|
||||
optionKeys: Set(pairs.map(\.key)))
|
||||
else { return nil }
|
||||
return pairs.first(where: { $0.key == key })?.value
|
||||
}
|
||||
|
||||
/// `orientation-requested=3|4` → `"portrait"`/`"landscape"` (#183).
|
||||
public static func extractOrientation(fromOptionsString options: String) -> String? {
|
||||
extractOption(named: "orientation-requested", fromOptionsString: options)
|
||||
.map { $0 == "4" ? "landscape" : "portrait" }
|
||||
return pairs.first(where: { $0.key == "EPIJ_Medi" })?.value
|
||||
}
|
||||
|
||||
/// Driver "no colour adjustment" key=value for `lpoptions -l` keys
|
||||
|
||||
@@ -3,6 +3,7 @@ import Foundation
|
||||
/// Errors from CUPS tool invocations.
|
||||
public enum CupsError: LocalizedError, Equatable {
|
||||
case toolFailed(tool: String, code: Int32, stderr: String)
|
||||
case tiffMissing(String)
|
||||
case noPrinterSelected
|
||||
|
||||
public var errorDescription: String? {
|
||||
@@ -12,6 +13,8 @@ public enum CupsError: LocalizedError, Equatable {
|
||||
return detail.isEmpty
|
||||
? "\(tool) failed with exit code \(code)"
|
||||
: "\(tool) failed (\(code)): \(detail)"
|
||||
case .tiffMissing(let path):
|
||||
return "Target TIFF does not exist: \(path)"
|
||||
case .noPrinterSelected:
|
||||
return "No printer selected."
|
||||
}
|
||||
@@ -24,11 +27,9 @@ public enum CupsError: LocalizedError, Equatable {
|
||||
/// `ProcessManager.runCaptured` so spawns are logged, get killAll
|
||||
/// coverage, and share the dup-id discipline; `binaryDir`/`ppdDir` are
|
||||
/// injectable so tests use fixture scripts and never touch real CUPS.
|
||||
/// Target spooling is **not** here — it is the app-target
|
||||
/// `NativeTargetSpooler` (`NSPrintOperation`, #201 D1/D4).
|
||||
public struct CupsService: Sendable {
|
||||
public let processManager: ProcessManager
|
||||
/// Directory containing `lpstat`/`lpoptions` — `/usr/bin` in
|
||||
/// Directory containing `lpstat`/`lpoptions`/`lp` — `/usr/bin` in
|
||||
/// production, a fixture dir under test.
|
||||
public let binaryDir: URL
|
||||
/// `/etc/cups/ppd` in production.
|
||||
@@ -52,11 +53,11 @@ public struct CupsService: Sendable {
|
||||
// lpstat exits non-zero when no destinations exist — an empty
|
||||
// queue list is a valid result, not a failure (issue 12).
|
||||
let destinationsOut = try await run(
|
||||
"lpstat", ["-e"], id: ProcessID.cupsLpstat("e"), tolerateFailure: true)
|
||||
"lpstat", ["-e"], id: ProcessID.lpstat("e"), tolerateFailure: true)
|
||||
let statusOut = try await run(
|
||||
"lpstat", ["-p"], id: ProcessID.cupsLpstat("p"), tolerateFailure: true)
|
||||
"lpstat", ["-p"], id: ProcessID.lpstat("p"), tolerateFailure: true)
|
||||
let defaultOut = try await run(
|
||||
"lpstat", ["-d"], id: ProcessID.cupsLpstat("d"), tolerateFailure: true)
|
||||
"lpstat", ["-d"], id: ProcessID.lpstat("d"), tolerateFailure: true)
|
||||
|
||||
let names = CupsParsers.lpstatDestinations(destinationsOut.stdout)
|
||||
let statuses = CupsParsers.lpstatStatuses(statusOut.stdout)
|
||||
@@ -79,7 +80,7 @@ public struct CupsService: Sendable {
|
||||
/// display name, docs/11 §binding).
|
||||
public func displayName(for queue: String) async throws -> String? {
|
||||
let result = try await run(
|
||||
"lpoptions", ["-p", queue], id: ProcessID.cupsLpoptions(queue))
|
||||
"lpoptions", ["-p", queue], id: ProcessID.lpoptions(queue))
|
||||
return CupsParsers.lpoptionsDisplayName(result.stdout)
|
||||
}
|
||||
|
||||
@@ -89,7 +90,7 @@ public struct CupsService: Sendable {
|
||||
/// to media-key and colour-bypass detection (docs/11 layer ④).
|
||||
public func optionListings(for queue: String) async throws -> [CupsOptionListing] {
|
||||
let result = try await run(
|
||||
"lpoptions", ["-p", queue, "-l"], id: ProcessID.cupsLpoptions("\(queue)-l"))
|
||||
"lpoptions", ["-p", queue, "-l"], id: ProcessID.lpoptions("\(queue)-l"))
|
||||
return CupsParsers.lpoptionsList(result.stdout)
|
||||
}
|
||||
|
||||
@@ -131,28 +132,8 @@ public struct CupsService: Sendable {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Print quality — the detected roster key's listing maps to
|
||||
// `PrinterQuality` with PPD labels and the `*` default (#183).
|
||||
var qualities: [PrinterQuality] = []
|
||||
var qualityDefault: String?
|
||||
let qualityKey = CupsParsers.detectQualityKey(
|
||||
optionKeys: Set(listings.map(\.key)))
|
||||
if let qualityKey,
|
||||
let listing = listings.first(where: { $0.key == qualityKey }) {
|
||||
let labels = ppd.map {
|
||||
CupsParsers.ppdChoiceLabels($0, key: qualityKey)
|
||||
} ?? [:]
|
||||
qualities = listing.choices.map {
|
||||
PrinterQuality(id: $0, name: labels[$0] ?? $0)
|
||||
}
|
||||
qualityDefault = listing.defaultChoice
|
||||
}
|
||||
|
||||
return PrinterCapabilities(
|
||||
trays: trays, paperSizes: sizes, mediaTypes: media,
|
||||
qualityKey: qualityKey, qualities: qualities,
|
||||
qualityDefault: qualityDefault)
|
||||
trays: trays, paperSizes: sizes, mediaTypes: media)
|
||||
}
|
||||
|
||||
/// The set of option keys a queue advertises — input to
|
||||
@@ -161,15 +142,32 @@ public struct CupsService: Sendable {
|
||||
Set(try await optionListings(for: queue).map(\.key))
|
||||
}
|
||||
|
||||
// MARK: - Spool (issue 15)
|
||||
|
||||
/// `lp -d <queue> … <tiff>` — spool one target page unmanaged.
|
||||
/// Never uses `-o raw` (#92). `page` disambiguates the process id
|
||||
/// when several pages are spooled in sequence.
|
||||
public func printTarget(
|
||||
queue: String,
|
||||
tiffPath: String,
|
||||
options: PrintOptions,
|
||||
page: Int = 0
|
||||
) async throws {
|
||||
guard FileManager.default.fileExists(atPath: tiffPath) else {
|
||||
throw CupsError.tiffMissing(tiffPath)
|
||||
}
|
||||
let optionKeys = (try? await self.optionKeys(for: queue)) ?? []
|
||||
let argv = try LpArgs.build(
|
||||
queue: queue, tiffPath: tiffPath,
|
||||
options: options, optionKeys: optionKeys)
|
||||
try await run("lp", argv, id: ProcessID.lp(queue, page: page))
|
||||
}
|
||||
|
||||
// MARK: - PPD
|
||||
|
||||
private func loadPPD(for queue: String) -> String? {
|
||||
let url = ppdDir.appendingPathComponent("\(queue).ppd")
|
||||
// UTF-8 first — the Canon Thai labels are UTF-8 and a blanket
|
||||
// Latin-1 read would mojibake them (#181, R10). Latin-1 only
|
||||
// when UTF-8 decoding fails outright.
|
||||
return (try? String(contentsOf: url, encoding: .utf8))
|
||||
?? (try? String(contentsOf: url, encoding: .isoLatin1))
|
||||
return try? String(contentsOf: url, encoding: .utf8)
|
||||
}
|
||||
|
||||
// MARK: - Spawn
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import Foundation
|
||||
|
||||
/// Errors from `buildLpArgs`.
|
||||
public enum LpArgsError: LocalizedError, Equatable {
|
||||
case unsanitisedOption(String)
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .unsanitisedOption(let option):
|
||||
return "Captured CUPS option contains unsafe characters: \(option)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `lp` argv builder — issue 15, docs/11 `build_lp_args`.
|
||||
///
|
||||
/// ```
|
||||
/// lp -d <queue> -t "ICCery Target - <file>"
|
||||
/// -o AP_ColorMatchingMode=AP_ApplicationColorMatching
|
||||
/// -o AP.ColorMatchingMode=AP_ApplicationColorMatching
|
||||
/// <captured cups_options>
|
||||
/// <media_type, if no media key already captured>
|
||||
/// <driver bypass, if no bypass key captured>
|
||||
/// <orientation-requested=3|4, unless captured>
|
||||
/// <PageSize, unless captured>
|
||||
/// <tiff>
|
||||
/// ```
|
||||
///
|
||||
/// - **Never `-o raw`** — `raw` skips the raster filter that honours
|
||||
/// `AP_ApplicationColorMatching` (#92).
|
||||
/// - Captured options **win** over explicit fields: any key already
|
||||
/// present (case-insensitive) suppresses the derived `-o`.
|
||||
/// - Captured keys/values are sanitised — `;`, newlines, or shell
|
||||
/// metacharacters throw `unsanitisedOption`; args are passed as a
|
||||
/// `Process` argv array, never through a shell.
|
||||
/// - The TIFF path is always the **last** argument.
|
||||
public enum LpArgs {
|
||||
|
||||
/// `options` = the captured `PrintOptions`; `optionKeys` = the
|
||||
/// queue's `lpoptions -l` key set (for media-key/bypass detection).
|
||||
public static func build(
|
||||
queue: String,
|
||||
tiffPath: String,
|
||||
options: PrintOptions,
|
||||
optionKeys: Set<String>
|
||||
) throws -> [String] {
|
||||
var argv: [String] = [
|
||||
"-d", queue,
|
||||
"-t", "ICCery Target - \((tiffPath as NSString).lastPathComponent)",
|
||||
"-o", "AP_ColorMatchingMode=AP_ApplicationColorMatching",
|
||||
"-o", "AP.ColorMatchingMode=AP_ApplicationColorMatching",
|
||||
]
|
||||
var addedKeys: Set<String> = [
|
||||
"ap_colormatchingmode", "ap.colormatchingmode",
|
||||
]
|
||||
|
||||
// Captured CUPS options — sanitised, lowercased-key dedup.
|
||||
if let captured = options.cupsOptions, !captured.isEmpty {
|
||||
// Newlines can't survive the tokeniser — check the raw
|
||||
// string so embedded line breaks are still rejected.
|
||||
if captured.contains("\n") || captured.contains("\r") {
|
||||
throw LpArgsError.unsanitisedOption(captured)
|
||||
}
|
||||
for pair in CupsParsers.lpoptions(captured) {
|
||||
try sanitize(pair.key, pair.value)
|
||||
let lowered = pair.key.lowercased()
|
||||
// Defence in depth: never let a captured `raw` reach
|
||||
// argv — `-o raw` skips the raster filter that honours
|
||||
// AP_ApplicationColorMatching (#92).
|
||||
if lowered == "raw" { continue }
|
||||
guard !addedKeys.contains(lowered) else { continue }
|
||||
addedKeys.insert(lowered)
|
||||
argv += ["-o", "\(pair.key)=\(pair.value)"]
|
||||
}
|
||||
}
|
||||
|
||||
// Media type — only when the captured options didn't carry one.
|
||||
if let mediaType = options.mediaType,
|
||||
let mediaKey = CupsParsers.detectMediaTypeKey(optionKeys: optionKeys),
|
||||
!addedKeys.contains(mediaKey.lowercased()) {
|
||||
addedKeys.insert(mediaKey.lowercased())
|
||||
argv += ["-o", "\(mediaKey)=\(mediaType)"]
|
||||
}
|
||||
|
||||
// Driver colour bypass — when no bypass key was captured. NOT
|
||||
// gated on ppdUncorrectedPassthrough (macOS always bypasses).
|
||||
let capturedKeys = Set(
|
||||
CupsParsers.lpoptions(options.cupsOptions ?? "")
|
||||
.map { $0.key })
|
||||
if capturedKeys.isDisjoint(with: CupsParsers.bypassKeys),
|
||||
let bypass = CupsParsers.detectDriverColorBypass(optionKeys: optionKeys),
|
||||
!addedKeys.contains(bypass.key.lowercased()) {
|
||||
addedKeys.insert(bypass.key.lowercased())
|
||||
argv += ["-o", "\(bypass.key)=\(bypass.value)"]
|
||||
}
|
||||
|
||||
// Orientation — portrait=3, landscape=4.
|
||||
if let orientation = options.orientation,
|
||||
!addedKeys.contains("orientation-requested") {
|
||||
let value = orientation == "landscape" ? "4" : "3"
|
||||
addedKeys.insert("orientation-requested")
|
||||
argv += ["-o", "orientation-requested=\(value)"]
|
||||
}
|
||||
|
||||
// PageSize — the printtarg layout page size.
|
||||
if let paperSize = options.paperSize, !paperSize.isEmpty,
|
||||
!addedKeys.contains("pagesize") {
|
||||
argv += ["-o", "PageSize=\(paperSize)"]
|
||||
}
|
||||
|
||||
argv.append(tiffPath)
|
||||
return argv
|
||||
}
|
||||
|
||||
/// Reject shell/metachar injection — args go to `Process` as an
|
||||
/// argv array, but a hostile captured string must not smuggle a
|
||||
/// second option or command.
|
||||
static func sanitize(_ key: String, _ value: String) throws {
|
||||
let forbidden = CharacterSet(charactersIn: ";\n\r`|$&<>\\\"'")
|
||||
if key.rangeOfCharacter(from: forbidden) != nil
|
||||
|| value.rangeOfCharacter(from: forbidden) != nil
|
||||
|| key.isEmpty {
|
||||
throw LpArgsError.unsanitisedOption("\(key)=\(value)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,18 +66,6 @@ public struct PrinterMediaType: Codable, Equatable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Print quality: `id` is the option token (e.g. `"303"`), `name` the
|
||||
/// human label after PPD enrichment (mirrors `PrinterMediaType`, #183).
|
||||
public struct PrinterQuality: Codable, Equatable, Sendable {
|
||||
public var id: String
|
||||
public var name: String
|
||||
|
||||
public init(id: String, name: String) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
}
|
||||
}
|
||||
|
||||
public struct PrinterCapabilities: Codable, Equatable, Sendable {
|
||||
public var trays: [PrinterTray]
|
||||
public var paperSizes: [PrinterPaperSize]
|
||||
@@ -85,50 +73,35 @@ public struct PrinterCapabilities: Codable, Equatable, Sendable {
|
||||
/// Always `true` on macOS (spec parity — CUPS honours
|
||||
/// `orientation-requested`).
|
||||
public var supportsOrientation: Bool
|
||||
/// The queue's detected quality enumeration key
|
||||
/// (`CupsParsers.detectQualityKey`), e.g. `EPIJ_Qual` (#183).
|
||||
public var qualityKey: String?
|
||||
public var qualities: [PrinterQuality]
|
||||
/// The `*`-marked default choice from `lpoptions -l`, if any.
|
||||
public var qualityDefault: String?
|
||||
|
||||
public init(
|
||||
trays: [PrinterTray] = [],
|
||||
paperSizes: [PrinterPaperSize] = [],
|
||||
mediaTypes: [PrinterMediaType] = [],
|
||||
supportsOrientation: Bool = true,
|
||||
qualityKey: String? = nil,
|
||||
qualities: [PrinterQuality] = [],
|
||||
qualityDefault: String? = nil
|
||||
supportsOrientation: Bool = true
|
||||
) {
|
||||
self.trays = trays
|
||||
self.paperSizes = paperSizes
|
||||
self.mediaTypes = mediaTypes
|
||||
self.supportsOrientation = supportsOrientation
|
||||
self.qualityKey = qualityKey
|
||||
self.qualities = qualities
|
||||
self.qualityDefault = qualityDefault
|
||||
}
|
||||
}
|
||||
|
||||
/// The Stage 2 mirror — panel selections and the captured `k=v`
|
||||
/// string (docs/10 §PrintOptions). Since #201 removed the `lp` path
|
||||
/// these fields feed `TargetPrintOverrides` (Stage 2 always wins, D6)
|
||||
/// and the mirror apply-back; the opaque vendor state now travels
|
||||
/// inside the `PrintTicket`, not a flattened option string.
|
||||
/// Options carried into `lp` (docs/10 §PrintOptions). On macOS
|
||||
/// `paperSource` is ignored unless already present inside captured
|
||||
/// `cupsOptions`; `ppdUncorrectedPassthrough` is stored (the panel sets
|
||||
/// it on OK) but never gates the argv — macOS always bypasses driver
|
||||
/// colour management.
|
||||
public struct PrintOptions: Codable, Equatable, Sendable {
|
||||
public var paperSource: Int?
|
||||
/// `"portrait"` / `"landscape"` → `orientation-requested=3|4`.
|
||||
public var orientation: String?
|
||||
/// Stage 2 paper token → `PageSize=` ticket write + `PMPaper`.
|
||||
/// printtarg layout page size → `PageSize=` (skipped if captured).
|
||||
public var paperSize: String?
|
||||
public var mediaType: String?
|
||||
/// Print-quality token → `<detectedQualityKey>=` ticket write.
|
||||
public var quality: String?
|
||||
public var ppdUncorrectedPassthrough: Bool?
|
||||
/// Space-separated `key=value` captured from
|
||||
/// `PMPrintSettingsToOptions` and filtered (docs/11 layer ⑥) —
|
||||
/// the Stage 2 mirror only, never a spool payload.
|
||||
/// `PMPrintSettingsToOptions` and filtered (docs/11 layer ⑥).
|
||||
public var cupsOptions: String?
|
||||
|
||||
public init(
|
||||
@@ -136,7 +109,6 @@ public struct PrintOptions: Codable, Equatable, Sendable {
|
||||
orientation: String? = nil,
|
||||
paperSize: String? = nil,
|
||||
mediaType: String? = nil,
|
||||
quality: String? = nil,
|
||||
ppdUncorrectedPassthrough: Bool? = nil,
|
||||
cupsOptions: String? = nil
|
||||
) {
|
||||
@@ -144,7 +116,6 @@ public struct PrintOptions: Codable, Equatable, Sendable {
|
||||
self.orientation = orientation
|
||||
self.paperSize = paperSize
|
||||
self.mediaType = mediaType
|
||||
self.quality = quality
|
||||
self.ppdUncorrectedPassthrough = ppdUncorrectedPassthrough
|
||||
self.cupsOptions = cupsOptions
|
||||
}
|
||||
|
||||
@@ -18,8 +18,8 @@ public enum ProcessID {
|
||||
public static func applycal(_ stem: String) -> String { "applycal_\(stem)" }
|
||||
|
||||
/// CUPS system tools (`/usr/bin/…`) — captured one-shots, not
|
||||
/// streaming Argyll children. Named `cups*` so no `lp*`-prefixed
|
||||
/// identifier survives the target-print removal (#201).
|
||||
public static func cupsLpstat(_ mode: String) -> String { "lpstat_\(mode)" }
|
||||
public static func cupsLpoptions(_ queue: String) -> String { "lpoptions_\(queue)" }
|
||||
/// streaming Argyll children.
|
||||
public static func lpstat(_ mode: String) -> String { "lpstat_\(mode)" }
|
||||
public static func lpoptions(_ queue: String) -> String { "lpoptions_\(queue)" }
|
||||
public static func lp(_ queue: String, page: Int) -> String { "lp_\(queue)_\(page)" }
|
||||
}
|
||||
|
||||
@@ -25,8 +25,7 @@ as AGPLv3 child processes. The GUI never `dlopen`s or links Argyll.
|
||||
| M7 | Pre-UAT hardening — shipped |
|
||||
| M8 | Deduplication contracts & UAT-ready hardening (#79–#86) — shipped |
|
||||
| M9 | macOS 12 / Xcode 14.2 retarget (PR #145) — shipped |
|
||||
| M10 | Studio workflow: media library (#146), gamut compare (#147), Spot Read (#148), project files (#149) — shipped on `develop` |
|
||||
| M11 | Printer settings completeness & dialog binding (#183, #180, #181, #186) — shipped on `develop` (PR #199) |
|
||||
| M10 | Studio workflow: gamut compare (#147), Spot Read (#148), project files (#149) shipped on `develop`; media library (#146) is in the tree, issue still open |
|
||||
| Licence | Proprietary source in [`LICENCE.md`](LICENCE.md); bundled Argyll sidecars remain AGPLv3 |
|
||||
|
||||
## What it does
|
||||
@@ -126,35 +125,7 @@ The script builds with a fixed derived data path (`build/DerivedData`), locates
|
||||
`Release/ICCery.app` from it, signs the bundle, recursively verifies every
|
||||
bundled Mach-O sidecar (`scripts/verify-sidecar-signatures.sh`), builds a
|
||||
HiDPI TIFF from `Resources/dmg-background.png` (+ `@2x`) via `tiffutil`, and
|
||||
writes the DMG with `dmgbuild==1.6.7`.
|
||||
|
||||
### Versioning
|
||||
|
||||
`scripts/version.sh` resolves the version triple and is the single source for
|
||||
packaging and CI:
|
||||
|
||||
- **`ICCERY_RELEASE_TAG`** — `RELEASE_TAG` env when it matches `v[0-9]*` (CI
|
||||
tag builds), else `git describe --tags --always --dirty --match 'v[0-9]*'`.
|
||||
Stamped into the bundle's `ICCeryReleaseTag` resource (a generated
|
||||
Info.plist cannot carry custom keys); the About dialog shows
|
||||
`tag (marketing)` — e.g. `v2.0.0-pre2-grok (2.0.0)` — falling back to the
|
||||
plain version when untagged.
|
||||
- **`MARKETING_VERSION`** (`CFBundleShortVersionString`) — first three numeric
|
||||
components of the tag core. Must equal `project.yml`'s
|
||||
`MARKETING_VERSION` on tag builds — packaging hard-fails on mismatch, so
|
||||
bump `project.yml` on `develop` *before* tagging.
|
||||
- **`CURRENT_PROJECT_VERSION`** (`CFBundleVersion`) — `BUILD_NUMBER` env
|
||||
override, else `git rev-list --count HEAD`. A single monotonically
|
||||
increasing integer per Apple's macOS convention (Mac build numbers never
|
||||
reset per version, unlike iOS).
|
||||
|
||||
Tagged/described DMGs carry the tag: `ICCery-2.0.0-pre2-grok-<build>.dmg`;
|
||||
plain releases keep `ICCery-<ver>-<build>.dmg`.
|
||||
|
||||
Release procedure: bump `MARKETING_VERSION` in `project.yml` on `develop` →
|
||||
merge `develop` → `main` → tag the release commit `vX.Y.Z[-suffix]` → push
|
||||
the tag. CI builds, signs, notarizes (when secrets exist) and attaches the
|
||||
DMG to the release.
|
||||
writes `ICCery-${VERSION}-${BUILD_NUM}.dmg` with `dmgbuild==1.6.7`.
|
||||
|
||||
Sidecars stay ad-hoc signed inside the bundle — the app is never
|
||||
`codesign --deep`ed.
|
||||
@@ -174,8 +145,6 @@ Environment variables read by the pipeline:
|
||||
| `CODESIGN_IDENTITY` | Developer ID identity for the outer `.app`; unset or `-` = ad-hoc |
|
||||
| `DEVELOPMENT_TEAM` | team ID passed to `xcodebuild` when signing |
|
||||
| `NOTARIZE_APPLE_ID` / `NOTARIZE_PASSWORD` / `APPLE_TEAM_ID` | `notarytool` + staple when all three are set |
|
||||
| `RELEASE_TAG` | release tag string (CI sets `github.ref_name`); stamped into the bundle + DMG name |
|
||||
| `BUILD_NUMBER` | `CFBundleVersion` override; default `git rev-list --count HEAD` |
|
||||
|
||||
## Layout
|
||||
|
||||
@@ -237,13 +206,9 @@ xcodebuild test -scheme ICCery -destination 'platform=macOS' \
|
||||
-only-testing:ICCeryUITests/Milestone5UITests
|
||||
```
|
||||
|
||||
CI (`.gitea/workflows/macos.yml` on Gitea, `.github/workflows/macos.yml` on
|
||||
GitHub) runs `build-and-test` then `package` on
|
||||
CI (`.gitea/workflows/macos.yml`) runs `build-and-test` then `package` on
|
||||
`develop` and on `v*` tags. Tags whose name contains `prerelease` skip the
|
||||
test job and still package. `pull_request` is wired for **`develop` only**.
|
||||
The GitHub file is the same pipeline on `macos-14` (github.com retired
|
||||
`macos-12`), `actions/upload-artifact@v4`, and `gh release upload` for tag
|
||||
DMGs.
|
||||
|
||||
UI tests need an unlocked console (`IOConsoleLocked=false`). Mock Argyll /
|
||||
CUPS fixtures live under the test bundles; they must not be treated as proof
|
||||
@@ -300,8 +265,8 @@ fix/<issue>-<slug>
|
||||
|
||||
Open feature/fix PRs against **`develop`**. A `milestone/m…` integration
|
||||
branch is used only while that milestone is assembling; `milestone/m10-studio`
|
||||
and `milestone/m11-print-settings` have been merged (branches retained). Do
|
||||
not open umbrella “bugfix” branches that mix tickets.
|
||||
has been merged and deleted. Do not open umbrella “bugfix” branches that mix
|
||||
tickets.
|
||||
|
||||
`main` is push-protected and requires status check
|
||||
`macOS CI / build-and-test (push)`. Protected **file** patterns on `main`
|
||||
|
||||
@@ -6,8 +6,6 @@ import ICCeryCore
|
||||
struct AboutView: View {
|
||||
let onClose: () -> Void
|
||||
|
||||
@State private var showingLicenses = false
|
||||
|
||||
private let info = ArtefactFiles.appInfo()
|
||||
|
||||
var body: some View {
|
||||
@@ -56,12 +54,6 @@ struct AboutView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Button("View Licenses") {
|
||||
showingLicenses = true
|
||||
}
|
||||
.controlSize(.large)
|
||||
.accessibilityIdentifier("viewLicensesBtn")
|
||||
|
||||
Button("Close") {
|
||||
onClose()
|
||||
}
|
||||
@@ -74,8 +66,5 @@ struct AboutView: View {
|
||||
.background(Theme.panel)
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityIdentifier("aboutDialog")
|
||||
.sheet(isPresented: $showingLicenses) {
|
||||
LicenseWindowView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,14 +106,12 @@ enum UITestHooks {
|
||||
|
||||
// MARK: - Print panel / CUPS stubs (issue 13/17)
|
||||
|
||||
/// Directory of mock `lpstat`/`lpoptions` fixture scripts —
|
||||
/// Directory of mock `lp`/`lpstat`/`lpoptions` fixture scripts —
|
||||
/// `CupsService.binaryDir` under UI tests.
|
||||
static var cupsBinaryDir: URL? { url("ICCERY_CUPS_BIN_DIR") }
|
||||
|
||||
/// Path the `RecordingTargetSpooler` appends resolved ticket
|
||||
/// lines to, for assertions (#201 D8 — replaces the `lp` argv
|
||||
/// log).
|
||||
static var spoolLogURL: URL? { url("ICCERY_TEST_SPOOL_LOG") }
|
||||
/// Path the mock `lp` script appends its argv to, for assertions.
|
||||
static var lpArgvOutURL: URL? { url("ICCERY_TEST_LP_ARGV") }
|
||||
|
||||
/// Whether the `NSPrintPanel` should be stubbed under UI testing —
|
||||
/// separate from the stub's *result* so "cancel" (`nil`) does not
|
||||
@@ -138,19 +136,9 @@ enum UITestHooks {
|
||||
$0.isEmpty ? nil : $0
|
||||
} ?? queue,
|
||||
options: PrintOptions(
|
||||
orientation: options.flatMap {
|
||||
CupsParsers.extractOrientation(fromOptionsString: $0)
|
||||
},
|
||||
paperSize: options.flatMap {
|
||||
CupsParsers.extractOption(
|
||||
named: "PageSize", fromOptionsString: $0)
|
||||
},
|
||||
mediaType: options.flatMap {
|
||||
CupsParsers.extractMediaType(fromOptionsString: $0)
|
||||
},
|
||||
quality: options.flatMap {
|
||||
CupsParsers.extractQuality(fromOptionsString: $0)
|
||||
},
|
||||
ppdUncorrectedPassthrough: true,
|
||||
cupsOptions: options))
|
||||
default:
|
||||
|
||||
@@ -56,41 +56,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
}
|
||||
|
||||
/// docs/21 §Shell: 1280×800 content, min 1100×700, centred.
|
||||
/// GitHub-hosted Macs (and any display smaller than 1280×800) must
|
||||
/// not get a window that hangs off-screen — XCTest then reports
|
||||
/// sidebar controls at negative x as not hittable (run 34864198118).
|
||||
private func configureMainWindow(_ window: NSWindow) {
|
||||
let desired = NSSize(width: 1280, height: 800)
|
||||
let minimum = NSSize(width: 1100, height: 700)
|
||||
let visible = (window.screen ?? NSScreen.main)?.visibleFrame
|
||||
?? NSRect(origin: .zero, size: desired)
|
||||
|
||||
window.contentMinSize = NSSize(
|
||||
width: min(minimum.width, visible.width),
|
||||
height: min(minimum.height, visible.height)
|
||||
)
|
||||
window.setContentSize(NSSize(
|
||||
width: min(desired.width, visible.width),
|
||||
height: min(desired.height, max(minimum.height, visible.height - 40))
|
||||
))
|
||||
window.setContentSize(NSSize(width: 1280, height: 800))
|
||||
window.contentMinSize = NSSize(width: 1100, height: 700)
|
||||
window.center()
|
||||
|
||||
var frame = window.frame
|
||||
if frame.width > visible.width {
|
||||
frame.size.width = visible.width
|
||||
}
|
||||
if frame.height > visible.height {
|
||||
frame.size.height = visible.height
|
||||
}
|
||||
frame.origin.x = min(
|
||||
max(frame.origin.x, visible.minX),
|
||||
visible.maxX - frame.width
|
||||
)
|
||||
frame.origin.y = min(
|
||||
max(frame.origin.y, visible.minY),
|
||||
visible.maxY - frame.height
|
||||
)
|
||||
window.setFrame(frame, display: true)
|
||||
}
|
||||
|
||||
/// Dock-click reopen: let the WindowGroup re-show or recreate the
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
/// Detailed license and attribution window (issue #31, docs/21 §Modals).
|
||||
struct LicenseWindowView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
private let icceryLicense: String
|
||||
private let argyllLicense: String
|
||||
|
||||
init() {
|
||||
// ICCery license from LICENCE.md (embedded at compile time)
|
||||
self.icceryLicense = """
|
||||
# LICENCE
|
||||
|
||||
**Copyright (c) 2026 Gordon Bolton**
|
||||
**All Rights Reserved.**
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), strictly to view the source code and execute the Software for the sole purpose of personal testing, evaluation, and providing feedback.
|
||||
|
||||
Under this licence, you may **not**:
|
||||
|
||||
* Modify, alter, or create derivative works of the Software.
|
||||
* Distribute, publish, or sublicense the Software or any derivatives.
|
||||
* Use the Software for any commercial or production purpose.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
---
|
||||
|
||||
## Bundled ArgyllCMS sidecar binaries
|
||||
|
||||
This application bundles and invokes command-line binaries from the Gronod fork of ArgyllCMS. Those binaries are licensed separately under the **GNU Affero General Public License v3 (AGPLv3)**. They are executed strictly as independent subprocesses — they are never linked, loaded, or incorporated into this application — and a copy of `License.txt` is shipped beside the binaries in `Resources/Argyll/`. The terms above apply only to the ICCery application source code, not to the ArgyllCMS binaries.
|
||||
"""
|
||||
|
||||
// ArgyllCMS license from bundled License.txt
|
||||
if let url = Bundle.main.url(forResource: "License", withExtension: "txt", subdirectory: "Argyll"),
|
||||
let content = try? String(contentsOf: url, encoding: .utf8) {
|
||||
self.argyllLicense = content
|
||||
} else {
|
||||
self.argyllLicense = """
|
||||
ArgyllCMS license not found — run `scripts/fetch-argyll.sh` to bundle binaries and license.
|
||||
|
||||
The ArgyllCMS binaries are licensed under the GNU Affero General Public License v3 (AGPLv3).
|
||||
A copy of the license should be present at Resources/Argyll/License.txt.
|
||||
|
||||
See: https://git.i3omb.com/gronod/argyllcms/releases
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Title bar
|
||||
HStack {
|
||||
Text("Licenses & Attribution")
|
||||
.font(.headline)
|
||||
.foregroundStyle(Theme.text)
|
||||
Spacer()
|
||||
Button("Close") {
|
||||
dismiss()
|
||||
}
|
||||
.keyboardShortcut(.cancelAction)
|
||||
.accessibilityIdentifier("closeLicenseBtn")
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 12)
|
||||
.background(Theme.panel.opacity(0.9))
|
||||
|
||||
Divider()
|
||||
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
// Section 1: ICCery License
|
||||
licenseSection(
|
||||
title: "ICCery License",
|
||||
content: icceryLicense,
|
||||
identifier: "icceryLicenseSection"
|
||||
)
|
||||
|
||||
Divider()
|
||||
|
||||
// Section 2: ArgyllCMS License
|
||||
licenseSection(
|
||||
title: "ArgyllCMS License (AGPLv3)",
|
||||
content: argyllLicense,
|
||||
identifier: "argyllLicenseSection"
|
||||
)
|
||||
|
||||
Divider()
|
||||
|
||||
// Section 3: Attribution & Links
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Attribution & Links")
|
||||
.font(.headline)
|
||||
.foregroundStyle(Theme.text)
|
||||
.accessibilityIdentifier("attributionHeader")
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Link("ArgyllCMS by Graeme Gill → https://www.argyllcms.com/",
|
||||
destination: URL(string: "https://www.argyllcms.com/")!)
|
||||
.font(.callout)
|
||||
.foregroundStyle(Theme.accent)
|
||||
.accessibilityIdentifier("argyllUpstreamLink")
|
||||
|
||||
Link("Gronod ArgyllCMS fork (v3.5.0-ICCery.1.x) → https://git.i3omb.com/gronod/argyllcms",
|
||||
destination: URL(string: "https://git.i3omb.com/gronod/argyllcms")!)
|
||||
.font(.callout)
|
||||
.foregroundStyle(Theme.accent)
|
||||
.accessibilityIdentifier("argyllForkLink")
|
||||
}
|
||||
|
||||
Text("ICCery bundles and invokes ArgyllCMS binaries as isolated subprocesses per AGPLv3 isolation requirements. The ArgyllCMS binaries are never linked, loaded, or incorporated into the ICCery application binary.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.accessibilityIdentifier("agplIsolationNote")
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityIdentifier("attributionSection")
|
||||
}
|
||||
.padding(24)
|
||||
}
|
||||
.frame(minWidth: 600, minHeight: 500)
|
||||
.background(Theme.panel)
|
||||
}
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityIdentifier("licenseWindow")
|
||||
}
|
||||
|
||||
private func licenseSection(title: String, content: String, identifier: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
.foregroundStyle(Theme.text)
|
||||
.accessibilityIdentifier(identifier + "Header")
|
||||
|
||||
Text(content)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.foregroundStyle(Theme.text)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.accessibilityIdentifier(identifier + "Content")
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
}
|
||||
@@ -84,35 +84,6 @@ struct ColorSyncSuppressor {
|
||||
return applied
|
||||
}
|
||||
|
||||
// MARK: - Layer ⑤′ Quartz vocabulary (#201 D2)
|
||||
|
||||
/// With `lp` gone there is one print path and it carries **both**
|
||||
/// dictionaries: write keys 3–5 of the resolver order into
|
||||
/// `PMPrintSettings` so the PDE and the spool job see them
|
||||
/// (docs/14 §7). Warn-only — a rejected key never aborts.
|
||||
@discardableResult
|
||||
func applyQuartzMode(to settings: PMPrintSettings) -> Int {
|
||||
var applied = 0
|
||||
let pairs: [(key: String, value: String, locked: Bool)] = [
|
||||
(ColorMatchingAttempts.quartzModeKey,
|
||||
ColorMatchingAttempts.quartzCustomMatching, true),
|
||||
(ColorMatchingAttempts.quartzProfileKey, "", false),
|
||||
(ColorMatchingAttempts.quartzLegacyModeKey,
|
||||
ColorMatchingAttempts.quartzCustomMatching, false),
|
||||
]
|
||||
for pair in pairs {
|
||||
let status = PMPrintSettingsSetValue(
|
||||
settings, pair.key as CFString, pair.value as CFString,
|
||||
pair.locked)
|
||||
if status == 0 { applied += 1 }
|
||||
}
|
||||
if applied == 0 {
|
||||
log("ColorSync: PMPrintSettingsSetValue rejected the "
|
||||
+ "Quartz colour-matching keys")
|
||||
}
|
||||
return applied
|
||||
}
|
||||
|
||||
// MARK: - Layer ④ driver bypass
|
||||
|
||||
/// Pre-select the driver "no colour adjustment" option, unlocked —
|
||||
@@ -141,11 +112,7 @@ struct ColorSyncSuppressor {
|
||||
// MARK: - Layer ⑤ NSPrintInfo mirror
|
||||
|
||||
/// Mirror the applied keys into `printSettings` so the PDE pick
|
||||
/// sees them. Also populates the nested
|
||||
/// `com.apple.print.printSettings` sub-dictionary of
|
||||
/// `printInfo.dictionary()` with the AP_* **and** Quartz keys —
|
||||
/// the single remaining path carries both vocabularies (#201 D2,
|
||||
/// docs/14 §7).
|
||||
/// sees them.
|
||||
func mirror(
|
||||
into printInfo: NSPrintInfo,
|
||||
driverBypass: (key: String, value: String)?
|
||||
@@ -154,29 +121,9 @@ struct ColorSyncSuppressor {
|
||||
for key in ColorMatchingAttempts.printSettingsKeys {
|
||||
settings[key as NSString] = ColorMatchingAttempts.applicationMatchingValue as NSString
|
||||
}
|
||||
settings[ColorMatchingAttempts.quartzModeKey as NSString] =
|
||||
ColorMatchingAttempts.quartzCustomMatching as NSString
|
||||
settings[ColorMatchingAttempts.quartzProfileKey as NSString] =
|
||||
"" as NSString
|
||||
settings[ColorMatchingAttempts.quartzLegacyModeKey as NSString] =
|
||||
ColorMatchingAttempts.quartzCustomMatching as NSString
|
||||
if let driverBypass {
|
||||
settings[driverBypass.key as NSString] = driverBypass.value as NSString
|
||||
}
|
||||
|
||||
// Nested mirror — drivers that read the flattened dictionary.
|
||||
let nestedKey = ColorMatchingAttempts.quartzNestedDictKey as NSString
|
||||
let nested = (settings[nestedKey] as? NSMutableDictionary)
|
||||
?? NSMutableDictionary()
|
||||
for key in ColorMatchingAttempts.printSettingsKeys {
|
||||
nested[key] = ColorMatchingAttempts.applicationMatchingValue
|
||||
}
|
||||
nested[ColorMatchingAttempts.quartzModeKey] =
|
||||
ColorMatchingAttempts.quartzCustomMatching
|
||||
nested[ColorMatchingAttempts.quartzProfileKey] = ""
|
||||
nested[ColorMatchingAttempts.quartzLegacyModeKey] =
|
||||
ColorMatchingAttempts.quartzCustomMatching
|
||||
settings[nestedKey] = nested
|
||||
}
|
||||
|
||||
// MARK: - Layer ⑥ capture
|
||||
|
||||
@@ -1,317 +0,0 @@
|
||||
import AppKit
|
||||
import ApplicationServices
|
||||
import ICCeryCore
|
||||
|
||||
/// One page to draw — the TIFF plus its printtarg manifest size for
|
||||
/// the drift check (#201 D9).
|
||||
struct TargetPrintPage: Equatable {
|
||||
let url: URL
|
||||
let expectedWidthMm: Double?
|
||||
let expectedHeightMm: Double?
|
||||
}
|
||||
|
||||
/// One spool job — a single-element `pages` in per-page mode, all
|
||||
/// pages when `singleJobForAllPages` is on (#201 D5).
|
||||
struct TargetPrintRequest {
|
||||
let queue: String
|
||||
let displayName: String?
|
||||
let pages: [TargetPrintPage]
|
||||
let jobTitle: String // "ICCery Target - <basename>"
|
||||
/// nil → bind + PM defaults + suppression only (the ticket carries
|
||||
/// the captured PDE state; without it the job is Stage 2 writes
|
||||
/// over queue defaults).
|
||||
let ticket: PrintTicket?
|
||||
let overrides: TargetPrintOverrides
|
||||
/// The queue's `lpoptions -l` key roster — feeds vendor media-key
|
||||
/// and driver-bypass detection.
|
||||
let optionKeys: Set<String>
|
||||
/// Test-only: `.save` to this URL instead of `.spool` (#201 D8).
|
||||
var savePDFTo: URL?
|
||||
}
|
||||
|
||||
enum TargetSpoolError: LocalizedError, Equatable {
|
||||
case noPages
|
||||
case printerUnresolved(queue: String)
|
||||
case operationFailed(queue: String, jobTitle: String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .noPages:
|
||||
return "Nothing to print."
|
||||
case .printerUnresolved(let queue):
|
||||
return "The print queue '\(queue)' could not be resolved."
|
||||
case .operationFailed(_, let jobTitle):
|
||||
return "The print operation for '\(jobTitle)' failed."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
protocol TargetSpooling {
|
||||
/// Warn-only diagnostics (manifest drift, oversize page, rejected
|
||||
/// key) are delivered here; the throw path is reserved for real
|
||||
/// failures.
|
||||
var diagnostics: ((Notice) -> Void)? { get set }
|
||||
func spool(_ request: TargetPrintRequest) throws
|
||||
}
|
||||
|
||||
/// Production spooler — rehydrates the captured ticket, applies the
|
||||
/// resolved Stage 2 writes, and draws each TIFF 1:1 through a silent
|
||||
/// `NSPrintOperation` (#201, the S1–S14 trace in the M12 megaplan).
|
||||
@MainActor
|
||||
final class NativeTargetSpooler: TargetSpooling {
|
||||
var suppressor = ColorSyncSuppressor()
|
||||
var diagnostics: ((Notice) -> Void)?
|
||||
var log: (LogLevel, String) -> Void = { AppLogger.shared.log($0, $1) }
|
||||
|
||||
func spool(_ request: TargetPrintRequest) throws {
|
||||
guard !request.pages.isEmpty else { throw TargetSpoolError.noPages }
|
||||
|
||||
// The PM printer handle is held for the whole operation and
|
||||
// released on every path (S14).
|
||||
let printer = PMTicketBridge.makePrinter(queue: request.queue)
|
||||
defer { if let printer { PMTicketBridge.release(printer) } }
|
||||
|
||||
let (printInfo, _) = try makePrintInfo(for: request, boundTo: printer)
|
||||
|
||||
// S10 — decode every page 1:1. Warn-only findings ride the
|
||||
// `diagnostics` seam, never the throw path (D9).
|
||||
var rasters: [TargetPageRaster] = []
|
||||
rasters.reserveCapacity(request.pages.count)
|
||||
for page in request.pages {
|
||||
let raster = try TargetRasterLoader.load(
|
||||
tiff: page.url,
|
||||
expectedWidthMm: page.expectedWidthMm,
|
||||
expectedHeightMm: page.expectedHeightMm)
|
||||
if let drift = raster.manifestDrift {
|
||||
diagnostics?(Notice(kind: .warning, text: drift))
|
||||
}
|
||||
if raster.pointSize.width > printInfo.paperSize.width + 0.5
|
||||
|| raster.pointSize.height
|
||||
> printInfo.paperSize.height + 0.5 {
|
||||
diagnostics?(Notice(
|
||||
kind: .warning,
|
||||
text: "\(page.url.lastPathComponent) exceeds the "
|
||||
+ "paper size — it will be clipped, not scaled."))
|
||||
}
|
||||
rasters.append(raster)
|
||||
}
|
||||
|
||||
// S11–S13 — headless canvas + silent operation (D7).
|
||||
let canvas = TargetPageCanvasView(
|
||||
pages: rasters, paperSize: printInfo.paperSize)
|
||||
let operation = NSPrintOperation(view: canvas, printInfo: printInfo)
|
||||
operation.showsPrintPanel = false
|
||||
operation.showsProgressPanel = false
|
||||
operation.canSpawnSeparateThread = false
|
||||
operation.jobTitle = request.jobTitle
|
||||
guard operation.run() else {
|
||||
throw TargetSpoolError.operationFailed(
|
||||
queue: request.queue, jobTitle: request.jobTitle)
|
||||
}
|
||||
}
|
||||
|
||||
/// S1–S9 — a fully configured `NSPrintInfo` plus the resolved
|
||||
/// write list. Extracted for the PDF harness + unit tests;
|
||||
/// `spool(_:)` is the production entry point (it keeps the PM
|
||||
/// printer handle alive through `run()`).
|
||||
func makePrintInfo(
|
||||
for request: TargetPrintRequest
|
||||
) throws -> (NSPrintInfo, ResolvedTicketWrites) {
|
||||
let printer = PMTicketBridge.makePrinter(queue: request.queue)
|
||||
defer { if let printer { PMTicketBridge.release(printer) } }
|
||||
return try makePrintInfo(for: request, boundTo: printer)
|
||||
}
|
||||
|
||||
private func makePrintInfo(
|
||||
for request: TargetPrintRequest,
|
||||
boundTo printer: PMPrinter?
|
||||
) throws -> (NSPrintInfo, ResolvedTicketWrites) {
|
||||
// S1
|
||||
let printInfo = NSPrintInfo()
|
||||
|
||||
// S2 — Cocoa-level printer first: it runs before binding and
|
||||
// ticket restore, so a printer-driven settings reset cannot
|
||||
// clobber the ticket (R9). Best effort; PM binding is
|
||||
// authoritative.
|
||||
var resolvedNSPrinter = false
|
||||
if let nsPrinter = NSPrinter(name: request.queue)
|
||||
?? request.displayName.flatMap({ NSPrinter(name: $0) }) {
|
||||
printInfo.printer = nsPrinter
|
||||
resolvedNSPrinter = true
|
||||
}
|
||||
|
||||
// S3 — PM binding + session defaults. A failed bind degrades
|
||||
// to the NSPrinter fallback; with neither, the job would land
|
||||
// on the default queue — a wrong answer, so it throws.
|
||||
var boundViaPM = false
|
||||
if let printer {
|
||||
do {
|
||||
try PMTicketBridge.bind(printer: printer, to: printInfo)
|
||||
boundViaPM = true
|
||||
} catch {
|
||||
log(.warn, "Spool: PM bind failed for "
|
||||
+ "'\(request.queue)' — continuing on the "
|
||||
+ "NSPrinter fallback")
|
||||
}
|
||||
}
|
||||
guard boundViaPM || resolvedNSPrinter else {
|
||||
throw TargetSpoolError.printerUnresolved(queue: request.queue)
|
||||
}
|
||||
|
||||
// S4 — rehydrate the captured ticket. `restore` itself refuses
|
||||
// a cross-queue replay (R3).
|
||||
if let ticket = request.ticket {
|
||||
try PMTicketBridge.restore(ticket, into: printInfo)
|
||||
}
|
||||
|
||||
// S5 — Stage 2 writes (AP_* + Quartz vocabularies + media /
|
||||
// quality / bypass, D2/D6) onto the live PMPrintSettings.
|
||||
let resolved = TicketWriteResolver.resolve(
|
||||
overrides: request.overrides, optionKeys: request.optionKeys)
|
||||
let settings = PMTicketBridge.settings(printInfo)
|
||||
for write in resolved.writes {
|
||||
// R4 — write only when the value differs, and log old→new
|
||||
// so a vendor companion-key desync is diagnosable.
|
||||
let current = PMTicketBridge.stringValue(
|
||||
forKey: write.key, in: settings)
|
||||
guard current != write.value else { continue }
|
||||
if let current {
|
||||
log(.info, "Spool: \(write.key): '\(current)' "
|
||||
+ "→ '\(write.value)'")
|
||||
}
|
||||
PMTicketBridge.setValue(
|
||||
write.value, forKey: write.key, locked: write.locked,
|
||||
in: settings, context: "Spool")
|
||||
}
|
||||
printInfo.updateFromPMPrintSettings()
|
||||
mirror(resolved.writes, into: printInfo)
|
||||
|
||||
// S6 — paper override: `PMPaper` match on the bound printer;
|
||||
// `Custom.<w>x<h>` tokens (already points) set the Cocoa paper
|
||||
// size directly.
|
||||
if let token = resolved.paperToken {
|
||||
if let printer, boundViaPM {
|
||||
PMTicketBridge.applyPaper(
|
||||
token: token, printer: printer,
|
||||
session: PMTicketBridge.session(printInfo),
|
||||
printInfo: printInfo)
|
||||
} else if let custom = PMTicketBridge
|
||||
.customPaperDimensions(from: token) {
|
||||
printInfo.paperSize = NSSize(
|
||||
width: custom.width, height: custom.height)
|
||||
}
|
||||
}
|
||||
|
||||
// S7 — orientation on the Cocoa page format; the
|
||||
// `orientation-requested` write above is the driver half.
|
||||
if let orientation = resolved.orientation {
|
||||
printInfo.orientation = orientation == "landscape"
|
||||
? .landscape : .portrait
|
||||
}
|
||||
|
||||
// S8 — private SPI on the live session; only meaningful with
|
||||
// a bound printer (same constraint as the panel path).
|
||||
if boundViaPM {
|
||||
suppressor.applySPIMode(to: PMTicketBridge.session(printInfo))
|
||||
}
|
||||
|
||||
// S9 — Cocoa geometry: margins 0, `.clip` pagination, 1:1
|
||||
// scale, no centring; `.spool`, or `.save` + jobSavingURL
|
||||
// under the PDF harness (D8/D9).
|
||||
printInfo.topMargin = 0
|
||||
printInfo.bottomMargin = 0
|
||||
printInfo.leftMargin = 0
|
||||
printInfo.rightMargin = 0
|
||||
printInfo.horizontalPagination = .clip
|
||||
printInfo.verticalPagination = .clip
|
||||
printInfo.scalingFactor = 1.0
|
||||
printInfo.isHorizontallyCentered = false
|
||||
printInfo.isVerticallyCentered = false
|
||||
if let saveURL = request.savePDFTo {
|
||||
printInfo.jobDisposition = .save
|
||||
printInfo.dictionary()[
|
||||
NSPrintInfo.AttributeKey.jobSavingURL] = saveURL
|
||||
} else {
|
||||
printInfo.jobDisposition = .spool
|
||||
}
|
||||
|
||||
return (printInfo, resolved)
|
||||
}
|
||||
|
||||
/// The resolved writes also land in `NSPrintInfo.printSettings` so
|
||||
/// the driver sees them through Cocoa — the
|
||||
/// `com.apple.print.printSettings` sub-dictionary carries the
|
||||
/// colour keys (D2 / docs/14 §7).
|
||||
private func mirror(
|
||||
_ writes: [TicketWrite], into printInfo: NSPrintInfo
|
||||
) {
|
||||
let settings = printInfo.printSettings
|
||||
for write in writes {
|
||||
settings[write.key as NSString] = write.value as NSString
|
||||
}
|
||||
let nestedKey = ColorMatchingAttempts.quartzNestedDictKey as NSString
|
||||
let nested = (settings[nestedKey] as? NSMutableDictionary)
|
||||
?? NSMutableDictionary()
|
||||
for write in writes where Self.isColourKey(write.key) {
|
||||
nested[write.key] = write.value
|
||||
}
|
||||
settings[nestedKey] = nested
|
||||
}
|
||||
|
||||
private static func isColourKey(_ key: String) -> Bool {
|
||||
ColorMatchingAttempts.printSettingsKeys.contains(key)
|
||||
|| key == ColorMatchingAttempts.quartzModeKey
|
||||
|| key == ColorMatchingAttempts.quartzProfileKey
|
||||
|| key == ColorMatchingAttempts.quartzLegacyModeKey
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
/// UI-test seam (#201 D8). Runs the same `TicketWriteResolver` the
|
||||
/// native spooler runs, appends one deterministic line per request to
|
||||
/// `ICCERY_TEST_SPOOL_LOG`, and never touches the print system — so
|
||||
/// UI tests need no queue, no driver and no PM binding.
|
||||
///
|
||||
/// `ICCERY_TEST_SPOOL_FAIL=1` injects an `operationFailed` error for
|
||||
/// the failure-notice tests.
|
||||
@MainActor
|
||||
final class RecordingTargetSpooler: TargetSpooling {
|
||||
let logURL: URL
|
||||
var diagnostics: ((Notice) -> Void)?
|
||||
|
||||
init(logURL: URL) {
|
||||
self.logURL = logURL
|
||||
}
|
||||
|
||||
/// `queue=<q> title=<t> page=<basename> pages=<n> paper=<token|->
|
||||
/// orientation=<o> keys=<k=v k=v …>` — keys sorted alphabetically
|
||||
/// so assertions are order-free.
|
||||
func spool(_ request: TargetPrintRequest) throws {
|
||||
guard !request.pages.isEmpty else { throw TargetSpoolError.noPages }
|
||||
if ProcessInfo.processInfo.environment["ICCERY_TEST_SPOOL_FAIL"]
|
||||
== "1" {
|
||||
throw TargetSpoolError.operationFailed(
|
||||
queue: request.queue, jobTitle: request.jobTitle)
|
||||
}
|
||||
let resolved = TicketWriteResolver.resolve(
|
||||
overrides: request.overrides, optionKeys: request.optionKeys)
|
||||
let keys = resolved.writes
|
||||
.sorted { $0.key < $1.key }
|
||||
.map { "\($0.key)=\($0.value)" }
|
||||
.joined(separator: " ")
|
||||
let line = "queue=\(request.queue)"
|
||||
+ " title=\(request.jobTitle)"
|
||||
+ " page=\(request.pages[0].url.lastPathComponent)"
|
||||
+ " pages=\(request.pages.count)"
|
||||
+ " paper=\(resolved.paperToken ?? "-")"
|
||||
+ " orientation=\(resolved.orientation ?? "-")"
|
||||
+ " keys=\(keys)\n"
|
||||
// Atomic append — existing content + one line via the
|
||||
// `.tmp`-then-rename convention (#213).
|
||||
let existing = (try? String(contentsOf: logURL, encoding: .utf8))
|
||||
?? ""
|
||||
try AtomicFileWriter.write(existing + line, to: logURL)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -1,389 +0,0 @@
|
||||
import AppKit
|
||||
import ApplicationServices
|
||||
import ICCeryCore
|
||||
|
||||
/// Errors raised by Core Printing bridge operations.
|
||||
enum PMTicketError: LocalizedError, Equatable {
|
||||
case printerUnknown(queue: String)
|
||||
case sessionBindingFailed(OSStatus)
|
||||
case serialiseFailed(stage: String, status: OSStatus)
|
||||
case restoreFailed(stage: String, status: OSStatus)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .printerUnknown(let queue):
|
||||
return "No printer found for queue '\(queue)'."
|
||||
case .sessionBindingFailed(let status):
|
||||
return "Could not bind the print session to the queue (OSStatus \(status))."
|
||||
case .serialiseFailed(let stage, let status):
|
||||
return "Print ticket capture failed at \(stage) (OSStatus \(status))."
|
||||
case .restoreFailed(let stage, let status):
|
||||
return "Print ticket restore failed at \(stage) (OSStatus \(status))."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every Core Printing (`PM*`) call in ICCery lives here (#201 D12).
|
||||
///
|
||||
/// Ownership — the ONLY rules, do not improvise:
|
||||
/// * `PMPrinterCreateFromPrinterID`, `PMCreatePageFormatWithPMPaper`,
|
||||
/// `PMPrintSettingsCreateWithDataRepresentation`,
|
||||
/// `PMPageFormatCreateWithDataRepresentation` return **+1** →
|
||||
/// `PMRelease(object(_:))` on every path (`defer`).
|
||||
/// * `PM*CreateDataRepresentation` writes a **+1 CFData** →
|
||||
/// `takeRetainedValue()` (the headers say "the caller is responsible
|
||||
/// for releasing"; the out-param is not `CF_RETURNS_RETAINED`-annotated,
|
||||
/// so Swift hands back an unbalanced `Unmanaged`).
|
||||
/// * `PMPrinterGetPaperList`, `PMPaperGetID`, `PMPrinterGetID`,
|
||||
/// `PMPrintSettingsGetValue` are **borrowed** → `takeUnretainedValue()`,
|
||||
/// never released. `PMSessionGetCurrentPrinter` hands back the
|
||||
/// session's own printer — also borrowed, never `PMRelease`d (an
|
||||
/// over-release here dangles the session and crashes AppKit's
|
||||
/// `_printerInPrintSession` / session teardown).
|
||||
/// * `printInfo.pmPrintSession()/pmPrintSettings()/pmPageFormat()` are
|
||||
/// borrowed from the `NSPrintInfo` → never released.
|
||||
@MainActor
|
||||
enum PMTicketBridge {
|
||||
|
||||
// MARK: Handles
|
||||
|
||||
/// Borrowed `PMPrintSession` from the `NSPrintInfo` — never released.
|
||||
static func session(_ printInfo: NSPrintInfo) -> PMPrintSession {
|
||||
unsafeBitCast(printInfo.pmPrintSession(), to: PMPrintSession.self)
|
||||
}
|
||||
|
||||
/// Borrowed `PMPrintSettings` from the `NSPrintInfo` — never released.
|
||||
static func settings(_ printInfo: NSPrintInfo) -> PMPrintSettings {
|
||||
unsafeBitCast(printInfo.pmPrintSettings(), to: PMPrintSettings.self)
|
||||
}
|
||||
|
||||
/// Borrowed `PMPageFormat` from the `NSPrintInfo` — never released.
|
||||
static func pageFormat(_ printInfo: NSPrintInfo) -> PMPageFormat {
|
||||
unsafeBitCast(printInfo.pmPageFormat(), to: PMPageFormat.self)
|
||||
}
|
||||
|
||||
/// Any `PM*` handle → `PMObject` for `PMRelease` — the Carbon API
|
||||
/// wants `UnsafeRawPointer`, Swift imports handles as `OpaquePointer`.
|
||||
static func object<Handle>(_ handle: Handle) -> PMObject {
|
||||
unsafeBitCast(handle, to: PMObject.self)
|
||||
}
|
||||
|
||||
// MARK: Binding (layer ①)
|
||||
|
||||
/// `PMPrinterCreateFromPrinterID` — **+1**, caller must
|
||||
/// `PMTicketBridge.release` on every path.
|
||||
static func makePrinter(queue: String) -> PMPrinter? {
|
||||
PMPrinterCreateFromPrinterID(queue as CFString)
|
||||
}
|
||||
|
||||
/// Balances `makePrinter(queue:)` / any +1 PM handle.
|
||||
static func release(_ printer: PMPrinter) {
|
||||
PMRelease(object(printer))
|
||||
}
|
||||
|
||||
/// `PMSessionSetCurrentPMPrinter` + session defaults (warn-only).
|
||||
/// Throws `PMTicketError.sessionBindingFailed` when the bind itself
|
||||
/// fails — the caller still owns `printer` on every path.
|
||||
static func bind(printer: PMPrinter, to printInfo: NSPrintInfo) throws {
|
||||
let session = session(printInfo)
|
||||
let status = PMSessionSetCurrentPMPrinter(session, printer)
|
||||
guard status == 0 else {
|
||||
throw PMTicketError.sessionBindingFailed(status)
|
||||
}
|
||||
// Warn-only: defaults keep the panel consistent with the
|
||||
// queue but are not fatal when they fail.
|
||||
_ = PMSessionDefaultPrintSettings(session, settings(printInfo))
|
||||
_ = PMSessionDefaultPageFormat(session, pageFormat(printInfo))
|
||||
}
|
||||
|
||||
/// `PMSessionGetCurrentPrinter` → `PMPrinterGetID` → String.
|
||||
static func currentPrinterID(
|
||||
session: PMPrintSession,
|
||||
fallback: String
|
||||
) -> String {
|
||||
var current: PMPrinter?
|
||||
guard PMSessionGetCurrentPrinter(session, ¤t) == 0,
|
||||
let printer = current
|
||||
else { return fallback }
|
||||
// Borrowed from the session — never released (see header doc).
|
||||
guard let id = PMPrinterGetID(printer)
|
||||
else { return fallback }
|
||||
return id.takeUnretainedValue() as String
|
||||
}
|
||||
|
||||
// MARK: Ticket (#201 D3)
|
||||
|
||||
/// Capture the live `PMPrintSettings` + `PMPageFormat` as XML
|
||||
/// `Data` plus a plist-safe `NSPrintInfo.dictionary()` fallback.
|
||||
/// The CFData out-params are **+1** by header contract
|
||||
/// ("the caller is responsible for releasing") →
|
||||
/// `takeRetainedValue()`.
|
||||
static func serialise(
|
||||
_ printInfo: NSPrintInfo,
|
||||
queue: String
|
||||
) throws -> PrintTicket {
|
||||
var settingsRef: Unmanaged<CFData>?
|
||||
let settingsStatus = PMPrintSettingsCreateDataRepresentation(
|
||||
settings(printInfo), &settingsRef, kPMDataFormatXMLDefault)
|
||||
guard settingsStatus == noErr, let settingsRef else {
|
||||
throw PMTicketError.serialiseFailed(
|
||||
stage: "printSettings", status: settingsStatus)
|
||||
}
|
||||
let settingsData = settingsRef.takeRetainedValue() as Data
|
||||
|
||||
var pageFormatRef: Unmanaged<CFData>?
|
||||
let pageFormatStatus = PMPageFormatCreateDataRepresentation(
|
||||
pageFormat(printInfo), &pageFormatRef, kPMDataFormatXMLDefault)
|
||||
guard pageFormatStatus == noErr, let pageFormatRef else {
|
||||
throw PMTicketError.serialiseFailed(
|
||||
stage: "pageFormat", status: pageFormatStatus)
|
||||
}
|
||||
let pageFormatData = pageFormatRef.takeRetainedValue() as Data
|
||||
|
||||
// Cocoa-level fallback — warn-only, never fatal: filter the
|
||||
// dictionary to plist-safe values so one exotic attribute
|
||||
// cannot fail the whole snapshot.
|
||||
let plist = try? PropertyListSerialization.data(
|
||||
fromPropertyList: plistSafe(printInfo.dictionary()) ?? [:],
|
||||
format: .binary, options: 0)
|
||||
|
||||
return PrintTicket(
|
||||
queue: queue,
|
||||
printSettings: settingsData,
|
||||
pageFormat: pageFormatData,
|
||||
printInfoPlist: plist,
|
||||
capturedAt: Date())
|
||||
}
|
||||
|
||||
/// Rehydrate a ticket into `printInfo`'s live PM objects. Order is
|
||||
/// load-bearing: create → copy → session-validate → Cocoa update.
|
||||
/// Cross-queue replay is refused (R3) — the destination's bound
|
||||
/// queue is the session's current printer; a destination with no
|
||||
/// bound printer accepts the ticket (the spooler always binds
|
||||
/// first, so production replay is always guarded).
|
||||
/// A page-format failure is warn-only — paper is re-derived
|
||||
/// upstream by the spooler's S6/S7.
|
||||
static func restore(
|
||||
_ ticket: PrintTicket,
|
||||
into printInfo: NSPrintInfo
|
||||
) throws {
|
||||
let bound = currentPrinterID(
|
||||
session: session(printInfo),
|
||||
fallback: ticket.queue)
|
||||
guard ticket.queue == bound else {
|
||||
AppLogger.shared.warn(
|
||||
"PrintTicket: refusing to replay a ticket captured "
|
||||
+ "for '\(ticket.queue)' onto '\(bound)'")
|
||||
return
|
||||
}
|
||||
|
||||
var srcSettings: PMPrintSettings?
|
||||
let createStatus = PMPrintSettingsCreateWithDataRepresentation(
|
||||
ticket.printSettings as CFData, &srcSettings)
|
||||
defer {
|
||||
if let srcSettings { PMRelease(object(srcSettings)) }
|
||||
}
|
||||
guard createStatus == noErr, let srcSettings else {
|
||||
throw PMTicketError.restoreFailed(
|
||||
stage: "printSettings", status: createStatus)
|
||||
}
|
||||
let copyStatus = PMCopyPrintSettings(
|
||||
srcSettings, settings(printInfo))
|
||||
guard copyStatus == noErr else {
|
||||
throw PMTicketError.restoreFailed(
|
||||
stage: "printSettings", status: copyStatus)
|
||||
}
|
||||
var changed = DarwinBoolean(false)
|
||||
_ = PMSessionValidatePrintSettings(
|
||||
session(printInfo), settings(printInfo), &changed)
|
||||
if changed.boolValue {
|
||||
AppLogger.shared.info(
|
||||
"PrintTicket: driver adjusted the restored ticket")
|
||||
}
|
||||
printInfo.updateFromPMPrintSettings()
|
||||
|
||||
// Page format — warn-only.
|
||||
var srcFormat: PMPageFormat?
|
||||
let formatStatus = PMPageFormatCreateWithDataRepresentation(
|
||||
ticket.pageFormat as CFData, &srcFormat)
|
||||
defer {
|
||||
if let srcFormat { PMRelease(object(srcFormat)) }
|
||||
}
|
||||
guard formatStatus == noErr, let srcFormat else {
|
||||
AppLogger.shared.warn(
|
||||
"PrintTicket: page format restore failed "
|
||||
+ "(\(formatStatus)) — paper re-derived upstream")
|
||||
return
|
||||
}
|
||||
guard PMCopyPageFormat(srcFormat, pageFormat(printInfo)) == noErr
|
||||
else {
|
||||
AppLogger.shared.warn(
|
||||
"PrintTicket: page format copy failed — "
|
||||
+ "paper re-derived upstream")
|
||||
return
|
||||
}
|
||||
var formatChanged = DarwinBoolean(false)
|
||||
_ = PMSessionValidatePageFormat(
|
||||
session(printInfo), pageFormat(printInfo), &formatChanged)
|
||||
printInfo.updateFromPMPageFormat()
|
||||
}
|
||||
|
||||
/// Recursive plist-safety filter for `NSPrintInfo.dictionary()`:
|
||||
/// keeps String / NSNumber / Bool / Date / Data / URL (→
|
||||
/// absoluteString) / Array / Dictionary, drops everything else, so
|
||||
/// one non-plist attribute cannot fail the whole snapshot.
|
||||
private static func plistSafe(_ value: Any) -> Any? {
|
||||
switch value {
|
||||
case let string as String:
|
||||
return string
|
||||
case let number as NSNumber:
|
||||
return number
|
||||
case let date as Date:
|
||||
return date
|
||||
case let data as Data:
|
||||
return data
|
||||
case let url as URL:
|
||||
return url.absoluteString
|
||||
case let array as [Any]:
|
||||
return array.compactMap(plistSafe)
|
||||
case let dictionary as [String: Any]:
|
||||
var safe: [String: Any] = [:]
|
||||
for (key, element) in dictionary {
|
||||
if let filtered = plistSafe(element) {
|
||||
safe[key] = filtered
|
||||
}
|
||||
}
|
||||
return safe
|
||||
case let dictionary as [NSPrintInfo.AttributeKey: Any]:
|
||||
var safe: [String: Any] = [:]
|
||||
for (key, element) in dictionary {
|
||||
if let filtered = plistSafe(element) {
|
||||
safe[key.rawValue] = filtered
|
||||
}
|
||||
}
|
||||
return safe
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Values
|
||||
|
||||
/// Warn-only `PMPrintSettingsSetValue`: a driver that rejects a key
|
||||
/// must not abort the caller's flow. Returns `true` on success.
|
||||
@discardableResult
|
||||
static func setValue(
|
||||
_ value: String,
|
||||
forKey key: String,
|
||||
locked: Bool,
|
||||
in settings: PMPrintSettings,
|
||||
context: String
|
||||
) -> Bool {
|
||||
let status = PMPrintSettingsSetValue(
|
||||
settings, key as CFString, value as CFString, locked)
|
||||
if status != 0 {
|
||||
AppLogger.shared.warn(
|
||||
"\(context): PMPrintSettingsSetValue(\(key)) "
|
||||
+ "rejected (\(status))")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/// `PMPrintSettingsGetValue` — borrowed value, never released.
|
||||
/// `nil` for absent keys, non-string values, or lookup errors.
|
||||
static func stringValue(
|
||||
forKey key: String,
|
||||
in settings: PMPrintSettings
|
||||
) -> String? {
|
||||
var value: Unmanaged<CFTypeRef>?
|
||||
guard PMPrintSettingsGetValue(
|
||||
settings, key as CFString, &value) == 0,
|
||||
let ref = value?.takeUnretainedValue()
|
||||
else { return nil }
|
||||
return ref as? String
|
||||
}
|
||||
|
||||
// MARK: Paper
|
||||
|
||||
/// The `PMPageFormat` half of paper preselect (#186 E1): the
|
||||
/// panel's paper dropdown reflects the page format's `PMPaper`,
|
||||
/// not `PMPrintSettings`. Match the Stage 2 `PageSize` token to a
|
||||
/// paper from `PMPrinterGetPaperList`, rebuild the page format
|
||||
/// around it, and copy it into the printInfo's format (TN2248:
|
||||
/// `PMCreatePageFormatWithPMPaper` → `PMSessionValidatePageFormat`
|
||||
/// → `PMCopyPageFormat` → `updateFromPMPageFormat`).
|
||||
/// `Custom.<w>x<h>` tokens (already points) have no `PMPaper` —
|
||||
/// set the Cocoa `paperSize` directly. Warn-only throughout: a
|
||||
/// missed match must not keep the panel from opening.
|
||||
static func applyPaper(
|
||||
token: String,
|
||||
printer: PMPrinter,
|
||||
session: PMPrintSession,
|
||||
printInfo: NSPrintInfo
|
||||
) {
|
||||
if let custom = customPaperDimensions(from: token) {
|
||||
printInfo.paperSize = NSSize(
|
||||
width: custom.width, height: custom.height)
|
||||
return
|
||||
}
|
||||
var paperList: Unmanaged<CFArray>?
|
||||
guard PMPrinterGetPaperList(printer, &paperList) == 0,
|
||||
let papers = paperList?.takeUnretainedValue()
|
||||
else {
|
||||
AppLogger.shared.warn(
|
||||
"Print panel: PMPrinterGetPaperList failed — "
|
||||
+ "paper preselect skipped")
|
||||
return
|
||||
}
|
||||
// The list (and its elements) is owned by the printer —
|
||||
// borrowed, never released.
|
||||
var match: PMPaper?
|
||||
for index in 0..<CFArrayGetCount(papers) {
|
||||
let paper = unsafeBitCast(
|
||||
CFArrayGetValueAtIndex(papers, index), to: PMPaper.self)
|
||||
var idRef: Unmanaged<CFString>?
|
||||
guard PMPaperGetID(paper, &idRef) == 0,
|
||||
let paperID = idRef?.takeUnretainedValue() as String?
|
||||
else { continue }
|
||||
if paperID == token {
|
||||
match = paper
|
||||
break
|
||||
}
|
||||
}
|
||||
guard let paper = match else {
|
||||
AppLogger.shared.warn(
|
||||
"Print panel: no PMPaper id matches '\(token)'")
|
||||
return
|
||||
}
|
||||
var created: PMPageFormat?
|
||||
guard PMCreatePageFormatWithPMPaper(&created, paper) == 0,
|
||||
let newFormat = created
|
||||
else {
|
||||
AppLogger.shared.warn(
|
||||
"Print panel: PMCreatePageFormatWithPMPaper failed "
|
||||
+ "for '\(token)'")
|
||||
return
|
||||
}
|
||||
defer { PMRelease(object(newFormat)) }
|
||||
_ = PMSessionValidatePageFormat(session, newFormat, nil)
|
||||
_ = PMCopyPageFormat(newFormat, pageFormat(printInfo))
|
||||
printInfo.updateFromPMPageFormat()
|
||||
}
|
||||
|
||||
/// `Custom.<w>x<h>` → dimensions in points (the token builder
|
||||
/// emits integer points, mm × 72/25.4). `nil` for non-custom or
|
||||
/// malformed tokens — a malformed `Custom.*` then misses the
|
||||
/// `PMPaper` match and logs instead of guessing a size.
|
||||
static func customPaperDimensions(
|
||||
from token: String
|
||||
) -> (width: Double, height: Double)? {
|
||||
guard token.hasPrefix("Custom.") else { return nil }
|
||||
let dims = token.dropFirst("Custom.".count).split(separator: "x")
|
||||
guard dims.count == 2,
|
||||
let width = Double(dims[0]), let height = Double(dims[1]),
|
||||
width > 0, height > 0
|
||||
else { return nil }
|
||||
return (width, height)
|
||||
}
|
||||
}
|
||||
@@ -4,34 +4,19 @@ import ICCeryCore
|
||||
|
||||
/// Errors raised while preparing the bound print panel.
|
||||
enum PrintPanelError: LocalizedError {
|
||||
case sessionBindingFailed(OSStatus)
|
||||
case noPrinterFound(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .sessionBindingFailed(let status):
|
||||
return "Could not bind the print session to the queue (OSStatus \(status))."
|
||||
case .noPrinterFound(let name):
|
||||
return "No printer found for '\(name)'."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stage 2 selections pre-applied to the bound print panel before it
|
||||
/// opens (#183/#186). Every write is warn-only — the panel still
|
||||
/// opens when a driver ignores a key.
|
||||
struct PrintPanelInitialSelections {
|
||||
/// CUPS `PageSize` token, e.g. `"A4"` / `"Custom.595x842"`. Written
|
||||
/// to `PMPrintSettings` **and** `PMPageFormat` (#186 E1).
|
||||
var paperSize: String?
|
||||
/// The queue's detected quality enumeration key, e.g. `EPIJ_Qual`.
|
||||
var qualityKey: String?
|
||||
/// The selected quality token.
|
||||
var quality: String?
|
||||
/// The selected media token — written to the queue's detected
|
||||
/// vendor key (`CNIJMediaType`/`EPIJ_Medi`/…) (#186).
|
||||
var mediaType: String?
|
||||
/// `"portrait"`/`"landscape"` → `orientation-requested` 3|4 (#186).
|
||||
var orientation: String?
|
||||
}
|
||||
|
||||
/// Preferences → native `NSPrintPanel` bound to the selected CUPS
|
||||
/// queue (issue 13, docs/11).
|
||||
///
|
||||
@@ -56,15 +41,11 @@ struct PrintPanelService {
|
||||
func showProperties(
|
||||
queue: String,
|
||||
displayName: String?,
|
||||
cupsService: CupsService,
|
||||
initialSelections: PrintPanelInitialSelections =
|
||||
PrintPanelInitialSelections()
|
||||
) async throws -> PanelCaptureResult? {
|
||||
cupsService: CupsService
|
||||
) async throws -> PrintPropertiesResult? {
|
||||
#if DEBUG
|
||||
if UITestHooks.printPanelStubbed {
|
||||
return UITestHooks.printPanelResult(forQueue: queue).map {
|
||||
PanelCaptureResult(properties: $0, ticket: nil)
|
||||
}
|
||||
return UITestHooks.printPanelResult(forQueue: queue)
|
||||
}
|
||||
#endif
|
||||
// `??` rhs is a non-async @autoclosure — fetch first.
|
||||
@@ -75,8 +56,7 @@ struct PrintPanelService {
|
||||
let optionKeys = (try? await cupsService.optionKeys(for: queue))
|
||||
?? []
|
||||
return try runNativePanel(
|
||||
queue: queue, displayName: display, optionKeys: optionKeys,
|
||||
initialSelections: initialSelections)
|
||||
queue: queue, displayName: display, optionKeys: optionKeys)
|
||||
}
|
||||
|
||||
// MARK: - Panel
|
||||
@@ -84,45 +64,34 @@ struct PrintPanelService {
|
||||
private func runNativePanel(
|
||||
queue: String,
|
||||
displayName: String?,
|
||||
optionKeys: Set<String>,
|
||||
initialSelections: PrintPanelInitialSelections
|
||||
) throws -> PanelCaptureResult? {
|
||||
optionKeys: Set<String>
|
||||
) throws -> PrintPropertiesResult? {
|
||||
let printInfo = NSPrintInfo()
|
||||
var pmPrinter: PMPrinter?
|
||||
var boundViaPM = false
|
||||
|
||||
// ① Bind the session to the selected CUPS queue (docs/11).
|
||||
if let printer = PMTicketBridge.makePrinter(queue: queue) {
|
||||
if let printer = PMPrinterCreateFromPrinterID(queue as CFString) {
|
||||
pmPrinter = printer
|
||||
let session = PMTicketBridge.session(printInfo)
|
||||
let settings = PMTicketBridge.settings(printInfo)
|
||||
let session = unsafeBitCast(
|
||||
printInfo.pmPrintSession(), to: PMPrintSession.self)
|
||||
let settings = unsafeBitCast(
|
||||
printInfo.pmPrintSettings(), to: PMPrintSettings.self)
|
||||
let pageFormat = unsafeBitCast(
|
||||
printInfo.pmPageFormat(), to: PMPageFormat.self)
|
||||
|
||||
do {
|
||||
try PMTicketBridge.bind(printer: printer, to: printInfo)
|
||||
} catch {
|
||||
PMTicketBridge.release(printer)
|
||||
throw error
|
||||
}
|
||||
// Initial selections — after `PMSessionDefault*`, before
|
||||
// ColorSync suppression ②–⑤ (locked write order,
|
||||
// #183/#186). Paper is TWO writes (E1): the `PageSize`
|
||||
// print-settings value drivers/capture read AND the
|
||||
// `PMPageFormat` paper the panel's dropdown reflects.
|
||||
applyInitialSelections(
|
||||
initialSelections, to: settings, optionKeys: optionKeys)
|
||||
if let paperToken = initialSelections.paperSize {
|
||||
PMTicketBridge.applyPaper(
|
||||
token: paperToken, printer: printer,
|
||||
session: session, printInfo: printInfo)
|
||||
let status = PMSessionSetCurrentPMPrinter(session, printer)
|
||||
if status != 0 {
|
||||
PMRelease(Self.pmObject(printer))
|
||||
throw PrintPanelError.sessionBindingFailed(status)
|
||||
}
|
||||
// Warn-only: defaults keep the panel consistent with the
|
||||
// queue but are not fatal when they fail.
|
||||
_ = PMSessionDefaultPrintSettings(session, settings)
|
||||
_ = PMSessionDefaultPageFormat(session, pageFormat)
|
||||
boundViaPM = true
|
||||
} else {
|
||||
// Fallback: NSPrinter by display name (docs/11 §binding).
|
||||
// Warn — the display name can resolve a *different* queue
|
||||
// (#186 E2: diagnosable, not a proven defect).
|
||||
AppLogger.shared.warn(
|
||||
"Print panel: PM binding unavailable for '\(queue)' — "
|
||||
+ "falling back to NSPrinter(displayName)")
|
||||
guard let displayName,
|
||||
let nsPrinter = NSPrinter(name: displayName)
|
||||
else {
|
||||
@@ -134,20 +103,21 @@ struct PrintPanelService {
|
||||
}
|
||||
defer {
|
||||
if let printer = pmPrinter {
|
||||
PMTicketBridge.release(printer)
|
||||
PMRelease(Self.pmObject(printer))
|
||||
}
|
||||
}
|
||||
|
||||
// ②–⑤ ColourSync suppression — only on the PM path: the SPI
|
||||
// and PMPrintSettingsSetValue need a session with a current
|
||||
// printer to attach to.
|
||||
var settings = PMTicketBridge.settings(printInfo)
|
||||
var settings = unsafeBitCast(
|
||||
printInfo.pmPrintSettings(), to: PMPrintSettings.self)
|
||||
var driverBypass: (key: String, value: String)?
|
||||
if boundViaPM {
|
||||
let session = PMTicketBridge.session(printInfo)
|
||||
let session = unsafeBitCast(
|
||||
printInfo.pmPrintSession(), to: PMPrintSession.self)
|
||||
suppressor.applySPIMode(to: session) // ②
|
||||
suppressor.applyLockedKeys(to: settings) // ③
|
||||
suppressor.applyQuartzMode(to: settings) // ⑤′-a (D2)
|
||||
driverBypass = suppressor.applyDriverBypass( // ④
|
||||
to: settings, optionKeys: optionKeys)
|
||||
suppressor.mirror(into: printInfo, driverBypass: driverBypass) // ⑤
|
||||
@@ -168,79 +138,49 @@ struct PrintPanelService {
|
||||
|
||||
// ⑥ Capture the user's choices — filtered replay options plus
|
||||
// the media type they picked. Re-fetch the settings handle so
|
||||
// we read back what the modal wrote. Paper size, quality, and
|
||||
// orientation ride back parsed from the captured `k=v` string
|
||||
// (#183); the PDE may rewrite or drop them (R12).
|
||||
// we read back what the modal wrote.
|
||||
var cupsOptions: String?
|
||||
var mediaType: String?
|
||||
if boundViaPM {
|
||||
settings = PMTicketBridge.settings(printInfo)
|
||||
settings = unsafeBitCast(
|
||||
printInfo.pmPrintSettings(), to: PMPrintSettings.self)
|
||||
let captured = suppressor.captureOptions(from: settings)
|
||||
cupsOptions = captured.cupsOptions
|
||||
mediaType = captured.mediaType
|
||||
}
|
||||
let capturedOptions = cupsOptions ?? ""
|
||||
let resolvedQueue = boundViaPM
|
||||
? PMTicketBridge.currentPrinterID(
|
||||
session: PMTicketBridge.session(printInfo),
|
||||
fallback: queue)
|
||||
: queue
|
||||
// ⑦ Serialise the native ticket — the payload `lp -o` could
|
||||
// never carry (#201). Warn-only via `try?`: a serialise
|
||||
// failure must not lose the Stage 2 mirror above.
|
||||
let ticket = try? PMTicketBridge.serialise(
|
||||
printInfo, queue: resolvedQueue)
|
||||
return PanelCaptureResult(
|
||||
properties: PrintPropertiesResult(
|
||||
selectedPrinter: boundViaPM ? resolvedQueue : nil,
|
||||
options: PrintOptions(
|
||||
orientation: CupsParsers.extractOrientation(
|
||||
fromOptionsString: capturedOptions),
|
||||
paperSize: CupsParsers.extractOption(
|
||||
named: "PageSize",
|
||||
fromOptionsString: capturedOptions),
|
||||
mediaType: mediaType,
|
||||
quality: CupsParsers.extractQuality(
|
||||
fromOptionsString: capturedOptions),
|
||||
ppdUncorrectedPassthrough: true,
|
||||
cupsOptions: cupsOptions)),
|
||||
ticket: ticket)
|
||||
return PrintPropertiesResult(
|
||||
selectedPrinter: boundViaPM
|
||||
? Self.currentPrinterID(
|
||||
session: unsafeBitCast(
|
||||
printInfo.pmPrintSession(), to: PMPrintSession.self),
|
||||
fallback: queue)
|
||||
: nil,
|
||||
options: PrintOptions(
|
||||
mediaType: mediaType,
|
||||
ppdUncorrectedPassthrough: true,
|
||||
cupsOptions: cupsOptions))
|
||||
}
|
||||
|
||||
/// Initial-selection `PMPrintSettings` writes — paper, quality,
|
||||
/// media type, orientation. All warn-only: a driver that ignores
|
||||
/// a key must not keep the panel from opening (R12 surfaces via
|
||||
/// the capture echo instead).
|
||||
private func applyInitialSelections(
|
||||
_ selections: PrintPanelInitialSelections,
|
||||
to settings: PMPrintSettings,
|
||||
optionKeys: Set<String>
|
||||
) {
|
||||
if let paperSize = selections.paperSize {
|
||||
PMTicketBridge.setValue(
|
||||
paperSize, forKey: "PageSize", locked: false,
|
||||
in: settings, context: "Print panel")
|
||||
}
|
||||
if let key = selections.qualityKey, let value = selections.quality {
|
||||
PMTicketBridge.setValue(
|
||||
value, forKey: key, locked: false,
|
||||
in: settings, context: "Print panel")
|
||||
}
|
||||
// Media type via the queue's detected vendor key (#186).
|
||||
if let mediaType = selections.mediaType,
|
||||
let mediaKey = CupsParsers.detectMediaTypeKey(
|
||||
optionKeys: optionKeys) {
|
||||
PMTicketBridge.setValue(
|
||||
mediaType, forKey: mediaKey, locked: false,
|
||||
in: settings, context: "Print panel")
|
||||
}
|
||||
// Orientation — portrait=3, landscape=4 (CUPS IPP codes).
|
||||
if let orientation = selections.orientation {
|
||||
let code = orientation == "landscape" ? "4" : "3"
|
||||
PMTicketBridge.setValue(
|
||||
code, forKey: "orientation-requested", locked: false,
|
||||
in: settings, context: "Print panel")
|
||||
}
|
||||
// MARK: - PM helpers
|
||||
|
||||
/// `PMPrinter` → `PMObject` for `PMRelease` — the Carbon API wants
|
||||
/// `UnsafeRawPointer`, Swift imports `PMPrinter` as `OpaquePointer`.
|
||||
static func pmObject(_ printer: PMPrinter) -> PMObject {
|
||||
unsafeBitCast(printer, to: PMObject.self)
|
||||
}
|
||||
|
||||
/// `PMSessionGetCurrentPrinter` → `PMPrinterGetID` → String.
|
||||
private static func currentPrinterID(
|
||||
session: PMPrintSession,
|
||||
fallback: String
|
||||
) -> String {
|
||||
var current: PMPrinter?
|
||||
guard PMSessionGetCurrentPrinter(session, ¤t) == 0,
|
||||
let printer = current
|
||||
else { return fallback }
|
||||
defer { PMRelease(pmObject(printer)) }
|
||||
guard let id = PMPrinterGetID(printer)
|
||||
else { return fallback }
|
||||
return id.takeUnretainedValue() as String
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,74 +2,26 @@ import Combine
|
||||
import Foundation
|
||||
import ICCeryCore
|
||||
|
||||
/// CUPS queue selection, bound print panel, and native headless spool
|
||||
/// (issues 12–15, 17 / #85, #201).
|
||||
/// CUPS queue selection, bound print panel, and `lp` spool (issues 12–15, 17 / #85).
|
||||
@MainActor
|
||||
final class PrintSessionViewModel: ObservableObject {
|
||||
let wizard: WizardViewModel
|
||||
let environment: AppEnvironment
|
||||
/// Stage 1/2 form state — read for paper seeding/mirroring only;
|
||||
/// `workflow.pageSize` is the printtarg layout and is never written
|
||||
/// back from the print side (#183).
|
||||
weak var workflow: TargetWorkflowViewModel?
|
||||
|
||||
@Published var printers: [Printer] = []
|
||||
@Published var selectedPrinter = "" {
|
||||
didSet {
|
||||
// Tickets are queue- and driver-version-specific — a stale
|
||||
// ticket must never replay onto a re-selected queue (R3).
|
||||
if selectedPrinter != oldValue {
|
||||
capturedTickets[oldValue] = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@Published var selectedPrinter = ""
|
||||
@Published var printerCaps = PrinterCapabilities()
|
||||
@Published var selectedTray: Int?
|
||||
@Published var selectedMediaType: String?
|
||||
/// `PrinterPaperSize.id` — `0` is the synthetic custom entry (#183).
|
||||
@Published var selectedPaperSize: Int?
|
||||
/// Print-quality option token, e.g. `"303"` (#183).
|
||||
@Published var selectedQuality: String?
|
||||
@Published var printOrientation = "portrait"
|
||||
@Published var capturedCupsOptions: [String: String] = [:]
|
||||
/// Native print tickets per queue — the spool payload (#201).
|
||||
/// Session-only; cleared whenever the queue's mirror is.
|
||||
@Published var capturedTickets: [String: PrintTicket] = [:]
|
||||
@Published var printNotice: Notice?
|
||||
@Published var isPrinting = false
|
||||
/// Session-only job granularity (#201 D5): off → one job per page
|
||||
/// (per-page notices + error attribution); on → a single job.
|
||||
@Published var singleJobForAllPages = false
|
||||
/// The spool backend — the `ICCERY_TEST_SPOOL_LOG` recording seam
|
||||
/// under UI testing, `NSPrintOperation` otherwise (#201 D8).
|
||||
/// `internal` so unit tests inject.
|
||||
var spooler: TargetSpooling
|
||||
private var printTask: Task<Void, Never>?
|
||||
|
||||
init(wizard: WizardViewModel, environment: AppEnvironment) {
|
||||
self.wizard = wizard
|
||||
self.environment = environment
|
||||
#if DEBUG
|
||||
if UITestHooks.isEnabled, let logURL = UITestHooks.spoolLogURL {
|
||||
spooler = RecordingTargetSpooler(logURL: logURL)
|
||||
} else {
|
||||
spooler = NativeTargetSpooler()
|
||||
}
|
||||
#else
|
||||
spooler = NativeTargetSpooler()
|
||||
#endif
|
||||
attachDiagnostics()
|
||||
}
|
||||
|
||||
/// Warn-only spooler diagnostics (manifest drift, oversize page)
|
||||
/// surface as the in-panel notice. Re-attached per request so an
|
||||
/// injected spooler picks it up too — and so `spooler` needs no
|
||||
/// `didSet` (mutating `diagnostics` through the existential would
|
||||
/// re-fire the observer and recurse).
|
||||
private func attachDiagnostics() {
|
||||
spooler.diagnostics = { [weak self] notice in
|
||||
self?.printNotice = notice
|
||||
}
|
||||
}
|
||||
|
||||
private var printerEnumTask: Task<[Printer]?, Never>?
|
||||
@@ -123,80 +75,22 @@ final class PrintSessionViewModel: ObservableObject {
|
||||
if selectedTray == nil {
|
||||
selectedTray = printerCaps.trays.first?.id
|
||||
}
|
||||
if selectedQuality == nil {
|
||||
selectedQuality = printerCaps.qualityDefault
|
||||
?? printerCaps.qualities.first?.id
|
||||
}
|
||||
// Caps reload is a re-mirror trigger for the paper picker
|
||||
// (#183 E4) — pageSize + printer changes route here too.
|
||||
seedPaperSelection()
|
||||
} catch {
|
||||
printerCaps = PrinterCapabilities()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Paper / quality selection (#183)
|
||||
|
||||
/// Seed `selectedPaperSize` from Stage 1's `workflow.pageSize`:
|
||||
/// a capability whose name matches `pageSize.rawValue` → its id;
|
||||
/// `.custom` → the synthetic `Custom.<pt>x<pt>` entry (`id: 0`);
|
||||
/// no match → nil (never guess). Called only on pageSize / printer /
|
||||
/// caps triggers — never on unrelated publishes (R14).
|
||||
func seedPaperSelection() {
|
||||
guard let pageSize = workflow?.pageSize else { return }
|
||||
if pageSize == .custom {
|
||||
let token = customPaperToken()
|
||||
if let index = printerCaps.paperSizes.firstIndex(where: { $0.id == 0 }) {
|
||||
printerCaps.paperSizes[index].name = token
|
||||
} else {
|
||||
printerCaps.paperSizes.append(
|
||||
PrinterPaperSize(id: 0, name: token))
|
||||
}
|
||||
selectedPaperSize = 0
|
||||
return
|
||||
}
|
||||
selectedPaperSize = printerCaps.paperSizes
|
||||
.first { $0.name == pageSize.rawValue }?.id
|
||||
}
|
||||
|
||||
/// `Custom.<w>x<h>` in **points** — mm × 72/25.4 (#183 E5/R8). The
|
||||
/// PPD template token `Custom.WIDTHxHEIGHT` is never emitted verbatim.
|
||||
func customPaperToken() -> String {
|
||||
let w = workflow?.customPageW ?? 0
|
||||
let h = workflow?.customPageH ?? 0
|
||||
let wPt = (w * 72.0 / 25.4).rounded()
|
||||
let hPt = (h * 72.0 / 25.4).rounded()
|
||||
return "Custom.\(Int(wPt))x\(Int(hPt))"
|
||||
}
|
||||
|
||||
/// The CUPS `PageSize` token for the current Stage 2 pick — live
|
||||
/// `Custom.<pt>x<pt>` for the synthetic entry, else the capability
|
||||
/// name. Resolved into the `PageSize` ticket write and the
|
||||
/// `PMPaper` match on the native spool path (#201).
|
||||
var selectedPaperSizeToken: String? {
|
||||
guard let id = selectedPaperSize else { return nil }
|
||||
if id == 0 { return customPaperToken() }
|
||||
return printerCaps.paperSizes.first { $0.id == id }?.name
|
||||
}
|
||||
|
||||
func openPrinterPreferences() {
|
||||
guard !selectedPrinter.isEmpty else { return }
|
||||
let queue = selectedPrinter
|
||||
let displayName = printers.first { $0.name == queue }?.displayName
|
||||
let cups = environment.cupsService
|
||||
let selections = PrintPanelInitialSelections(
|
||||
paperSize: selectedPaperSizeToken,
|
||||
qualityKey: printerCaps.qualityKey,
|
||||
quality: selectedQuality,
|
||||
mediaType: selectedMediaType,
|
||||
orientation: printOrientation)
|
||||
Task { @MainActor in
|
||||
do {
|
||||
guard let result = try await PrintPanelService()
|
||||
.showProperties(
|
||||
queue: queue, displayName: displayName,
|
||||
cupsService: cups,
|
||||
initialSelections: selections)
|
||||
cupsService: cups)
|
||||
else {
|
||||
printNotice = Notice(
|
||||
kind: .info,
|
||||
@@ -205,37 +99,18 @@ final class PrintSessionViewModel: ObservableObject {
|
||||
)
|
||||
return
|
||||
}
|
||||
if let selected = result.properties.selectedPrinter,
|
||||
if let selected = result.selectedPrinter,
|
||||
printers.contains(where: { $0.name == selected }),
|
||||
selected != queue {
|
||||
selectedPrinter = selected
|
||||
await reloadSelectedCapabilities()
|
||||
}
|
||||
if let captured = result.properties.options.cupsOptions {
|
||||
if let captured = result.options.cupsOptions {
|
||||
capturedCupsOptions[selectedPrinter] = captured
|
||||
}
|
||||
// The native ticket rides alongside the mirror (#201).
|
||||
if let ticket = result.ticket {
|
||||
capturedTickets[ticket.queue] = ticket
|
||||
}
|
||||
if let media = result.properties.options.mediaType {
|
||||
if let media = result.options.mediaType {
|
||||
selectedMediaType = media
|
||||
}
|
||||
// Capture-return (#183/#186): a dialog paper/quality/
|
||||
// orientation change updates the Stage 2 selections —
|
||||
// never `workflow.pageSize` (printtarg layout is
|
||||
// sacred).
|
||||
if let paper = result.properties.options.paperSize,
|
||||
let match = printerCaps.paperSizes
|
||||
.first(where: { $0.name == paper }) {
|
||||
selectedPaperSize = match.id
|
||||
}
|
||||
if let quality = result.properties.options.quality {
|
||||
selectedQuality = quality
|
||||
}
|
||||
if let orientation = result.properties.options.orientation {
|
||||
printOrientation = orientation
|
||||
}
|
||||
printNotice = Notice(
|
||||
kind: .info,
|
||||
text: "Settings captured for \(selectedPrinter).",
|
||||
@@ -247,38 +122,17 @@ final class PrintSessionViewModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func printAllPages(from result: PrinttargResult) {
|
||||
func printAllPages(from result: PrinttargResult, pageSize: PageSize) {
|
||||
guard !isPrinting else { return }
|
||||
isPrinting = true
|
||||
let task = Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
// `defer` cannot mutate isolated state under Swift 5.7
|
||||
// (Xcode 14.2 / macOS 12 runner), so clear explicitly (#113).
|
||||
if singleJobForAllPages {
|
||||
// D5 — one job carries every page; a failure names the
|
||||
// job, not a page (R14).
|
||||
do {
|
||||
try await spoolAll(result)
|
||||
printNotice = Notice(
|
||||
kind: .info,
|
||||
text: "Sent \(result.pages.count) page(s) in "
|
||||
+ "one job to \(selectedPrinter).",
|
||||
autoHideAfter: nil
|
||||
)
|
||||
} catch {
|
||||
printNotice = Notice(
|
||||
kind: .error,
|
||||
text: "Print failed: \(error.localizedDescription)"
|
||||
)
|
||||
}
|
||||
isPrinting = false
|
||||
self.printTask = nil
|
||||
return
|
||||
}
|
||||
var printed = 0
|
||||
for page in result.pages {
|
||||
do {
|
||||
try await spool(page)
|
||||
try await spool(page, index: page.index, pageSize: pageSize)
|
||||
printed += 1
|
||||
} catch {
|
||||
printNotice = Notice(
|
||||
@@ -302,13 +156,13 @@ final class PrintSessionViewModel: ObservableObject {
|
||||
printTask = task
|
||||
}
|
||||
|
||||
func printPage(_ page: GalleryPage) {
|
||||
func printPage(_ page: GalleryPage, pageSize: PageSize) {
|
||||
guard !isPrinting else { return }
|
||||
isPrinting = true
|
||||
let task = Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
do {
|
||||
try await spool(page)
|
||||
try await spool(page, index: page.index, pageSize: pageSize)
|
||||
printNotice = Notice(
|
||||
kind: .info,
|
||||
text: "Sent \(page.page.filename) to \(selectedPrinter).",
|
||||
@@ -326,55 +180,21 @@ final class PrintSessionViewModel: ObservableObject {
|
||||
printTask = task
|
||||
}
|
||||
|
||||
private func spool(_ page: GalleryPage) async throws {
|
||||
let request = try await makeRequest(
|
||||
pages: [page], title: page.page.filename)
|
||||
try spooler.spool(request)
|
||||
wizard.printerName = selectedPrinter
|
||||
}
|
||||
|
||||
private func spoolAll(_ result: PrinttargResult) async throws {
|
||||
let request = try await makeRequest(
|
||||
pages: result.pages,
|
||||
title: result.ti2URL.deletingPathExtension()
|
||||
.lastPathComponent)
|
||||
try spooler.spool(request)
|
||||
wizard.printerName = selectedPrinter
|
||||
}
|
||||
|
||||
/// Assemble the deterministic spool request: the captured ticket
|
||||
/// (when the panel produced one) plus the Stage 2 overrides and
|
||||
/// the queue's option-key roster for vendor-key detection (#201).
|
||||
/// The Stage 2 paper token feeds the `PageSize` write;
|
||||
/// `workflow.pageSize` remains the printtarg layout input only
|
||||
/// (#183).
|
||||
private func makeRequest(
|
||||
pages: [GalleryPage], title: String
|
||||
) async throws -> TargetPrintRequest {
|
||||
private func spool(_ page: GalleryPage, index: Int, pageSize: PageSize) async throws {
|
||||
guard !selectedPrinter.isEmpty else {
|
||||
throw CupsError.noPrinterSelected
|
||||
}
|
||||
let queue = selectedPrinter
|
||||
let optionKeys = (try? await environment.cupsService
|
||||
.optionKeys(for: queue)) ?? []
|
||||
attachDiagnostics()
|
||||
return TargetPrintRequest(
|
||||
queue: queue,
|
||||
displayName: printers.first { $0.name == queue }?.displayName,
|
||||
pages: pages.map {
|
||||
TargetPrintPage(
|
||||
url: $0.fileURL,
|
||||
expectedWidthMm: $0.page.widthMm,
|
||||
expectedHeightMm: $0.page.heightMm)
|
||||
},
|
||||
jobTitle: "ICCery Target - \(title)",
|
||||
ticket: capturedTickets[queue],
|
||||
overrides: TargetPrintOverrides(
|
||||
paperSize: selectedPaperSizeToken,
|
||||
mediaType: selectedMediaType,
|
||||
qualityKey: printerCaps.qualityKey,
|
||||
quality: selectedQuality,
|
||||
orientation: printOrientation),
|
||||
optionKeys: optionKeys)
|
||||
let options = PrintOptions(
|
||||
orientation: printOrientation,
|
||||
paperSize: pageSize == .custom ? nil : pageSize.rawValue,
|
||||
mediaType: selectedMediaType,
|
||||
ppdUncorrectedPassthrough: true,
|
||||
cupsOptions: capturedCupsOptions[selectedPrinter])
|
||||
try await environment.cupsService.printTarget(
|
||||
queue: selectedPrinter,
|
||||
tiffPath: page.fileURL.path,
|
||||
options: options,
|
||||
page: index)
|
||||
wizard.printerName = selectedPrinter
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import Foundation
|
||||
import ICCeryCore
|
||||
|
||||
/// A captured native print ticket (#201) — the payload `lp -o` could
|
||||
/// never carry. Vendor PDE state, including opaque binary blobs, is
|
||||
/// preserved verbatim inside `printSettings`.
|
||||
///
|
||||
/// In-memory, session-only. Never written to `settings.json` or an
|
||||
/// `.icceryproj` (a ticket is queue- and driver-version-specific).
|
||||
struct PrintTicket: Equatable, Sendable {
|
||||
/// CUPS queue id this ticket was captured for. Replay onto any other
|
||||
/// queue is refused (R3).
|
||||
let queue: String
|
||||
/// `PMPrintSettingsCreateDataRepresentation(…, kPMDataFormatXMLDefault)`.
|
||||
let printSettings: Data
|
||||
/// `PMPageFormatCreateDataRepresentation(…, kPMDataFormatXMLDefault)`.
|
||||
let pageFormat: Data
|
||||
/// Binary-plist snapshot of `NSPrintInfo.dictionary()`, plist-filtered.
|
||||
/// Cocoa-level fallback only — never the primary restore path.
|
||||
let printInfoPlist: Data?
|
||||
let capturedAt: Date
|
||||
}
|
||||
|
||||
/// App-level panel outcome: the ICCeryCore `PrintPropertiesResult`
|
||||
/// (Stage 2 mirror values, #183/#186) **plus** the native ticket the
|
||||
/// spooler replays (#201).
|
||||
struct PanelCaptureResult {
|
||||
var properties: PrintPropertiesResult
|
||||
var ticket: PrintTicket?
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import AppKit
|
||||
|
||||
/// Headless print canvas (#201, docs/14 §6). Never installed in a
|
||||
/// window; `NSPrintOperation` is its only client.
|
||||
@MainActor
|
||||
final class TargetPageCanvasView: NSView {
|
||||
|
||||
private let pages: [TargetPageRaster]
|
||||
private let paperSize: NSSize
|
||||
|
||||
/// Test probe — invoked once per drawn page with the live context
|
||||
/// so the interpolation/antialias contract is assertable without a
|
||||
/// printer.
|
||||
var drawProbe: ((NSGraphicsContext, Int, NSRect) -> Void)?
|
||||
|
||||
init(pages: [TargetPageRaster], paperSize: NSSize) {
|
||||
self.pages = pages
|
||||
self.paperSize = paperSize
|
||||
super.init(frame: NSRect(
|
||||
x: 0, y: 0,
|
||||
width: paperSize.width,
|
||||
height: paperSize.height * CGFloat(pages.count)))
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("TargetPageCanvasView is code-only")
|
||||
}
|
||||
|
||||
/// Flipped: the CoreGraphics coordinate space is correctly oriented
|
||||
/// top-down, so page 1 is the TOP rect.
|
||||
override var isFlipped: Bool { true }
|
||||
override var isOpaque: Bool { true }
|
||||
|
||||
override func knowsPageRange(_ range: NSRangePointer) -> Bool {
|
||||
range.pointee = NSRange(location: 1, length: pages.count)
|
||||
return true
|
||||
}
|
||||
|
||||
/// 1-based page → its paper-sized rect, stacked top-down.
|
||||
override func rectForPage(_ page: Int) -> NSRect {
|
||||
NSRect(x: 0,
|
||||
y: CGFloat(page - 1) * paperSize.height,
|
||||
width: paperSize.width,
|
||||
height: paperSize.height)
|
||||
}
|
||||
|
||||
/// Where page `page`'s image lands: top-left anchored inside
|
||||
/// `rectForPage`, sized `raster.pointSize`, origin snapped to
|
||||
/// 0.001 pt (docs/14 §6) so a fractional origin cannot trigger
|
||||
/// device resampling. `internal` for unit tests.
|
||||
func destinationRect(forPage page: Int) -> NSRect {
|
||||
let pageRect = rectForPage(page)
|
||||
let size = pages[page - 1].pointSize
|
||||
return NSRect(x: Self.snap(pageRect.minX),
|
||||
y: Self.snap(pageRect.minY),
|
||||
width: size.width,
|
||||
height: size.height)
|
||||
}
|
||||
|
||||
override func draw(_ dirtyRect: NSRect) {
|
||||
guard let ctx = NSGraphicsContext.current else { return }
|
||||
ctx.imageInterpolation = .none
|
||||
ctx.shouldAntialias = false
|
||||
let cg = ctx.cgContext
|
||||
cg.interpolationQuality = .none
|
||||
cg.setShouldAntialias(false)
|
||||
cg.setAllowsAntialiasing(false)
|
||||
cg.setShouldSmoothFonts(false)
|
||||
for index in pages.indices {
|
||||
let pageRect = rectForPage(index + 1)
|
||||
guard pageRect.intersects(dirtyRect) else { continue }
|
||||
cg.saveGState() // page N never inherits N-1's CTM
|
||||
NSColor.white.setFill()
|
||||
pageRect.fill()
|
||||
cg.draw(pages[index].cgImage,
|
||||
in: destinationRect(forPage: index + 1))
|
||||
cg.restoreGState()
|
||||
drawProbe?(ctx, index + 1, destinationRect(forPage: index + 1))
|
||||
}
|
||||
}
|
||||
|
||||
private static func snap(_ value: CGFloat) -> CGFloat {
|
||||
(value * 1000).rounded() / 1000
|
||||
}
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
import ICCeryCore
|
||||
import ImageIO
|
||||
|
||||
/// One decoded target page ready to draw 1:1 (#201, docs/14 §6).
|
||||
struct TargetPageRaster: Equatable {
|
||||
let sourceURL: URL
|
||||
/// Re-tagged into a **device** colour space — the image Quartz draws.
|
||||
let cgImage: CGImage
|
||||
let pixelWidth: Int
|
||||
let pixelHeight: Int
|
||||
let dpiX: Double
|
||||
let dpiY: Double
|
||||
let componentCount: Int
|
||||
let bitsPerComponent: Int
|
||||
/// Expected physical size from the printtarg manifest, when known.
|
||||
let expectedWidthMm: Double?
|
||||
let expectedHeightMm: Double?
|
||||
|
||||
/// `pixels / dpi * 72`. The ONLY size the canvas ever draws at.
|
||||
/// 72 pt = 1 in. **Never** `backingScaleFactor` (docs/14 §6).
|
||||
var pointSize: CGSize {
|
||||
CGSize(width: Double(pixelWidth) / dpiX * 72,
|
||||
height: Double(pixelHeight) / dpiY * 72)
|
||||
}
|
||||
|
||||
/// `pixels / dpi * 25.4` — the physical size the DPI implies.
|
||||
var physicalMm: CGSize {
|
||||
CGSize(width: Double(pixelWidth) / dpiX * 25.4,
|
||||
height: Double(pixelHeight) / dpiY * 25.4)
|
||||
}
|
||||
|
||||
/// `nil` when within 0.5 mm of the manifest, else the drift
|
||||
/// message (D9 — warn + spool, never rescale).
|
||||
var manifestDrift: String? {
|
||||
var parts: [String] = []
|
||||
if let expectedWidthMm,
|
||||
abs(physicalMm.width - expectedWidthMm) > 0.5 {
|
||||
parts.append(String(
|
||||
format: "width %.2f mm vs manifest %.2f mm",
|
||||
physicalMm.width, expectedWidthMm))
|
||||
}
|
||||
if let expectedHeightMm,
|
||||
abs(physicalMm.height - expectedHeightMm) > 0.5 {
|
||||
parts.append(String(
|
||||
format: "height %.2f mm vs manifest %.2f mm",
|
||||
physicalMm.height, expectedHeightMm))
|
||||
}
|
||||
guard !parts.isEmpty else { return nil }
|
||||
return "TIFF DPI disagrees with the printtarg manifest — "
|
||||
+ parts.joined(separator: "; ")
|
||||
}
|
||||
|
||||
static func == (lhs: TargetPageRaster, rhs: TargetPageRaster) -> Bool {
|
||||
lhs.sourceURL == rhs.sourceURL
|
||||
&& lhs.cgImage === rhs.cgImage
|
||||
&& lhs.pixelWidth == rhs.pixelWidth
|
||||
&& lhs.pixelHeight == rhs.pixelHeight
|
||||
&& lhs.dpiX == rhs.dpiX
|
||||
&& lhs.dpiY == rhs.dpiY
|
||||
&& lhs.componentCount == rhs.componentCount
|
||||
&& lhs.bitsPerComponent == rhs.bitsPerComponent
|
||||
&& lhs.expectedWidthMm == rhs.expectedWidthMm
|
||||
&& lhs.expectedHeightMm == rhs.expectedHeightMm
|
||||
}
|
||||
}
|
||||
|
||||
enum TargetRasterError: LocalizedError, Equatable {
|
||||
case tiffMissing(String)
|
||||
case undecodable(String)
|
||||
case unsupportedLayout(path: String, components: Int,
|
||||
bitsPerComponent: Int, hasAlpha: Bool)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .tiffMissing(let path):
|
||||
return "Target TIFF is missing: \(path)"
|
||||
case .undecodable(let path):
|
||||
return "Target TIFF could not be decoded: \(path)"
|
||||
case .unsupportedLayout(let path, let components,
|
||||
let bitsPerComponent, let hasAlpha):
|
||||
return "Target TIFF has an unsupported layout "
|
||||
+ "(\(components) components, \(bitsPerComponent) bpc"
|
||||
+ "\(hasAlpha ? ", alpha" : "")): \(path)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum TargetRasterLoader {
|
||||
/// Absent DPI metadata defaults to 72 (docs/14 §6) — and logs `warn`.
|
||||
static let fallbackDPI: Double = 72
|
||||
|
||||
static func load(
|
||||
tiff url: URL,
|
||||
expectedWidthMm: Double? = nil,
|
||||
expectedHeightMm: Double? = nil
|
||||
) throws -> TargetPageRaster {
|
||||
guard FileManager.default.fileExists(atPath: url.path) else {
|
||||
throw TargetRasterError.tiffMissing(url.path)
|
||||
}
|
||||
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil)
|
||||
else {
|
||||
throw TargetRasterError.undecodable(url.path)
|
||||
}
|
||||
let options: [CFString: Any] = [
|
||||
kCGImageSourceShouldCache: true,
|
||||
kCGImageSourceShouldAllowFloat: false,
|
||||
]
|
||||
guard let image = CGImageSourceCreateImageAtIndex(
|
||||
source, 0, options as CFDictionary)
|
||||
else {
|
||||
throw TargetRasterError.undecodable(url.path)
|
||||
}
|
||||
|
||||
// DPI lives in the container metadata, not the CGImage.
|
||||
var dpiX = fallbackDPI
|
||||
var dpiY = fallbackDPI
|
||||
var foundX = false
|
||||
var foundY = false
|
||||
if let properties = CGImageSourceCopyPropertiesAtIndex(
|
||||
source, 0, nil) as? [CFString: Any] {
|
||||
if let x = (properties[kCGImagePropertyDPIWidth]
|
||||
as? NSNumber)?.doubleValue, x > 0 {
|
||||
dpiX = x
|
||||
foundX = true
|
||||
}
|
||||
if let y = (properties[kCGImagePropertyDPIHeight]
|
||||
as? NSNumber)?.doubleValue, y > 0 {
|
||||
dpiY = y
|
||||
foundY = true
|
||||
}
|
||||
}
|
||||
if !foundX || !foundY {
|
||||
AppLogger.shared.warn(
|
||||
"TargetRaster: '\(url.lastPathComponent)' carries no "
|
||||
+ "usable DPI metadata — assuming \(fallbackDPI) dpi")
|
||||
}
|
||||
|
||||
let components = image.colorSpace?.numberOfComponents ?? 0
|
||||
guard !Self.hasRealAlpha(image), let tagged = deviceTagged(image)
|
||||
else {
|
||||
throw TargetRasterError.unsupportedLayout(
|
||||
path: url.path,
|
||||
components: components,
|
||||
bitsPerComponent: image.bitsPerComponent,
|
||||
hasAlpha: Self.hasRealAlpha(image))
|
||||
}
|
||||
|
||||
return TargetPageRaster(
|
||||
sourceURL: url,
|
||||
cgImage: tagged,
|
||||
pixelWidth: image.width,
|
||||
pixelHeight: image.height,
|
||||
dpiX: dpiX,
|
||||
dpiY: dpiY,
|
||||
componentCount: components,
|
||||
bitsPerComponent: image.bitsPerComponent,
|
||||
expectedWidthMm: expectedWidthMm,
|
||||
expectedHeightMm: expectedHeightMm)
|
||||
}
|
||||
|
||||
/// Re-tag into `DeviceGray` (1 comp) / `DeviceRGB` (3) /
|
||||
/// `DeviceCMYK` (4) by reusing the decoded image's own
|
||||
/// `dataProvider`, `bitsPerComponent`, `bitsPerPixel`, `bytesPerRow`
|
||||
/// and `bitmapInfo` — **no resample, no bit-depth change, no
|
||||
/// `CGImageCreateCopyWithColorSpace` into a calibrated space**
|
||||
/// (docs/14 §6.3). `shouldInterpolate: false`,
|
||||
/// `intent: .defaultIntent`.
|
||||
///
|
||||
/// Returns `nil` (caller throws `.unsupportedLayout`) for alpha,
|
||||
/// indexed, float, or >4-component images — a profiling target
|
||||
/// never has those, and guessing would corrupt patches.
|
||||
static func deviceTagged(_ image: CGImage) -> CGImage? {
|
||||
guard !hasRealAlpha(image),
|
||||
!image.bitmapInfo.contains(.floatComponents),
|
||||
let colorSpace = image.colorSpace,
|
||||
colorSpace.model != .indexed,
|
||||
colorSpace.model != .pattern,
|
||||
let space = deviceSpace(for: colorSpace.numberOfComponents),
|
||||
let provider = image.dataProvider
|
||||
else { return nil }
|
||||
return CGImage(
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
bitsPerComponent: image.bitsPerComponent,
|
||||
bitsPerPixel: image.bitsPerPixel,
|
||||
bytesPerRow: image.bytesPerRow,
|
||||
space: space,
|
||||
bitmapInfo: image.bitmapInfo,
|
||||
provider: provider,
|
||||
decode: image.decode,
|
||||
shouldInterpolate: false,
|
||||
intent: .defaultIntent)
|
||||
}
|
||||
|
||||
/// Skip-only padding (`noneSkipLast`/`noneSkipFirst`) is **not**
|
||||
/// alpha — ImageIO pads opaque RGB TIFFs to 32 bpp that way, so
|
||||
/// accepting it is required for real printtarg files. True coverage
|
||||
/// alpha (`last`/`first`/`premultiplied*`/`only`) is rejected.
|
||||
static func hasRealAlpha(_ image: CGImage) -> Bool {
|
||||
switch image.alphaInfo {
|
||||
case .none, .noneSkipLast, .noneSkipFirst:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/// The device colour space for a component count — `nil` for
|
||||
/// layouts a profiling target can never legally be (2 channels,
|
||||
/// DeviceN, >4 components).
|
||||
static func deviceSpace(for componentCount: Int) -> CGColorSpace? {
|
||||
switch componentCount {
|
||||
case 1: return CGColorSpaceCreateDeviceGray()
|
||||
case 3: return CGColorSpaceCreateDeviceRGB()
|
||||
case 4: return CGColorSpaceCreateDeviceCMYK()
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
import Foundation
|
||||
import ICCeryCore
|
||||
|
||||
/// Stage 2 inline overrides applied on top of a rehydrated ticket
|
||||
/// (#201 AC3 / D6 — Stage 2 always wins).
|
||||
struct TargetPrintOverrides: Equatable {
|
||||
var paperSize: String? // CUPS `PageSize` token or `Custom.<w>x<h>`
|
||||
var mediaType: String?
|
||||
var qualityKey: String? // PrinterCapabilities.qualityKey
|
||||
var quality: String?
|
||||
var orientation: String? // "portrait" | "landscape"
|
||||
}
|
||||
|
||||
/// One resolved ticket write.
|
||||
struct TicketWrite: Equatable {
|
||||
let key: String
|
||||
let value: String
|
||||
let locked: Bool
|
||||
}
|
||||
|
||||
/// The fully-resolved write list for one job — a **pure value**,
|
||||
/// computed with zero Core Printing calls so it is unit- and
|
||||
/// UI-testable without a live queue. Replaces the old `lp` argv
|
||||
/// builder (#201 D4/D8).
|
||||
struct ResolvedTicketWrites: Equatable {
|
||||
var writes: [TicketWrite]
|
||||
var paperToken: String?
|
||||
var orientation: String?
|
||||
}
|
||||
|
||||
enum TicketWriteResolver {
|
||||
|
||||
/// Quartz vocabulary constants (docs/14 §7) live on
|
||||
/// `ColorMatchingAttempts` next to the AP_* half — written
|
||||
/// alongside because there is no `lp` path left (D2).
|
||||
///
|
||||
/// Order is locked and is the contract the tests assert:
|
||||
/// 1 `AP_ColorMatchingMode` = AP_ApplicationColorMatching (locked)
|
||||
/// 2 `AP.ColorMatchingMode` = AP_ApplicationColorMatching (locked)
|
||||
/// 3 `PMColorMatchingMode` = APCustomColorMatching (locked)
|
||||
/// 4 `PMCustomColorMatchingProfile` = "" (unlocked)
|
||||
/// 5 `com.apple.print.PrintSettings.PMColorMatchingMode`
|
||||
/// = APCustomColorMatching (unlocked)
|
||||
/// 6 `PageSize` = overrides.paperSize
|
||||
/// 7 detectMediaTypeKey(optionKeys) = overrides.mediaType
|
||||
/// 8 overrides.qualityKey = overrides.quality
|
||||
/// 9 detectDriverColorBypass(optionKeys) (unlocked)
|
||||
/// 10 `orientation-requested` = 3 | 4
|
||||
///
|
||||
/// Invariants: `raw` can never appear (there is no `lp`); nothing
|
||||
/// is suppressed because a captured key already exists (D6 — the
|
||||
/// ticket carries the captured keys and Stage 2 writes land on
|
||||
/// top unconditionally); an override with a `nil` value emits no
|
||||
/// write.
|
||||
static func resolve(
|
||||
overrides: TargetPrintOverrides,
|
||||
optionKeys: Set<String>
|
||||
) -> ResolvedTicketWrites {
|
||||
var writes: [TicketWrite] = []
|
||||
|
||||
// 1–2 — locked AP_* pair (layer ③).
|
||||
for key in ColorMatchingAttempts.printSettingsKeys {
|
||||
writes.append(TicketWrite(
|
||||
key: key,
|
||||
value: ColorMatchingAttempts.applicationMatchingValue,
|
||||
locked: true))
|
||||
}
|
||||
// 3–5 — Quartz/`NSPrintOperation` vocabulary (D2).
|
||||
writes.append(TicketWrite(
|
||||
key: ColorMatchingAttempts.quartzModeKey,
|
||||
value: ColorMatchingAttempts.quartzCustomMatching,
|
||||
locked: true))
|
||||
writes.append(TicketWrite(
|
||||
key: ColorMatchingAttempts.quartzProfileKey,
|
||||
value: "",
|
||||
locked: false))
|
||||
writes.append(TicketWrite(
|
||||
key: ColorMatchingAttempts.quartzLegacyModeKey,
|
||||
value: ColorMatchingAttempts.quartzCustomMatching,
|
||||
locked: false))
|
||||
|
||||
// 6 — the Stage 2 paper token.
|
||||
var paperToken: String?
|
||||
if let paperSize = overrides.paperSize, !paperSize.isEmpty {
|
||||
paperToken = paperSize
|
||||
writes.append(TicketWrite(
|
||||
key: "PageSize", value: paperSize, locked: false))
|
||||
}
|
||||
|
||||
// 7 — media type via the queue's detected vendor key.
|
||||
if let mediaType = overrides.mediaType,
|
||||
let mediaKey = CupsParsers.detectMediaTypeKey(
|
||||
optionKeys: optionKeys) {
|
||||
writes.append(TicketWrite(
|
||||
key: mediaKey, value: mediaType, locked: false))
|
||||
}
|
||||
|
||||
// 8 — print quality; the detected enumeration key travels in
|
||||
// the overrides (`PrinterCapabilities.qualityKey`, #183).
|
||||
if let qualityKey = overrides.qualityKey,
|
||||
let quality = overrides.quality {
|
||||
writes.append(TicketWrite(
|
||||
key: qualityKey, value: quality, locked: false))
|
||||
}
|
||||
|
||||
// 9 — driver "no colour adjustment" bypass. Never gated on
|
||||
// `ppdUncorrectedPassthrough` — macOS always bypasses.
|
||||
if let bypass = CupsParsers.detectDriverColorBypass(
|
||||
optionKeys: optionKeys) {
|
||||
writes.append(TicketWrite(
|
||||
key: bypass.key, value: bypass.value, locked: false))
|
||||
}
|
||||
|
||||
// 10 — orientation, CUPS IPP codes portrait=3 / landscape=4.
|
||||
if let orientation = overrides.orientation {
|
||||
writes.append(TicketWrite(
|
||||
key: "orientation-requested",
|
||||
value: orientation == "landscape" ? "4" : "3",
|
||||
locked: false))
|
||||
}
|
||||
|
||||
return ResolvedTicketWrites(
|
||||
writes: writes.filter { $0.key.lowercased() != "raw" },
|
||||
paperToken: paperToken,
|
||||
orientation: overrides.orientation)
|
||||
}
|
||||
}
|
||||
@@ -218,7 +218,7 @@ struct Stage2View: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Raw print panel (#rawPrintPanel) — native spool path (#201)
|
||||
// MARK: - Raw print panel (#rawPrintPanel) — unmanaged lp path
|
||||
|
||||
private var printPanel: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
@@ -255,7 +255,6 @@ struct Stage2View: View {
|
||||
.onChange(of: workflow.print.selectedPrinter) { _ in
|
||||
workflow.print.selectedTray = nil
|
||||
workflow.print.selectedMediaType = nil
|
||||
workflow.print.selectedQuality = nil
|
||||
Task { @MainActor in await workflow.print.reloadSelectedCapabilities() }
|
||||
}
|
||||
if let selected = workflow.print.printers
|
||||
@@ -267,11 +266,6 @@ struct Stage2View: View {
|
||||
.clipShape(Capsule())
|
||||
.accessibilityIdentifier("printerStatusBadge")
|
||||
}
|
||||
// Placeholder — Phase 5 (AirPrint detection, M12)
|
||||
// replaces this with the live unmanaged-colour
|
||||
// warning badge for AirPrint queues.
|
||||
EmptyView()
|
||||
.accessibilityIdentifier("airPrintWarningBadge")
|
||||
Button(action: workflow.print.refreshPrinters) {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
}
|
||||
@@ -285,9 +279,7 @@ struct Stage2View: View {
|
||||
.accessibilityIdentifier("btnPrinterProperties")
|
||||
}
|
||||
|
||||
// Tray / media / paper / quality / orientation — from
|
||||
// queue capabilities. Extracted subviews keep every
|
||||
// ViewBuilder ≤10 children (R13).
|
||||
// Tray / media / orientation — from queue capabilities.
|
||||
HStack(spacing: 14) {
|
||||
if !workflow.print.printerCaps.trays.isEmpty {
|
||||
Picker("Tray", selection: $workflow.print.selectedTray) {
|
||||
@@ -309,12 +301,6 @@ struct Stage2View: View {
|
||||
.accessibilityIdentifier("mediaTypeGroup")
|
||||
.accessibilityIdentifier("printerMediaTypeSelect")
|
||||
}
|
||||
if !workflow.print.printerCaps.paperSizes.isEmpty {
|
||||
paperSizeGroup
|
||||
}
|
||||
if !workflow.print.printerCaps.qualities.isEmpty {
|
||||
qualityGroup
|
||||
}
|
||||
HStack(spacing: 0) {
|
||||
Button("Portrait") { workflow.print.printOrientation = "portrait" }
|
||||
.buttonStyle(.bordered)
|
||||
@@ -327,10 +313,27 @@ struct Stage2View: View {
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
// Stage 1 owns the custom dimensions — the caption lives
|
||||
// inside `paperSizeGroup` (#183).
|
||||
|
||||
printAllRow
|
||||
HStack(spacing: 8) {
|
||||
Button(action: {
|
||||
if let result = workflow.printtargResult {
|
||||
workflow.print.printAllPages(from: result, pageSize: workflow.pageSize)
|
||||
}
|
||||
}) {
|
||||
Label(workflow.print.isPrinting ? "Printing…" : "Print All",
|
||||
systemImage: "printer")
|
||||
}
|
||||
.controlSize(.large)
|
||||
.disabled(workflow.print.isPrinting
|
||||
|| workflow.printtargResult == nil
|
||||
|| workflow.print.selectedPrinter.isEmpty)
|
||||
.accessibilityIdentifier("btnPrintAll")
|
||||
Spacer()
|
||||
Button("Advance to Stage 3") { workflow.advanceToStage3() }
|
||||
.accessibilityIdentifier("btnAdvanceToStage3")
|
||||
.disabled(workflow.printtargResult == nil
|
||||
|| !workflow.wizard.isUnlocked(.measure))
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(Theme.panel)
|
||||
@@ -341,81 +344,6 @@ struct Stage2View: View {
|
||||
.onChange(of: workflow.printtargResult?.pages.count) { _ in
|
||||
schedulePrinterRefresh()
|
||||
}
|
||||
// Editable picker that re-mirrors Stage 1's pageSize (#183 E4).
|
||||
.onChange(of: workflow.pageSize) { _ in
|
||||
workflow.print.seedPaperSelection()
|
||||
}
|
||||
}
|
||||
|
||||
/// Print All + single-job granularity (#201 D5) + advance.
|
||||
/// Extracted so every ViewBuilder body stays ≤10 children (R13 —
|
||||
/// Xcode 14.2 has no `buildPartialBlock`).
|
||||
private var printAllRow: some View {
|
||||
HStack(spacing: 8) {
|
||||
Button(action: {
|
||||
if let result = workflow.printtargResult {
|
||||
workflow.print.printAllPages(from: result)
|
||||
}
|
||||
}) {
|
||||
Label(workflow.print.isPrinting ? "Printing…" : "Print All",
|
||||
systemImage: "printer")
|
||||
}
|
||||
.controlSize(.large)
|
||||
.disabled(workflow.print.isPrinting
|
||||
|| workflow.printtargResult == nil
|
||||
|| workflow.print.selectedPrinter.isEmpty)
|
||||
.accessibilityIdentifier("btnPrintAll")
|
||||
Toggle("Single spool job",
|
||||
isOn: $workflow.print.singleJobForAllPages)
|
||||
.help("Send all pages as one print job instead of "
|
||||
+ "one job per page")
|
||||
.accessibilityIdentifier("chkSingleSpoolJob")
|
||||
Spacer()
|
||||
Button("Advance to Stage 3") { workflow.advanceToStage3() }
|
||||
.accessibilityIdentifier("btnAdvanceToStage3")
|
||||
.disabled(workflow.printtargResult == nil
|
||||
|| !workflow.wizard.isUnlocked(.measure))
|
||||
}
|
||||
}
|
||||
|
||||
/// Paper picker + custom-size caption under the `paperSizeGroup`
|
||||
/// container (#183). `caps.paperSizes` plus the synthetic custom
|
||||
/// entry (`id: 0`, shown as `Custom (W×H mm)`).
|
||||
private var paperSizeGroup: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Picker("Paper", selection: $workflow.print.selectedPaperSize) {
|
||||
ForEach(workflow.print.printerCaps.paperSizes, id: \.id) { size in
|
||||
Text(size.id == 0
|
||||
? "Custom (\(Int(workflow.customPageW))×\(Int(workflow.customPageH)) mm)"
|
||||
: size.name)
|
||||
.tag(Optional(size.id))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 200)
|
||||
.accessibilityIdentifier("printerPaperSizeSelect")
|
||||
if workflow.print.selectedPaperSize == 0 {
|
||||
Text("Custom (\(Int(workflow.customPageW))×\(Int(workflow.customPageH)) mm)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityIdentifier("paperSizeGroup")
|
||||
}
|
||||
|
||||
/// Quality picker — driver tokens with PPD-enriched labels (#183).
|
||||
private var qualityGroup: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Picker("Quality", selection: $workflow.print.selectedQuality) {
|
||||
ForEach(workflow.print.printerCaps.qualities, id: \.id) {
|
||||
Text($0.name).tag(Optional($0.id))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 200)
|
||||
.accessibilityIdentifier("printerQualitySelect")
|
||||
}
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityIdentifier("qualityGroup")
|
||||
}
|
||||
|
||||
/// Auto-enumerates printers once a manifest exists and whenever it
|
||||
@@ -464,7 +392,7 @@ private struct GalleryPageView: View {
|
||||
Text("\(page.page.patches) patches · " +
|
||||
"\(Int(page.page.widthMm))×\(Int(page.page.heightMm)) mm")
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
Button("Print") { workflow.print.printPage(page) }
|
||||
Button("Print") { workflow.print.printPage(page, pageSize: workflow.pageSize) }
|
||||
.disabled(workflow.print.isPrinting
|
||||
|| workflow.print.selectedPrinter.isEmpty)
|
||||
.accessibilityIdentifier("btnPrintPage-\(page.index)")
|
||||
|
||||
@@ -137,9 +137,6 @@ final class TargetWorkflowViewModel: ObservableObject {
|
||||
environment: environment
|
||||
)
|
||||
self.print = PrintSessionViewModel(wizard: wizard, environment: environment)
|
||||
// Paper-size seeding reads the Stage 1 form through this weak
|
||||
// back-reference; the print side never writes it (#183).
|
||||
self.print.workflow = self
|
||||
self.calibration = nil
|
||||
self.calibration = CalibrationViewModel(
|
||||
workflow: self,
|
||||
|
||||
@@ -102,62 +102,6 @@ final class TiffPreviewTests: XCTestCase {
|
||||
}
|
||||
}
|
||||
|
||||
final class AppInfoTests: XCTestCase {
|
||||
// MARK: displayVersion (#189)
|
||||
|
||||
func testNoTagFallsBackToShortVersion() {
|
||||
XCTAssertEqual(
|
||||
ArtefactFiles.displayVersion(shortVersion: "2.0.0", releaseTag: ""),
|
||||
"2.0.0"
|
||||
)
|
||||
}
|
||||
|
||||
func testWhitespaceTagFallsBackToShortVersion() {
|
||||
XCTAssertEqual(
|
||||
ArtefactFiles.displayVersion(shortVersion: "2.0.0", releaseTag: " "),
|
||||
"2.0.0"
|
||||
)
|
||||
}
|
||||
|
||||
func testExactReleaseTagDedupes() {
|
||||
XCTAssertEqual(
|
||||
ArtefactFiles.displayVersion(shortVersion: "2.0.0", releaseTag: "v2.0.0"),
|
||||
"v2.0.0"
|
||||
)
|
||||
}
|
||||
|
||||
func testBareVersionTagDedupes() {
|
||||
XCTAssertEqual(
|
||||
ArtefactFiles.displayVersion(shortVersion: "2.0.0", releaseTag: "2.0.0"),
|
||||
"2.0.0"
|
||||
)
|
||||
}
|
||||
|
||||
func testPrereleaseTagShowsMarketingInParens() {
|
||||
XCTAssertEqual(
|
||||
ArtefactFiles.displayVersion(
|
||||
shortVersion: "2.0.0", releaseTag: "v2.0.0-pre2-grok"),
|
||||
"v2.0.0-pre2-grok (2.0.0)"
|
||||
)
|
||||
}
|
||||
|
||||
func testDescribeStringShowsMarketingInParens() {
|
||||
XCTAssertEqual(
|
||||
ArtefactFiles.displayVersion(
|
||||
shortVersion: "2.0.0", releaseTag: "v2.0.0-5-gdeadbee"),
|
||||
"v2.0.0-5-gdeadbee (2.0.0)"
|
||||
)
|
||||
}
|
||||
|
||||
func testAppInfoUsesComposedVersion() {
|
||||
// .main in the test host is the ICCery app under test; whatever it
|
||||
// resolves to, build must be a non-empty digits-or-default string.
|
||||
let info = ArtefactFiles.appInfo()
|
||||
XCTAssertFalse(info.version.isEmpty)
|
||||
XCTAssertFalse(info.build.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
final class ArtefactFilesTests: XCTestCase {
|
||||
func testBase64RoundTrip() throws {
|
||||
let url = tempURL("a.txt")
|
||||
|
||||
@@ -24,17 +24,6 @@ final class CupsOptionsFilterTests: XCTestCase {
|
||||
XCTAssertEqual(CupsOptionsFilter.filter(raw), raw)
|
||||
}
|
||||
|
||||
/// #180 — a captured `EPIJ_Qual` (and the other canonical quality
|
||||
/// keys) survives the filter so it reaches the Stage 2 mirror.
|
||||
func testKeepsQualityKeys() {
|
||||
let raw = "EPIJ_Qual=304 CNIJPrintQuality=3 PrintQuality=2 "
|
||||
+ "cupsPrintQuality=High Quality=Best "
|
||||
+ "com.apple.print.JobTicket.PMTotalSidesImaged=0"
|
||||
XCTAssertEqual(CupsOptionsFilter.filter(raw),
|
||||
"EPIJ_Qual=304 CNIJPrintQuality=3 PrintQuality=2 "
|
||||
+ "cupsPrintQuality=High Quality=Best")
|
||||
}
|
||||
|
||||
func testKeepsUnknown() {
|
||||
let raw = "VendorFooBar=baz MediaType=Plain"
|
||||
XCTAssertEqual(CupsOptionsFilter.filter(raw), raw)
|
||||
|
||||
@@ -4,7 +4,7 @@ import Foundation
|
||||
|
||||
/// Issue 12 — CUPS enumeration parsers on recorded fixtures
|
||||
/// (docs/10–11). No live `lpstat`/`lpoptions` is spawned here.
|
||||
final class CupsParserTests: XCTestCase {
|
||||
final class CupsParsersTests: XCTestCase {
|
||||
|
||||
// Recorded on an Epson XP-55 + Canon Pro9500 host.
|
||||
private let lpstatE = """
|
||||
@@ -30,7 +30,7 @@ final class CupsParserTests: XCTestCase {
|
||||
MediaType/Media Type: *Stationery PhotographicHighGloss Photographic PhotographicMatte Envelope
|
||||
ColorModel/Output Mode: *RGB Gray
|
||||
Duplex/Duplex: *None DuplexNoTumble DuplexTumble
|
||||
EPIJ_Qual/Print Quality: 301 302 *303 308 304 305 307
|
||||
cupsPrintQuality/cupsPrintQuality: Draft *Normal High
|
||||
"""
|
||||
|
||||
func testDestinations() {
|
||||
@@ -117,157 +117,6 @@ final class CupsParserTests: XCTestCase {
|
||||
XCTAssertNil(CupsParsers.detectMediaTypeKey(optionKeys: ["PageSize"]))
|
||||
}
|
||||
|
||||
// MARK: - #183 quality key + option extraction
|
||||
|
||||
func testQualityKeyRosterOrder() {
|
||||
// Vendor keys beat the generic ones; OutputMode/Resolution sit
|
||||
// last (they are colour-ish keys on some drivers — #183/#180).
|
||||
XCTAssertEqual(CupsParsers.detectQualityKey(
|
||||
optionKeys: ["EPIJ_Qual", "Quality", "OutputMode"]), "EPIJ_Qual")
|
||||
// A full Epson key set — EPIJ_Qual wins over the colour-mode
|
||||
// key, the generic keys, and Resolution (#180, R11).
|
||||
XCTAssertEqual(CupsParsers.detectQualityKey(
|
||||
optionKeys: ["EPIJ_Qual", "OutputMode", "Resolution",
|
||||
"cupsPrintQuality", "PrintQuality",
|
||||
"ColorModel"]), "EPIJ_Qual")
|
||||
XCTAssertEqual(CupsParsers.detectQualityKey(
|
||||
optionKeys: ["Quality", "OutputMode", "Resolution"]), "Quality")
|
||||
XCTAssertEqual(CupsParsers.detectQualityKey(
|
||||
optionKeys: ["cupsPrintQuality", "CNIJQuality"]),
|
||||
"CNIJQuality")
|
||||
XCTAssertEqual(CupsParsers.detectQualityKey(
|
||||
optionKeys: ["OutputMode", "Resolution"]), "OutputMode")
|
||||
XCTAssertEqual(CupsParsers.detectQualityKey(
|
||||
optionKeys: ["Resolution"]), "Resolution")
|
||||
XCTAssertNil(CupsParsers.detectQualityKey(optionKeys: ["PageSize"]))
|
||||
}
|
||||
|
||||
func testCapabilitiesQuality() {
|
||||
let service = CupsService()
|
||||
let listings = CupsParsers.lpoptionsList(lpoptionsL)
|
||||
let caps = service.capabilities(from: listings, ppd: nil)
|
||||
|
||||
// EPIJ_Qual is the roster member — all seven Epson codes
|
||||
// enumerate in the driver's own (non-sorted) order (#180).
|
||||
XCTAssertEqual(caps.qualityKey, "EPIJ_Qual")
|
||||
XCTAssertEqual(caps.qualities.map(\.id),
|
||||
["301", "302", "303", "308", "304", "305", "307"])
|
||||
XCTAssertEqual(caps.qualityDefault, "303")
|
||||
}
|
||||
|
||||
func testCapabilitiesQualityPpdLabels() {
|
||||
// Epson XP-55 PPD fragment — the seven `*EPIJ_Qual id/Label`
|
||||
// lines in the driver's own order (#180).
|
||||
let ppd = """
|
||||
*OpenUI *EPIJ_Qual/Print Quality: PickOne
|
||||
*DefaultEPIJ_Qual: 303
|
||||
*EPIJ_Qual 301/Fast Economy: ""
|
||||
*EPIJ_Qual 302/Economy: ""
|
||||
*EPIJ_Qual 303/Normal: ""
|
||||
*EPIJ_Qual 308/Draft: ""
|
||||
*EPIJ_Qual 304/Fine: ""
|
||||
*EPIJ_Qual 305/Quality: ""
|
||||
*EPIJ_Qual 307/Best Quality: ""
|
||||
*CloseUI: *EPIJ_Qual
|
||||
"""
|
||||
let service = CupsService()
|
||||
let listings = CupsParsers.lpoptionsList(
|
||||
"EPIJ_Qual/Print Quality: 301 302 *303 308 304 305 307\n")
|
||||
let caps = service.capabilities(from: listings, ppd: ppd)
|
||||
|
||||
XCTAssertEqual(caps.qualityKey, "EPIJ_Qual")
|
||||
XCTAssertEqual(caps.qualities, [
|
||||
PrinterQuality(id: "301", name: "Fast Economy"),
|
||||
PrinterQuality(id: "302", name: "Economy"),
|
||||
PrinterQuality(id: "303", name: "Normal"),
|
||||
PrinterQuality(id: "308", name: "Draft"),
|
||||
PrinterQuality(id: "304", name: "Fine"),
|
||||
PrinterQuality(id: "305", name: "Quality"),
|
||||
PrinterQuality(id: "307", name: "Best Quality"),
|
||||
])
|
||||
XCTAssertEqual(caps.qualityDefault, "303")
|
||||
}
|
||||
|
||||
/// #180 — the Epson listing also carries `OutputMode` (a colour
|
||||
/// mode) and `Resolution`; detection must still pick `EPIJ_Qual`.
|
||||
func testCapabilitiesQualityEpsonDetection() {
|
||||
let service = CupsService()
|
||||
let listings = CupsParsers.lpoptionsList("""
|
||||
PageSize/Media Size: *A4 Letter
|
||||
EPIJ_Qual/Print Quality: 301 302 *303 308 304 305 307
|
||||
OutputMode/Color Mode: *Color Mono
|
||||
Resolution/Resolution: *360dpi 720dpi
|
||||
""")
|
||||
let caps = service.capabilities(from: listings, ppd: nil)
|
||||
|
||||
XCTAssertEqual(caps.qualityKey, "EPIJ_Qual")
|
||||
XCTAssertEqual(caps.qualities.count, 7)
|
||||
XCTAssertEqual(caps.qualityDefault, "303")
|
||||
XCTAssertFalse(caps.qualities.contains { $0.id == "Color" })
|
||||
}
|
||||
|
||||
func testExtractOption() {
|
||||
let options = "PageSize=A4 EPIJ_Qual=303 printer-info='EPSON XP-55'"
|
||||
XCTAssertEqual(CupsParsers.extractOption(
|
||||
named: "PageSize", fromOptionsString: options), "A4")
|
||||
// Case-insensitive key match.
|
||||
XCTAssertEqual(CupsParsers.extractOption(
|
||||
named: "epij_qual", fromOptionsString: options), "303")
|
||||
// Quoted values come back unquoted.
|
||||
XCTAssertEqual(CupsParsers.extractOption(
|
||||
named: "printer-info", fromOptionsString: options), "EPSON XP-55")
|
||||
XCTAssertNil(CupsParsers.extractOption(
|
||||
named: "InputSlot", fromOptionsString: options))
|
||||
}
|
||||
|
||||
func testExtractQualityAndOrientation() {
|
||||
let options = "orientation-requested=4 OutputMode=Gray EPIJ_Qual=305"
|
||||
XCTAssertEqual(CupsParsers.extractQuality(
|
||||
fromOptionsString: options), "305")
|
||||
XCTAssertEqual(CupsParsers.extractOrientation(
|
||||
fromOptionsString: options), "landscape")
|
||||
XCTAssertNil(CupsParsers.extractQuality(
|
||||
fromOptionsString: "PageSize=A4"))
|
||||
XCTAssertNil(CupsParsers.extractOrientation(
|
||||
fromOptionsString: "PageSize=A4"))
|
||||
}
|
||||
|
||||
// MARK: - #186 capture-return
|
||||
|
||||
/// A captured `k=v` string maps to all four `PrintOptions`
|
||||
/// fields — `PageSize`, the detected quality key,
|
||||
/// `orientation-requested`, and a vendor media key (#186).
|
||||
func testCapturedStringMapsAllFields() {
|
||||
let captured =
|
||||
"PageSize=A4 EPIJ_Qual=305 orientation-requested=4 CNIJMediaType=Photo"
|
||||
XCTAssertEqual(CupsParsers.extractOption(
|
||||
named: "PageSize", fromOptionsString: captured), "A4")
|
||||
XCTAssertEqual(CupsParsers.extractQuality(
|
||||
fromOptionsString: captured), "305")
|
||||
XCTAssertEqual(CupsParsers.extractOrientation(
|
||||
fromOptionsString: captured), "landscape")
|
||||
XCTAssertEqual(CupsParsers.extractMediaType(
|
||||
fromOptionsString: captured), "Photo")
|
||||
}
|
||||
|
||||
/// Vendor media keys beyond `MediaType`/`EPIJ_Medi` extract via
|
||||
/// the detection roster — `CNIJMediaType`/`StpMediaType` included
|
||||
/// (#186). `MediaType` still wins when present alongside them.
|
||||
func testExtractMediaTypeRosterFallback() {
|
||||
XCTAssertEqual(CupsParsers.extractMediaType(
|
||||
fromOptionsString: "CNIJMediaType=PhotoPlus"), "PhotoPlus")
|
||||
XCTAssertEqual(CupsParsers.extractMediaType(
|
||||
fromOptionsString: "StpMediaType=Glossy"), "Glossy")
|
||||
XCTAssertEqual(CupsParsers.extractMediaType(
|
||||
fromOptionsString: "EPIJ_Medi=Photo"), "Photo")
|
||||
// `MediaType` keeps first precedence (docs/11 §tests).
|
||||
XCTAssertEqual(CupsParsers.extractMediaType(
|
||||
fromOptionsString: "CNIJMediaType=PhotoPlus MediaType=Plain"),
|
||||
"Plain")
|
||||
XCTAssertNil(CupsParsers.extractMediaType(
|
||||
fromOptionsString: "PageSize=A4"))
|
||||
}
|
||||
|
||||
func testDriverBypass() {
|
||||
func pair(_ keys: Set<String>) -> String? {
|
||||
CupsParsers.detectDriverColorBypass(optionKeys: keys)
|
||||
@@ -282,177 +131,4 @@ final class CupsParserTests: XCTestCase {
|
||||
XCTAssertEqual(pair(["EpsonColorMode"]), "EpsonColorMode=Off")
|
||||
XCTAssertNil(pair(["PageSize"]))
|
||||
}
|
||||
|
||||
// MARK: - #181 Canon media locale precedence + PPD encoding
|
||||
|
||||
/// The 18 Canon Pro9500 media types named in the issue — the ids
|
||||
/// are the numeric codes the driver enumerates via `lpoptions -l`.
|
||||
private let canonMedia: [(id: String, label: String)] = [
|
||||
("0", "Plain Paper"),
|
||||
("1", "Photo Paper Plus Glossy II"),
|
||||
("2", "Photo Paper Pro Platinum N"),
|
||||
("3", "Photo Paper Pro Platinum"),
|
||||
("4", "Photo Paper Pro Luster"),
|
||||
("5", "Photo Paper Plus Semi-gloss"),
|
||||
("6", "Matte Photo Paper"),
|
||||
("7", "Fine Art \"Photo Rag\""),
|
||||
("8", "Fine Art \"Museum Etching\""),
|
||||
("9", "Photo Paper Pro Premium Matte"),
|
||||
("10", "Fine Art Premium Matte"),
|
||||
("11", "Other Fine Art Paper"),
|
||||
("12", "Canvas"),
|
||||
("13", "Board Paper"),
|
||||
("14", "Ink Jet Hagaki"),
|
||||
("15", "Hagaki"),
|
||||
("16", "Printable disc"),
|
||||
("17", "Printable disc (bleed-proof)"),
|
||||
]
|
||||
|
||||
/// Canon Pro9500-shaped fragment: the unqualified base block comes
|
||||
/// early and the `th.` block trails at the end — the ordering that
|
||||
/// let Thai overwrite English under last-write-wins (#181).
|
||||
private var canonPPD: String {
|
||||
var lines = [
|
||||
"*OpenUI *CNIJMediaType/Media Type: PickOne",
|
||||
"*DefaultCNIJMediaType: 0",
|
||||
]
|
||||
for media in canonMedia {
|
||||
lines.append(
|
||||
"*CNIJMediaType \(media.id)/\(media.label): \"\"")
|
||||
}
|
||||
lines.append("*CloseUI: *CNIJMediaType")
|
||||
for media in canonMedia {
|
||||
lines.append(
|
||||
"*th.CNIJMediaType \(media.id)/กระดาษ\(media.id): \"\"")
|
||||
}
|
||||
return lines.joined(separator: "\n")
|
||||
}
|
||||
|
||||
func testPpdLabelsUnqualifiedSurvivesTrailingThai() {
|
||||
let labels = CupsParsers.ppdChoiceLabels(
|
||||
canonPPD, key: "CNIJMediaType")
|
||||
XCTAssertEqual(labels["0"], "Plain Paper")
|
||||
XCTAssertEqual(labels["17"], "Printable disc (bleed-proof)")
|
||||
}
|
||||
|
||||
func testPpdLabelsUnqualifiedWinsRegardlessOfOrder() {
|
||||
// `th.` block first — precedence is deterministic, not
|
||||
// positional (#181, E3).
|
||||
let ppd = """
|
||||
*th.CNIJMediaType 0/กระดาษธรรมดา: ""
|
||||
*CNIJMediaType 0/Plain Paper: ""
|
||||
"""
|
||||
let labels = CupsParsers.ppdChoiceLabels(ppd, key: "CNIJMediaType")
|
||||
XCTAssertEqual(labels["0"], "Plain Paper")
|
||||
}
|
||||
|
||||
func testPpdLabelsQualifiedFallbackOrder() {
|
||||
// en_US > en > first-qualified-seen (#181, E3).
|
||||
let ppd = """
|
||||
*en.CNIJMediaType 1/English Label: ""
|
||||
*en_US.CNIJMediaType 1/US English Label: ""
|
||||
*th.CNIJMediaType 1/กระดาษ: ""
|
||||
*fr.CNIJMediaType 2/Français: ""
|
||||
*de.CNIJMediaType 2/Deutsch: ""
|
||||
"""
|
||||
let labels = CupsParsers.ppdChoiceLabels(ppd, key: "CNIJMediaType")
|
||||
XCTAssertEqual(labels["1"], "US English Label")
|
||||
// A qualified-only id still gets its first-seen qualified
|
||||
// label — never left unlabeled (R9).
|
||||
XCTAssertEqual(labels["2"], "Français")
|
||||
}
|
||||
|
||||
func testPpdLabelsHexEscapeDecoding() {
|
||||
let ppd = """
|
||||
*CNIJMediaType 3/Photo Paper Plus Glossy<2F>Matte: ""
|
||||
*CNIJMediaType 4/Plain<20>Paper: ""
|
||||
*CNIJMediaType 5/Bad<ZZ>Escape: ""
|
||||
"""
|
||||
let labels = CupsParsers.ppdChoiceLabels(ppd, key: "CNIJMediaType")
|
||||
XCTAssertEqual(labels["3"], "Photo Paper Plus Glossy/Matte")
|
||||
XCTAssertEqual(labels["4"], "Plain Paper")
|
||||
XCTAssertEqual(labels["5"], "Bad<ZZ>Escape")
|
||||
}
|
||||
|
||||
/// Every `CNIJMediaType` choice enumerated by `lpoptions -l` gets a
|
||||
/// non-Thai label (E1 — the true count is the hardware gate's, so
|
||||
/// no count is hardcoded here); the 18 named AC labels are
|
||||
/// spot-checked.
|
||||
func testCapabilitiesCanonMediaAllNonThai() {
|
||||
var choices = canonMedia.map(\.id)
|
||||
choices[0] = "*\(choices[0])"
|
||||
let listings = CupsParsers.lpoptionsList(
|
||||
"CNIJMediaType/Media Type: \(choices.joined(separator: " "))\n")
|
||||
let caps = CupsService().capabilities(from: listings, ppd: canonPPD)
|
||||
|
||||
XCTAssertEqual(caps.mediaTypes.count, canonMedia.count)
|
||||
for type in caps.mediaTypes {
|
||||
XCTAssertFalse(type.name.unicodeScalars.contains {
|
||||
(0x0E00...0x0E7F).contains($0.value)
|
||||
}, "Thai label leaked into \(type.id): \(type.name)")
|
||||
}
|
||||
for media in canonMedia {
|
||||
XCTAssertEqual(
|
||||
caps.mediaTypes.first { $0.id == media.id }?.name,
|
||||
media.label)
|
||||
}
|
||||
}
|
||||
|
||||
/// UTF-8 PPD carrying Thai labels decodes intact — the English
|
||||
/// base block wins precedence and no mojibake leaks through (#181,
|
||||
/// R10). Exercises `loadPPD` through `capabilities(for:)`.
|
||||
func testLoadPPDUtf8ThaiSurvivesDecode() async throws {
|
||||
let (service, root) = try makeCupsService(
|
||||
ppdData: Data(canonPPD.utf8),
|
||||
listing: "CNIJMediaType/Media Type: *0 1")
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
|
||||
let caps = try await service.capabilities(for: "Canon_Test")
|
||||
XCTAssertEqual(caps.mediaTypes.map(\.name),
|
||||
["Plain Paper", "Photo Paper Plus Glossy II"])
|
||||
}
|
||||
|
||||
/// A PPD that is not valid UTF-8 (lone `0xE9` for `é`) falls back
|
||||
/// to ISO-Latin-1 instead of yielding nil → raw ids (#181, R10).
|
||||
func testLoadPPDLatin1Fallback() async throws {
|
||||
let ppd = "*CNIJMediaType 0/Papier Couché: \"\"\n"
|
||||
let (service, root) = try makeCupsService(
|
||||
ppdData: ppd.data(using: .isoLatin1)!,
|
||||
listing: "CNIJMediaType/Media Type: *0")
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
|
||||
let caps = try await service.capabilities(for: "Canon_Test")
|
||||
XCTAssertEqual(caps.mediaTypes,
|
||||
[PrinterMediaType(id: "0", name: "Papier Couché")])
|
||||
}
|
||||
|
||||
/// Fixture `lpoptions` + `ppdDir` so `capabilities(for:)` reaches
|
||||
/// the private `loadPPD` — same mock style as
|
||||
/// `MediaLibraryViewModelTests.installMockCups`.
|
||||
private func makeCupsService(
|
||||
ppdData: Data,
|
||||
listing: String,
|
||||
queue: String = "Canon_Test"
|
||||
) throws -> (CupsService, URL) {
|
||||
let root = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("iccery-ppd-\(UUID().uuidString)")
|
||||
let bin = root.appendingPathComponent("bin")
|
||||
let ppdDir = root.appendingPathComponent("ppd")
|
||||
for dir in [bin, ppdDir] {
|
||||
try FileManager.default.createDirectory(
|
||||
at: dir, withIntermediateDirectories: true)
|
||||
}
|
||||
let lpoptions = """
|
||||
#!/bin/sh
|
||||
printf '%s\\n' '\(listing)'
|
||||
"""
|
||||
let scriptURL = bin.appendingPathComponent("lpoptions")
|
||||
try lpoptions.write(to: scriptURL, atomically: true, encoding: .utf8)
|
||||
try FileManager.default.setAttributes(
|
||||
[.posixPermissions: 0o755], ofItemAtPath: scriptURL.path)
|
||||
try ppdData.write(to: ppdDir.appendingPathComponent("\(queue).ppd"))
|
||||
return (CupsService(
|
||||
processManager: ProcessManager(),
|
||||
binaryDir: bin, ppdDir: ppdDir), root)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import XCTest
|
||||
import Foundation
|
||||
@testable import ICCeryCore
|
||||
|
||||
/// Issue 15 — `lp` argv goldens (docs/11 `build_lp_args`).
|
||||
/// `-d`/`options`/`-t` handling is in `CupsService`; these tests cover
|
||||
/// flag order, captured-option precedence, and sanitisation.
|
||||
final class LpArgsTests: XCTestCase {
|
||||
|
||||
private let tiff = "/tmp/work/target_001.tif"
|
||||
private let queue = "EPSON_XP_55_Series"
|
||||
|
||||
private func build(
|
||||
options: PrintOptions = PrintOptions(),
|
||||
optionKeys: Set<String> = []
|
||||
) throws -> [String] {
|
||||
try LpArgs.build(
|
||||
queue: queue, tiffPath: tiff,
|
||||
options: options, optionKeys: optionKeys)
|
||||
}
|
||||
|
||||
func testHeader() throws {
|
||||
let argv = try build()
|
||||
XCTAssertEqual(Array(argv[0...1]), ["-d", queue])
|
||||
XCTAssertEqual(Array(argv[2...3]), ["-t", "ICCery Target - target_001.tif"])
|
||||
XCTAssertEqual(Array(argv[4...5]), ["-o", "AP_ColorMatchingMode=AP_ApplicationColorMatching"])
|
||||
XCTAssertEqual(Array(argv[6...7]), ["-o", "AP.ColorMatchingMode=AP_ApplicationColorMatching"])
|
||||
XCTAssertEqual(argv.last, tiff)
|
||||
XCTAssertFalse(argv.contains { $0 == "raw" || $0 == "-o raw" })
|
||||
}
|
||||
|
||||
func testNeverRaw() throws {
|
||||
let argv = try build(options: PrintOptions(
|
||||
cupsOptions: "raw=true MediaType=Photo"))
|
||||
for (i, arg) in argv.enumerated() where arg == "-o" {
|
||||
XCTAssertNotEqual(argv[i + 1], "raw")
|
||||
XCTAssertNotEqual(argv[i + 1], "raw=true")
|
||||
}
|
||||
XCTAssertFalse(argv.contains { $0.hasPrefix("raw=") })
|
||||
XCTAssertTrue(argv.contains("MediaType=Photo"))
|
||||
}
|
||||
|
||||
func testCapturedReplay() throws {
|
||||
let argv = try build(options: PrintOptions(
|
||||
cupsOptions: "InputSlot=Rear MediaType=Photo"))
|
||||
let rear = argv.firstIndex(of: "InputSlot=Rear")!
|
||||
let apFirst = argv.firstIndex(of:
|
||||
"AP_ColorMatchingMode=AP_ApplicationColorMatching")!
|
||||
XCTAssertTrue(rear > apFirst)
|
||||
}
|
||||
|
||||
func testCapturedWinsMedia() throws {
|
||||
let argv = try build(
|
||||
options: PrintOptions(
|
||||
mediaType: "Plain",
|
||||
cupsOptions: "MediaType=Glossy"),
|
||||
optionKeys: ["MediaType"])
|
||||
XCTAssertTrue(argv.contains("MediaType=Glossy"))
|
||||
XCTAssertFalse(argv.contains("MediaType=Plain"))
|
||||
}
|
||||
|
||||
func testMediaDerived() throws {
|
||||
let argv = try build(
|
||||
options: PrintOptions(mediaType: "SemiGloss"),
|
||||
optionKeys: ["CNIJMediaType", "MediaType"])
|
||||
// CNIJMediaType wins over MediaType in detection order.
|
||||
XCTAssertTrue(argv.contains("CNIJMediaType=SemiGloss"))
|
||||
XCTAssertFalse(argv.contains("MediaType=SemiGloss"))
|
||||
}
|
||||
|
||||
func testBypassRules() throws {
|
||||
let withBypass = try build(
|
||||
optionKeys: ["EPIJ_CMat"])
|
||||
XCTAssertTrue(withBypass.contains("EPIJ_CMat=3"))
|
||||
|
||||
let captured = try build(
|
||||
options: PrintOptions(cupsOptions: "EPIJ_CMat=1"),
|
||||
optionKeys: ["EPIJ_CMat"])
|
||||
// Captured value kept, detection not re-applied.
|
||||
XCTAssertEqual(captured.filter { $0.hasPrefix("EPIJ_CMat") }, ["EPIJ_CMat=1"])
|
||||
}
|
||||
|
||||
func testOrientation() throws {
|
||||
XCTAssertTrue(try build(options: PrintOptions(orientation: "portrait"))
|
||||
.contains("orientation-requested=3"))
|
||||
XCTAssertTrue(try build(options: PrintOptions(orientation: "landscape"))
|
||||
.contains("orientation-requested=4"))
|
||||
let capturedOrients = try build(options: PrintOptions(
|
||||
orientation: "landscape",
|
||||
cupsOptions: "orientation-requested=5"))
|
||||
XCTAssertFalse(capturedOrients.contains("orientation-requested=4"))
|
||||
XCTAssertTrue(capturedOrients.contains("orientation-requested=5"))
|
||||
}
|
||||
|
||||
func testPageSize() throws {
|
||||
XCTAssertTrue(try build(options: PrintOptions(paperSize: "A4"))
|
||||
.contains("PageSize=A4"))
|
||||
let capturedSize = try build(options: PrintOptions(
|
||||
paperSize: "A4", cupsOptions: "PageSize=Letter"))
|
||||
XCTAssertFalse(capturedSize.contains("PageSize=A4"))
|
||||
XCTAssertTrue(capturedSize.contains("PageSize=Letter"))
|
||||
}
|
||||
|
||||
func testSanitise() throws {
|
||||
XCTAssertThrowsError(try build(options: PrintOptions(
|
||||
cupsOptions: "InputSlot=Rear;rm -rf /"))) { error in
|
||||
XCTAssertTrue(error is LpArgsError)
|
||||
}
|
||||
XCTAssertThrowsError(try build(options: PrintOptions(
|
||||
cupsOptions: "InputSlot=Rear\nMediaType=Photo"))) { error in
|
||||
XCTAssertTrue(error is LpArgsError)
|
||||
}
|
||||
XCTAssertThrowsError(try build(options: PrintOptions(
|
||||
cupsOptions: "InputSlot=$(whoami)"))) { error in
|
||||
XCTAssertTrue(error is LpArgsError)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
import AppKit
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
import XCTest
|
||||
@testable import ICCery
|
||||
|
||||
/// Issue #201 Phase 3 — real `NSPrintOperation` with
|
||||
/// `jobDisposition = .save`: proves the canvas paginates at paper size,
|
||||
/// draws the raster 1:1 at the top-left, and survives end-to-end
|
||||
/// without a printer. Skippable via `ICCERY_SKIP_PRINT_PDF=1` for
|
||||
/// hosts where the print system cannot run (R8).
|
||||
@MainActor
|
||||
final class NativeSpoolPDFTests: XCTestCase {
|
||||
|
||||
private var tempRoot: URL!
|
||||
|
||||
/// A4 in points.
|
||||
private let paperSize = NSSize(width: 595.28, height: 841.89)
|
||||
/// Known top-left patch colour — solid fill across the fixture.
|
||||
private let patchBytes: [UInt8] = [230, 40, 50]
|
||||
|
||||
override func setUpWithError() throws {
|
||||
tempRoot = try TargetTestFixtures.makeTempDirectory()
|
||||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
try? FileManager.default.removeItem(at: tempRoot)
|
||||
}
|
||||
|
||||
func testSavePDFDrawsPagesOneToOne() throws {
|
||||
try XCTSkipIf(
|
||||
ProcessInfo.processInfo.environment["ICCERY_SKIP_PRINT_PDF"]
|
||||
== "1",
|
||||
"ICCERY_SKIP_PRINT_PDF=1 — print system unavailable")
|
||||
|
||||
// 72×72 px @ 72 dpi → a 72×72 pt solid block at the top-left.
|
||||
let tiff = try TargetTestFixtures.makeTIFF(
|
||||
px: CGSize(width: 72, height: 72), dpi: 72,
|
||||
components: 3, bitsPerComponent: 8,
|
||||
pixelBytes: patchBytes, in: tempRoot)
|
||||
let raster = try TargetRasterLoader.load(tiff: tiff)
|
||||
|
||||
let info = NSPrintInfo()
|
||||
info.paperSize = paperSize
|
||||
info.orientation = .portrait
|
||||
info.topMargin = 0
|
||||
info.bottomMargin = 0
|
||||
info.leftMargin = 0
|
||||
info.rightMargin = 0
|
||||
info.horizontalPagination = .clip
|
||||
info.verticalPagination = .clip
|
||||
info.scalingFactor = 1.0
|
||||
info.isHorizontallyCentered = false
|
||||
info.isVerticallyCentered = false
|
||||
info.jobDisposition = .save
|
||||
let pdfURL = tempRoot.appendingPathComponent("spool.pdf")
|
||||
info.dictionary()[NSPrintInfo.AttributeKey.jobSavingURL] = pdfURL
|
||||
|
||||
let canvas = TargetPageCanvasView(
|
||||
pages: [raster, raster], paperSize: paperSize)
|
||||
let operation = NSPrintOperation(view: canvas, printInfo: info)
|
||||
operation.showsPrintPanel = false
|
||||
operation.showsProgressPanel = false
|
||||
operation.canSpawnSeparateThread = false
|
||||
|
||||
XCTAssertTrue(operation.run(),
|
||||
"NSPrintOperation.save failed")
|
||||
XCTAssertTrue(
|
||||
FileManager.default.fileExists(atPath: pdfURL.path))
|
||||
|
||||
let document = try XCTUnwrap(
|
||||
CGPDFDocument(pdfURL as CFURL))
|
||||
XCTAssertEqual(document.numberOfPages, 2)
|
||||
|
||||
let page = try XCTUnwrap(document.page(at: 1))
|
||||
let mediaBox = page.getBoxRect(.mediaBox)
|
||||
XCTAssertEqual(mediaBox.width, paperSize.width, accuracy: 0.5)
|
||||
XCTAssertEqual(mediaBox.height, paperSize.height, accuracy: 0.5)
|
||||
|
||||
try assertTopLeftPatch(on: page, mediaBox: mediaBox)
|
||||
}
|
||||
|
||||
/// Render the page at 1 px/pt and check the fixture's solid block
|
||||
/// landed at the top-left at its point size — the coarse 1:1 +
|
||||
/// no-scaling check.
|
||||
private func assertTopLeftPatch(
|
||||
on page: CGPDFPage, mediaBox: CGRect
|
||||
) throws {
|
||||
let width = Int(ceil(mediaBox.width))
|
||||
let height = Int(ceil(mediaBox.height))
|
||||
let rep = try XCTUnwrap(NSBitmapImageRep(
|
||||
bitmapDataPlanes: nil,
|
||||
pixelsWide: width,
|
||||
pixelsHigh: height,
|
||||
bitsPerSample: 8,
|
||||
samplesPerPixel: 4,
|
||||
hasAlpha: true,
|
||||
isPlanar: false,
|
||||
colorSpaceName: .deviceRGB,
|
||||
bytesPerRow: 0,
|
||||
bitsPerPixel: 0))
|
||||
let context = try XCTUnwrap(
|
||||
NSGraphicsContext(bitmapImageRep: rep))
|
||||
let cg = context.cgContext
|
||||
let transform = page.getDrawingTransform(
|
||||
.mediaBox,
|
||||
rect: CGRect(x: 0, y: 0, width: width, height: height),
|
||||
rotate: 0, preserveAspectRatio: true)
|
||||
NSGraphicsContext.saveGraphicsState()
|
||||
NSGraphicsContext.current = context
|
||||
cg.concatenate(transform)
|
||||
cg.drawPDFPage(page)
|
||||
NSGraphicsContext.restoreGraphicsState()
|
||||
|
||||
func pixel(_ x: Int, _ y: Int) throws -> (Double, Double, Double) {
|
||||
let color = try XCTUnwrap(
|
||||
rep.colorAt(x: x, y: y)?.usingColorSpace(.deviceRGB))
|
||||
return (color.redComponent,
|
||||
color.greenComponent,
|
||||
color.blueComponent)
|
||||
}
|
||||
func isPatch(_ p: (Double, Double, Double)) -> Bool {
|
||||
abs(p.0 - Double(patchBytes[0]) / 255) < 0.04
|
||||
&& abs(p.1 - Double(patchBytes[1]) / 255) < 0.04
|
||||
&& abs(p.2 - Double(patchBytes[2]) / 255) < 0.04
|
||||
}
|
||||
|
||||
// The print system insets the view by the queue's unprintable
|
||||
// margin (R10) — host-dependent, ~18 pt on this machine — so
|
||||
// the block's anchor is the painted region's top-left, not
|
||||
// (0,0). Find the painted origin by the first opaque pixel.
|
||||
var originX = width, originY = height
|
||||
for y in 0..<height {
|
||||
for x in 0..<width
|
||||
where (rep.colorAt(x: x, y: y)?.alphaComponent ?? 0) > 0.5 {
|
||||
originX = min(originX, x)
|
||||
originY = min(originY, y)
|
||||
}
|
||||
}
|
||||
XCTAssertLessThan(originX, width, "PDF page rendered empty")
|
||||
XCTAssertLessThan(originY, height, "PDF page rendered empty")
|
||||
|
||||
// 10 px inside the 72 pt block on each axis → patch colour.
|
||||
XCTAssertTrue(try isPatch(pixel(originX + 10, originY + 10)),
|
||||
"top-left block is not the fixture colour")
|
||||
|
||||
// Measure the block's painted width on a row through it:
|
||||
// 72 pt at 1 px/pt — the 1:1 / no-scaling assertion.
|
||||
var blockWidth = 0
|
||||
var x = originX
|
||||
while x < width,
|
||||
isPatch(try pixel(x, originY + 10)) {
|
||||
blockWidth += 1
|
||||
x += 1
|
||||
}
|
||||
XCTAssertEqual(blockWidth, 72, accuracy: 2)
|
||||
|
||||
// Past the block edge the canvas's white page fill shows.
|
||||
let outside = try pixel(originX + 80, originY + 80)
|
||||
XCTAssertEqual(outside.0, 1, accuracy: 0.04)
|
||||
XCTAssertEqual(outside.1, 1, accuracy: 0.04)
|
||||
XCTAssertEqual(outside.2, 1, accuracy: 0.04)
|
||||
}
|
||||
}
|
||||
@@ -52,41 +52,6 @@ final class PrintPanelStubTests: XCTestCase {
|
||||
}
|
||||
}
|
||||
|
||||
/// #183 — the stub parses paper size / quality / orientation out of
|
||||
/// `ICCERY_TEST_PANEL_OPTIONS` so UI tests can verify apply-back.
|
||||
func testOkResultExtractsNewFields() throws {
|
||||
try withEnv([
|
||||
"ICCERY_UI_TESTING": "1",
|
||||
"ICCERY_TEST_PRINT_PANEL": "ok",
|
||||
"ICCERY_TEST_PANEL_OPTIONS":
|
||||
"PageSize=Letter EPIJ_Qual=305 orientation-requested=4",
|
||||
"ICCERY_TEST_PANEL_PRINTER": nil,
|
||||
]) {
|
||||
let result = UITestHooks.printPanelResult(forQueue: "q")
|
||||
XCTAssertEqual(result?.options.paperSize, "Letter")
|
||||
XCTAssertEqual(result?.options.quality, "305")
|
||||
XCTAssertEqual(result?.options.orientation, "landscape")
|
||||
}
|
||||
}
|
||||
|
||||
/// #186 — a vendor media key in the captured string reaches
|
||||
/// `options.mediaType` through the detection roster
|
||||
/// (`CNIJMediaType`/`StpMediaType`, not only `MediaType`).
|
||||
func testOkResultExtractsVendorMediaKey() throws {
|
||||
try withEnv([
|
||||
"ICCERY_UI_TESTING": "1",
|
||||
"ICCERY_TEST_PRINT_PANEL": "ok",
|
||||
"ICCERY_TEST_PANEL_OPTIONS":
|
||||
"PageSize=A4 orientation-requested=4 CNIJMediaType=Photo",
|
||||
"ICCERY_TEST_PANEL_PRINTER": nil,
|
||||
]) {
|
||||
let result = UITestHooks.printPanelResult(forQueue: "q")
|
||||
XCTAssertEqual(result?.options.paperSize, "A4")
|
||||
XCTAssertEqual(result?.options.orientation, "landscape")
|
||||
XCTAssertEqual(result?.options.mediaType, "Photo")
|
||||
}
|
||||
}
|
||||
|
||||
func testOkDefaultsPrinter() throws {
|
||||
try withEnv([
|
||||
"ICCERY_UI_TESTING": "1",
|
||||
|
||||
@@ -1,427 +0,0 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
@testable import ICCeryCore
|
||||
@testable import ICCery
|
||||
|
||||
/// Issue #183 — Stage 2 paper-size / quality selection: seeding from
|
||||
/// Stage 1 `pageSize`, the synthetic `Custom.<pt>x<pt>` entry, re-mirror
|
||||
/// triggers, and `TargetPrintOverrides` wiring into the resolved
|
||||
/// ticket writes (#201 — the `RecordingTargetSpooler` seam replaces
|
||||
/// the deleted `lp` argv log).
|
||||
/// `lpoptions` is a mock script in the test env's `cups-bin` — no
|
||||
/// live CUPS is touched.
|
||||
@MainActor
|
||||
final class PrintSessionViewModelTests: XCTestCase {
|
||||
|
||||
private var env: TestAppEnvironment!
|
||||
private var spoolLogURL: URL!
|
||||
|
||||
override func setUp() async throws {
|
||||
env = try TestAppEnvironment.make()
|
||||
spoolLogURL = env.root.appendingPathComponent("spool.log")
|
||||
try writeCupsFixtures()
|
||||
}
|
||||
|
||||
override func tearDown() async throws {
|
||||
env?.cleanup()
|
||||
env = nil
|
||||
spoolLogURL = nil
|
||||
}
|
||||
|
||||
private var binDir: URL {
|
||||
env.root.appendingPathComponent("cups-bin")
|
||||
}
|
||||
|
||||
/// Mock `lpoptions -l` advertises paper sizes + a quality key —
|
||||
/// the queue's option-key roster feeds media/quality detection.
|
||||
private func writeCupsFixtures() throws {
|
||||
try FileManager.default.createDirectory(
|
||||
at: binDir, withIntermediateDirectories: true)
|
||||
let lpoptions = """
|
||||
#!/bin/sh
|
||||
list=0
|
||||
queue=""
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
-l) list=1 ;;
|
||||
-*) ;;
|
||||
*) queue="$arg" ;;
|
||||
esac
|
||||
done
|
||||
if [ "$list" = "1" ]; then
|
||||
printf 'PageSize/Media Size: 4x6 5x7 *A4 Letter Legal Custom.WIDTHxHEIGHT\\n'
|
||||
printf 'InputSlot/Media Source: Auto *Main Rear\\n'
|
||||
printf 'MediaType/Media Type: *Stationery Glossy Matte\\n'
|
||||
printf 'EPIJ_Qual/Print Quality: 301 302 *303 308 304 305 307\\n'
|
||||
exit 0
|
||||
fi
|
||||
printf "printer-info='Mock %s' printer-type=42\\n" "$queue"
|
||||
exit 0
|
||||
"""
|
||||
let url = binDir.appendingPathComponent("lpoptions")
|
||||
try lpoptions.write(to: url, atomically: true, encoding: .utf8)
|
||||
try FileManager.default.setAttributes(
|
||||
[.posixPermissions: 0o755], ofItemAtPath: url.path)
|
||||
}
|
||||
|
||||
private func makeWorkflow() -> TargetWorkflowViewModel {
|
||||
TargetWorkflowViewModel(environment: env.environment)
|
||||
}
|
||||
|
||||
private func loadCaps(
|
||||
_ vm: PrintSessionViewModel, queue: String = "Mock_Q"
|
||||
) async {
|
||||
vm.selectedPrinter = queue
|
||||
await vm.reloadSelectedCapabilities()
|
||||
}
|
||||
|
||||
private func waitForFile(
|
||||
_ url: URL, timeout: TimeInterval = 10
|
||||
) async -> String {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if let text = try? String(contentsOf: url, encoding: .utf8),
|
||||
!text.isEmpty {
|
||||
return text
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
}
|
||||
return (try? String(contentsOf: url, encoding: .utf8)) ?? ""
|
||||
}
|
||||
|
||||
// MARK: - Seeding / mirror
|
||||
|
||||
/// `pageSize = .a4` matches the capability named "A4" → its id.
|
||||
func testSeedMatchesPageSizeRawValue() async {
|
||||
let workflow = makeWorkflow()
|
||||
workflow.pageSize = .a4
|
||||
await loadCaps(workflow.print)
|
||||
|
||||
XCTAssertEqual(workflow.pageSize, .a4)
|
||||
XCTAssertEqual(workflow.print.selectedPaperSize, 3)
|
||||
// Quality seeds from the `*` default on caps load.
|
||||
XCTAssertEqual(workflow.print.selectedQuality, "303")
|
||||
// All seven Epson codes enumerate in driver order (#180).
|
||||
XCTAssertEqual(workflow.print.printerCaps.qualities.map(\.id),
|
||||
["301", "302", "303", "308", "304", "305", "307"])
|
||||
}
|
||||
|
||||
/// `.custom` → synthetic `id: 0` entry whose token is the
|
||||
/// dimensions in **points** (mm × 72/25.4): 210×297 → `Custom.595x842`.
|
||||
func testSeedCustomPageSizeSyntheticEntry() async {
|
||||
let workflow = makeWorkflow()
|
||||
workflow.pageSize = .custom
|
||||
workflow.customPageW = 210
|
||||
workflow.customPageH = 297
|
||||
await loadCaps(workflow.print)
|
||||
|
||||
XCTAssertEqual(workflow.print.selectedPaperSize, 0)
|
||||
let synthetic = workflow.print.printerCaps.paperSizes
|
||||
.first { $0.id == 0 }
|
||||
XCTAssertEqual(synthetic?.name, "Custom.595x842")
|
||||
XCTAssertEqual(workflow.print.selectedPaperSizeToken, "Custom.595x842")
|
||||
}
|
||||
|
||||
/// A `workflow.pageSize` change re-mirrors the picker.
|
||||
func testReseedOnPageSizeChange() async {
|
||||
let workflow = makeWorkflow()
|
||||
workflow.pageSize = .a4
|
||||
await loadCaps(workflow.print)
|
||||
XCTAssertEqual(workflow.print.selectedPaperSize, 3)
|
||||
|
||||
workflow.pageSize = .letter
|
||||
workflow.print.seedPaperSelection()
|
||||
XCTAssertEqual(workflow.print.selectedPaperSize, 4)
|
||||
}
|
||||
|
||||
/// A user pick survives unrelated publishes — re-seed only fires
|
||||
/// on pageSize / printer / caps triggers (#183, R14).
|
||||
func testUserEditPreservedAcrossUnrelatedPublishes() async {
|
||||
let workflow = makeWorkflow()
|
||||
workflow.pageSize = .a4
|
||||
await loadCaps(workflow.print)
|
||||
workflow.print.selectedPaperSize = 5
|
||||
|
||||
workflow.print.selectedTray = 2
|
||||
workflow.print.printOrientation = "landscape"
|
||||
workflow.print.printNotice = Notice(kind: .info, text: "x")
|
||||
|
||||
XCTAssertEqual(workflow.print.selectedPaperSize, 5)
|
||||
}
|
||||
|
||||
/// A printer change re-seeds from `workflow.pageSize` after the
|
||||
/// capabilities reload (the picker re-mirrors, not guesses).
|
||||
func testPrinterChangeReseeds() async {
|
||||
let workflow = makeWorkflow()
|
||||
workflow.pageSize = .a4
|
||||
await loadCaps(workflow.print)
|
||||
workflow.print.selectedPaperSize = 5
|
||||
workflow.print.selectedQuality = "301"
|
||||
|
||||
// The view nils quality on printer change before reloading —
|
||||
// the VM re-seeds `when nil` only (#183 contract).
|
||||
workflow.print.selectedPrinter = "Other_Q"
|
||||
workflow.print.selectedQuality = nil
|
||||
await workflow.print.reloadSelectedCapabilities()
|
||||
|
||||
XCTAssertEqual(workflow.print.selectedPaperSize, 3)
|
||||
XCTAssertEqual(workflow.print.selectedQuality, "303")
|
||||
}
|
||||
|
||||
/// A pageSize with no capability match leaves the pick nil —
|
||||
/// never a guessed id.
|
||||
func testSeedNoMatchLeavesNil() async {
|
||||
let workflow = makeWorkflow()
|
||||
workflow.pageSize = .a2
|
||||
await loadCaps(workflow.print)
|
||||
XCTAssertNil(workflow.print.selectedPaperSize)
|
||||
XCTAssertNil(workflow.print.selectedPaperSizeToken)
|
||||
}
|
||||
|
||||
// MARK: - Spool wiring
|
||||
|
||||
/// `spool` resolves the Stage 2 paper token and quality into the
|
||||
/// ticket writes (`PageSize=`, `<qualityKey>=`) recorded by the
|
||||
/// DEBUG spool seam (#201 D8).
|
||||
func testSpoolPassesPaperTokenAndQuality() async throws {
|
||||
let workflow = makeWorkflow()
|
||||
workflow.pageSize = .a4
|
||||
await loadCaps(workflow.print)
|
||||
workflow.print.selectedPaperSize = 4 // Letter
|
||||
workflow.print.selectedQuality = "301"
|
||||
workflow.print.spooler = RecordingTargetSpooler(
|
||||
logURL: spoolLogURL)
|
||||
|
||||
let tiff = env.root.appendingPathComponent("page1.tif")
|
||||
try Data([0x49, 0x49]).write(to: tiff)
|
||||
let page = GalleryPage(
|
||||
index: 0,
|
||||
page: PrinttargPage(
|
||||
filename: "page1.tif", patches: 10,
|
||||
widthMm: 210, heightMm: 297),
|
||||
fileURL: tiff, previewPNG: nil, previewError: nil)
|
||||
let result = PrinttargResult(
|
||||
ti2URL: env.root.appendingPathComponent("target.ti2"),
|
||||
manifest: PrinttargManifest(pages: [page.page]),
|
||||
pages: [page])
|
||||
workflow.print.printAllPages(from: result)
|
||||
|
||||
let log = await waitForFile(spoolLogURL)
|
||||
XCTAssertTrue(log.contains("PageSize=Letter"), log)
|
||||
XCTAssertTrue(log.contains("EPIJ_Qual=301"), log)
|
||||
}
|
||||
|
||||
/// #201 — the stubbed panel never produces a `PrintTicket`, so a
|
||||
/// spool with `capturedTickets` empty must still resolve every
|
||||
/// Stage 2 write (the default UI-test path).
|
||||
func testSpoolWithoutTicketResolvesStage2Writes() async throws {
|
||||
let workflow = makeWorkflow()
|
||||
workflow.pageSize = .a4
|
||||
await loadCaps(workflow.print)
|
||||
workflow.print.spooler = RecordingTargetSpooler(
|
||||
logURL: spoolLogURL)
|
||||
XCTAssertTrue(workflow.print.capturedTickets.isEmpty)
|
||||
|
||||
let tiff = env.root.appendingPathComponent("page1.tif")
|
||||
try Data([0x49, 0x49]).write(to: tiff)
|
||||
let page = GalleryPage(
|
||||
index: 0,
|
||||
page: PrinttargPage(
|
||||
filename: "page1.tif", patches: 10,
|
||||
widthMm: 210, heightMm: 297),
|
||||
fileURL: tiff, previewPNG: nil, previewError: nil)
|
||||
let result = PrinttargResult(
|
||||
ti2URL: env.root.appendingPathComponent("target.ti2"),
|
||||
manifest: PrinttargManifest(pages: [page.page]),
|
||||
pages: [page])
|
||||
workflow.print.printAllPages(from: result)
|
||||
|
||||
let log = await waitForFile(spoolLogURL)
|
||||
XCTAssertTrue(log.contains("pages=1"), log)
|
||||
XCTAssertTrue(log.contains("PageSize=A4"), log)
|
||||
XCTAssertTrue(log.contains("EPIJ_Qual=303"), log)
|
||||
XCTAssertTrue(log.contains("MediaType=Stationery"), log)
|
||||
XCTAssertTrue(log.contains("orientation-requested=3"), log)
|
||||
XCTAssertTrue(log.contains(
|
||||
"AP_ColorMatchingMode=AP_ApplicationColorMatching"), log)
|
||||
XCTAssertTrue(log.contains(
|
||||
"PMColorMatchingMode=APCustomColorMatching"), log)
|
||||
}
|
||||
|
||||
// MARK: - Panel apply-back (stubbed NSPrintPanel)
|
||||
|
||||
/// The stubbed panel's captured `PageSize=`/`EPIJ_Qual=` apply back
|
||||
/// to `selectedPaperSize`/`selectedQuality` (#183 capture-return).
|
||||
func testPanelResultAppliesBackSelections() async throws {
|
||||
setenv("ICCERY_UI_TESTING", "1", 1)
|
||||
setenv("ICCERY_TEST_PRINT_PANEL", "ok", 1)
|
||||
setenv("ICCERY_TEST_PANEL_OPTIONS",
|
||||
"PageSize=Letter EPIJ_Qual=305", 1)
|
||||
defer {
|
||||
unsetenv("ICCERY_UI_TESTING")
|
||||
unsetenv("ICCERY_TEST_PRINT_PANEL")
|
||||
unsetenv("ICCERY_TEST_PANEL_OPTIONS")
|
||||
}
|
||||
|
||||
let workflow = makeWorkflow()
|
||||
workflow.pageSize = .a4
|
||||
await loadCaps(workflow.print)
|
||||
XCTAssertEqual(workflow.print.selectedPaperSize, 3)
|
||||
XCTAssertEqual(workflow.print.selectedQuality, "303")
|
||||
|
||||
workflow.print.openPrinterPreferences()
|
||||
let deadline = Date().addingTimeInterval(10)
|
||||
while Date() < deadline,
|
||||
workflow.print.selectedPaperSize != 4
|
||||
|| workflow.print.selectedQuality != "305" {
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
}
|
||||
XCTAssertEqual(workflow.print.selectedPaperSize, 4)
|
||||
XCTAssertEqual(workflow.print.selectedQuality, "305")
|
||||
}
|
||||
|
||||
/// #186 — the captured `orientation-requested`/media token apply
|
||||
/// back to `printOrientation`/`selectedMediaType`, and a dialog
|
||||
/// result never mutates `workflow.pageSize` (printtarg layout).
|
||||
func testPanelResultAppliesBackOrientationAndMedia() async throws {
|
||||
setenv("ICCERY_UI_TESTING", "1", 1)
|
||||
setenv("ICCERY_TEST_PRINT_PANEL", "ok", 1)
|
||||
setenv("ICCERY_TEST_PANEL_OPTIONS",
|
||||
"orientation-requested=4 MediaType=Glossy", 1)
|
||||
defer {
|
||||
unsetenv("ICCERY_UI_TESTING")
|
||||
unsetenv("ICCERY_TEST_PRINT_PANEL")
|
||||
unsetenv("ICCERY_TEST_PANEL_OPTIONS")
|
||||
}
|
||||
|
||||
let workflow = makeWorkflow()
|
||||
workflow.pageSize = .a4
|
||||
await loadCaps(workflow.print)
|
||||
XCTAssertEqual(workflow.print.printOrientation, "portrait")
|
||||
XCTAssertEqual(workflow.print.selectedMediaType, "Stationery")
|
||||
|
||||
workflow.print.openPrinterPreferences()
|
||||
await waitForNotice(workflow.print, containing: "Settings captured")
|
||||
XCTAssertEqual(workflow.print.printOrientation, "landscape")
|
||||
XCTAssertEqual(workflow.print.selectedMediaType, "Glossy")
|
||||
XCTAssertEqual(workflow.pageSize, .a4)
|
||||
}
|
||||
|
||||
/// #186 — a captured `PageSize` token with no capability match
|
||||
/// leaves `selectedPaperSize` unchanged (never a guessed id).
|
||||
func testPanelResultUnknownPaperLeavesSelection() async throws {
|
||||
setenv("ICCERY_UI_TESTING", "1", 1)
|
||||
setenv("ICCERY_TEST_PRINT_PANEL", "ok", 1)
|
||||
setenv("ICCERY_TEST_PANEL_OPTIONS", "PageSize=Bogus", 1)
|
||||
defer {
|
||||
unsetenv("ICCERY_UI_TESTING")
|
||||
unsetenv("ICCERY_TEST_PRINT_PANEL")
|
||||
unsetenv("ICCERY_TEST_PANEL_OPTIONS")
|
||||
}
|
||||
|
||||
let workflow = makeWorkflow()
|
||||
workflow.pageSize = .a4
|
||||
await loadCaps(workflow.print)
|
||||
XCTAssertEqual(workflow.print.selectedPaperSize, 3)
|
||||
|
||||
workflow.print.openPrinterPreferences()
|
||||
await waitForNotice(workflow.print, containing: "Settings captured")
|
||||
XCTAssertEqual(workflow.print.selectedPaperSize, 3)
|
||||
}
|
||||
|
||||
/// #186 — a stub result pointing at another queue in `printers`
|
||||
/// switches `selectedPrinter` and reloads its capabilities.
|
||||
func testPanelResultSwitchesToKnownQueue() async throws {
|
||||
setenv("ICCERY_UI_TESTING", "1", 1)
|
||||
setenv("ICCERY_TEST_PRINT_PANEL", "ok", 1)
|
||||
setenv("ICCERY_TEST_PANEL_OPTIONS", "PageSize=Letter", 1)
|
||||
setenv("ICCERY_TEST_PANEL_PRINTER", "Other_Q", 1)
|
||||
defer {
|
||||
unsetenv("ICCERY_UI_TESTING")
|
||||
unsetenv("ICCERY_TEST_PRINT_PANEL")
|
||||
unsetenv("ICCERY_TEST_PANEL_OPTIONS")
|
||||
unsetenv("ICCERY_TEST_PANEL_PRINTER")
|
||||
}
|
||||
|
||||
let workflow = makeWorkflow()
|
||||
workflow.pageSize = .a4
|
||||
workflow.print.printers = [
|
||||
Printer(name: "Mock_Q", isDefault: true),
|
||||
Printer(name: "Other_Q"),
|
||||
]
|
||||
await loadCaps(workflow.print)
|
||||
|
||||
workflow.print.openPrinterPreferences()
|
||||
await waitForNotice(
|
||||
workflow.print, containing: "Settings captured for Other_Q")
|
||||
XCTAssertEqual(workflow.print.selectedPrinter, "Other_Q")
|
||||
// Caps reloaded for the new queue: paper re-seeded, then the
|
||||
// captured PageSize applied back onto the new caps.
|
||||
XCTAssertEqual(workflow.print.selectedPaperSize, 4)
|
||||
XCTAssertEqual(workflow.print.capturedCupsOptions["Other_Q"],
|
||||
"PageSize=Letter")
|
||||
}
|
||||
|
||||
/// #186 — a stub result naming a queue absent from `printers`
|
||||
/// leaves the selection on the opened queue.
|
||||
func testPanelResultGhostQueueIgnored() async throws {
|
||||
setenv("ICCERY_UI_TESTING", "1", 1)
|
||||
setenv("ICCERY_TEST_PRINT_PANEL", "ok", 1)
|
||||
setenv("ICCERY_TEST_PANEL_PRINTER", "Ghost_Q", 1)
|
||||
defer {
|
||||
unsetenv("ICCERY_UI_TESTING")
|
||||
unsetenv("ICCERY_TEST_PRINT_PANEL")
|
||||
unsetenv("ICCERY_TEST_PANEL_PRINTER")
|
||||
}
|
||||
|
||||
let workflow = makeWorkflow()
|
||||
workflow.pageSize = .a4
|
||||
workflow.print.printers = [Printer(name: "Mock_Q", isDefault: true)]
|
||||
await loadCaps(workflow.print)
|
||||
|
||||
workflow.print.openPrinterPreferences()
|
||||
await waitForNotice(
|
||||
workflow.print, containing: "Settings captured for Mock_Q")
|
||||
XCTAssertEqual(workflow.print.selectedPrinter, "Mock_Q")
|
||||
}
|
||||
|
||||
/// #186 — cancel returns `nil`: info notice, no field changes.
|
||||
func testPanelCancelLeavesSelections() async throws {
|
||||
setenv("ICCERY_UI_TESTING", "1", 1)
|
||||
setenv("ICCERY_TEST_PRINT_PANEL", "cancel", 1)
|
||||
defer {
|
||||
unsetenv("ICCERY_UI_TESTING")
|
||||
unsetenv("ICCERY_TEST_PRINT_PANEL")
|
||||
}
|
||||
|
||||
let workflow = makeWorkflow()
|
||||
workflow.pageSize = .a4
|
||||
await loadCaps(workflow.print)
|
||||
workflow.print.printOrientation = "landscape"
|
||||
|
||||
workflow.print.openPrinterPreferences()
|
||||
await waitForNotice(workflow.print, containing: "cancelled")
|
||||
XCTAssertEqual(workflow.print.selectedPaperSize, 3)
|
||||
XCTAssertEqual(workflow.print.selectedQuality, "303")
|
||||
XCTAssertEqual(workflow.print.selectedMediaType, "Stationery")
|
||||
XCTAssertEqual(workflow.print.printOrientation, "landscape")
|
||||
XCTAssertTrue(workflow.print.capturedCupsOptions.isEmpty)
|
||||
}
|
||||
|
||||
/// Poll until the panel task posts a notice whose text contains
|
||||
/// `fragment` (the Task-completion signal for `nil` results too).
|
||||
private func waitForNotice(
|
||||
_ vm: PrintSessionViewModel,
|
||||
containing fragment: String,
|
||||
timeout: TimeInterval = 10
|
||||
) async {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if let text = vm.printNotice?.text, text.contains(fragment) {
|
||||
return
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
}
|
||||
XCTFail("Timed out waiting for notice containing '\(fragment)'")
|
||||
}
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
import XCTest
|
||||
@testable import ICCery
|
||||
|
||||
/// Issue #201 Phase 2 — `PMTicketBridge.serialise`/`restore` byte
|
||||
/// round-trip, cross-queue refusal (R3), plist fallback, and the
|
||||
/// `takeRetainedValue` ownership canary.
|
||||
@MainActor
|
||||
final class PrintTicketTests: XCTestCase {
|
||||
|
||||
/// The queue a destination `NSPrintInfo` will report as bound —
|
||||
/// the session's current printer, else the Cocoa printer name,
|
||||
/// else a synthetic token for a queue-less environment.
|
||||
private func boundQueue(of printInfo: NSPrintInfo) -> String {
|
||||
PMTicketBridge.currentPrinterID(
|
||||
session: PMTicketBridge.session(printInfo),
|
||||
fallback: "UnboundQueue")
|
||||
}
|
||||
|
||||
func testRoundTripRetainsVendorValue() throws {
|
||||
let source = NSPrintInfo()
|
||||
XCTAssertTrue(
|
||||
PMTicketBridge.setValue(
|
||||
"305", forKey: "EPIJ_Qual", locked: false,
|
||||
in: PMTicketBridge.settings(source),
|
||||
context: "PrintTicketTests"))
|
||||
|
||||
let target = NSPrintInfo()
|
||||
let ticket = try PMTicketBridge.serialise(
|
||||
source, queue: boundQueue(of: target))
|
||||
try PMTicketBridge.restore(ticket, into: target)
|
||||
|
||||
XCTAssertEqual(
|
||||
PMTicketBridge.stringValue(
|
||||
forKey: "EPIJ_Qual",
|
||||
in: PMTicketBridge.settings(target)),
|
||||
"305")
|
||||
}
|
||||
|
||||
func testPrintSettingsDataIsXmlPlist() throws {
|
||||
let ticket = try PMTicketBridge.serialise(
|
||||
NSPrintInfo(), queue: "AnyQueue")
|
||||
XCTAssertFalse(ticket.printSettings.isEmpty)
|
||||
XCTAssertFalse(ticket.pageFormat.isEmpty)
|
||||
XCTAssertEqual(
|
||||
String(decoding: ticket.printSettings.prefix(5),
|
||||
as: UTF8.self),
|
||||
"<?xml")
|
||||
}
|
||||
|
||||
func testCrossQueueReplayIsRefused() throws {
|
||||
let target = NSPrintInfo()
|
||||
let bound = boundQueue(of: target)
|
||||
let foreign = "DefinitelyNot_\(bound)"
|
||||
try XCTSkipIf(
|
||||
bound == foreign,
|
||||
"destination resolved to the foreign queue")
|
||||
|
||||
// Sentinel on the destination — a refused restore must leave
|
||||
// the target's settings untouched.
|
||||
XCTAssertTrue(
|
||||
PMTicketBridge.setValue(
|
||||
"999", forKey: "EPIJ_Qual", locked: false,
|
||||
in: PMTicketBridge.settings(target),
|
||||
context: "PrintTicketTests"))
|
||||
|
||||
let source = NSPrintInfo()
|
||||
XCTAssertTrue(
|
||||
PMTicketBridge.setValue(
|
||||
"305", forKey: "EPIJ_Qual", locked: false,
|
||||
in: PMTicketBridge.settings(source),
|
||||
context: "PrintTicketTests"))
|
||||
let ticket = try PMTicketBridge.serialise(
|
||||
source, queue: foreign)
|
||||
|
||||
try PMTicketBridge.restore(ticket, into: target)
|
||||
XCTAssertEqual(
|
||||
PMTicketBridge.stringValue(
|
||||
forKey: "EPIJ_Qual",
|
||||
in: PMTicketBridge.settings(target)),
|
||||
"999")
|
||||
}
|
||||
|
||||
func testPrintInfoPlistFallbackRoundTrips() throws {
|
||||
let source = NSPrintInfo()
|
||||
// The `com.apple.print.PrintSettings` entry only exists once
|
||||
// something writes into the attribute dictionary — the
|
||||
// ColorSync mirror does this on the real panel path.
|
||||
source.dictionary()[
|
||||
NSPrintInfo.AttributeKey("com.apple.print.PrintSettings")] =
|
||||
["PMColorMatchingMode": "APCustomColorMatching"]
|
||||
as NSDictionary
|
||||
let ticket = try PMTicketBridge.serialise(
|
||||
source, queue: "AnyQueue")
|
||||
let data = try XCTUnwrap(ticket.printInfoPlist)
|
||||
let object = try PropertyListSerialization.propertyList(
|
||||
from: data, options: [], format: nil)
|
||||
let dictionary = try XCTUnwrap(object as? [String: Any])
|
||||
XCTAssertNotNil(
|
||||
dictionary["com.apple.print.PrintSettings"])
|
||||
}
|
||||
|
||||
/// 200× serialise loop — a `takeRetainedValue` over/under-release
|
||||
/// canary. Byte counts must stay stable; ASan + Malloc Scribble
|
||||
/// catches an actual imbalance.
|
||||
func testSerialiseIsStableAcrossIterations() throws {
|
||||
let source = NSPrintInfo()
|
||||
var firstCount: Int?
|
||||
for _ in 0..<200 {
|
||||
let ticket = try PMTicketBridge.serialise(
|
||||
source, queue: "AnyQueue")
|
||||
if let first = firstCount {
|
||||
XCTAssertEqual(ticket.printSettings.count, first)
|
||||
} else {
|
||||
firstCount = ticket.printSettings.count
|
||||
}
|
||||
}
|
||||
XCTAssertNotNil(firstCount)
|
||||
}
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
import AppKit
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
import XCTest
|
||||
@testable import ICCery
|
||||
|
||||
/// Issue #201 Phase 3 — `TargetPageCanvasView` top-down page stacking,
|
||||
/// snapped top-left anchoring, and the draw-time interpolation /
|
||||
/// antialias contract asserted via `drawProbe`.
|
||||
@MainActor
|
||||
final class TargetCanvasGeometryTests: XCTestCase {
|
||||
|
||||
/// A4-ish paper, points.
|
||||
private let paperSize = NSSize(width: 595.28, height: 841.89)
|
||||
|
||||
private func raster(
|
||||
pixelWidth: Int = 2480,
|
||||
pixelHeight: Int = 3508,
|
||||
dpi: Double = 300
|
||||
) -> TargetPageRaster {
|
||||
let image = TargetTestFixtures.makeImage(
|
||||
px: CGSize(width: 8, height: 8),
|
||||
components: 3, bitsPerComponent: 8)!
|
||||
return TargetPageRaster(
|
||||
sourceURL: URL(fileURLWithPath: "/fixture.tiff"),
|
||||
cgImage: image,
|
||||
pixelWidth: pixelWidth,
|
||||
pixelHeight: pixelHeight,
|
||||
dpiX: dpi, dpiY: dpi,
|
||||
componentCount: 3,
|
||||
bitsPerComponent: 8,
|
||||
expectedWidthMm: nil,
|
||||
expectedHeightMm: nil)
|
||||
}
|
||||
|
||||
// MARK: - Page stacking
|
||||
|
||||
func testRectForPageStacksTopDown() {
|
||||
let view = TargetPageCanvasView(
|
||||
pages: [raster(), raster(), raster()],
|
||||
paperSize: paperSize)
|
||||
XCTAssertTrue(view.isFlipped)
|
||||
|
||||
let page1 = view.rectForPage(1)
|
||||
let page3 = view.rectForPage(3)
|
||||
XCTAssertEqual(page1.minY, 0, accuracy: 0.001)
|
||||
XCTAssertEqual(page3.minY, 2 * paperSize.height, accuracy: 0.001)
|
||||
XCTAssertEqual(page1.size, paperSize)
|
||||
XCTAssertEqual(page3.size, paperSize)
|
||||
}
|
||||
|
||||
func testKnowsPageRangeAndFrame() {
|
||||
let view = TargetPageCanvasView(
|
||||
pages: [raster(), raster()],
|
||||
paperSize: paperSize)
|
||||
var range = NSRange()
|
||||
XCTAssertTrue(view.knowsPageRange(&range))
|
||||
XCTAssertEqual(range, NSRange(location: 1, length: 2))
|
||||
XCTAssertEqual(
|
||||
view.frame.height, 2 * paperSize.height, accuracy: 0.001)
|
||||
XCTAssertEqual(view.frame.width, paperSize.width, accuracy: 0.001)
|
||||
}
|
||||
|
||||
// MARK: - Destination rect
|
||||
|
||||
func testDestinationRectAnchorsTopLeftAndSnaps() {
|
||||
let view = TargetPageCanvasView(
|
||||
pages: [raster(), raster()],
|
||||
paperSize: paperSize)
|
||||
|
||||
let first = view.destinationRect(forPage: 1)
|
||||
XCTAssertEqual(first.origin.x, 0, accuracy: 0.001)
|
||||
XCTAssertEqual(first.origin.y, 0, accuracy: 0.001)
|
||||
XCTAssertEqual(first.width, 595.2, accuracy: 0.01)
|
||||
XCTAssertEqual(first.height, 841.92, accuracy: 0.01)
|
||||
|
||||
let second = view.destinationRect(forPage: 2)
|
||||
XCTAssertEqual(
|
||||
second.origin.y, paperSize.height, accuracy: 0.001)
|
||||
XCTAssertEqual(second.size, first.size)
|
||||
|
||||
for rect in [first, second] {
|
||||
XCTAssertEqual(
|
||||
rect.origin.x * 1000,
|
||||
(rect.origin.x * 1000).rounded(),
|
||||
accuracy: 1e-9)
|
||||
XCTAssertEqual(
|
||||
rect.origin.y * 1000,
|
||||
(rect.origin.y * 1000).rounded(),
|
||||
accuracy: 1e-9)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Draw flags
|
||||
|
||||
/// Render the canvas into an offscreen bitmap context and assert —
|
||||
/// from inside `drawProbe` — that interpolation and antialiasing
|
||||
/// are off and the drawn rect is `destinationRect(forPage:)`.
|
||||
func testDrawDisablesInterpolationAndAntialias() throws {
|
||||
let pages = [raster(), raster()]
|
||||
let view = TargetPageCanvasView(
|
||||
pages: pages, paperSize: paperSize)
|
||||
|
||||
let rep = try XCTUnwrap(NSBitmapImageRep(
|
||||
bitmapDataPlanes: nil,
|
||||
pixelsWide: Int(ceil(view.frame.width)),
|
||||
pixelsHigh: Int(ceil(view.frame.height)),
|
||||
bitsPerSample: 8,
|
||||
samplesPerPixel: 4,
|
||||
hasAlpha: true,
|
||||
isPlanar: false,
|
||||
colorSpaceName: .deviceRGB,
|
||||
bytesPerRow: 0,
|
||||
bitsPerPixel: 0))
|
||||
let context = try XCTUnwrap(
|
||||
NSGraphicsContext(bitmapImageRep: rep))
|
||||
|
||||
var probed: [(page: Int, rect: NSRect)] = []
|
||||
view.drawProbe = { ctx, page, rect in
|
||||
XCTAssertEqual(ctx.imageInterpolation, .none,
|
||||
"page \(page): interpolation not .none")
|
||||
XCTAssertFalse(ctx.shouldAntialias,
|
||||
"page \(page): antialiasing still on")
|
||||
probed.append((page, rect))
|
||||
}
|
||||
|
||||
NSGraphicsContext.saveGraphicsState()
|
||||
NSGraphicsContext.current = context
|
||||
view.draw(view.bounds)
|
||||
NSGraphicsContext.restoreGraphicsState()
|
||||
|
||||
XCTAssertEqual(probed.count, pages.count)
|
||||
for (page, rect) in probed {
|
||||
XCTAssertEqual(
|
||||
rect, view.destinationRect(forPage: page),
|
||||
"page \(page): probe rect mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
/// A dirty rect covering only page 2's band must draw only page 2.
|
||||
func testDrawSkipsPagesOutsideDirtyRect() throws {
|
||||
let view = TargetPageCanvasView(
|
||||
pages: [raster(), raster(), raster()],
|
||||
paperSize: paperSize)
|
||||
|
||||
let rep = try XCTUnwrap(NSBitmapImageRep(
|
||||
bitmapDataPlanes: nil,
|
||||
pixelsWide: Int(ceil(view.frame.width)),
|
||||
pixelsHigh: Int(ceil(view.frame.height)),
|
||||
bitsPerSample: 8,
|
||||
samplesPerPixel: 4,
|
||||
hasAlpha: true,
|
||||
isPlanar: false,
|
||||
colorSpaceName: .deviceRGB,
|
||||
bytesPerRow: 0,
|
||||
bitsPerPixel: 0))
|
||||
let context = try XCTUnwrap(
|
||||
NSGraphicsContext(bitmapImageRep: rep))
|
||||
|
||||
var probedPages: [Int] = []
|
||||
view.drawProbe = { _, page, _ in probedPages.append(page) }
|
||||
|
||||
NSGraphicsContext.saveGraphicsState()
|
||||
NSGraphicsContext.current = context
|
||||
view.draw(view.rectForPage(2))
|
||||
NSGraphicsContext.restoreGraphicsState()
|
||||
|
||||
XCTAssertEqual(probedPages, [2])
|
||||
}
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
import ImageIO
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
/// Shared TIFF fixture builder for the #201 raster / canvas / PDF
|
||||
/// harness tests. Writes real TIFFs via `CGImageDestination` so the
|
||||
/// loader exercises the same decode path as production.
|
||||
enum TargetTestFixtures {
|
||||
|
||||
enum FixtureError: Error {
|
||||
case imageNotCreated
|
||||
case destinationNotCreated
|
||||
case finalizeFailed
|
||||
}
|
||||
|
||||
/// Fresh per-test directory under the temp root.
|
||||
static func makeTempDirectory() throws -> URL {
|
||||
let url = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("iccery-raster-\(UUID().uuidString)")
|
||||
try FileManager.default.createDirectory(
|
||||
at: url, withIntermediateDirectories: true)
|
||||
return url
|
||||
}
|
||||
|
||||
/// A solid-fill (or repeating-pattern) `CGImage` with a device
|
||||
/// colour space for 1/3/4 components at 8 or 16 bpc. `alpha: true`
|
||||
/// appends one alpha sample per pixel (`last`). `pixelBytes` is the
|
||||
/// repeating per-pixel pattern (big-endian for 16 bpc).
|
||||
static func makeImage(
|
||||
px: CGSize,
|
||||
components: Int,
|
||||
bitsPerComponent: Int,
|
||||
alpha: Bool = false,
|
||||
pixelBytes: [UInt8]? = nil
|
||||
) -> CGImage? {
|
||||
let width = Int(px.width)
|
||||
let height = Int(px.height)
|
||||
let bytesPerComponent = bitsPerComponent / 8
|
||||
let samples = components + (alpha ? 1 : 0)
|
||||
let bytesPerPixel = samples * bytesPerComponent
|
||||
let bytesPerRow = width * bytesPerPixel
|
||||
let pattern = pixelBytes
|
||||
?? Array(0..<bytesPerPixel).map { UInt8(($0 * 37 + 11) & 0xff) }
|
||||
var row = [UInt8](repeating: 0, count: bytesPerRow)
|
||||
for offset in stride(from: 0, to: bytesPerRow, by: pattern.count) {
|
||||
for (index, byte) in pattern.enumerated()
|
||||
where offset + index < bytesPerRow {
|
||||
row[offset + index] = byte
|
||||
}
|
||||
}
|
||||
var bytes = [UInt8]()
|
||||
bytes.reserveCapacity(bytesPerRow * height)
|
||||
for _ in 0..<height {
|
||||
bytes.append(contentsOf: row)
|
||||
}
|
||||
let data = Data(bytes)
|
||||
|
||||
let space: CGColorSpace
|
||||
switch components {
|
||||
case 1: space = CGColorSpaceCreateDeviceGray()
|
||||
case 3: space = CGColorSpaceCreateDeviceRGB()
|
||||
case 4: space = CGColorSpaceCreateDeviceCMYK()
|
||||
default: return nil
|
||||
}
|
||||
var info = CGBitmapInfo()
|
||||
if bitsPerComponent == 16 {
|
||||
info.insert(.byteOrder16Big)
|
||||
}
|
||||
if alpha {
|
||||
info = CGBitmapInfo(
|
||||
rawValue: info.rawValue | CGImageAlphaInfo.last.rawValue)
|
||||
}
|
||||
guard let provider = CGDataProvider(data: data as CFData)
|
||||
else { return nil }
|
||||
return CGImage(
|
||||
width: width, height: height,
|
||||
bitsPerComponent: bitsPerComponent,
|
||||
bitsPerPixel: bitsPerComponent * samples,
|
||||
bytesPerRow: bytesPerRow,
|
||||
space: space,
|
||||
bitmapInfo: info,
|
||||
provider: provider,
|
||||
decode: nil,
|
||||
shouldInterpolate: false,
|
||||
intent: .defaultIntent)
|
||||
}
|
||||
|
||||
/// Writes `makeImage` output to a single-page TIFF. `dpi` nil
|
||||
/// produces a TIFF with no resolution tags (the 72-fallback path).
|
||||
static func makeTIFF(
|
||||
px: CGSize,
|
||||
dpi: Double?,
|
||||
components: Int = 3,
|
||||
bitsPerComponent: Int = 8,
|
||||
alpha: Bool = false,
|
||||
pixelBytes: [UInt8]? = nil,
|
||||
in directory: URL
|
||||
) throws -> URL {
|
||||
guard let image = makeImage(
|
||||
px: px, components: components,
|
||||
bitsPerComponent: bitsPerComponent,
|
||||
alpha: alpha, pixelBytes: pixelBytes)
|
||||
else { throw FixtureError.imageNotCreated }
|
||||
let url = directory.appendingPathComponent(
|
||||
"fixture-\(UUID().uuidString).tiff")
|
||||
guard let destination = CGImageDestinationCreateWithURL(
|
||||
url as CFURL, UTType.tiff.identifier as CFString, 1, nil)
|
||||
else { throw FixtureError.destinationNotCreated }
|
||||
var properties: [CFString: Any] = [:]
|
||||
if let dpi {
|
||||
properties[kCGImagePropertyDPIWidth] = dpi
|
||||
properties[kCGImagePropertyDPIHeight] = dpi
|
||||
properties[kCGImagePropertyTIFFDictionary] = [
|
||||
kCGImagePropertyTIFFXResolution: dpi,
|
||||
kCGImagePropertyTIFFYResolution: dpi,
|
||||
kCGImagePropertyTIFFResolutionUnit: 2, // inches
|
||||
]
|
||||
}
|
||||
CGImageDestinationAddImage(
|
||||
destination, image, properties as CFDictionary)
|
||||
guard CGImageDestinationFinalize(destination)
|
||||
else { throw FixtureError.finalizeFailed }
|
||||
return url
|
||||
}
|
||||
}
|
||||
@@ -1,241 +0,0 @@
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
import ImageIO
|
||||
import XCTest
|
||||
@testable import ICCery
|
||||
|
||||
/// Issue #201 Phase 3 — `TargetRasterLoader` DPI handling, device
|
||||
/// re-tag (D10), unsupported-layout rejection, and manifest drift (D9).
|
||||
final class TargetRasterTests: XCTestCase {
|
||||
|
||||
private var tempRoot: URL!
|
||||
|
||||
override func setUpWithError() throws {
|
||||
tempRoot = try TargetTestFixtures.makeTempDirectory()
|
||||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
try? FileManager.default.removeItem(at: tempRoot)
|
||||
}
|
||||
|
||||
private func load(_ url: URL) throws -> TargetPageRaster {
|
||||
try TargetRasterLoader.load(tiff: url)
|
||||
}
|
||||
|
||||
/// Decode index 0 the same way the loader does, for provider-byte
|
||||
/// comparisons that prove the re-tag did not resample.
|
||||
private func decoded(_ url: URL) throws -> CGImage {
|
||||
let source = try XCTUnwrap(
|
||||
CGImageSourceCreateWithURL(url as CFURL, nil))
|
||||
return try XCTUnwrap(
|
||||
CGImageSourceCreateImageAtIndex(source, 0, nil))
|
||||
}
|
||||
|
||||
// MARK: - pointSize / physicalMm
|
||||
|
||||
func testPointSizeFromDPI() throws {
|
||||
let url = try TargetTestFixtures.makeTIFF(
|
||||
px: CGSize(width: 2480, height: 3508), dpi: 300,
|
||||
in: tempRoot)
|
||||
let raster = try load(url)
|
||||
XCTAssertEqual(raster.pointSize.width, 595.2, accuracy: 0.01)
|
||||
XCTAssertEqual(raster.pointSize.height, 841.92, accuracy: 0.01)
|
||||
XCTAssertEqual(raster.physicalMm.width, 209.97, accuracy: 0.01)
|
||||
XCTAssertEqual(raster.physicalMm.height, 297.01, accuracy: 0.01)
|
||||
}
|
||||
|
||||
func testPointSizeAt600dpi() throws {
|
||||
let url = try TargetTestFixtures.makeTIFF(
|
||||
px: CGSize(width: 1200, height: 1800), dpi: 600,
|
||||
in: tempRoot)
|
||||
let raster = try load(url)
|
||||
XCTAssertEqual(raster.pointSize.width, 144, accuracy: 0.01)
|
||||
XCTAssertEqual(raster.pointSize.height, 216, accuracy: 0.01)
|
||||
}
|
||||
|
||||
func testMissingDPIFallsBackTo72() throws {
|
||||
let url = try TargetTestFixtures.makeTIFF(
|
||||
px: CGSize(width: 100, height: 80), dpi: nil,
|
||||
in: tempRoot)
|
||||
let raster = try load(url)
|
||||
XCTAssertEqual(raster.dpiX, TargetRasterLoader.fallbackDPI)
|
||||
XCTAssertEqual(raster.dpiY, TargetRasterLoader.fallbackDPI)
|
||||
XCTAssertEqual(raster.pointSize.width, 100, accuracy: 0.01)
|
||||
XCTAssertEqual(raster.pointSize.height, 80, accuracy: 0.01)
|
||||
}
|
||||
|
||||
func testMissingFileThrows() {
|
||||
let url = tempRoot.appendingPathComponent("nope.tiff")
|
||||
XCTAssertThrowsError(try load(url)) { error in
|
||||
XCTAssertEqual(
|
||||
error as? TargetRasterError,
|
||||
.tiffMissing(url.path))
|
||||
}
|
||||
}
|
||||
|
||||
func testGarbageFileThrowsUndecodable() throws {
|
||||
let url = tempRoot.appendingPathComponent("junk.tiff")
|
||||
try Data([0x00, 0x01, 0x02, 0x03]).write(to: url)
|
||||
XCTAssertThrowsError(try load(url)) { error in
|
||||
XCTAssertEqual(
|
||||
error as? TargetRasterError,
|
||||
.undecodable(url.path))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - deviceTagged (D10)
|
||||
|
||||
func testDeviceTaggedRGB8() throws {
|
||||
let source = try XCTUnwrap(TargetTestFixtures.makeImage(
|
||||
px: CGSize(width: 16, height: 16),
|
||||
components: 3, bitsPerComponent: 8))
|
||||
let tagged = try XCTUnwrap(
|
||||
TargetRasterLoader.deviceTagged(source))
|
||||
XCTAssertEqual(tagged.colorSpace?.model, .rgb)
|
||||
XCTAssertEqual(tagged.bitsPerComponent, 8)
|
||||
XCTAssertFalse(tagged.shouldInterpolate)
|
||||
XCTAssertEqual(
|
||||
tagged.dataProvider?.data as? Data,
|
||||
source.dataProvider?.data as? Data)
|
||||
}
|
||||
|
||||
func testDeviceTaggedCMYK8() throws {
|
||||
let source = try XCTUnwrap(TargetTestFixtures.makeImage(
|
||||
px: CGSize(width: 16, height: 16),
|
||||
components: 4, bitsPerComponent: 8))
|
||||
let tagged = try XCTUnwrap(
|
||||
TargetRasterLoader.deviceTagged(source))
|
||||
XCTAssertEqual(tagged.colorSpace?.model, .cmyk)
|
||||
XCTAssertEqual(tagged.bitsPerComponent, 8)
|
||||
XCTAssertEqual(
|
||||
tagged.dataProvider?.data as? Data,
|
||||
source.dataProvider?.data as? Data)
|
||||
}
|
||||
|
||||
func testDeviceTaggedGray8() throws {
|
||||
let source = try XCTUnwrap(TargetTestFixtures.makeImage(
|
||||
px: CGSize(width: 16, height: 16),
|
||||
components: 1, bitsPerComponent: 8))
|
||||
let tagged = try XCTUnwrap(
|
||||
TargetRasterLoader.deviceTagged(source))
|
||||
XCTAssertEqual(tagged.colorSpace?.model, .monochrome)
|
||||
XCTAssertEqual(tagged.bitsPerComponent, 8)
|
||||
XCTAssertEqual(
|
||||
tagged.dataProvider?.data as? Data,
|
||||
source.dataProvider?.data as? Data)
|
||||
}
|
||||
|
||||
func testDeviceTaggedRGB16KeepsBitDepth() throws {
|
||||
let source = try XCTUnwrap(TargetTestFixtures.makeImage(
|
||||
px: CGSize(width: 16, height: 16),
|
||||
components: 3, bitsPerComponent: 16))
|
||||
let tagged = try XCTUnwrap(
|
||||
TargetRasterLoader.deviceTagged(source))
|
||||
XCTAssertEqual(tagged.colorSpace?.model, .rgb)
|
||||
XCTAssertEqual(tagged.bitsPerComponent, 16)
|
||||
XCTAssertEqual(
|
||||
tagged.dataProvider?.data as? Data,
|
||||
source.dataProvider?.data as? Data)
|
||||
}
|
||||
|
||||
/// The loaded raster reuses the decoder's provider verbatim — byte
|
||||
/// equality is the no-resample guarantee (R5).
|
||||
func testLoadedRasterSharesDecodedBytes() throws {
|
||||
let url = try TargetTestFixtures.makeTIFF(
|
||||
px: CGSize(width: 32, height: 24), dpi: 300,
|
||||
in: tempRoot)
|
||||
let raster = try load(url)
|
||||
let decoded = try decoded(url)
|
||||
XCTAssertEqual(
|
||||
raster.cgImage.dataProvider?.data as? Data,
|
||||
decoded.dataProvider?.data as? Data)
|
||||
XCTAssertEqual(raster.cgImage.colorSpace?.model, .rgb)
|
||||
}
|
||||
|
||||
// MARK: - Rejections
|
||||
|
||||
func testAlphaTIFFThrowsUnsupportedLayout() throws {
|
||||
let url = try TargetTestFixtures.makeTIFF(
|
||||
px: CGSize(width: 16, height: 16), dpi: 300,
|
||||
components: 3, alpha: true, in: tempRoot)
|
||||
XCTAssertThrowsError(try load(url)) { error in
|
||||
guard case .unsupportedLayout(_, _, _, let hasAlpha) =
|
||||
error as? TargetRasterError
|
||||
else {
|
||||
return XCTFail("expected unsupportedLayout, got \(error)")
|
||||
}
|
||||
XCTAssertTrue(hasAlpha)
|
||||
}
|
||||
}
|
||||
|
||||
func testDeviceTaggedRejectsFloat() throws {
|
||||
var info = CGBitmapInfo(rawValue: CGImageAlphaInfo.none.rawValue)
|
||||
info.insert(.floatComponents)
|
||||
info.insert(.byteOrder32Little)
|
||||
var bytes = [Float](repeating: 0.5, count: 4 * 4 * 3)
|
||||
let floatImage = bytes.withUnsafeMutableBytes { buffer -> CGImage? in
|
||||
CGImage(
|
||||
width: 4, height: 4,
|
||||
bitsPerComponent: 32, bitsPerPixel: 96,
|
||||
bytesPerRow: 4 * 3 * 4,
|
||||
space: CGColorSpaceCreateDeviceRGB(),
|
||||
bitmapInfo: info,
|
||||
provider: CGDataProvider(data: Data(buffer) as CFData)!,
|
||||
decode: nil, shouldInterpolate: false,
|
||||
intent: .defaultIntent)
|
||||
}
|
||||
bytes = []
|
||||
let image = try XCTUnwrap(floatImage)
|
||||
XCTAssertNil(TargetRasterLoader.deviceTagged(image))
|
||||
}
|
||||
|
||||
func testDeviceTaggedRejectsIndexed() throws {
|
||||
var table = [UInt8](repeating: 0, count: 256 * 3)
|
||||
let indexed = try XCTUnwrap(CGColorSpace(
|
||||
indexedBaseSpace: CGColorSpaceCreateDeviceRGB(),
|
||||
last: 255, colorTable: &table))
|
||||
let image = try XCTUnwrap(CGImage(
|
||||
width: 8, height: 8,
|
||||
bitsPerComponent: 8, bitsPerPixel: 8,
|
||||
bytesPerRow: 8,
|
||||
space: indexed,
|
||||
bitmapInfo: CGBitmapInfo(),
|
||||
provider: CGDataProvider(
|
||||
data: Data(repeating: 7, count: 64) as CFData)!,
|
||||
decode: nil, shouldInterpolate: false,
|
||||
intent: .defaultIntent))
|
||||
XCTAssertNil(TargetRasterLoader.deviceTagged(image))
|
||||
}
|
||||
|
||||
/// No 2-component `CGImage` is fabricatable through public API —
|
||||
/// the rejection lives in `deviceSpace(for:)`, asserted here.
|
||||
func testDeviceSpaceRejectsTwoAndFiveComponents() {
|
||||
XCTAssertNil(TargetRasterLoader.deviceSpace(for: 2))
|
||||
XCTAssertNil(TargetRasterLoader.deviceSpace(for: 0))
|
||||
XCTAssertNil(TargetRasterLoader.deviceSpace(for: 5))
|
||||
XCTAssertNotNil(TargetRasterLoader.deviceSpace(for: 1))
|
||||
XCTAssertNotNil(TargetRasterLoader.deviceSpace(for: 3))
|
||||
XCTAssertNotNil(TargetRasterLoader.deviceSpace(for: 4))
|
||||
}
|
||||
|
||||
// MARK: - Manifest drift (D9)
|
||||
|
||||
func testManifestDriftWhenMismatch() throws {
|
||||
let url = try TargetTestFixtures.makeTIFF(
|
||||
px: CGSize(width: 2480, height: 3508), dpi: 300,
|
||||
in: tempRoot)
|
||||
let raster = try TargetRasterLoader.load(
|
||||
tiff: url, expectedWidthMm: 200, expectedHeightMm: 297)
|
||||
XCTAssertNotNil(raster.manifestDrift)
|
||||
}
|
||||
|
||||
func testManifestDriftNilWhenMatching() throws {
|
||||
let url = try TargetTestFixtures.makeTIFF(
|
||||
px: CGSize(width: 2480, height: 3508), dpi: 300,
|
||||
in: tempRoot)
|
||||
// 2480 px @300 dpi = 209.97 mm — inside the 0.5 mm band.
|
||||
let raster = try TargetRasterLoader.load(
|
||||
tiff: url, expectedWidthMm: 210, expectedHeightMm: 297)
|
||||
XCTAssertNil(raster.manifestDrift)
|
||||
}
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
import XCTest
|
||||
@testable import ICCeryCore
|
||||
@testable import ICCery
|
||||
|
||||
/// Issue #201 Phase 4 — the resolved ticket-write list that replaces
|
||||
/// the `lp` argv goldens: locked order 1–10, D6 (Stage 2 always
|
||||
/// wins), `raw` can never appear.
|
||||
final class TicketWriteResolverTests: XCTestCase {
|
||||
|
||||
private func resolve(
|
||||
overrides: TargetPrintOverrides = TargetPrintOverrides(),
|
||||
optionKeys: Set<String> = []
|
||||
) -> ResolvedTicketWrites {
|
||||
TicketWriteResolver.resolve(
|
||||
overrides: overrides, optionKeys: optionKeys)
|
||||
}
|
||||
|
||||
private func keys(_ r: ResolvedTicketWrites) -> [String] {
|
||||
r.writes.map(\.key)
|
||||
}
|
||||
|
||||
private func value(
|
||||
_ key: String, in r: ResolvedTicketWrites
|
||||
) -> String? {
|
||||
r.writes.first { $0.key == key }?.value
|
||||
}
|
||||
|
||||
/// Both AP_* spellings are always present and locked; all three
|
||||
/// Quartz keys are always present (D2 dual vocabulary).
|
||||
func testColourKeysAlwaysPresent() {
|
||||
let r = resolve()
|
||||
let ap = r.writes.filter {
|
||||
ColorMatchingAttempts.printSettingsKeys.contains($0.key)
|
||||
}
|
||||
XCTAssertEqual(ap.count, 2)
|
||||
XCTAssertTrue(ap.allSatisfy {
|
||||
$0.locked
|
||||
&& $0.value == "AP_ApplicationColorMatching"
|
||||
})
|
||||
XCTAssertEqual(value("PMColorMatchingMode", in: r),
|
||||
"APCustomColorMatching")
|
||||
XCTAssertEqual(value("PMCustomColorMatchingProfile", in: r), "")
|
||||
XCTAssertEqual(
|
||||
value("com.apple.print.PrintSettings.PMColorMatchingMode",
|
||||
in: r),
|
||||
"APCustomColorMatching")
|
||||
// The Quartz mode key is locked; profile + legacy key are not.
|
||||
XCTAssertTrue(r.writes.first { $0.key == "PMColorMatchingMode" }!
|
||||
.locked)
|
||||
XCTAssertFalse(r.writes.first {
|
||||
$0.key == "PMCustomColorMatchingProfile" }!.locked)
|
||||
XCTAssertFalse(r.writes.first {
|
||||
$0.key == "com.apple.print.PrintSettings.PMColorMatchingMode"
|
||||
}!.locked)
|
||||
}
|
||||
|
||||
/// `raw` can never appear — there is no `lp` and the resolver has
|
||||
/// no captured-options input that could smuggle it in (#92).
|
||||
func testRawNeverAppears() {
|
||||
for optionKeys in [Set<String>(), ["raw"],
|
||||
["MediaType", "raw"]] {
|
||||
let r = resolve(
|
||||
overrides: TargetPrintOverrides(
|
||||
paperSize: "raw", mediaType: "raw",
|
||||
qualityKey: "raw", quality: "raw",
|
||||
orientation: "raw"),
|
||||
optionKeys: optionKeys)
|
||||
XCTAssertFalse(r.writes.contains {
|
||||
$0.key.lowercased() == "raw"
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// D6 inversion — the deleted argv builder suppressed the Stage 2
|
||||
/// value when the captured string already carried the key (the old
|
||||
/// media branch at `build`'s line 81). There is no captured-wins
|
||||
/// path any more:
|
||||
/// the explicit Stage 2 value is emitted unconditionally.
|
||||
func testStage2MediaTypeAlwaysWins() {
|
||||
let r = resolve(
|
||||
overrides: TargetPrintOverrides(mediaType: "Matte"),
|
||||
optionKeys: ["MediaType"])
|
||||
XCTAssertEqual(value("MediaType", in: r), "Matte")
|
||||
// …and likewise for a vendor key — nothing consults captured
|
||||
// options before writing.
|
||||
let epson = resolve(
|
||||
overrides: TargetPrintOverrides(mediaType: "Matte"),
|
||||
optionKeys: ["EPIJ_Medi"])
|
||||
XCTAssertEqual(value("EPIJ_Medi", in: epson), "Matte")
|
||||
}
|
||||
|
||||
/// Vendor media-key selection follows `CupsParsers.mediaTypeKeys`:
|
||||
/// `EPIJ_Medi` is picked when present; `CNIJMediaType` beats
|
||||
/// `MediaType`.
|
||||
func testVendorMediaKeySelection() {
|
||||
let epson = resolve(
|
||||
overrides: TargetPrintOverrides(mediaType: "Photo"),
|
||||
optionKeys: ["EPIJ_Medi", "MediaType"])
|
||||
XCTAssertEqual(value("EPIJ_Medi", in: epson), "Photo")
|
||||
XCTAssertNil(value("MediaType", in: epson))
|
||||
|
||||
let canon = resolve(
|
||||
overrides: TargetPrintOverrides(mediaType: "Photo"),
|
||||
optionKeys: ["CNIJMediaType", "MediaType"])
|
||||
XCTAssertEqual(value("CNIJMediaType", in: canon), "Photo")
|
||||
XCTAssertNil(value("MediaType", in: canon))
|
||||
}
|
||||
|
||||
/// Driver "no colour adjustment" bypass — Canon, Epson (CCor
|
||||
/// preferred over CMat), Gutenprint — always unlocked and never
|
||||
/// gated on `ppdUncorrectedPassthrough`.
|
||||
func testDriverBypass() {
|
||||
let canon = resolve(optionKeys: ["CNIJIntent2"])
|
||||
XCTAssertEqual(value("CNIJIntent2", in: canon), "4")
|
||||
XCTAssertFalse(canon.writes.first { $0.key == "CNIJIntent2" }!
|
||||
.locked)
|
||||
|
||||
let epson = resolve(optionKeys: ["EPIJ_CCor", "EPIJ_CMat"])
|
||||
XCTAssertEqual(value("EPIJ_CCor", in: epson), "0")
|
||||
XCTAssertNil(value("EPIJ_CMat", in: epson))
|
||||
|
||||
let epsonMat = resolve(optionKeys: ["EPIJ_CMat"])
|
||||
XCTAssertEqual(value("EPIJ_CMat", in: epsonMat), "3")
|
||||
|
||||
let gutenprint = resolve(optionKeys: ["StpColorCorrection"])
|
||||
XCTAssertEqual(value("StpColorCorrection", in: gutenprint),
|
||||
"Uncorrected")
|
||||
|
||||
XCTAssertNil(value("EPIJ_CMat", in: resolve()))
|
||||
}
|
||||
|
||||
/// Orientation: landscape → 4, portrait → 3, nil → no write.
|
||||
func testOrientation() {
|
||||
XCTAssertEqual(
|
||||
value("orientation-requested", in: resolve(
|
||||
overrides: TargetPrintOverrides(
|
||||
orientation: "landscape"))),
|
||||
"4")
|
||||
XCTAssertEqual(
|
||||
value("orientation-requested", in: resolve(
|
||||
overrides: TargetPrintOverrides(
|
||||
orientation: "portrait"))),
|
||||
"3")
|
||||
XCTAssertNil(value("orientation-requested", in: resolve()))
|
||||
}
|
||||
|
||||
/// A `nil` override emits no write for that key; quality needs
|
||||
/// **both** the detected key and the value.
|
||||
func testNilOverridesEmitNoWrite() {
|
||||
let r = resolve(optionKeys: ["MediaType", "EPIJ_Qual"])
|
||||
XCTAssertNil(value("PageSize", in: r))
|
||||
XCTAssertNil(value("MediaType", in: r))
|
||||
XCTAssertNil(value("EPIJ_Qual", in: r))
|
||||
XCTAssertNil(value("orientation-requested", in: r))
|
||||
XCTAssertNil(r.paperToken)
|
||||
XCTAssertNil(r.orientation)
|
||||
|
||||
let keyOnly = resolve(
|
||||
overrides: TargetPrintOverrides(qualityKey: "EPIJ_Qual"),
|
||||
optionKeys: ["EPIJ_Qual"])
|
||||
XCTAssertNil(value("EPIJ_Qual", in: keyOnly))
|
||||
let valueOnly = resolve(
|
||||
overrides: TargetPrintOverrides(quality: "305"),
|
||||
optionKeys: ["EPIJ_Qual"])
|
||||
XCTAssertNil(value("EPIJ_Qual", in: valueOnly))
|
||||
// An empty paper token emits no write and no paperToken.
|
||||
let emptyPaper = resolve(
|
||||
overrides: TargetPrintOverrides(paperSize: ""))
|
||||
XCTAssertNil(value("PageSize", in: emptyPaper))
|
||||
XCTAssertNil(emptyPaper.paperToken)
|
||||
}
|
||||
|
||||
/// The write list equals the locked 1–10 order, exactly.
|
||||
func testLockedWriteOrder() {
|
||||
let r = resolve(
|
||||
overrides: TargetPrintOverrides(
|
||||
paperSize: "A4", mediaType: "Photo",
|
||||
qualityKey: "EPIJ_Qual", quality: "305",
|
||||
orientation: "landscape"),
|
||||
optionKeys: ["MediaType", "EPIJ_Qual", "EPIJ_CMat"])
|
||||
XCTAssertEqual(keys(r), [
|
||||
"AP_ColorMatchingMode",
|
||||
"AP.ColorMatchingMode",
|
||||
"PMColorMatchingMode",
|
||||
"PMCustomColorMatchingProfile",
|
||||
"com.apple.print.PrintSettings.PMColorMatchingMode",
|
||||
"PageSize",
|
||||
"MediaType",
|
||||
"EPIJ_Qual",
|
||||
"EPIJ_CMat",
|
||||
"orientation-requested",
|
||||
])
|
||||
XCTAssertEqual(r.paperToken, "A4")
|
||||
XCTAssertEqual(r.orientation, "landscape")
|
||||
}
|
||||
}
|
||||
@@ -20,13 +20,6 @@ final class AboutHelpUITests: XCTestCase {
|
||||
private func element(_ id: String) -> XCUIElement {
|
||||
let inApp = app.descendants(matching: .any)[id].firstMatch
|
||||
if inApp.exists { return inApp }
|
||||
// Search all sheets (including nested sheets) for the element.
|
||||
// The license window is a nested sheet (sheet presented from AboutView).
|
||||
for sheet in app.sheets.allElementsBoundByIndex {
|
||||
let inSheet = sheet.descendants(matching: .any)[id].firstMatch
|
||||
if inSheet.exists { return inSheet }
|
||||
}
|
||||
// Fallback to original behavior
|
||||
return app.sheets.firstMatch.descendants(matching: .any)[id].firstMatch
|
||||
}
|
||||
|
||||
@@ -92,139 +85,4 @@ final class AboutHelpUITests: XCTestCase {
|
||||
"Toggling global help must not reflow the sidebar.")
|
||||
XCTAssertTrue(app.descendants(matching: .any)["openSettingsBtn"].exists)
|
||||
}
|
||||
|
||||
func testAboutDialogShowsViewLicensesButton() throws {
|
||||
launchApp()
|
||||
|
||||
let openAbout = app.buttons["openAboutBtn"]
|
||||
XCTAssertTrue(openAbout.waitForExistence(timeout: 10))
|
||||
openAbout.click()
|
||||
|
||||
_ = waitFor("aboutVersion", timeout: 10)
|
||||
|
||||
let viewLicensesBtn = waitFor("viewLicensesBtn", timeout: 10)
|
||||
XCTAssertTrue(viewLicensesBtn.exists)
|
||||
viewLicensesBtn.click()
|
||||
|
||||
// License window should open as a sheet
|
||||
_ = waitFor("licenseWindow", timeout: 10)
|
||||
XCTAssertTrue(element("licenseWindow").exists)
|
||||
|
||||
// Close license window
|
||||
let closeLicenseBtn = waitFor("closeLicenseBtn", timeout: 5)
|
||||
closeLicenseBtn.click()
|
||||
|
||||
// License window should be dismissed
|
||||
XCTAssertFalse(element("licenseWindow").exists)
|
||||
|
||||
// Close about dialog
|
||||
let closeAboutBtn = waitFor("closeAboutBtn", timeout: 5)
|
||||
closeAboutBtn.click()
|
||||
XCTAssertFalse(element("aboutDialog").exists)
|
||||
}
|
||||
|
||||
func testLicenseWindowShowsICCeryLicense() throws {
|
||||
launchApp()
|
||||
|
||||
let openAbout = app.buttons["openAboutBtn"]
|
||||
XCTAssertTrue(openAbout.waitForExistence(timeout: 10))
|
||||
openAbout.click()
|
||||
|
||||
let viewLicensesBtn = waitFor("viewLicensesBtn", timeout: 10)
|
||||
viewLicensesBtn.click()
|
||||
|
||||
_ = waitFor("licenseWindow", timeout: 10)
|
||||
|
||||
// Verify ICCery license section exists
|
||||
let icceryLicenseSection = waitFor("icceryLicenseSectionHeader", timeout: 5)
|
||||
XCTAssertTrue(icceryLicenseSection.exists)
|
||||
|
||||
// Verify ICCery license content contains key phrases
|
||||
let icceryLicenseContent = element("icceryLicenseSectionContent")
|
||||
XCTAssertTrue(icceryLicenseContent.waitForExistence(timeout: 5))
|
||||
let licenseText = icceryLicenseContent.value as? String ?? ""
|
||||
XCTAssertTrue(licenseText.contains("Copyright (c) 2026 Gordon Bolton"))
|
||||
XCTAssertTrue(licenseText.contains("All Rights Reserved"))
|
||||
XCTAssertTrue(licenseText.contains("AGPLv3"))
|
||||
|
||||
// Close license window
|
||||
let closeLicenseBtn = waitFor("closeLicenseBtn", timeout: 5)
|
||||
closeLicenseBtn.click()
|
||||
|
||||
let closeAboutBtn = waitFor("closeAboutBtn", timeout: 5)
|
||||
closeAboutBtn.click()
|
||||
}
|
||||
|
||||
func testLicenseWindowShowsArgyllLicense() throws {
|
||||
launchApp()
|
||||
|
||||
let openAbout = app.buttons["openAboutBtn"]
|
||||
XCTAssertTrue(openAbout.waitForExistence(timeout: 10))
|
||||
openAbout.click()
|
||||
|
||||
let viewLicensesBtn = waitFor("viewLicensesBtn", timeout: 10)
|
||||
viewLicensesBtn.click()
|
||||
|
||||
_ = waitFor("licenseWindow", timeout: 10)
|
||||
|
||||
// Verify ArgyllCMS license section exists
|
||||
let argyllLicenseSection = waitFor("argyllLicenseSectionHeader", timeout: 5)
|
||||
XCTAssertTrue(argyllLicenseSection.exists)
|
||||
|
||||
// Verify Argyll license content exists (may be fallback if License.txt not bundled)
|
||||
let argyllLicenseContent = element("argyllLicenseSectionContent")
|
||||
XCTAssertTrue(argyllLicenseContent.waitForExistence(timeout: 5))
|
||||
let licenseText = argyllLicenseContent.value as? String ?? ""
|
||||
// Should contain either the actual AGPLv3 license or the fallback notice
|
||||
XCTAssertTrue(licenseText.contains("AGPLv3") || licenseText.contains("GNU Affero General Public License") || licenseText.contains("fetch-argyll"))
|
||||
|
||||
// Close license window
|
||||
let closeLicenseBtn = waitFor("closeLicenseBtn", timeout: 5)
|
||||
closeLicenseBtn.click()
|
||||
|
||||
let closeAboutBtn = waitFor("closeAboutBtn", timeout: 5)
|
||||
closeAboutBtn.click()
|
||||
}
|
||||
|
||||
func testLicenseWindowShowsAttributionLinks() throws {
|
||||
launchApp()
|
||||
|
||||
let openAbout = app.buttons["openAboutBtn"]
|
||||
XCTAssertTrue(openAbout.waitForExistence(timeout: 10))
|
||||
openAbout.click()
|
||||
|
||||
let viewLicensesBtn = waitFor("viewLicensesBtn", timeout: 10)
|
||||
viewLicensesBtn.click()
|
||||
|
||||
_ = waitFor("licenseWindow", timeout: 10)
|
||||
|
||||
// Verify attribution section exists
|
||||
let attributionHeader = waitFor("attributionHeader", timeout: 5)
|
||||
XCTAssertTrue(attributionHeader.exists)
|
||||
|
||||
// Verify upstream link
|
||||
let upstreamLink = element("argyllUpstreamLink")
|
||||
XCTAssertTrue(upstreamLink.waitForExistence(timeout: 5))
|
||||
let upstreamLabel = upstreamLink.label
|
||||
XCTAssertTrue(upstreamLabel.contains("Graeme Gill") || upstreamLabel.contains("argyllcms.com"))
|
||||
|
||||
// Verify fork link
|
||||
let forkLink = element("argyllForkLink")
|
||||
XCTAssertTrue(forkLink.waitForExistence(timeout: 5))
|
||||
let forkLabel = forkLink.label
|
||||
XCTAssertTrue(forkLabel.contains("Gronod") || forkLabel.contains("git.i3omb.com"))
|
||||
|
||||
// Verify AGPL isolation note
|
||||
let isolationNote = element("agplIsolationNote")
|
||||
XCTAssertTrue(isolationNote.waitForExistence(timeout: 5))
|
||||
let noteText = isolationNote.value as? String ?? ""
|
||||
XCTAssertTrue(noteText.contains("isolated subprocesses") || noteText.contains("AGPLv3 isolation"))
|
||||
|
||||
// Close license window
|
||||
let closeLicenseBtn = waitFor("closeLicenseBtn", timeout: 5)
|
||||
closeLicenseBtn.click()
|
||||
|
||||
let closeAboutBtn = waitFor("closeAboutBtn", timeout: 5)
|
||||
closeAboutBtn.click()
|
||||
}
|
||||
}
|
||||
|
||||
Executable
+14
@@ -0,0 +1,14 @@
|
||||
#!/bin/sh
|
||||
# Mock lp for Milestone3UITests. Appends its full argv to
|
||||
# ICCERY_TEST_LP_ARGV so the test can assert flag order and option
|
||||
# replay, then exits 0 (or ICCERY_MOCK_LP_EXIT for failure injection).
|
||||
{
|
||||
printf 'lp'
|
||||
for arg in "$@"; do printf ' %s' "$arg"; done
|
||||
printf '\n'
|
||||
} >> "${ICCERY_TEST_LP_ARGV:-/dev/null}"
|
||||
if [ "${ICCERY_MOCK_LP_EXIT:-0}" -ne 0 ]; then
|
||||
echo "mock lp failure" >&2
|
||||
exit "$ICCERY_MOCK_LP_EXIT"
|
||||
fi
|
||||
exit 0
|
||||
@@ -1,8 +1,6 @@
|
||||
#!/bin/sh
|
||||
# Mock lpoptions for Milestone3UITests. `-p <q>` prints printer-info;
|
||||
# `-p <q> -l` prints Key/Label listings incl. Epson bypass keys and the
|
||||
# recorded XP-55 EPIJ_Qual line — the driver's own non-sorted order
|
||||
# (308 sits between 303 and 304, #180).
|
||||
# `-p <q> -l` prints Key/Label listings incl. Epson bypass keys.
|
||||
queue=""
|
||||
list=0
|
||||
for arg in "$@"; do
|
||||
@@ -17,7 +15,6 @@ if [ "$list" = "1" ]; then
|
||||
printf 'PageSize/Media Size: 4x6 5x7 *A4 Letter Legal\n'
|
||||
printf 'InputSlot/Media Source: Auto *Main Rear\n'
|
||||
printf 'MediaType/Media Type: *Stationery PhotographicGlossy PhotographicMatte\n'
|
||||
printf 'EPIJ_Qual/Print Quality: 301 302 *303 308 304 305 307\n'
|
||||
printf 'EPIJ_CMat/Color Adjust: *0 1 2 3\n'
|
||||
printf 'ColorModel/Output Mode: *RGB Gray\n'
|
||||
exit 0
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
#!/bin/sh
|
||||
# Mock printtarg for Milestone2UITests. Writes N 2x2 TIFFs, a pretty
|
||||
# Mock printtarg for Milestone2UITests. Writes one 2x2 TIFF, a pretty
|
||||
# manifest on stdout, and a fake .ti2 next to the basename (last argv).
|
||||
# N defaults to 1; ICCERY_MOCK_PRINTTARG_PAGES raises it for the
|
||||
# single-spool-job granularity test (#201 D5).
|
||||
# Exit code is overridable via ICCERY_MOCK_PRINTTARG_EXIT.
|
||||
last=""
|
||||
for arg in "$@"; do last="$arg"; done
|
||||
@@ -10,16 +8,7 @@ if [ "${ICCERY_MOCK_PRINTTARG_EXIT:-0}" -ne 0 ]; then
|
||||
echo "mock printtarg failure" >&2
|
||||
exit "$ICCERY_MOCK_PRINTTARG_EXIT"
|
||||
fi
|
||||
pages="${ICCERY_MOCK_PRINTTARG_PAGES:-1}"
|
||||
i=1
|
||||
printf '{\n "event": "manifest",\n "pages": [\n'
|
||||
while [ "$i" -le "$pages" ]; do
|
||||
echo 'SUkqAAgAAAAKAAABAwABAAAAAgAAAAEBAwABAAAAAgAAAAIBAwABAAAACAAAAAMBAwABAAAAAQAAAAYBAwABAAAAAQAAABEBBAABAAAAhgAAABUBAwABAAAAAQAAABYBAwABAAAAAgAAABcBBAABAAAABAAAABwBAwABAAAAAQAAAAAAAAA8eLTw' | /usr/bin/base64 -D > "page$i.tif"
|
||||
sep=","
|
||||
if [ "$i" -eq "$pages" ]; then sep=""; fi
|
||||
printf ' {"filename": "page%s.tif", "patches": 4, "width_mm": 210, "height_mm": 297}%s\n' "$i" "$sep"
|
||||
i=$((i + 1))
|
||||
done
|
||||
printf ' ]\n}\n'
|
||||
echo 'SUkqAAgAAAAKAAABAwABAAAAAgAAAAEBAwABAAAAAgAAAAIBAwABAAAACAAAAAMBAwABAAAAAQAAAAYBAwABAAAAAQAAABEBBAABAAAAhgAAABUBAwABAAAAAQAAABYBAwABAAAAAgAAABcBBAABAAAABAAAABwBAwABAAAAAQAAAAAAAAA8eLTw' | /usr/bin/base64 -D > "page1.tif"
|
||||
printf '{\n "event": "manifest",\n "pages": [\n {"filename": "page1.tif", "patches": 4, "width_mm": 210, "height_mm": 297}\n ]\n}\n'
|
||||
touch "$last.ti2"
|
||||
exit 0
|
||||
|
||||
@@ -1,285 +0,0 @@
|
||||
import XCTest
|
||||
|
||||
/// Milestone 11 UI tests — issue #183 Stage 2 paper size + print
|
||||
/// quality pickers. Runs against the same mock CUPS fixture binaries
|
||||
/// as `Milestone3UITests`; the `NSPrintPanel` stays stubbed through
|
||||
/// `ICCERY_TEST_PRINT_PANEL` (XCUITest cannot drive the system modal).
|
||||
@MainActor
|
||||
final class Milestone11PrintSettingsUITests: XCTestCase {
|
||||
|
||||
private var app: XCUIApplication!
|
||||
private var testRoot: URL!
|
||||
private var binDir: URL!
|
||||
private var workDir: URL!
|
||||
private var spoolLogURL: URL!
|
||||
|
||||
override func setUp() async throws {
|
||||
continueAfterFailure = false
|
||||
testRoot = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("iccery-ui11-\(UUID().uuidString)")
|
||||
binDir = URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Fixtures/bin")
|
||||
workDir = testRoot.appendingPathComponent("work")
|
||||
spoolLogURL = testRoot.appendingPathComponent("spool.log")
|
||||
try FileManager.default.createDirectory(
|
||||
at: workDir, withIntermediateDirectories: true)
|
||||
|
||||
app = XCUIApplication()
|
||||
app.launchEnvironment = [
|
||||
"ICCERY_UI_TESTING": "1",
|
||||
"ICCERY_TEST_ROOT": testRoot.path,
|
||||
"ICCERY_ARGYLL_BINARY_DIR": binDir.path,
|
||||
"ICCERY_CUPS_BIN_DIR": binDir.path,
|
||||
"ICCERY_TEST_SAVE_TARGET":
|
||||
workDir.appendingPathComponent("mytarget.ti1").path,
|
||||
"ICCERY_TEST_WORKDIR": workDir.path,
|
||||
"ICCERY_TEST_SPOOL_LOG": spoolLogURL.path,
|
||||
]
|
||||
}
|
||||
|
||||
override func tearDown() async throws {
|
||||
app?.terminate()
|
||||
app = nil
|
||||
if let testRoot {
|
||||
try? FileManager.default.removeItem(at: testRoot)
|
||||
}
|
||||
testRoot = nil
|
||||
}
|
||||
|
||||
private func element(_ id: String) -> XCUIElement {
|
||||
let inApp = app.descendants(matching: .any)[id]
|
||||
if inApp.exists { return inApp }
|
||||
return app.sheets.firstMatch.descendants(matching: .any)[id]
|
||||
}
|
||||
|
||||
private func waitFor(_ id: String, timeout: TimeInterval = 15) -> XCUIElement {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
let el = element(id)
|
||||
if el.exists { return el }
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||
}
|
||||
let el = element(id)
|
||||
XCTAssertTrue(el.exists, "Expected element \(id)")
|
||||
return el
|
||||
}
|
||||
|
||||
/// Drive the app through targen + printtarg so the print panel is
|
||||
/// live with a manifest.
|
||||
private func reachPrintPanel() {
|
||||
app.buttons["btnBrowse"].click()
|
||||
app.buttons["btnGenerate"].click()
|
||||
_ = waitFor("btnCreateLayout", timeout: 25)
|
||||
app.buttons["btnCreateLayout"].click()
|
||||
_ = waitFor("galleryPage-0", timeout: 25)
|
||||
}
|
||||
|
||||
private func spoolLogLines() -> [String] {
|
||||
((try? String(contentsOf: spoolLogURL, encoding: .utf8)) ?? "")
|
||||
.split(separator: "\n").map(String.init)
|
||||
}
|
||||
|
||||
private func waitForSpoolLine(_ timeout: TimeInterval = 10) -> String {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
let lines = spoolLogLines()
|
||||
if !lines.isEmpty { return lines.joined(separator: "\n") }
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||
}
|
||||
return spoolLogLines().joined(separator: "\n")
|
||||
}
|
||||
|
||||
/// Poll until the spool log holds at least `count` lines.
|
||||
@discardableResult
|
||||
private func waitForSpoolLineCount(
|
||||
_ count: Int, timeout: TimeInterval = 15
|
||||
) -> [String] {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
let lines = spoolLogLines()
|
||||
if lines.count >= count { return lines }
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||
}
|
||||
return spoolLogLines()
|
||||
}
|
||||
|
||||
// MARK: - Tests
|
||||
|
||||
/// The paper-size and quality pickers exist with their new ids;
|
||||
/// the existing tray / media / orientation ids are unchanged (#183).
|
||||
func testPaperAndQualityPickersExist() throws {
|
||||
launchAppWithDefaults()
|
||||
reachPrintPanel()
|
||||
_ = waitFor("printerStatusBadge")
|
||||
|
||||
XCTAssertTrue(element("printerPaperSizeSelect").exists)
|
||||
XCTAssertTrue(element("printerQualitySelect").exists)
|
||||
XCTAssertTrue(element("paperSizeGroup").exists)
|
||||
XCTAssertTrue(element("qualityGroup").exists)
|
||||
|
||||
// Existing ids untouched. (`mediaTypeGroup` is not asserted —
|
||||
// stacked `.accessibilityIdentifier` modifiers collapse to the
|
||||
// last one, so it never resolved even before this change.)
|
||||
XCTAssertTrue(element("printerSelect").exists)
|
||||
XCTAssertTrue(element("printerTraySelect").exists)
|
||||
XCTAssertTrue(element("printerMediaTypeSelect").exists)
|
||||
XCTAssertTrue(element("btnOrientPortrait").exists)
|
||||
XCTAssertTrue(element("btnOrientLandscape").exists)
|
||||
XCTAssertTrue(element("btnPrinterProperties").exists)
|
||||
}
|
||||
|
||||
/// The displayed selection of a picker — `AXTitle` for a popup
|
||||
/// button, falling back to label/value depending on how AppKit
|
||||
/// exposes the current item.
|
||||
private func selection(of id: String) -> String {
|
||||
let el = element(id)
|
||||
for candidate in [el.title, el.label, el.value as? String ?? ""] {
|
||||
if !candidate.isEmpty, candidate != el.identifier {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return el.title
|
||||
}
|
||||
|
||||
/// The paper picker seeds from Stage 1's `pageSize` (A4 default)
|
||||
/// and the quality picker from the driver's `*` default choice.
|
||||
func testPickersSeedFromStage1AndDriverDefault() throws {
|
||||
launchAppWithDefaults()
|
||||
reachPrintPanel()
|
||||
_ = waitFor("printerStatusBadge")
|
||||
|
||||
_ = waitFor("printerPaperSizeSelect")
|
||||
_ = waitFor("printerQualitySelect")
|
||||
XCTAssertEqual(selection(of: "printerPaperSizeSelect"), "A4")
|
||||
XCTAssertEqual(selection(of: "printerQualitySelect"), "303")
|
||||
}
|
||||
|
||||
/// #180 — the quality picker lists all seven Epson `EPIJ_Qual`
|
||||
/// codes in the driver's own order (308 between 303 and 304); no
|
||||
/// PPD is injected under UI testing so items show raw tokens.
|
||||
func testQualityPickerListsAllSevenDriverOptions() throws {
|
||||
launchAppWithDefaults()
|
||||
reachPrintPanel()
|
||||
_ = waitFor("printerStatusBadge")
|
||||
|
||||
let picker = app.popUpButtons["printerQualitySelect"]
|
||||
XCTAssertTrue(picker.waitForExistence(timeout: 10))
|
||||
picker.click()
|
||||
|
||||
let expected = ["301", "302", "303", "308", "304", "305", "307"]
|
||||
for token in expected {
|
||||
XCTAssertTrue(
|
||||
app.menuItems[token].waitForExistence(timeout: 5),
|
||||
"Missing quality menu item \(token)")
|
||||
}
|
||||
let titles = app.menuItems.allElementsBoundByIndex
|
||||
.map(\.title)
|
||||
.filter { expected.contains($0) }
|
||||
XCTAssertEqual(titles, expected)
|
||||
|
||||
app.typeKey(XCUIKeyboardKey.escape, modifierFlags: [])
|
||||
}
|
||||
|
||||
/// The stubbed panel result's captured `PageSize=`/`EPIJ_Qual=`
|
||||
/// apply back into the Stage 2 pickers and reach the recorded
|
||||
/// ticket writes (#201 — the real modal is never driven).
|
||||
func testPanelResultAppliesBackToPickers() throws {
|
||||
app.launchEnvironment["ICCERY_TEST_PRINT_PANEL"] = "ok"
|
||||
app.launchEnvironment["ICCERY_TEST_PANEL_OPTIONS"] =
|
||||
"PageSize=Letter EPIJ_Qual=305"
|
||||
launchAppWithDefaults()
|
||||
reachPrintPanel()
|
||||
_ = waitFor("printerStatusBadge")
|
||||
|
||||
element("btnPrinterProperties").click()
|
||||
let notice = element("printNotificationText")
|
||||
XCTAssertTrue(notice.waitForExistence(timeout: 10))
|
||||
XCTAssertTrue((notice.value as? String ?? "")
|
||||
.contains("Settings captured"))
|
||||
|
||||
XCTAssertEqual(selection(of: "printerPaperSizeSelect"), "Letter")
|
||||
XCTAssertEqual(selection(of: "printerQualitySelect"), "305")
|
||||
|
||||
app.buttons["btnPrintAll"].click()
|
||||
let log = waitForSpoolLine()
|
||||
XCTAssertTrue(log.contains("PageSize=Letter"), log)
|
||||
XCTAssertTrue(log.contains("EPIJ_Qual=305"), log)
|
||||
}
|
||||
|
||||
/// #186 — the stubbed panel result's `orientation-requested=` /
|
||||
/// `MediaType=` apply back to the Stage 2 selections and reach the
|
||||
/// recorded ticket writes via the Stage 2 overrides (#201 D6).
|
||||
func testPanelResultAppliesBackOrientationAndMedia() throws {
|
||||
app.launchEnvironment["ICCERY_TEST_PRINT_PANEL"] = "ok"
|
||||
app.launchEnvironment["ICCERY_TEST_PANEL_OPTIONS"] =
|
||||
"PageSize=Letter EPIJ_Qual=305 orientation-requested=4 MediaType=PhotographicGlossy"
|
||||
launchAppWithDefaults()
|
||||
reachPrintPanel()
|
||||
_ = waitFor("printerStatusBadge")
|
||||
|
||||
element("btnPrinterProperties").click()
|
||||
let notice = element("printNotificationText")
|
||||
XCTAssertTrue(notice.waitForExistence(timeout: 10))
|
||||
XCTAssertTrue((notice.value as? String ?? "")
|
||||
.contains("Settings captured"))
|
||||
|
||||
// `printerMediaTypeSelect` is the group's id — the popup is a
|
||||
// descendant (stacked identifiers collapse to the container).
|
||||
// The popup's AX title lags the binding — poll for the
|
||||
// apply-back value.
|
||||
let mediaPopup = element("printerMediaTypeSelect")
|
||||
.descendants(matching: .popUpButton).firstMatch
|
||||
XCTAssertTrue(mediaPopup.waitForExistence(timeout: 5))
|
||||
var mediaSelection = ""
|
||||
let deadline = Date().addingTimeInterval(10)
|
||||
while Date() < deadline, mediaSelection != "PhotographicGlossy" {
|
||||
mediaSelection = [
|
||||
mediaPopup.title, mediaPopup.label,
|
||||
mediaPopup.value as? String ?? "",
|
||||
].first { !$0.isEmpty } ?? ""
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.2))
|
||||
}
|
||||
XCTAssertEqual(mediaSelection, "PhotographicGlossy")
|
||||
XCTAssertEqual(selection(of: "printerPaperSizeSelect"), "Letter")
|
||||
|
||||
app.buttons["btnPrintAll"].click()
|
||||
let log = waitForSpoolLine()
|
||||
XCTAssertTrue(log.contains("orientation-requested=4"), log)
|
||||
XCTAssertTrue(log.contains("MediaType=PhotographicGlossy"), log)
|
||||
XCTAssertTrue(log.contains("PageSize=Letter"), log)
|
||||
XCTAssertTrue(log.contains("EPIJ_Qual=305"), log)
|
||||
}
|
||||
|
||||
/// #201 D5 — per-page mode records one line per page; the
|
||||
/// `chkSingleSpoolJob` toggle collapses the job into a single
|
||||
/// request logged once with `pages=N`.
|
||||
func testSingleSpoolJobTogglesGranularity() throws {
|
||||
app.launchEnvironment["ICCERY_MOCK_PRINTTARG_PAGES"] = "2"
|
||||
launchAppWithDefaults()
|
||||
reachPrintPanel()
|
||||
_ = waitFor("printerStatusBadge")
|
||||
XCTAssertTrue(element("chkSingleSpoolJob").exists)
|
||||
|
||||
// Default off → one request per page → one line per page.
|
||||
app.buttons["btnPrintAll"].click()
|
||||
var lines = waitForSpoolLineCount(2)
|
||||
XCTAssertEqual(lines.count, 2)
|
||||
XCTAssertTrue(lines.allSatisfy { $0.contains("pages=1") },
|
||||
lines.joined(separator: "\n"))
|
||||
|
||||
// Toggle on → one request for all pages → a single line.
|
||||
element("chkSingleSpoolJob").click()
|
||||
app.buttons["btnPrintAll"].click()
|
||||
lines = waitForSpoolLineCount(3)
|
||||
XCTAssertEqual(lines.count, 3)
|
||||
XCTAssertEqual(
|
||||
lines.filter { $0.contains("pages=2") }.count, 1,
|
||||
lines.joined(separator: "\n"))
|
||||
}
|
||||
|
||||
private func launchAppWithDefaults() {
|
||||
app.launch()
|
||||
app.activate()
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,6 @@ final class Milestone2UITests: XCTestCase {
|
||||
"ICCERY_UI_TESTING": "1",
|
||||
"ICCERY_TEST_ROOT": testRoot.path,
|
||||
"ICCERY_ARGYLL_BINARY_DIR": binDir.path,
|
||||
"ICCERY_CUPS_BIN_DIR": binDir.path,
|
||||
"ICCERY_TEST_SAVE_TARGET":
|
||||
workDir.appendingPathComponent("mytarget.ti1").path,
|
||||
"ICCERY_TEST_WORKDIR": workDir.path,
|
||||
@@ -219,12 +218,11 @@ final class Milestone2UITests: XCTestCase {
|
||||
XCTAssertTrue(FileManager.default.fileExists(
|
||||
atPath: workDir.appendingPathComponent("mytarget.ti2").path))
|
||||
|
||||
// Print panel is live from M3. GitHub macos-14 has no system
|
||||
// queues, so mock CUPS (`ICCERY_CUPS_BIN_DIR`) must enumerate
|
||||
// before Print All / per-page enable (run 34867767434).
|
||||
// Print panel is live from M3; a default printer is selected
|
||||
// so both the all-pages and per-page print buttons are enabled.
|
||||
XCTAssertTrue(element("rawPrintPanel").exists)
|
||||
_ = waitUntilEnabled("btnPrintAll", timeout: 15)
|
||||
_ = waitUntilEnabled("btnPrintPage-0", timeout: 15)
|
||||
XCTAssertTrue(app.buttons["btnPrintAll"].isEnabled)
|
||||
XCTAssertTrue(app.buttons["btnPrintPage-0"].isEnabled)
|
||||
XCTAssertTrue(app.buttons["btnAdvanceToStage3"].isEnabled)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,10 +3,9 @@ import XCTest
|
||||
/// Milestone 3 UI tests — issue #17 print panel end-to-end with mock
|
||||
/// CUPS binaries and a stubbed `NSPrintPanel`. The real panel is a
|
||||
/// system modal XCUITest cannot drive; `ICCERY_TEST_PRINT_PANEL`
|
||||
/// returns a canned `PrintPropertiesResult` instead. The DEBUG
|
||||
/// `RecordingTargetSpooler` appends one resolved-ticket line per
|
||||
/// request to `ICCERY_TEST_SPOOL_LOG` (#201 D8) — that file is the
|
||||
/// evidence that the Stage 2 selections reach the spool.
|
||||
/// returns a canned `PrintPropertiesResult` instead. Mock `lp` appends
|
||||
/// its argv to `ICCERY_TEST_LP_ARGV` for assertions — that file is the
|
||||
/// evidence that captured options are replayed (docs/11 §tests).
|
||||
@MainActor
|
||||
final class Milestone3UITests: XCTestCase {
|
||||
|
||||
@@ -14,7 +13,7 @@ final class Milestone3UITests: XCTestCase {
|
||||
private var testRoot: URL!
|
||||
private var binDir: URL!
|
||||
private var workDir: URL!
|
||||
private var spoolLogURL: URL!
|
||||
private var lpArgvURL: URL!
|
||||
|
||||
override func setUp() async throws {
|
||||
continueAfterFailure = false
|
||||
@@ -24,7 +23,7 @@ final class Milestone3UITests: XCTestCase {
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Fixtures/bin")
|
||||
workDir = testRoot.appendingPathComponent("work")
|
||||
spoolLogURL = testRoot.appendingPathComponent("spool.log")
|
||||
lpArgvURL = testRoot.appendingPathComponent("lp-argv.log")
|
||||
try FileManager.default.createDirectory(
|
||||
at: workDir, withIntermediateDirectories: true)
|
||||
|
||||
@@ -37,7 +36,7 @@ final class Milestone3UITests: XCTestCase {
|
||||
"ICCERY_TEST_SAVE_TARGET":
|
||||
workDir.appendingPathComponent("mytarget.ti1").path,
|
||||
"ICCERY_TEST_WORKDIR": workDir.path,
|
||||
"ICCERY_TEST_SPOOL_LOG": spoolLogURL.path,
|
||||
"ICCERY_TEST_LP_ARGV": lpArgvURL.path,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -100,18 +99,18 @@ final class Milestone3UITests: XCTestCase {
|
||||
_ = waitFor("galleryPage-0", timeout: 25)
|
||||
}
|
||||
|
||||
private func recordedSpoolLog() -> String {
|
||||
(try? String(contentsOf: spoolLogURL, encoding: .utf8)) ?? ""
|
||||
private func recordedLpArgv() -> String {
|
||||
(try? String(contentsOf: lpArgvURL, encoding: .utf8)) ?? ""
|
||||
}
|
||||
|
||||
private func waitForSpoolLine(_ timeout: TimeInterval = 10) -> String {
|
||||
private func waitForLpLine(_ timeout: TimeInterval = 10) -> String {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
let out = recordedSpoolLog()
|
||||
let out = recordedLpArgv()
|
||||
if !out.isEmpty { return out }
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||
}
|
||||
return recordedSpoolLog()
|
||||
return recordedLpArgv()
|
||||
}
|
||||
|
||||
/// Drags `#galleryPage-0`'s TIFF upward so `identifier`'s button
|
||||
@@ -204,13 +203,10 @@ final class Milestone3UITests: XCTestCase {
|
||||
XCTAssertEqual(element("printNotificationIcon").value as? String, "info")
|
||||
}
|
||||
|
||||
/// Preferences OK → the captured media selection applies back to
|
||||
/// Stage 2 and reaches the recorded ticket writes alongside the
|
||||
/// mandatory colour keys (issue 17's acceptance test, ported to
|
||||
/// the native spool seam in #201). The captured `InputSlot` is no
|
||||
/// longer replayed — captured vendor state lives inside the
|
||||
/// `PrintTicket`, which the stub deliberately does not produce.
|
||||
func testPanelSelectionsApplyBackToSpoolWrites() throws {
|
||||
/// Preferences OK → captured options are replayed verbatim in the
|
||||
/// `lp` argv alongside the two mandatory AP_* headers (issue 17's
|
||||
/// acceptance test: "captured options replayed in argv").
|
||||
func testCapturedOptionsReplayedInLpArgv() throws {
|
||||
app.launchEnvironment["ICCERY_TEST_PRINT_PANEL"] = "ok"
|
||||
app.launchEnvironment["ICCERY_TEST_PANEL_OPTIONS"] =
|
||||
"InputSlot=Rear MediaType=PhotographicGlossy"
|
||||
@@ -225,17 +221,20 @@ final class Milestone3UITests: XCTestCase {
|
||||
.contains("Settings captured"))
|
||||
|
||||
app.buttons["btnPrintAll"].click()
|
||||
let log = waitForSpoolLine()
|
||||
XCTAssertTrue(log.contains(
|
||||
"AP_ColorMatchingMode=AP_ApplicationColorMatching"), log)
|
||||
XCTAssertTrue(log.contains(
|
||||
"AP.ColorMatchingMode=AP_ApplicationColorMatching"), log)
|
||||
XCTAssertTrue(log.contains("MediaType=PhotographicGlossy"), log)
|
||||
let argv = waitForLpLine()
|
||||
XCTAssertTrue(argv.contains(
|
||||
"AP_ColorMatchingMode=AP_ApplicationColorMatching"), argv)
|
||||
XCTAssertTrue(argv.contains(
|
||||
"AP.ColorMatchingMode=AP_ApplicationColorMatching"), argv)
|
||||
XCTAssertTrue(argv.contains("InputSlot=Rear"), argv)
|
||||
XCTAssertTrue(argv.contains("MediaType=PhotographicGlossy"), argv)
|
||||
// Detected bypass for the mock queue (EPIJ_CMat present in
|
||||
// lpoptions -l) is always written.
|
||||
XCTAssertTrue(log.contains("EPIJ_CMat=3"), log)
|
||||
XCTAssertTrue(log.contains("orientation-requested=3"), log)
|
||||
XCTAssertTrue(log.contains("page=page1.tif"), log)
|
||||
// lpoptions -l) is appended when not captured.
|
||||
XCTAssertTrue(argv.contains("EPIJ_CMat=3"), argv)
|
||||
XCTAssertTrue(argv.contains("orientation-requested=3"), argv)
|
||||
// Last token is the TIFF.
|
||||
XCTAssertTrue(argv.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.hasSuffix("page1.tif"), argv)
|
||||
}
|
||||
|
||||
/// Per-page print uses the same spool path (btnPrintPage-N).
|
||||
@@ -263,16 +262,14 @@ final class Milestone3UITests: XCTestCase {
|
||||
return
|
||||
}
|
||||
printPage.click()
|
||||
let log = waitForSpoolLine()
|
||||
XCTAssertTrue(log.contains("AP_ColorMatchingMode"), log)
|
||||
XCTAssertTrue(log.contains("page=page1.tif"), log)
|
||||
let argv = waitForLpLine()
|
||||
XCTAssertTrue(argv.contains("AP_ColorMatchingMode"), argv)
|
||||
XCTAssertTrue(argv.contains("page1.tif"), argv)
|
||||
}
|
||||
|
||||
/// A spool failure surfaces in the in-panel notice, not the
|
||||
/// wizard banner (`ICCERY_TEST_SPOOL_FAIL` makes the recording
|
||||
/// spooler throw `TargetSpoolError.operationFailed`).
|
||||
func testSpoolFailureShowsPrintNotice() throws {
|
||||
app.launchEnvironment["ICCERY_TEST_SPOOL_FAIL"] = "1"
|
||||
/// lp failure surfaces in the in-panel notice, not the wizard banner.
|
||||
func testLpFailureShowsPrintNotice() throws {
|
||||
app.launchEnvironment["ICCERY_MOCK_LP_EXIT"] = "1"
|
||||
launchApp()
|
||||
reachPrintPanel()
|
||||
_ = waitFor("printerStatusBadge")
|
||||
@@ -293,7 +290,7 @@ final class Milestone3UITests: XCTestCase {
|
||||
_ = waitFor("printerStatusBadge")
|
||||
|
||||
app.buttons["btnPrintAll"].click()
|
||||
_ = waitForSpoolLine()
|
||||
_ = waitForLpLine()
|
||||
let stateURL = testRoot
|
||||
.appendingPathComponent("AppData")
|
||||
.appendingPathComponent("wizard_state.json")
|
||||
|
||||
@@ -98,9 +98,23 @@ final class Milestone4UITests: XCTestCase {
|
||||
app.buttons["btnDetectInstruments"].click()
|
||||
_ = waitFor("chartreadInstrumentSelect", timeout: 20)
|
||||
|
||||
// Wait until Start is enabled before clicking. Existence-only
|
||||
// clicks are no-ops on the disabled control (runs 35251, 35293).
|
||||
driveOnePass(startButton: "btnStartRead")
|
||||
// Keep Auto (port 1) and start the session.
|
||||
XCTAssertTrue(app.buttons["btnStartRead"].waitForExistence(timeout: 5))
|
||||
app.buttons["btnStartRead"].click()
|
||||
|
||||
// Calibrate.
|
||||
let calibrate = element("btnCalibrate")
|
||||
if !calibrate.waitForExistence(timeout: 25) {
|
||||
let error = element("chartreadLastError").label
|
||||
let value = element("chartreadLastError").value as? String ?? "<nil>"
|
||||
XCTFail("No calibrate button. lastError.label='\(error)' value='\(value)'")
|
||||
}
|
||||
app.buttons["btnCalibrate"].click()
|
||||
|
||||
// Trigger each strip until all are read → Done & Save appears.
|
||||
driveStripsUntilDone()
|
||||
XCTAssertTrue(element("btnDoneRead").exists)
|
||||
app.buttons["btnDoneRead"].firstMatch.click()
|
||||
|
||||
// Averaging panel appears with one pass snapshot.
|
||||
_ = waitFor("chartreadAveragingPanel", timeout: 20)
|
||||
@@ -154,34 +168,15 @@ final class Milestone4UITests: XCTestCase {
|
||||
|
||||
/// Runs the mock handheld chartread session to completion
|
||||
/// (start → calibrate → strip A → strip B → Done & Save).
|
||||
///
|
||||
/// `isEnabled` can be true in AX while the SwiftUI action is still
|
||||
/// a no-op (runs 35251, 35443). Re-click until the session is
|
||||
/// actually running (`btnCancel` is shown whenever
|
||||
/// `isChartreadRunning`), then wait for Calibrate.
|
||||
private func driveOnePass(startButton: String) {
|
||||
let start = app.buttons[startButton]
|
||||
XCTAssertTrue(start.waitForExistence(timeout: 10), startButton)
|
||||
let enabledBy = Date().addingTimeInterval(10)
|
||||
while Date() < enabledBy, !start.isEnabled {
|
||||
XCTAssertTrue(start.waitForExistence(timeout: 10))
|
||||
let deadline = Date().addingTimeInterval(10)
|
||||
while Date() < deadline, !start.isEnabled {
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||
}
|
||||
XCTAssertTrue(start.isEnabled, "\(startButton) never enabled")
|
||||
|
||||
let sessionBy = Date().addingTimeInterval(25)
|
||||
while Date() < sessionBy, !chartreadSessionRunning {
|
||||
if start.exists, start.isEnabled {
|
||||
start.click()
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.4))
|
||||
}
|
||||
XCTAssertTrue(
|
||||
chartreadSessionRunning,
|
||||
"\(startButton) click never started chartread; "
|
||||
+ "notice=\(element("noticeText").value as? String ?? "") "
|
||||
+ "start.exists=\(start.exists)"
|
||||
)
|
||||
|
||||
XCTAssertTrue(start.isEnabled)
|
||||
start.click()
|
||||
_ = waitFor("btnCalibrate", timeout: 25)
|
||||
app.buttons["btnCalibrate"].click()
|
||||
driveStripsUntilDone()
|
||||
@@ -189,14 +184,6 @@ final class Milestone4UITests: XCTestCase {
|
||||
app.buttons["btnDoneRead"].firstMatch.click()
|
||||
}
|
||||
|
||||
/// `btnCancel` is in the tree for the whole chartread session;
|
||||
/// Calibrate/Trigger only appear after the first classified prompt.
|
||||
private var chartreadSessionRunning: Bool {
|
||||
element("btnCancel").exists
|
||||
|| element("btnCalibrate").exists
|
||||
|| element("btnTrigger").exists
|
||||
}
|
||||
|
||||
/// Clicks Trigger for each remaining strip until `btnDoneRead`
|
||||
/// appears — the button is re-polled each pass so a click that
|
||||
/// races a state transition isn't lost.
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-4
@@ -43,8 +43,6 @@ Do not install Argyll to `$PATH`. ICCery never searches `$PATH` for binaries.
|
||||
| `NOTARIZE_APPLE_ID` | Apple ID for `notarytool` |
|
||||
| `NOTARIZE_PASSWORD` | App-specific password for `notarytool` |
|
||||
| `APPLE_TEAM_ID` | Team ID for `notarytool` |
|
||||
| `RELEASE_TAG` | Optional. Simulates a tag build (`vX.Y.Z[-suffix]`); stamped into the bundle and DMG name |
|
||||
| `BUILD_NUMBER` | Optional `CFBundleVersion` override; default `git rev-list --count HEAD` |
|
||||
|
||||
Invocation for a signed, notarized DMG:
|
||||
|
||||
@@ -58,8 +56,7 @@ export APPLE_TEAM_ID="TEAMID"
|
||||
scripts/package-release.sh
|
||||
```
|
||||
|
||||
The script will emit `ICCery-<version>-<build>.dmg` (or `ICCery-<tag>-<build>.dmg`
|
||||
when `RELEASE_TAG`/a `v*` git tag applies). After mounting, verify Gatekeeper acceptance:
|
||||
The script will emit `ICCery-<version>-<build>.dmg`. After mounting, verify Gatekeeper acceptance:
|
||||
|
||||
```
|
||||
spctl -a -t open --context context:primary-signature -v ICCery-*.dmg
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
@startuml ICCery-UI-sheets
|
||||
title Sheets Settings, presets, media, Spot Read, Gamut, project
|
||||
!theme plain
|
||||
skinparam backgroundColor white
|
||||
skinparam defaultFontColor black
|
||||
skinparam defaultFontSize 13
|
||||
skinparam mapBackgroundColor white
|
||||
skinparam mapBorderColor #222222
|
||||
skinparam mapFontColor black
|
||||
skinparam arrowColor #222222
|
||||
skinparam shadowing false
|
||||
hide circle
|
||||
top to bottom direction
|
||||
|
||||
map "SettingsView.swift" as set {
|
||||
Argyll dir => TextField+Browse : empty = bundled
|
||||
Default instrument => Picker : seeds Stage 3 and Spot Read
|
||||
Enable i1Pro 2 LEDs => Toggle
|
||||
settingsDeltaEGood => TextField
|
||||
settingsDeltaEWarning => TextField : must be greater than Good
|
||||
settingsCalStaleDays => TextField
|
||||
Install location => Picker User or System
|
||||
Ask before overwriting => Toggle
|
||||
Open ColorSync after install => Toggle
|
||||
Log level => Picker
|
||||
Open log folder => Button
|
||||
Cancel / Save => Save stays if validation fails
|
||||
}
|
||||
|
||||
map "Presets PresetDialogs.swift" as pre {
|
||||
savePresetName => TextField
|
||||
savePresetDesc => TextField
|
||||
btnCloseSavePresetDialog => Button
|
||||
btnConfirmSavePreset => Button : disabled if name empty
|
||||
presetRow-id => Row : built-in cannot delete
|
||||
btnImportPreset => Button
|
||||
btnExportActivePreset => Button : custom selected
|
||||
btnCloseManagePresetsDialog => Button
|
||||
}
|
||||
|
||||
map "Media MediaLibraryDialogs.swift" as media {
|
||||
saveMediaName/Paper/Ink => TextField
|
||||
saveMediaApplyCal => Toggle : disabled if CAL_ or missing file
|
||||
btnConfirmSaveMedia => Button : name+paper+ink required
|
||||
mediaLibraryList => List
|
||||
btnMediaLibraryApply-id => Button per row
|
||||
manageMediaNotice => Caption : failed Apply in-sheet
|
||||
btnMediaLibraryApply => Button : disabled if no selection
|
||||
btnCloseManageMediaDialog => Button
|
||||
}
|
||||
|
||||
map "spotReadView SpotReadView.swift" as spot {
|
||||
btnSpotDetectInstruments => Button
|
||||
spotInstrumentSelect => Picker : disabled while running
|
||||
spotSetDefault => Toggle
|
||||
btnSpotStart => Button : needs sidecar, cwd, not chartread
|
||||
btnSpotCalibrate => Button : calibrating
|
||||
btnSpotTrigger => Button : Read, awaitingStrip
|
||||
btnSpotStop => Button : while running
|
||||
btnSpotCopyLab => Button : disabled if no sample
|
||||
btnSpotExportCsv => Button : disabled if no history
|
||||
btnCloseSpotRead => Button Esc : dismiss = Stop
|
||||
}
|
||||
|
||||
map "gamutView GamutView.swift" as gam {
|
||||
gamutLayer-srgb => Toggle : can hide, cannot remove
|
||||
gamutLayer-profile => Toggle : disabled if no .gam
|
||||
gamutLayer-compare => Toggle : disabled if no compare
|
||||
btnGamutAddCompare => Menu
|
||||
btnGamutRemoveCompare => Button
|
||||
btnGamutSampleTiff => Button
|
||||
btnResetGamutCamera => Button R
|
||||
gamutLabEntryL/A/B => TextField
|
||||
btnGamutInspectLab => Button
|
||||
btnCloseGamut => Button Esc
|
||||
}
|
||||
|
||||
map "Project alerts" as proj {
|
||||
projectNewAlert => Alert : New Cancel / Confirm
|
||||
Dirty save => Alert : Save / Don't Save / Cancel
|
||||
projectRelocateSheet => Sheet : cwd missing on Open
|
||||
}
|
||||
|
||||
set -[hidden]down- pre
|
||||
pre -[hidden]down- media
|
||||
media -[hidden]down- spot
|
||||
spot -[hidden]down- gam
|
||||
gam -[hidden]down- proj
|
||||
@enduml
|
||||
@@ -0,0 +1,81 @@
|
||||
@startuml ICCery-UI-shell
|
||||
title Shell, sidebar, stepper, File menu
|
||||
!theme plain
|
||||
skinparam backgroundColor white
|
||||
skinparam defaultFontColor black
|
||||
skinparam defaultFontSize 13
|
||||
skinparam mapBackgroundColor white
|
||||
skinparam mapBorderColor #222222
|
||||
skinparam mapFontColor black
|
||||
skinparam arrowColor #222222
|
||||
skinparam shadowing false
|
||||
hide circle
|
||||
top to bottom direction
|
||||
|
||||
map "Window" as win {
|
||||
WindowGroup => ICCeryApp.swift : 1280x800 min 1100x700
|
||||
RootView => RootView.swift : hosts every sheet
|
||||
noticeText => NoticeBanner : shown if wizard.notice set; auto-hide 6s
|
||||
}
|
||||
|
||||
map "Sidebar header" as hdr {
|
||||
openSettingsBtn => Button : always / Settings sheet
|
||||
openAboutBtn => Button : always / About
|
||||
btnToggleAllHelp => Button : toggles yellow help dots
|
||||
}
|
||||
|
||||
map "Presets" as pre {
|
||||
presetSelect => Picker.menu : none + preset id; none is not factory reset
|
||||
btnSavePresetModal => Button : always
|
||||
btnOpenPresetsDialog => Button : always
|
||||
}
|
||||
|
||||
map "Media library" as med {
|
||||
mediaSelect => Picker.menu : never reuses presetSelect
|
||||
mediaRecipeStale => Caption : shown if staleReasons nonempty
|
||||
btnMediaLibraryCapture => Button : disabled if no Stage 2 printer
|
||||
btnMediaLibraryManage => Button : always
|
||||
}
|
||||
|
||||
map "Studio (not stepper)" as stu {
|
||||
btnCalibratePrinter => Button : always; Stage 0
|
||||
btnViewGamut => Button : sidebar always on; same id as Stage 5
|
||||
btnSpotRead => Button : disabled if no cwd or chartread running
|
||||
}
|
||||
|
||||
map "Stepper 1-5 disk is truth" as stp {
|
||||
Stage1 Generate => always
|
||||
Stage2 Lay out => .ti1
|
||||
Stage3 Measure => .ti1 AND .ti2
|
||||
Stage4 Build => .ti3 (not .ti2 alone)
|
||||
Stage5 Verify => .ti3 AND .icc/.icm
|
||||
Calibrate => not a stepper row; always available
|
||||
}
|
||||
|
||||
map "Project chip" as chip {
|
||||
projectChipName => Text : bound name or No project
|
||||
projectChipPath => Text : shown if bound
|
||||
projectChipStale => Caption : diskBehindNotes
|
||||
btnProjectReveal => Button : shown if bound
|
||||
btnProjectSave => Button : canSave = bound + basename + cwd
|
||||
btnProjectOpen => Button : shown if not bound
|
||||
}
|
||||
|
||||
map "File menu" as menu {
|
||||
menuProjectNew => Button Cmd-N
|
||||
menuProjectOpen => Button Cmd-O
|
||||
menuProjectRecents => Menu : disabled if recents empty
|
||||
menuProjectSave => Button Cmd-S : !canSave
|
||||
menuProjectSaveAs => Button Shift-Cmd-S : basename + cwd
|
||||
menuProjectReport => Button : !canReport
|
||||
menuProjectClose => Button : !isBound
|
||||
}
|
||||
|
||||
win -[hidden]down- hdr
|
||||
hdr -[hidden]down- pre
|
||||
pre -[hidden]down- med
|
||||
med -[hidden]down- stu
|
||||
stu -[hidden]down- stp
|
||||
stp -[hidden]down- chip
|
||||
chip -[hidden]down- menu
|
||||
@enduml
|
||||
@@ -0,0 +1,83 @@
|
||||
@startuml ICCery-UI-stage1-2
|
||||
title Stages 1-2 Generate Target / Lay Out and Print
|
||||
!theme plain
|
||||
skinparam backgroundColor white
|
||||
skinparam defaultFontColor black
|
||||
skinparam defaultFontSize 13
|
||||
skinparam mapBackgroundColor white
|
||||
skinparam mapBorderColor #222222
|
||||
skinparam mapFontColor black
|
||||
skinparam arrowColor #222222
|
||||
skinparam shadowing false
|
||||
hide circle
|
||||
top to bottom direction
|
||||
|
||||
map "stage-1 Stage1View.swift" as s1 {
|
||||
colourSpace => Picker.segmented : RGB or CMYK
|
||||
patchCountPreset => Picker
|
||||
patchCountCustom => TextField : shown if preset is custom
|
||||
whitePatches => Stepper 0-50
|
||||
blackPatches => Stepper 0-50
|
||||
targetBasename => TextField
|
||||
btnBrowse => Button
|
||||
btnSelectWorkDir => Button
|
||||
btnOpenExisting => Button : .ti1 or .ti2
|
||||
btn-import-dataset => Button : unlocks stage 4
|
||||
selectedPathDisplay => Text
|
||||
targenAdvancedDetails => DisclosureGroup : UI tests pre-expand
|
||||
btnGenerate => Button : needs basename AND directory
|
||||
targenLog => ProcessLogView
|
||||
}
|
||||
|
||||
map "Stage 1 Advanced (inside disclosure)" as adv {
|
||||
targenGreySteps => Toggle+field : field iff on
|
||||
targenSingleChannelSteps => Toggle+field
|
||||
targenNeutralSteps => Toggle+field
|
||||
targenNeutralConcentration => Toggle+Slider 0-1
|
||||
targenAdaptation => Toggle+Slider 0-1
|
||||
targenPrecondProfile => TextField
|
||||
btnBrowsePrecondProfile => Button
|
||||
targenHighQuality => Toggle
|
||||
targenAlgorithm => Picker
|
||||
targenInkLimitGroup => Toggle+field : CMYK only
|
||||
targenDarkEmphasis => Toggle+Slider 0-3
|
||||
targenDevicePower => Toggle+Slider 0-3
|
||||
}
|
||||
|
||||
map "stage-2 Stage2View.swift" as s2 {
|
||||
cmWarningBanner => Banner : always on stage 2
|
||||
instrumentSelect => Picker : chart code, not USB port
|
||||
pageSizeSelect => Picker
|
||||
customPageW / customPageH => TextField : pageSize custom
|
||||
tiffDpi => Stepper 72-600
|
||||
printtargLayoutOrder => Picker
|
||||
printtargCustomSeed => TextField : custom seed order
|
||||
btnToggleLabelEdit => Button
|
||||
targetMetadataPrinter => TextField
|
||||
targetMetadataInkSet => TextField
|
||||
targetMetadataDriverPaper => TextField
|
||||
targetMetadataActualPaper => TextField
|
||||
targetLabelPreview => TextField or Text
|
||||
btnCreateLayout => Button : disabled if no basename
|
||||
tiffGallery => Grid : after printtarg
|
||||
btnPrintPage-N => Button : disabled if no printer
|
||||
}
|
||||
|
||||
map "rawPrintPanel" as prn {
|
||||
printerSelect => Picker CUPS
|
||||
printerStatusBadge => Caption
|
||||
btnRefreshPrinters => Button
|
||||
btnPrinterProperties => Button : disabled if no printer
|
||||
printerTraySelect => Picker : if trays exist
|
||||
printerMediaTypeSelect => Picker : if media types exist
|
||||
btnOrientPortrait => Button
|
||||
btnOrientLandscape => Button
|
||||
btnPrintAll => Button : needs layout AND printer
|
||||
btnAdvanceToStage3 => Button : needs .ti2 unlock
|
||||
printNotificationText => Caption : if printNotice set
|
||||
}
|
||||
|
||||
s1 -[hidden]down- adv
|
||||
adv -[hidden]down- s2
|
||||
s2 -[hidden]down- prn
|
||||
@enduml
|
||||
@@ -0,0 +1,84 @@
|
||||
@startuml ICCery-UI-stage3-5-cal
|
||||
title Stages 3-5 and Calibrate Printer
|
||||
!theme plain
|
||||
skinparam backgroundColor white
|
||||
skinparam defaultFontColor black
|
||||
skinparam defaultFontSize 13
|
||||
skinparam mapBackgroundColor white
|
||||
skinparam mapBorderColor #222222
|
||||
skinparam mapFontColor black
|
||||
skinparam arrowColor #222222
|
||||
skinparam shadowing false
|
||||
hide circle
|
||||
top to bottom direction
|
||||
|
||||
map "stage-3 Stage3View.swift" as s3 {
|
||||
btnDetectInstruments => Button : disabled while detecting
|
||||
chartreadInstrumentSelect => Picker.menu
|
||||
xyTableHint => Caption : if XY instrument
|
||||
xyTablePanel => Place/Align/Scan/Remove
|
||||
chartreadPrompt => Text
|
||||
chartreadLastError => Caption : if notice set
|
||||
btnStartRead => Button : shown if not running; needs basename+cwd
|
||||
btnCalibrate => Button : running and calibrating
|
||||
btnTrigger => Button : running and awaitingStrip
|
||||
btnDoneReadEarly => Button : awaitingStrip
|
||||
btnAccept => Button : place / align / continue / warning
|
||||
btnRetry => Button : error state
|
||||
btnDoneRead => Button : allStripsRead
|
||||
btnCancel => Button : while running
|
||||
swatchGrid => swatch-rowId-loc
|
||||
btnMeasureAnotherSheet => Button : after a finished pass
|
||||
btnFinishAndAverage => Button : finished AND at least one pass
|
||||
}
|
||||
|
||||
map "stage-4 Stage4View.swift" as s4 {
|
||||
colprofAlgorithm => Picker
|
||||
colprofQuality => Picker
|
||||
colprofFwa => Picker
|
||||
colprofFwaCustomPath => TextField : custom spectrum
|
||||
btnBrowseFwaSp => Button
|
||||
colprofIlluminant => TextField
|
||||
colprofObserver => TextField
|
||||
colprofInputViewCond => TextField
|
||||
colprofOutputViewCond => TextField
|
||||
colprofDescription => TextField
|
||||
colprofCopyright => TextField
|
||||
colprofApplyCalibration => Toggle
|
||||
colprofCalibrationFile => TextField : if applyCalibration
|
||||
btnBrowseCalibrationFile => Button : if applyCalibration
|
||||
btnCreateProfile => Button : basename + cwd, not running
|
||||
}
|
||||
|
||||
map "stage-5 Stage5View.swift" as s5 {
|
||||
btnVerifyProfile => Button : needs created profile
|
||||
btnViewGamut => Button : disabled if no .gam (same id as sidebar)
|
||||
btnInstallProfile => Button : needs created profile
|
||||
driftPrinterFilter => Picker
|
||||
btnExportHistory => Button
|
||||
btnClearHistory => Button
|
||||
verificationHistoryTable => Table
|
||||
driftChart => not interactive
|
||||
profileOverwriteBtn => Alert : install collision
|
||||
profileRenameBtn => Alert
|
||||
profileCancelCollisionBtn => Alert
|
||||
}
|
||||
|
||||
map "stage-cal CalibrationView.swift" as cal {
|
||||
Colour space => Picker.segmented : no a11y id
|
||||
calSteps => TextField
|
||||
White patches => TextField : no a11y id
|
||||
calInkExplore => TextField : CMYK only
|
||||
calNeutralEmphasis => Toggle
|
||||
btnCalGenerate => Button : needs basename + cwd
|
||||
btnCalLayout => Button : same
|
||||
btnCalMeasure => Button : needs .ti3
|
||||
btnCalCompute => Button : .ti3 and not computing
|
||||
calApplyToggle => Toggle : after curves exist
|
||||
btnCalReturn => Button Esc : restores original basename
|
||||
}
|
||||
|
||||
s3 -[hidden]down- s4
|
||||
s4 -[hidden]down- s5
|
||||
s5 -[hidden]down- cal
|
||||
@enduml
|
||||
@@ -0,0 +1,12 @@
|
||||
# Interactive UI map
|
||||
|
||||
PlantUML **maps** (not class diagrams). Class diagrams laid out 6500px wide, so Gitea on a phone showed a sliver of a black arrow and the UML “C” icon.
|
||||
|
||||
Each file is one stacked column (~560–780px) that Kroki actually fits in the page.
|
||||
|
||||
| Diagram | File | Kroki size |
|
||||
| --- | --- | --- |
|
||||
| Shell, sidebar, stepper, File menu | [ui-interactive-map-shell.puml](ui-interactive-map-shell.puml) | 563 × 1367 |
|
||||
| Stages 1–2 | [ui-interactive-map-stage1-2.puml](ui-interactive-map-stage1-2.puml) | 625 × 1366 |
|
||||
| Stages 3–5 + Calibrate | [ui-interactive-map-stage3-5-cal.puml](ui-interactive-map-stage3-5-cal.puml) | 568 × 1386 |
|
||||
| Sheets | [ui-interactive-map-sheets.puml](ui-interactive-map-sheets.puml) | 782 × 1502 |
|
||||
+1
-17
@@ -42,15 +42,6 @@ targets:
|
||||
echo "note: Vendor/Argyll absent — run scripts/fetch-argyll.sh"
|
||||
fi
|
||||
basedOnDependencyAnalysis: false
|
||||
- name: Stamp release tag
|
||||
# GENERATE_INFOPLIST_FILE writes Info.plist after script phases and
|
||||
# only honours Apple's INFOPLIST_KEY_* allowlist — so the tag ships as
|
||||
# a bundled resource read by ArtefactFiles.appInfo (#189).
|
||||
script: |
|
||||
DEST="$TARGET_BUILD_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH"
|
||||
mkdir -p "$DEST"
|
||||
printf '%s' "${ICCERY_RELEASE_TAG:-}" > "$DEST/ICCeryReleaseTag"
|
||||
basedOnDependencyAnalysis: false
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.gronod.iccery2
|
||||
@@ -61,15 +52,8 @@ targets:
|
||||
INFOPLIST_KEY_LSMinimumSystemVersion: "12.0"
|
||||
INFOPLIST_KEY_NSPrincipalClass: NSApplication
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright: "Copyright © 2026 Gronod. AGPLv3."
|
||||
MARKETING_VERSION: "2.0.1"
|
||||
MARKETING_VERSION: "2.0.0"
|
||||
CURRENT_PROJECT_VERSION: "1"
|
||||
# Stamped by scripts/version.sh at packaging/CI time (release tag or
|
||||
# `git describe` string); About shows it next to the marketing version.
|
||||
# Written to the bundled ICCeryReleaseTag resource by the
|
||||
# "Stamp release tag" phase — GENERATE_INFOPLIST_FILE drops custom
|
||||
# INFOPLIST_KEY_* names and generates the plist after script phases.
|
||||
# Tag builds hard-fail when the tag's X.Y.Z != MARKETING_VERSION above.
|
||||
ICCERY_RELEASE_TAG: ""
|
||||
ENABLE_HARDENED_RUNTIME: YES
|
||||
CODE_SIGN_ENTITLEMENTS: Resources/ICCery.entitlements
|
||||
CODE_SIGN_IDENTITY: "-"
|
||||
|
||||
@@ -106,19 +106,9 @@ fi
|
||||
|
||||
mkdir -p "$DEST"
|
||||
cp -R "$BIN_DIR"/. "$DEST"/
|
||||
# Copy License.txt from archive root (same level as bin/) to Vendor/Argyll/ root
|
||||
VENDOR_ROOT="$ROOT/Vendor/Argyll"
|
||||
for license_src in "$EXTRACT"/Argyll_V*/License.txt "$EXTRACT"/License.txt; do
|
||||
if [ -f "$license_src" ]; then
|
||||
cp "$license_src" "$VENDOR_ROOT/License.txt"
|
||||
break
|
||||
fi
|
||||
done
|
||||
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
|
||||
# Also remove quarantine from Vendor/Argyll root if License.txt was copied
|
||||
xattr -dr com.apple.quarantine "$VENDOR_ROOT" 2>/dev/null || true
|
||||
|
||||
# Ad-hoc sign every Mach-O (#165: unsigned arm64 → "Killed: 9"), then
|
||||
# verify — an unsigned sidecar fails the script. The tree may nest
|
||||
|
||||
@@ -45,13 +45,6 @@ DEST="platform=macOS"
|
||||
IDENTITY="${CODESIGN_IDENTITY:--}"
|
||||
DEVELOPMENT_TEAM="${DEVELOPMENT_TEAM:-}"
|
||||
|
||||
# Resolve the version triple from the release tag / git state. On tag builds
|
||||
# (RELEASE_TAG=v*) this hard-fails if the tag's X.Y.Z != project.yml
|
||||
# MARKETING_VERSION, so a mis-tag cannot ship mismatched metadata (#189).
|
||||
echo "==> Resolving version"
|
||||
eval "$(scripts/version.sh)"
|
||||
echo "version=$MARKETING_VERSION build=$CURRENT_PROJECT_VERSION tag=$ICCERY_RELEASE_TAG"
|
||||
|
||||
echo "==> Building universal Release app"
|
||||
BUILD_EXTRA=""
|
||||
if [ -n "$DEVELOPMENT_TEAM" ]; then
|
||||
@@ -67,9 +60,6 @@ xcodebuild \
|
||||
ONLY_ACTIVE_ARCH=NO \
|
||||
CODE_SIGNING_ALLOWED=YES \
|
||||
CODE_SIGN_IDENTITY="$IDENTITY" \
|
||||
MARKETING_VERSION="$MARKETING_VERSION" \
|
||||
CURRENT_PROJECT_VERSION="$CURRENT_PROJECT_VERSION" \
|
||||
ICCERY_RELEASE_TAG="$ICCERY_RELEASE_TAG" \
|
||||
$BUILD_EXTRA \
|
||||
build
|
||||
|
||||
@@ -152,14 +142,7 @@ fi
|
||||
echo "==> Building DMG"
|
||||
VERSION="$(plutil -extract CFBundleShortVersionString raw "$APP/Contents/Info.plist" 2>/dev/null || printf '2.0.0')"
|
||||
BUILD_NUM="$(plutil -extract CFBundleVersion raw "$APP/Contents/Info.plist" 2>/dev/null || printf '1')"
|
||||
# Tagged/described builds carry the tag in the DMG name so prerelease
|
||||
# artefacts are self-describing (ICCery-2.0.0-pre2-grok-236.dmg); a plain
|
||||
# release tag keeps the classic ICCery-<ver>-<build>.dmg form.
|
||||
TAG_NAME="${ICCERY_RELEASE_TAG#v}"
|
||||
case "$TAG_NAME" in
|
||||
''|"$VERSION"|dev) DMG="ICCery-${VERSION}-${BUILD_NUM}.dmg" ;;
|
||||
*) DMG="ICCery-${TAG_NAME}-${BUILD_NUM}.dmg" ;;
|
||||
esac
|
||||
DMG="ICCery-${VERSION}-${BUILD_NUM}.dmg"
|
||||
VOLUME_NAME="ICCery ${VERSION}"
|
||||
|
||||
DMG_APP="$APP" \
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
#!/bin/sh
|
||||
# scripts/version.sh
|
||||
#
|
||||
# Resolve the ICCery version triple and print KEY=value lines for eval:
|
||||
#
|
||||
# ICCERY_RELEASE_TAG release tag / describe string stamped into the
|
||||
# bundle's ICCeryReleaseTag Info.plist key and shown
|
||||
# in the About dialog.
|
||||
# MARKETING_VERSION CFBundleShortVersionString — strict X.Y.Z only
|
||||
# (Apple forbids suffixes; tag payload never lands here).
|
||||
# CURRENT_PROJECT_VERSION CFBundleVersion — monotonically increasing integer.
|
||||
# macOS/App-Store convention: never reset per version.
|
||||
#
|
||||
# Resolution:
|
||||
# RELEASE_TAG env matching 'v[0-9]*' wins (CI tag builds; branch pushes pass
|
||||
# the branch name and are ignored). Otherwise `git describe` on the worktree.
|
||||
# Marketing version = first three numeric components of the tag core; a tag
|
||||
# with no numeric core falls back to project.yml's MARKETING_VERSION.
|
||||
# Build number = BUILD_NUMBER env override, else `git rev-list --count HEAD`.
|
||||
#
|
||||
# Hard fails (release-tag builds only): tag core not 1-3 numeric components,
|
||||
# or parsed X.Y.Z != project.yml MARKETING_VERSION — bump project.yml or fix
|
||||
# the tag before packaging.
|
||||
#
|
||||
# Usage: eval "$(scripts/version.sh)"
|
||||
|
||||
set -eu
|
||||
|
||||
ROOT="$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
YML_MARKETING="$(sed -n 's/^ *MARKETING_VERSION: *"\(.*\)"/\1/p' project.yml | head -n 1)"
|
||||
|
||||
TAG="${RELEASE_TAG:-}"
|
||||
case "$TAG" in
|
||||
v[0-9]*) ;; # real release tag
|
||||
*) TAG="" ;; # branch name (develop, feat/...) or empty
|
||||
esac
|
||||
IS_RELEASE_TAG=0
|
||||
[ -n "$TAG" ] && IS_RELEASE_TAG=1
|
||||
|
||||
if [ -z "$TAG" ]; then
|
||||
TAG="$(git describe --tags --always --dirty --match 'v[0-9]*' 2>/dev/null || true)"
|
||||
fi
|
||||
[ -n "$TAG" ] || TAG="dev"
|
||||
|
||||
# Marketing version: strip leading v, cut at first '-', keep first 3 dot
|
||||
# components. "v2.0.0-pre2" -> "2.0.0"; "v2.0.0.0-x" -> "2.0.0";
|
||||
# "v2.0.0-5-gsha" -> "2.0.0"; bare sha/"dev" -> no numeric core -> fallback.
|
||||
CORE="${TAG#v}"
|
||||
CORE="${CORE%%-*}"
|
||||
MV=""
|
||||
case "$CORE" in
|
||||
*[!0-9.]*|'') ;; # non-numeric core — not a version tag
|
||||
*)
|
||||
MV="$(printf '%s' "$CORE" | cut -d. -f1-3)"
|
||||
# Every kept component must be non-empty digits.
|
||||
case "$MV" in
|
||||
*[!0-9.]*|''|*..*|.*|*.) MV="" ;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "$IS_RELEASE_TAG" -eq 1 ]; then
|
||||
if [ -z "$MV" ]; then
|
||||
echo "error: release tag '$TAG' has no X.Y.Z numeric core" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$YML_MARKETING" ] && [ "$MV" != "$YML_MARKETING" ]; then
|
||||
echo "error: tag '$TAG' resolves to $MV but project.yml MARKETING_VERSION=$YML_MARKETING; bump project.yml or fix the tag" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
[ -n "$MV" ] || MV="$YML_MARKETING"
|
||||
[ -n "$MV" ] || MV="0.0.0"
|
||||
|
||||
BUILD="${BUILD_NUMBER:-}"
|
||||
if [ -z "$BUILD" ]; then
|
||||
BUILD="$(git rev-list --count HEAD 2>/dev/null || true)"
|
||||
fi
|
||||
case "$BUILD" in
|
||||
''|*[!0-9]*) BUILD="1" ;;
|
||||
esac
|
||||
[ "$BUILD" -gt 0 ] 2>/dev/null || BUILD="1"
|
||||
|
||||
printf 'ICCERY_RELEASE_TAG=%s\n' "$TAG"
|
||||
printf 'MARKETING_VERSION=%s\n' "$MV"
|
||||
printf 'CURRENT_PROJECT_VERSION=%s\n' "$BUILD"
|
||||
Reference in New Issue
Block a user