Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79209eb911 | ||
|
|
51c30737c7 | ||
|
|
37d1f7eb24 | ||
|
|
bb3cc3c858 | ||
|
|
eb73bf0106 | ||
|
|
736fd080cb | ||
|
|
14cd544e05 | ||
|
|
2fefe98434 | ||
|
|
90d034baab | ||
|
|
8cb79e8ffa | ||
|
|
c1c37bb7d7 | ||
|
|
d2466d9a66 | ||
|
|
4af2d96294 | ||
|
|
d17aa962ce | ||
|
|
93acd32b51 | ||
|
|
67cf452e59 | ||
|
|
83ed53c7fc | ||
|
|
cba9476af7 | ||
|
|
288b08e3d9 | ||
|
|
18f1d1ff33 | ||
|
|
9dfa79ebd0 |
@@ -17,9 +17,16 @@ jobs:
|
|||||||
runs-on: macos-12
|
runs-on: macos-12
|
||||||
env:
|
env:
|
||||||
DERIVED: build/DerivedData-test
|
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:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
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
|
- name: Assert Xcode 14+ toolchain
|
||||||
run: |
|
run: |
|
||||||
@@ -51,6 +58,10 @@ jobs:
|
|||||||
- name: Build for testing (host arch)
|
- name: Build for testing (host arch)
|
||||||
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
|
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
|
||||||
run: |
|
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 \
|
xcodebuild build-for-testing \
|
||||||
-scheme ICCery \
|
-scheme ICCery \
|
||||||
-destination 'platform=macOS' \
|
-destination 'platform=macOS' \
|
||||||
@@ -59,7 +70,10 @@ jobs:
|
|||||||
ARCHS="$(uname -m)" \
|
ARCHS="$(uname -m)" \
|
||||||
ONLY_ACTIVE_ARCH=NO \
|
ONLY_ACTIVE_ARCH=NO \
|
||||||
CODE_SIGNING_ALLOWED=YES \
|
CODE_SIGNING_ALLOWED=YES \
|
||||||
CODE_SIGN_IDENTITY='-'
|
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
|
# Xcode embeds the shared ICCeryCore package framework into the app
|
||||||
# and the test bundle without signing it. Ad-hoc hosts still require
|
# and the test bundle without signing it. Ad-hoc hosts still require
|
||||||
@@ -191,6 +205,10 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
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;
|
# scripts/package-release.sh runs `xcodegen generate` and dmgbuild;
|
||||||
# see build-and-test for why brew is not used on macOS 12 (#109).
|
# see build-and-test for why brew is not used on macOS 12 (#109).
|
||||||
@@ -202,6 +220,7 @@ jobs:
|
|||||||
- name: Package release
|
- name: Package release
|
||||||
run: scripts/package-release.sh
|
run: scripts/package-release.sh
|
||||||
env:
|
env:
|
||||||
|
RELEASE_TAG: ${{ github.ref_name }}
|
||||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
CODESIGN_IDENTITY: ${{ secrets.CODESIGN_IDENTITY }}
|
CODESIGN_IDENTITY: ${{ secrets.CODESIGN_IDENTITY }}
|
||||||
DEVELOPMENT_TEAM: ${{ secrets.DEVELOPMENT_TEAM }}
|
DEVELOPMENT_TEAM: ${{ secrets.DEVELOPMENT_TEAM }}
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
# 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,6 +18,9 @@ Vendor/Argyll/
|
|||||||
# XcodeGen output (regenerate with `make gen`)
|
# XcodeGen output (regenerate with `make gen`)
|
||||||
ICCery.xcodeproj/
|
ICCery.xcodeproj/
|
||||||
|
|
||||||
|
# Local-only developer helpers (never committed)
|
||||||
|
/set-version.sh
|
||||||
|
|
||||||
# macOS
|
# macOS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,13 @@ Empty cwd illegal (#59). Atomic writes = `.tmp` + rename (#213).
|
|||||||
User-supplied strings via SwiftUI `Text` only (#114).
|
User-supplied strings via SwiftUI `Text` only (#114).
|
||||||
TIFF never rendered directly — host-side PNG preview (#58).
|
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
|
## Branching
|
||||||
`develop` ← `milestone/mN-<name>` ← `feat/<issue#>-<slug>`.
|
`develop` ← `milestone/mN-<name>` ← `feat/<issue#>-<slug>`.
|
||||||
PRs via Gitea MCP. Every issue/PR: `Project/ICCery-v2` + `Feature/*` or `Bug/*` + `Priority/*`.
|
PRs via Gitea MCP. Every issue/PR: `Project/ICCery-v2` + `Feature/*` or `Bug/*` + `Priority/*`.
|
||||||
@@ -54,3 +61,14 @@ Universal (`ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO`) is still required for rel
|
|||||||
2-arg `(PMPrintSession, CFStringRef) -> OSStatus`. Never pass integer `1`.
|
2-arg `(PMPrintSession, CFStringRef) -> OSStatus`. Never pass integer `1`.
|
||||||
Modes: `AP_ApplicationColorMatching` then `ApplicationColorMatching`.
|
Modes: `AP_ApplicationColorMatching` then `ApplicationColorMatching`.
|
||||||
`lp` path and Quartz/`ICCeryPrintKit` path use **different** ColorSync dictionaries. Never mix.
|
`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.
|
||||||
|
|||||||
+4
-3
@@ -1,6 +1,6 @@
|
|||||||
# BUILD-PLAN.md — ICCery v2 Mac
|
# BUILD-PLAN.md — ICCery v2 Mac
|
||||||
|
|
||||||
Spec snapshot: `docs/`. Source of tickets: Gitea milestones M1–M6 + M10 (id 32) + Later.
|
Spec snapshot: `docs/`. Source of tickets: Gitea milestones M1–M6 + M10 (id 32) + M11 (id 34) + Later.
|
||||||
|
|
||||||
## Sprint rule
|
## Sprint rule
|
||||||
Do not start milestone N+1 implementation until milestone N **CI/mock gate** is green.
|
Do not start milestone N+1 implementation until milestone N **CI/mock gate** is green.
|
||||||
@@ -20,11 +20,12 @@ Hardware gates block *release of that sprint*, not filing, and not starting codi
|
|||||||
| M8 | Deduplicate & consolidate | 79–86 | (already shipped on `develop`) | N/A |
|
| 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 |
|
| 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` |
|
| 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 |
|
| Later | Quartz / TargetPrint | 16 | `ICCeryPrintKit` standalone + seam test | 1:1 on paper vs TIFF |
|
||||||
|
|
||||||
M8 and M9 merged to `develop` via PR #104 / #145; M10 starts from `800c980`.
|
M8–M10 are merged to `develop`; M11 assembles on `milestone/m11-print-settings` from `736fd08`.
|
||||||
|
|
||||||
Issue **16 is not an M3, M6, or M10 exit gate.**
|
Issue **16 is not an M3, M6, M10, or M11 exit gate.**
|
||||||
|
|
||||||
## Branch taxonomy
|
## Branch taxonomy
|
||||||
|
|
||||||
|
|||||||
@@ -20,8 +20,14 @@ public enum ArtefactFiles {
|
|||||||
bundle: Bundle = .main
|
bundle: Bundle = .main
|
||||||
) -> (version: String, build: String, buildDate: String) {
|
) -> (version: String, build: String, buildDate: String) {
|
||||||
let info = bundle.infoDictionary ?? [:]
|
let info = bundle.infoDictionary ?? [:]
|
||||||
let version = info["CFBundleShortVersionString"] as? String ?? "0.0.0"
|
let short = info["CFBundleShortVersionString"] as? String ?? "0.0.0"
|
||||||
let build = info["CFBundleVersion"] as? String ?? "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 url = bundle.executableURL ?? bundle.bundleURL
|
||||||
let buildDate: String
|
let buildDate: String
|
||||||
@@ -37,4 +43,15 @@ public enum ArtefactFiles {
|
|||||||
|
|
||||||
return (version, build, buildDate)
|
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))"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,8 +25,9 @@ public enum CupsOptionsFilter {
|
|||||||
"EPIJ_OSColMat", "ColorCorrection", "StpColorCorrection",
|
"EPIJ_OSColMat", "ColorCorrection", "StpColorCorrection",
|
||||||
"EpsonColorMode", "ColorModel",
|
"EpsonColorMode", "ColorModel",
|
||||||
// Quality
|
// Quality
|
||||||
"Resolution", "cupsPrintQuality", "Quality", "EPIJ_Quality",
|
"Resolution", "cupsPrintQuality", "Quality", "EPIJ_Qual",
|
||||||
"CNIJQuality", "StpQuality", "OutputMode",
|
"EPIJ_Quality", "CNIJQuality", "CNIJPrintQuality",
|
||||||
|
"PrintQuality", "StpQuality", "OutputMode",
|
||||||
// Duplex
|
// Duplex
|
||||||
"Duplex", "sides",
|
"Duplex", "sides",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -232,6 +232,47 @@ public enum CupsParsers {
|
|||||||
return pairs.first(where: { $0.key == "EPIJ_Medi" })?.value
|
return pairs.first(where: { $0.key == "EPIJ_Medi" })?.value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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" }
|
||||||
|
}
|
||||||
|
|
||||||
/// Driver "no colour adjustment" key=value for `lpoptions -l` keys
|
/// Driver "no colour adjustment" key=value for `lpoptions -l` keys
|
||||||
/// (docs/11 layer ④): Canon `CNIJIntent2=4` else `CNIJIntent=4`;
|
/// (docs/11 layer ④): Canon `CNIJIntent2=4` else `CNIJIntent=4`;
|
||||||
/// Epson `EPIJ_CCor=0` when the key exists else `EPIJ_CMat=3`;
|
/// Epson `EPIJ_CCor=0` when the key exists else `EPIJ_CMat=3`;
|
||||||
|
|||||||
@@ -132,8 +132,28 @@ public struct CupsService: Sendable {
|
|||||||
continue
|
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(
|
return PrinterCapabilities(
|
||||||
trays: trays, paperSizes: sizes, mediaTypes: media)
|
trays: trays, paperSizes: sizes, mediaTypes: media,
|
||||||
|
qualityKey: qualityKey, qualities: qualities,
|
||||||
|
qualityDefault: qualityDefault)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The set of option keys a queue advertises — input to
|
/// The set of option keys a queue advertises — input to
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ public enum LpArgsError: LocalizedError, Equatable {
|
|||||||
/// -o AP.ColorMatchingMode=AP_ApplicationColorMatching
|
/// -o AP.ColorMatchingMode=AP_ApplicationColorMatching
|
||||||
/// <captured cups_options>
|
/// <captured cups_options>
|
||||||
/// <media_type, if no media key already captured>
|
/// <media_type, if no media key already captured>
|
||||||
|
/// <quality, if no quality key already captured> (#183)
|
||||||
/// <driver bypass, if no bypass key captured>
|
/// <driver bypass, if no bypass key captured>
|
||||||
/// <orientation-requested=3|4, unless captured>
|
/// <orientation-requested=3|4, unless captured>
|
||||||
/// <PageSize, unless captured>
|
/// <PageSize, unless captured>
|
||||||
@@ -82,6 +83,15 @@ public enum LpArgs {
|
|||||||
argv += ["-o", "\(mediaKey)=\(mediaType)"]
|
argv += ["-o", "\(mediaKey)=\(mediaType)"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Print quality — after media, before the driver bypass; the
|
||||||
|
// detected queue key is skipped when already captured (#183).
|
||||||
|
if let quality = options.quality,
|
||||||
|
let qualityKey = CupsParsers.detectQualityKey(optionKeys: optionKeys),
|
||||||
|
!addedKeys.contains(qualityKey.lowercased()) {
|
||||||
|
addedKeys.insert(qualityKey.lowercased())
|
||||||
|
argv += ["-o", "\(qualityKey)=\(quality)"]
|
||||||
|
}
|
||||||
|
|
||||||
// Driver colour bypass — when no bypass key was captured. NOT
|
// Driver colour bypass — when no bypass key was captured. NOT
|
||||||
// gated on ppdUncorrectedPassthrough (macOS always bypasses).
|
// gated on ppdUncorrectedPassthrough (macOS always bypasses).
|
||||||
let capturedKeys = Set(
|
let capturedKeys = Set(
|
||||||
|
|||||||
@@ -66,6 +66,18 @@ 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 struct PrinterCapabilities: Codable, Equatable, Sendable {
|
||||||
public var trays: [PrinterTray]
|
public var trays: [PrinterTray]
|
||||||
public var paperSizes: [PrinterPaperSize]
|
public var paperSizes: [PrinterPaperSize]
|
||||||
@@ -73,17 +85,29 @@ public struct PrinterCapabilities: Codable, Equatable, Sendable {
|
|||||||
/// Always `true` on macOS (spec parity — CUPS honours
|
/// Always `true` on macOS (spec parity — CUPS honours
|
||||||
/// `orientation-requested`).
|
/// `orientation-requested`).
|
||||||
public var supportsOrientation: Bool
|
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(
|
public init(
|
||||||
trays: [PrinterTray] = [],
|
trays: [PrinterTray] = [],
|
||||||
paperSizes: [PrinterPaperSize] = [],
|
paperSizes: [PrinterPaperSize] = [],
|
||||||
mediaTypes: [PrinterMediaType] = [],
|
mediaTypes: [PrinterMediaType] = [],
|
||||||
supportsOrientation: Bool = true
|
supportsOrientation: Bool = true,
|
||||||
|
qualityKey: String? = nil,
|
||||||
|
qualities: [PrinterQuality] = [],
|
||||||
|
qualityDefault: String? = nil
|
||||||
) {
|
) {
|
||||||
self.trays = trays
|
self.trays = trays
|
||||||
self.paperSizes = paperSizes
|
self.paperSizes = paperSizes
|
||||||
self.mediaTypes = mediaTypes
|
self.mediaTypes = mediaTypes
|
||||||
self.supportsOrientation = supportsOrientation
|
self.supportsOrientation = supportsOrientation
|
||||||
|
self.qualityKey = qualityKey
|
||||||
|
self.qualities = qualities
|
||||||
|
self.qualityDefault = qualityDefault
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,9 +120,12 @@ public struct PrintOptions: Codable, Equatable, Sendable {
|
|||||||
public var paperSource: Int?
|
public var paperSource: Int?
|
||||||
/// `"portrait"` / `"landscape"` → `orientation-requested=3|4`.
|
/// `"portrait"` / `"landscape"` → `orientation-requested=3|4`.
|
||||||
public var orientation: String?
|
public var orientation: String?
|
||||||
/// printtarg layout page size → `PageSize=` (skipped if captured).
|
/// Stage 2 paper token → `PageSize=` (skipped if captured, #183).
|
||||||
public var paperSize: String?
|
public var paperSize: String?
|
||||||
public var mediaType: String?
|
public var mediaType: String?
|
||||||
|
/// Print-quality token → `-o <detectedQualityKey>=` (skipped if
|
||||||
|
/// captured, #183).
|
||||||
|
public var quality: String?
|
||||||
public var ppdUncorrectedPassthrough: Bool?
|
public var ppdUncorrectedPassthrough: Bool?
|
||||||
/// Space-separated `key=value` captured from
|
/// Space-separated `key=value` captured from
|
||||||
/// `PMPrintSettingsToOptions` and filtered (docs/11 layer ⑥).
|
/// `PMPrintSettingsToOptions` and filtered (docs/11 layer ⑥).
|
||||||
@@ -109,6 +136,7 @@ public struct PrintOptions: Codable, Equatable, Sendable {
|
|||||||
orientation: String? = nil,
|
orientation: String? = nil,
|
||||||
paperSize: String? = nil,
|
paperSize: String? = nil,
|
||||||
mediaType: String? = nil,
|
mediaType: String? = nil,
|
||||||
|
quality: String? = nil,
|
||||||
ppdUncorrectedPassthrough: Bool? = nil,
|
ppdUncorrectedPassthrough: Bool? = nil,
|
||||||
cupsOptions: String? = nil
|
cupsOptions: String? = nil
|
||||||
) {
|
) {
|
||||||
@@ -116,6 +144,7 @@ public struct PrintOptions: Codable, Equatable, Sendable {
|
|||||||
self.orientation = orientation
|
self.orientation = orientation
|
||||||
self.paperSize = paperSize
|
self.paperSize = paperSize
|
||||||
self.mediaType = mediaType
|
self.mediaType = mediaType
|
||||||
|
self.quality = quality
|
||||||
self.ppdUncorrectedPassthrough = ppdUncorrectedPassthrough
|
self.ppdUncorrectedPassthrough = ppdUncorrectedPassthrough
|
||||||
self.cupsOptions = cupsOptions
|
self.cupsOptions = cupsOptions
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ as AGPLv3 child processes. The GUI never `dlopen`s or links Argyll.
|
|||||||
| M7 | Pre-UAT hardening — shipped |
|
| M7 | Pre-UAT hardening — shipped |
|
||||||
| M8 | Deduplication contracts & UAT-ready hardening (#79–#86) — shipped |
|
| M8 | Deduplication contracts & UAT-ready hardening (#79–#86) — shipped |
|
||||||
| M9 | macOS 12 / Xcode 14.2 retarget (PR #145) — shipped |
|
| M9 | macOS 12 / Xcode 14.2 retarget (PR #145) — shipped |
|
||||||
| 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 |
|
| 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) — in flight on `milestone/m11-print-settings` |
|
||||||
| Licence | Proprietary source in [`LICENCE.md`](LICENCE.md); bundled Argyll sidecars remain AGPLv3 |
|
| Licence | Proprietary source in [`LICENCE.md`](LICENCE.md); bundled Argyll sidecars remain AGPLv3 |
|
||||||
|
|
||||||
## What it does
|
## What it does
|
||||||
@@ -125,7 +126,35 @@ The script builds with a fixed derived data path (`build/DerivedData`), locates
|
|||||||
`Release/ICCery.app` from it, signs the bundle, recursively verifies every
|
`Release/ICCery.app` from it, signs the bundle, recursively verifies every
|
||||||
bundled Mach-O sidecar (`scripts/verify-sidecar-signatures.sh`), builds a
|
bundled Mach-O sidecar (`scripts/verify-sidecar-signatures.sh`), builds a
|
||||||
HiDPI TIFF from `Resources/dmg-background.png` (+ `@2x`) via `tiffutil`, and
|
HiDPI TIFF from `Resources/dmg-background.png` (+ `@2x`) via `tiffutil`, and
|
||||||
writes `ICCery-${VERSION}-${BUILD_NUM}.dmg` with `dmgbuild==1.6.7`.
|
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.
|
||||||
|
|
||||||
Sidecars stay ad-hoc signed inside the bundle — the app is never
|
Sidecars stay ad-hoc signed inside the bundle — the app is never
|
||||||
`codesign --deep`ed.
|
`codesign --deep`ed.
|
||||||
@@ -145,6 +174,8 @@ Environment variables read by the pipeline:
|
|||||||
| `CODESIGN_IDENTITY` | Developer ID identity for the outer `.app`; unset or `-` = ad-hoc |
|
| `CODESIGN_IDENTITY` | Developer ID identity for the outer `.app`; unset or `-` = ad-hoc |
|
||||||
| `DEVELOPMENT_TEAM` | team ID passed to `xcodebuild` when signing |
|
| `DEVELOPMENT_TEAM` | team ID passed to `xcodebuild` when signing |
|
||||||
| `NOTARIZE_APPLE_ID` / `NOTARIZE_PASSWORD` / `APPLE_TEAM_ID` | `notarytool` + staple when all three are set |
|
| `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
|
## Layout
|
||||||
|
|
||||||
@@ -206,9 +237,13 @@ xcodebuild test -scheme ICCery -destination 'platform=macOS' \
|
|||||||
-only-testing:ICCeryUITests/Milestone5UITests
|
-only-testing:ICCeryUITests/Milestone5UITests
|
||||||
```
|
```
|
||||||
|
|
||||||
CI (`.gitea/workflows/macos.yml`) runs `build-and-test` then `package` on
|
CI (`.gitea/workflows/macos.yml` on Gitea, `.github/workflows/macos.yml` on
|
||||||
|
GitHub) runs `build-and-test` then `package` on
|
||||||
`develop` and on `v*` tags. Tags whose name contains `prerelease` skip the
|
`develop` and on `v*` tags. Tags whose name contains `prerelease` skip the
|
||||||
test job and still package. `pull_request` is wired for **`develop` only**.
|
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 /
|
UI tests need an unlocked console (`IOConsoleLocked=false`). Mock Argyll /
|
||||||
CUPS fixtures live under the test bundles; they must not be treated as proof
|
CUPS fixtures live under the test bundles; they must not be treated as proof
|
||||||
@@ -264,9 +299,9 @@ fix/<issue>-<slug>
|
|||||||
```
|
```
|
||||||
|
|
||||||
Open feature/fix PRs against **`develop`**. A `milestone/m…` integration
|
Open feature/fix PRs against **`develop`**. A `milestone/m…` integration
|
||||||
branch is used only while that milestone is assembling; `milestone/m10-studio`
|
branch is used only while that milestone is assembling — currently
|
||||||
has been merged and deleted. Do not open umbrella “bugfix” branches that mix
|
`milestone/m11-print-settings` (`milestone/m10-studio` was merged and
|
||||||
tickets.
|
deleted). Do not open umbrella “bugfix” branches that mix tickets.
|
||||||
|
|
||||||
`main` is push-protected and requires status check
|
`main` is push-protected and requires status check
|
||||||
`macOS CI / build-and-test (push)`. Protected **file** patterns on `main`
|
`macOS CI / build-and-test (push)`. Protected **file** patterns on `main`
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import ICCeryCore
|
|||||||
struct AboutView: View {
|
struct AboutView: View {
|
||||||
let onClose: () -> Void
|
let onClose: () -> Void
|
||||||
|
|
||||||
|
@State private var showingLicenses = false
|
||||||
|
|
||||||
private let info = ArtefactFiles.appInfo()
|
private let info = ArtefactFiles.appInfo()
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -54,6 +56,12 @@ struct AboutView: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
|
Button("View Licenses") {
|
||||||
|
showingLicenses = true
|
||||||
|
}
|
||||||
|
.controlSize(.large)
|
||||||
|
.accessibilityIdentifier("viewLicensesBtn")
|
||||||
|
|
||||||
Button("Close") {
|
Button("Close") {
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
@@ -66,5 +74,8 @@ struct AboutView: View {
|
|||||||
.background(Theme.panel)
|
.background(Theme.panel)
|
||||||
.accessibilityElement(children: .contain)
|
.accessibilityElement(children: .contain)
|
||||||
.accessibilityIdentifier("aboutDialog")
|
.accessibilityIdentifier("aboutDialog")
|
||||||
|
.sheet(isPresented: $showingLicenses) {
|
||||||
|
LicenseWindowView()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,9 +136,19 @@ enum UITestHooks {
|
|||||||
$0.isEmpty ? nil : $0
|
$0.isEmpty ? nil : $0
|
||||||
} ?? queue,
|
} ?? queue,
|
||||||
options: PrintOptions(
|
options: PrintOptions(
|
||||||
|
orientation: options.flatMap {
|
||||||
|
CupsParsers.extractOrientation(fromOptionsString: $0)
|
||||||
|
},
|
||||||
|
paperSize: options.flatMap {
|
||||||
|
CupsParsers.extractOption(
|
||||||
|
named: "PageSize", fromOptionsString: $0)
|
||||||
|
},
|
||||||
mediaType: options.flatMap {
|
mediaType: options.flatMap {
|
||||||
CupsParsers.extractMediaType(fromOptionsString: $0)
|
CupsParsers.extractMediaType(fromOptionsString: $0)
|
||||||
},
|
},
|
||||||
|
quality: options.flatMap {
|
||||||
|
CupsParsers.extractQuality(fromOptionsString: $0)
|
||||||
|
},
|
||||||
ppdUncorrectedPassthrough: true,
|
ppdUncorrectedPassthrough: true,
|
||||||
cupsOptions: options))
|
cupsOptions: options))
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -56,10 +56,41 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// docs/21 §Shell: 1280×800 content, min 1100×700, centred.
|
/// 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) {
|
private func configureMainWindow(_ window: NSWindow) {
|
||||||
window.setContentSize(NSSize(width: 1280, height: 800))
|
let desired = NSSize(width: 1280, height: 800)
|
||||||
window.contentMinSize = NSSize(width: 1100, height: 700)
|
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.center()
|
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
|
/// Dock-click reopen: let the WindowGroup re-show or recreate the
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,21 @@ enum PrintPanelError: LocalizedError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stage 2 selections pre-applied to the bound print panel before it
|
||||||
|
/// opens (#183). This phase consumes `paperSize` + `qualityKey`/
|
||||||
|
/// `quality` only; `mediaType` and `orientation` preselect — and the
|
||||||
|
/// `PMPageFormat`/`PMPaper` half of paper — are #186's scope.
|
||||||
|
struct PrintPanelInitialSelections {
|
||||||
|
/// CUPS `PageSize` token, e.g. `"A4"` / `"Custom.595x842"`.
|
||||||
|
var paperSize: String?
|
||||||
|
/// The queue's detected quality enumeration key, e.g. `EPIJ_Qual`.
|
||||||
|
var qualityKey: String?
|
||||||
|
/// The selected quality token.
|
||||||
|
var quality: String?
|
||||||
|
var mediaType: String? // #186 consumes
|
||||||
|
var orientation: String? // #186 consumes
|
||||||
|
}
|
||||||
|
|
||||||
/// Preferences → native `NSPrintPanel` bound to the selected CUPS
|
/// Preferences → native `NSPrintPanel` bound to the selected CUPS
|
||||||
/// queue (issue 13, docs/11).
|
/// queue (issue 13, docs/11).
|
||||||
///
|
///
|
||||||
@@ -41,7 +56,9 @@ struct PrintPanelService {
|
|||||||
func showProperties(
|
func showProperties(
|
||||||
queue: String,
|
queue: String,
|
||||||
displayName: String?,
|
displayName: String?,
|
||||||
cupsService: CupsService
|
cupsService: CupsService,
|
||||||
|
initialSelections: PrintPanelInitialSelections =
|
||||||
|
PrintPanelInitialSelections()
|
||||||
) async throws -> PrintPropertiesResult? {
|
) async throws -> PrintPropertiesResult? {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
if UITestHooks.printPanelStubbed {
|
if UITestHooks.printPanelStubbed {
|
||||||
@@ -56,7 +73,8 @@ struct PrintPanelService {
|
|||||||
let optionKeys = (try? await cupsService.optionKeys(for: queue))
|
let optionKeys = (try? await cupsService.optionKeys(for: queue))
|
||||||
?? []
|
?? []
|
||||||
return try runNativePanel(
|
return try runNativePanel(
|
||||||
queue: queue, displayName: display, optionKeys: optionKeys)
|
queue: queue, displayName: display, optionKeys: optionKeys,
|
||||||
|
initialSelections: initialSelections)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Panel
|
// MARK: - Panel
|
||||||
@@ -64,7 +82,8 @@ struct PrintPanelService {
|
|||||||
private func runNativePanel(
|
private func runNativePanel(
|
||||||
queue: String,
|
queue: String,
|
||||||
displayName: String?,
|
displayName: String?,
|
||||||
optionKeys: Set<String>
|
optionKeys: Set<String>,
|
||||||
|
initialSelections: PrintPanelInitialSelections
|
||||||
) throws -> PrintPropertiesResult? {
|
) throws -> PrintPropertiesResult? {
|
||||||
let printInfo = NSPrintInfo()
|
let printInfo = NSPrintInfo()
|
||||||
var pmPrinter: PMPrinter?
|
var pmPrinter: PMPrinter?
|
||||||
@@ -89,6 +108,9 @@ struct PrintPanelService {
|
|||||||
// queue but are not fatal when they fail.
|
// queue but are not fatal when they fail.
|
||||||
_ = PMSessionDefaultPrintSettings(session, settings)
|
_ = PMSessionDefaultPrintSettings(session, settings)
|
||||||
_ = PMSessionDefaultPageFormat(session, pageFormat)
|
_ = PMSessionDefaultPageFormat(session, pageFormat)
|
||||||
|
// Initial selections — after `PMSessionDefault*`, before
|
||||||
|
// ColorSync suppression ②–⑤ (locked write order, #183).
|
||||||
|
applyInitialSelections(initialSelections, to: settings)
|
||||||
boundViaPM = true
|
boundViaPM = true
|
||||||
} else {
|
} else {
|
||||||
// Fallback: NSPrinter by display name (docs/11 §binding).
|
// Fallback: NSPrinter by display name (docs/11 §binding).
|
||||||
@@ -138,7 +160,9 @@ struct PrintPanelService {
|
|||||||
|
|
||||||
// ⑥ Capture the user's choices — filtered replay options plus
|
// ⑥ Capture the user's choices — filtered replay options plus
|
||||||
// the media type they picked. Re-fetch the settings handle so
|
// the media type they picked. Re-fetch the settings handle so
|
||||||
// we read back what the modal wrote.
|
// 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).
|
||||||
var cupsOptions: String?
|
var cupsOptions: String?
|
||||||
var mediaType: String?
|
var mediaType: String?
|
||||||
if boundViaPM {
|
if boundViaPM {
|
||||||
@@ -148,6 +172,7 @@ struct PrintPanelService {
|
|||||||
cupsOptions = captured.cupsOptions
|
cupsOptions = captured.cupsOptions
|
||||||
mediaType = captured.mediaType
|
mediaType = captured.mediaType
|
||||||
}
|
}
|
||||||
|
let capturedOptions = cupsOptions ?? ""
|
||||||
return PrintPropertiesResult(
|
return PrintPropertiesResult(
|
||||||
selectedPrinter: boundViaPM
|
selectedPrinter: boundViaPM
|
||||||
? Self.currentPrinterID(
|
? Self.currentPrinterID(
|
||||||
@@ -156,11 +181,35 @@ struct PrintPanelService {
|
|||||||
fallback: queue)
|
fallback: queue)
|
||||||
: nil,
|
: nil,
|
||||||
options: PrintOptions(
|
options: PrintOptions(
|
||||||
|
orientation: CupsParsers.extractOrientation(
|
||||||
|
fromOptionsString: capturedOptions),
|
||||||
|
paperSize: CupsParsers.extractOption(
|
||||||
|
named: "PageSize", fromOptionsString: capturedOptions),
|
||||||
mediaType: mediaType,
|
mediaType: mediaType,
|
||||||
|
quality: CupsParsers.extractQuality(
|
||||||
|
fromOptionsString: capturedOptions),
|
||||||
ppdUncorrectedPassthrough: true,
|
ppdUncorrectedPassthrough: true,
|
||||||
cupsOptions: cupsOptions))
|
cupsOptions: cupsOptions))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Initial-selection `PMPrintSettings` writes — paper size and
|
||||||
|
/// quality only this phase; media type / orientation and the
|
||||||
|
/// `PMPageFormat`/`PMPaper` paper half are #186's contract.
|
||||||
|
private func applyInitialSelections(
|
||||||
|
_ selections: PrintPanelInitialSelections,
|
||||||
|
to settings: PMPrintSettings
|
||||||
|
) {
|
||||||
|
if let paperSize = selections.paperSize {
|
||||||
|
_ = PMPrintSettingsSetValue(
|
||||||
|
settings, "PageSize" as CFString,
|
||||||
|
paperSize as CFString, false)
|
||||||
|
}
|
||||||
|
if let key = selections.qualityKey, let value = selections.quality {
|
||||||
|
_ = PMPrintSettingsSetValue(
|
||||||
|
settings, key as CFString, value as CFString, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - PM helpers
|
// MARK: - PM helpers
|
||||||
|
|
||||||
/// `PMPrinter` → `PMObject` for `PMRelease` — the Carbon API wants
|
/// `PMPrinter` → `PMObject` for `PMRelease` — the Carbon API wants
|
||||||
|
|||||||
@@ -7,12 +7,20 @@ import ICCeryCore
|
|||||||
final class PrintSessionViewModel: ObservableObject {
|
final class PrintSessionViewModel: ObservableObject {
|
||||||
let wizard: WizardViewModel
|
let wizard: WizardViewModel
|
||||||
let environment: AppEnvironment
|
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 printers: [Printer] = []
|
||||||
@Published var selectedPrinter = ""
|
@Published var selectedPrinter = ""
|
||||||
@Published var printerCaps = PrinterCapabilities()
|
@Published var printerCaps = PrinterCapabilities()
|
||||||
@Published var selectedTray: Int?
|
@Published var selectedTray: Int?
|
||||||
@Published var selectedMediaType: String?
|
@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 printOrientation = "portrait"
|
||||||
@Published var capturedCupsOptions: [String: String] = [:]
|
@Published var capturedCupsOptions: [String: String] = [:]
|
||||||
@Published var printNotice: Notice?
|
@Published var printNotice: Notice?
|
||||||
@@ -75,22 +83,79 @@ final class PrintSessionViewModel: ObservableObject {
|
|||||||
if selectedTray == nil {
|
if selectedTray == nil {
|
||||||
selectedTray = printerCaps.trays.first?.id
|
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 {
|
} catch {
|
||||||
printerCaps = PrinterCapabilities()
|
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. This is what `lp -o PageSize=` sees.
|
||||||
|
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() {
|
func openPrinterPreferences() {
|
||||||
guard !selectedPrinter.isEmpty else { return }
|
guard !selectedPrinter.isEmpty else { return }
|
||||||
let queue = selectedPrinter
|
let queue = selectedPrinter
|
||||||
let displayName = printers.first { $0.name == queue }?.displayName
|
let displayName = printers.first { $0.name == queue }?.displayName
|
||||||
let cups = environment.cupsService
|
let cups = environment.cupsService
|
||||||
|
let selections = PrintPanelInitialSelections(
|
||||||
|
paperSize: selectedPaperSizeToken,
|
||||||
|
qualityKey: printerCaps.qualityKey,
|
||||||
|
quality: selectedQuality,
|
||||||
|
mediaType: nil,
|
||||||
|
orientation: nil)
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
do {
|
do {
|
||||||
guard let result = try await PrintPanelService()
|
guard let result = try await PrintPanelService()
|
||||||
.showProperties(
|
.showProperties(
|
||||||
queue: queue, displayName: displayName,
|
queue: queue, displayName: displayName,
|
||||||
cupsService: cups)
|
cupsService: cups,
|
||||||
|
initialSelections: selections)
|
||||||
else {
|
else {
|
||||||
printNotice = Notice(
|
printNotice = Notice(
|
||||||
kind: .info,
|
kind: .info,
|
||||||
@@ -111,6 +176,17 @@ final class PrintSessionViewModel: ObservableObject {
|
|||||||
if let media = result.options.mediaType {
|
if let media = result.options.mediaType {
|
||||||
selectedMediaType = media
|
selectedMediaType = media
|
||||||
}
|
}
|
||||||
|
// Capture-return (#183): a dialog paper/quality change
|
||||||
|
// updates the Stage 2 selections — never
|
||||||
|
// `workflow.pageSize` (printtarg layout is sacred).
|
||||||
|
if let paper = result.options.paperSize,
|
||||||
|
let match = printerCaps.paperSizes
|
||||||
|
.first(where: { $0.name == paper }) {
|
||||||
|
selectedPaperSize = match.id
|
||||||
|
}
|
||||||
|
if let quality = result.options.quality {
|
||||||
|
selectedQuality = quality
|
||||||
|
}
|
||||||
printNotice = Notice(
|
printNotice = Notice(
|
||||||
kind: .info,
|
kind: .info,
|
||||||
text: "Settings captured for \(selectedPrinter).",
|
text: "Settings captured for \(selectedPrinter).",
|
||||||
@@ -122,7 +198,7 @@ final class PrintSessionViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func printAllPages(from result: PrinttargResult, pageSize: PageSize) {
|
func printAllPages(from result: PrinttargResult) {
|
||||||
guard !isPrinting else { return }
|
guard !isPrinting else { return }
|
||||||
isPrinting = true
|
isPrinting = true
|
||||||
let task = Task { @MainActor [weak self] in
|
let task = Task { @MainActor [weak self] in
|
||||||
@@ -132,7 +208,7 @@ final class PrintSessionViewModel: ObservableObject {
|
|||||||
var printed = 0
|
var printed = 0
|
||||||
for page in result.pages {
|
for page in result.pages {
|
||||||
do {
|
do {
|
||||||
try await spool(page, index: page.index, pageSize: pageSize)
|
try await spool(page, index: page.index)
|
||||||
printed += 1
|
printed += 1
|
||||||
} catch {
|
} catch {
|
||||||
printNotice = Notice(
|
printNotice = Notice(
|
||||||
@@ -156,13 +232,13 @@ final class PrintSessionViewModel: ObservableObject {
|
|||||||
printTask = task
|
printTask = task
|
||||||
}
|
}
|
||||||
|
|
||||||
func printPage(_ page: GalleryPage, pageSize: PageSize) {
|
func printPage(_ page: GalleryPage) {
|
||||||
guard !isPrinting else { return }
|
guard !isPrinting else { return }
|
||||||
isPrinting = true
|
isPrinting = true
|
||||||
let task = Task { @MainActor [weak self] in
|
let task = Task { @MainActor [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
do {
|
do {
|
||||||
try await spool(page, index: page.index, pageSize: pageSize)
|
try await spool(page, index: page.index)
|
||||||
printNotice = Notice(
|
printNotice = Notice(
|
||||||
kind: .info,
|
kind: .info,
|
||||||
text: "Sent \(page.page.filename) to \(selectedPrinter).",
|
text: "Sent \(page.page.filename) to \(selectedPrinter).",
|
||||||
@@ -180,14 +256,18 @@ final class PrintSessionViewModel: ObservableObject {
|
|||||||
printTask = task
|
printTask = task
|
||||||
}
|
}
|
||||||
|
|
||||||
private func spool(_ page: GalleryPage, index: Int, pageSize: PageSize) async throws {
|
private func spool(_ page: GalleryPage, index: Int) async throws {
|
||||||
guard !selectedPrinter.isEmpty else {
|
guard !selectedPrinter.isEmpty else {
|
||||||
throw CupsError.noPrinterSelected
|
throw CupsError.noPrinterSelected
|
||||||
}
|
}
|
||||||
|
// The Stage 2 paper token is what `lp -o PageSize=` sees;
|
||||||
|
// `workflow.pageSize` remains the printtarg layout input only
|
||||||
|
// (#183).
|
||||||
let options = PrintOptions(
|
let options = PrintOptions(
|
||||||
orientation: printOrientation,
|
orientation: printOrientation,
|
||||||
paperSize: pageSize == .custom ? nil : pageSize.rawValue,
|
paperSize: selectedPaperSizeToken,
|
||||||
mediaType: selectedMediaType,
|
mediaType: selectedMediaType,
|
||||||
|
quality: selectedQuality,
|
||||||
ppdUncorrectedPassthrough: true,
|
ppdUncorrectedPassthrough: true,
|
||||||
cupsOptions: capturedCupsOptions[selectedPrinter])
|
cupsOptions: capturedCupsOptions[selectedPrinter])
|
||||||
try await environment.cupsService.printTarget(
|
try await environment.cupsService.printTarget(
|
||||||
|
|||||||
@@ -255,6 +255,7 @@ struct Stage2View: View {
|
|||||||
.onChange(of: workflow.print.selectedPrinter) { _ in
|
.onChange(of: workflow.print.selectedPrinter) { _ in
|
||||||
workflow.print.selectedTray = nil
|
workflow.print.selectedTray = nil
|
||||||
workflow.print.selectedMediaType = nil
|
workflow.print.selectedMediaType = nil
|
||||||
|
workflow.print.selectedQuality = nil
|
||||||
Task { @MainActor in await workflow.print.reloadSelectedCapabilities() }
|
Task { @MainActor in await workflow.print.reloadSelectedCapabilities() }
|
||||||
}
|
}
|
||||||
if let selected = workflow.print.printers
|
if let selected = workflow.print.printers
|
||||||
@@ -279,7 +280,9 @@ struct Stage2View: View {
|
|||||||
.accessibilityIdentifier("btnPrinterProperties")
|
.accessibilityIdentifier("btnPrinterProperties")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tray / media / orientation — from queue capabilities.
|
// Tray / media / paper / quality / orientation — from
|
||||||
|
// queue capabilities. Extracted subviews keep every
|
||||||
|
// ViewBuilder ≤10 children (R13).
|
||||||
HStack(spacing: 14) {
|
HStack(spacing: 14) {
|
||||||
if !workflow.print.printerCaps.trays.isEmpty {
|
if !workflow.print.printerCaps.trays.isEmpty {
|
||||||
Picker("Tray", selection: $workflow.print.selectedTray) {
|
Picker("Tray", selection: $workflow.print.selectedTray) {
|
||||||
@@ -301,6 +304,12 @@ struct Stage2View: View {
|
|||||||
.accessibilityIdentifier("mediaTypeGroup")
|
.accessibilityIdentifier("mediaTypeGroup")
|
||||||
.accessibilityIdentifier("printerMediaTypeSelect")
|
.accessibilityIdentifier("printerMediaTypeSelect")
|
||||||
}
|
}
|
||||||
|
if !workflow.print.printerCaps.paperSizes.isEmpty {
|
||||||
|
paperSizeGroup
|
||||||
|
}
|
||||||
|
if !workflow.print.printerCaps.qualities.isEmpty {
|
||||||
|
qualityGroup
|
||||||
|
}
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
Button("Portrait") { workflow.print.printOrientation = "portrait" }
|
Button("Portrait") { workflow.print.printOrientation = "portrait" }
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
@@ -313,11 +322,13 @@ struct Stage2View: View {
|
|||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
|
// Stage 1 owns the custom dimensions — the caption lives
|
||||||
|
// inside `paperSizeGroup` (#183).
|
||||||
|
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
if let result = workflow.printtargResult {
|
if let result = workflow.printtargResult {
|
||||||
workflow.print.printAllPages(from: result, pageSize: workflow.pageSize)
|
workflow.print.printAllPages(from: result)
|
||||||
}
|
}
|
||||||
}) {
|
}) {
|
||||||
Label(workflow.print.isPrinting ? "Printing…" : "Print All",
|
Label(workflow.print.isPrinting ? "Printing…" : "Print All",
|
||||||
@@ -344,6 +355,50 @@ struct Stage2View: View {
|
|||||||
.onChange(of: workflow.printtargResult?.pages.count) { _ in
|
.onChange(of: workflow.printtargResult?.pages.count) { _ in
|
||||||
schedulePrinterRefresh()
|
schedulePrinterRefresh()
|
||||||
}
|
}
|
||||||
|
// Editable picker that re-mirrors Stage 1's pageSize (#183 E4).
|
||||||
|
.onChange(of: workflow.pageSize) { _ in
|
||||||
|
workflow.print.seedPaperSelection()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
/// Auto-enumerates printers once a manifest exists and whenever it
|
||||||
@@ -392,7 +447,7 @@ private struct GalleryPageView: View {
|
|||||||
Text("\(page.page.patches) patches · " +
|
Text("\(page.page.patches) patches · " +
|
||||||
"\(Int(page.page.widthMm))×\(Int(page.page.heightMm)) mm")
|
"\(Int(page.page.widthMm))×\(Int(page.page.heightMm)) mm")
|
||||||
.font(.caption2).foregroundStyle(.secondary)
|
.font(.caption2).foregroundStyle(.secondary)
|
||||||
Button("Print") { workflow.print.printPage(page, pageSize: workflow.pageSize) }
|
Button("Print") { workflow.print.printPage(page) }
|
||||||
.disabled(workflow.print.isPrinting
|
.disabled(workflow.print.isPrinting
|
||||||
|| workflow.print.selectedPrinter.isEmpty)
|
|| workflow.print.selectedPrinter.isEmpty)
|
||||||
.accessibilityIdentifier("btnPrintPage-\(page.index)")
|
.accessibilityIdentifier("btnPrintPage-\(page.index)")
|
||||||
|
|||||||
@@ -137,6 +137,9 @@ final class TargetWorkflowViewModel: ObservableObject {
|
|||||||
environment: environment
|
environment: environment
|
||||||
)
|
)
|
||||||
self.print = PrintSessionViewModel(wizard: wizard, 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 = nil
|
||||||
self.calibration = CalibrationViewModel(
|
self.calibration = CalibrationViewModel(
|
||||||
workflow: self,
|
workflow: self,
|
||||||
|
|||||||
@@ -102,6 +102,62 @@ 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 {
|
final class ArtefactFilesTests: XCTestCase {
|
||||||
func testBase64RoundTrip() throws {
|
func testBase64RoundTrip() throws {
|
||||||
let url = tempURL("a.txt")
|
let url = tempURL("a.txt")
|
||||||
|
|||||||
@@ -24,6 +24,18 @@ final class CupsOptionsFilterTests: XCTestCase {
|
|||||||
XCTAssertEqual(CupsOptionsFilter.filter(raw), raw)
|
XCTAssertEqual(CupsOptionsFilter.filter(raw), raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #180 — a captured `EPIJ_Qual` (and the other canonical quality
|
||||||
|
/// keys) survives the filter so it wins over the Stage 2 explicit
|
||||||
|
/// quality in `LpArgs`.
|
||||||
|
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() {
|
func testKeepsUnknown() {
|
||||||
let raw = "VendorFooBar=baz MediaType=Plain"
|
let raw = "VendorFooBar=baz MediaType=Plain"
|
||||||
XCTAssertEqual(CupsOptionsFilter.filter(raw), raw)
|
XCTAssertEqual(CupsOptionsFilter.filter(raw), raw)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import Foundation
|
|||||||
|
|
||||||
/// Issue 12 — CUPS enumeration parsers on recorded fixtures
|
/// Issue 12 — CUPS enumeration parsers on recorded fixtures
|
||||||
/// (docs/10–11). No live `lpstat`/`lpoptions` is spawned here.
|
/// (docs/10–11). No live `lpstat`/`lpoptions` is spawned here.
|
||||||
final class CupsParsersTests: XCTestCase {
|
final class CupsParserTests: XCTestCase {
|
||||||
|
|
||||||
// Recorded on an Epson XP-55 + Canon Pro9500 host.
|
// Recorded on an Epson XP-55 + Canon Pro9500 host.
|
||||||
private let lpstatE = """
|
private let lpstatE = """
|
||||||
@@ -30,7 +30,7 @@ final class CupsParsersTests: XCTestCase {
|
|||||||
MediaType/Media Type: *Stationery PhotographicHighGloss Photographic PhotographicMatte Envelope
|
MediaType/Media Type: *Stationery PhotographicHighGloss Photographic PhotographicMatte Envelope
|
||||||
ColorModel/Output Mode: *RGB Gray
|
ColorModel/Output Mode: *RGB Gray
|
||||||
Duplex/Duplex: *None DuplexNoTumble DuplexTumble
|
Duplex/Duplex: *None DuplexNoTumble DuplexTumble
|
||||||
cupsPrintQuality/cupsPrintQuality: Draft *Normal High
|
EPIJ_Qual/Print Quality: 301 302 *303 308 304 305 307
|
||||||
"""
|
"""
|
||||||
|
|
||||||
func testDestinations() {
|
func testDestinations() {
|
||||||
@@ -117,6 +117,121 @@ final class CupsParsersTests: XCTestCase {
|
|||||||
XCTAssertNil(CupsParsers.detectMediaTypeKey(optionKeys: ["PageSize"]))
|
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"))
|
||||||
|
}
|
||||||
|
|
||||||
func testDriverBypass() {
|
func testDriverBypass() {
|
||||||
func pair(_ keys: Set<String>) -> String? {
|
func pair(_ keys: Set<String>) -> String? {
|
||||||
CupsParsers.detectDriverColorBypass(optionKeys: keys)
|
CupsParsers.detectDriverColorBypass(optionKeys: keys)
|
||||||
|
|||||||
@@ -101,6 +101,61 @@ final class LpArgsTests: XCTestCase {
|
|||||||
XCTAssertTrue(capturedSize.contains("PageSize=Letter"))
|
XCTAssertTrue(capturedSize.contains("PageSize=Letter"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - #183 print quality
|
||||||
|
|
||||||
|
func testQualityDerived() throws {
|
||||||
|
let argv = try build(
|
||||||
|
options: PrintOptions(
|
||||||
|
orientation: "portrait", mediaType: "Photo", quality: "305"),
|
||||||
|
optionKeys: ["EPIJ_Qual", "MediaType"])
|
||||||
|
XCTAssertTrue(argv.contains("EPIJ_Qual=305"))
|
||||||
|
// Emit order: after the media option, before orientation.
|
||||||
|
let media = argv.firstIndex(of: "MediaType=Photo")!
|
||||||
|
let quality = argv.firstIndex(of: "EPIJ_Qual=305")!
|
||||||
|
let orient = argv.firstIndex(of: "orientation-requested=3")!
|
||||||
|
XCTAssertTrue(media < quality && quality < orient)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testQualityDetectedKey() throws {
|
||||||
|
// The detected queue key is used, not a hardcoded one.
|
||||||
|
let argv = try build(
|
||||||
|
options: PrintOptions(quality: "High"),
|
||||||
|
optionKeys: ["cupsPrintQuality"])
|
||||||
|
XCTAssertTrue(argv.contains("cupsPrintQuality=High"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCapturedWinsQuality() throws {
|
||||||
|
let argv = try build(
|
||||||
|
options: PrintOptions(
|
||||||
|
quality: "303",
|
||||||
|
cupsOptions: "EPIJ_Qual=308"),
|
||||||
|
optionKeys: ["EPIJ_Qual"])
|
||||||
|
XCTAssertTrue(argv.contains("EPIJ_Qual=308"))
|
||||||
|
XCTAssertFalse(argv.contains("EPIJ_Qual=303"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCapturedQualityCaseInsensitiveDedup() throws {
|
||||||
|
let argv = try build(
|
||||||
|
options: PrintOptions(
|
||||||
|
quality: "303",
|
||||||
|
cupsOptions: "epij_qual=308"),
|
||||||
|
optionKeys: ["EPIJ_Qual"])
|
||||||
|
XCTAssertFalse(argv.contains("EPIJ_Qual=303"))
|
||||||
|
XCTAssertTrue(argv.contains("epij_qual=308"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testQualityNilNoEmit() throws {
|
||||||
|
let argv = try build(
|
||||||
|
options: PrintOptions(mediaType: "Photo"),
|
||||||
|
optionKeys: ["EPIJ_Qual", "MediaType"])
|
||||||
|
XCTAssertFalse(argv.contains { $0.hasPrefix("EPIJ_Qual=") })
|
||||||
|
// No quality key on the queue → no emit either.
|
||||||
|
let noKey = try build(
|
||||||
|
options: PrintOptions(quality: "303"),
|
||||||
|
optionKeys: ["MediaType"])
|
||||||
|
XCTAssertFalse(noKey.contains { $0.hasPrefix("EPIJ_Qual=") })
|
||||||
|
}
|
||||||
|
|
||||||
func testSanitise() throws {
|
func testSanitise() throws {
|
||||||
XCTAssertThrowsError(try build(options: PrintOptions(
|
XCTAssertThrowsError(try build(options: PrintOptions(
|
||||||
cupsOptions: "InputSlot=Rear;rm -rf /"))) { error in
|
cupsOptions: "InputSlot=Rear;rm -rf /"))) { error in
|
||||||
|
|||||||
@@ -52,6 +52,23 @@ 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func testOkDefaultsPrinter() throws {
|
func testOkDefaultsPrinter() throws {
|
||||||
try withEnv([
|
try withEnv([
|
||||||
"ICCERY_UI_TESTING": "1",
|
"ICCERY_UI_TESTING": "1",
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
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 `PrintOptions` wiring into `lp` argv.
|
||||||
|
/// `lpoptions`/`lp` are mock scripts in the test env's `cups-bin` — no
|
||||||
|
/// live CUPS is touched.
|
||||||
|
@MainActor
|
||||||
|
final class PrintSessionViewModelTests: XCTestCase {
|
||||||
|
|
||||||
|
private var env: TestAppEnvironment!
|
||||||
|
private var lpArgvURL: URL!
|
||||||
|
|
||||||
|
override func setUp() async throws {
|
||||||
|
env = try TestAppEnvironment.make()
|
||||||
|
lpArgvURL = env.root.appendingPathComponent("lp-argv.log")
|
||||||
|
try writeCupsFixtures()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDown() async throws {
|
||||||
|
env?.cleanup()
|
||||||
|
env = nil
|
||||||
|
lpArgvURL = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private var binDir: URL {
|
||||||
|
env.root.appendingPathComponent("cups-bin")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mock `lpoptions -l` advertises paper sizes + a quality key;
|
||||||
|
/// mock `lp` appends its argv to `lpArgvURL` for assertions.
|
||||||
|
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 lp = """
|
||||||
|
#!/bin/sh
|
||||||
|
printf '%s\\n' "$*" >> "\(lpArgvURL.path)"
|
||||||
|
exit 0
|
||||||
|
"""
|
||||||
|
for (name, body) in [("lpoptions", lpoptions), ("lp", lp)] {
|
||||||
|
let url = binDir.appendingPathComponent(name)
|
||||||
|
try body.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` emits the Stage 2 paper token and quality through
|
||||||
|
/// `PrintOptions` → `lp` argv (`-o PageSize=`, `-o <qualityKey>=`).
|
||||||
|
func testSpoolPassesPaperTokenAndQuality() async throws {
|
||||||
|
let workflow = makeWorkflow()
|
||||||
|
workflow.pageSize = .a4
|
||||||
|
await loadCaps(workflow.print)
|
||||||
|
workflow.print.selectedPaperSize = 4 // Letter
|
||||||
|
workflow.print.selectedQuality = "301"
|
||||||
|
|
||||||
|
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 argv = await waitForFile(lpArgvURL)
|
||||||
|
XCTAssertTrue(argv.contains("PageSize=Letter"), argv)
|
||||||
|
XCTAssertTrue(argv.contains("EPIJ_Qual=301"), argv)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,13 @@ final class AboutHelpUITests: XCTestCase {
|
|||||||
private func element(_ id: String) -> XCUIElement {
|
private func element(_ id: String) -> XCUIElement {
|
||||||
let inApp = app.descendants(matching: .any)[id].firstMatch
|
let inApp = app.descendants(matching: .any)[id].firstMatch
|
||||||
if inApp.exists { return inApp }
|
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
|
return app.sheets.firstMatch.descendants(matching: .any)[id].firstMatch
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,4 +92,139 @@ final class AboutHelpUITests: XCTestCase {
|
|||||||
"Toggling global help must not reflow the sidebar.")
|
"Toggling global help must not reflow the sidebar.")
|
||||||
XCTAssertTrue(app.descendants(matching: .any)["openSettingsBtn"].exists)
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# Mock lpoptions for Milestone3UITests. `-p <q>` prints printer-info;
|
# Mock lpoptions for Milestone3UITests. `-p <q>` prints printer-info;
|
||||||
# `-p <q> -l` prints Key/Label listings incl. Epson bypass keys.
|
# `-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).
|
||||||
queue=""
|
queue=""
|
||||||
list=0
|
list=0
|
||||||
for arg in "$@"; do
|
for arg in "$@"; do
|
||||||
@@ -15,6 +17,7 @@ if [ "$list" = "1" ]; then
|
|||||||
printf 'PageSize/Media Size: 4x6 5x7 *A4 Letter Legal\n'
|
printf 'PageSize/Media Size: 4x6 5x7 *A4 Letter Legal\n'
|
||||||
printf 'InputSlot/Media Source: Auto *Main Rear\n'
|
printf 'InputSlot/Media Source: Auto *Main Rear\n'
|
||||||
printf 'MediaType/Media Type: *Stationery PhotographicGlossy PhotographicMatte\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 'EPIJ_CMat/Color Adjust: *0 1 2 3\n'
|
||||||
printf 'ColorModel/Output Mode: *RGB Gray\n'
|
printf 'ColorModel/Output Mode: *RGB Gray\n'
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
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 lpArgvURL: 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")
|
||||||
|
lpArgvURL = testRoot.appendingPathComponent("lp-argv.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_LP_ARGV": lpArgvURL.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 waitForLpLine(_ timeout: TimeInterval = 10) -> String {
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
while Date() < deadline {
|
||||||
|
let out = (try? String(contentsOf: lpArgvURL, encoding: .utf8)) ?? ""
|
||||||
|
if !out.isEmpty { return out }
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||||
|
}
|
||||||
|
return (try? String(contentsOf: lpArgvURL, encoding: .utf8)) ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 `lp` argv
|
||||||
|
/// (R15 — 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 argv = waitForLpLine()
|
||||||
|
XCTAssertTrue(argv.contains("PageSize=Letter"), argv)
|
||||||
|
XCTAssertTrue(argv.contains("EPIJ_Qual=305"), argv)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func launchAppWithDefaults() {
|
||||||
|
app.launch()
|
||||||
|
app.activate()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ final class Milestone2UITests: XCTestCase {
|
|||||||
"ICCERY_UI_TESTING": "1",
|
"ICCERY_UI_TESTING": "1",
|
||||||
"ICCERY_TEST_ROOT": testRoot.path,
|
"ICCERY_TEST_ROOT": testRoot.path,
|
||||||
"ICCERY_ARGYLL_BINARY_DIR": binDir.path,
|
"ICCERY_ARGYLL_BINARY_DIR": binDir.path,
|
||||||
|
"ICCERY_CUPS_BIN_DIR": binDir.path,
|
||||||
"ICCERY_TEST_SAVE_TARGET":
|
"ICCERY_TEST_SAVE_TARGET":
|
||||||
workDir.appendingPathComponent("mytarget.ti1").path,
|
workDir.appendingPathComponent("mytarget.ti1").path,
|
||||||
"ICCERY_TEST_WORKDIR": workDir.path,
|
"ICCERY_TEST_WORKDIR": workDir.path,
|
||||||
@@ -218,11 +219,12 @@ final class Milestone2UITests: XCTestCase {
|
|||||||
XCTAssertTrue(FileManager.default.fileExists(
|
XCTAssertTrue(FileManager.default.fileExists(
|
||||||
atPath: workDir.appendingPathComponent("mytarget.ti2").path))
|
atPath: workDir.appendingPathComponent("mytarget.ti2").path))
|
||||||
|
|
||||||
// Print panel is live from M3; a default printer is selected
|
// Print panel is live from M3. GitHub macos-14 has no system
|
||||||
// so both the all-pages and per-page print buttons are enabled.
|
// queues, so mock CUPS (`ICCERY_CUPS_BIN_DIR`) must enumerate
|
||||||
|
// before Print All / per-page enable (run 34867767434).
|
||||||
XCTAssertTrue(element("rawPrintPanel").exists)
|
XCTAssertTrue(element("rawPrintPanel").exists)
|
||||||
XCTAssertTrue(app.buttons["btnPrintAll"].isEnabled)
|
_ = waitUntilEnabled("btnPrintAll", timeout: 15)
|
||||||
XCTAssertTrue(app.buttons["btnPrintPage-0"].isEnabled)
|
_ = waitUntilEnabled("btnPrintPage-0", timeout: 15)
|
||||||
XCTAssertTrue(app.buttons["btnAdvanceToStage3"].isEnabled)
|
XCTAssertTrue(app.buttons["btnAdvanceToStage3"].isEnabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -98,23 +98,9 @@ final class Milestone4UITests: XCTestCase {
|
|||||||
app.buttons["btnDetectInstruments"].click()
|
app.buttons["btnDetectInstruments"].click()
|
||||||
_ = waitFor("chartreadInstrumentSelect", timeout: 20)
|
_ = waitFor("chartreadInstrumentSelect", timeout: 20)
|
||||||
|
|
||||||
// Keep Auto (port 1) and start the session.
|
// Wait until Start is enabled before clicking. Existence-only
|
||||||
XCTAssertTrue(app.buttons["btnStartRead"].waitForExistence(timeout: 5))
|
// clicks are no-ops on the disabled control (runs 35251, 35293).
|
||||||
app.buttons["btnStartRead"].click()
|
driveOnePass(startButton: "btnStartRead")
|
||||||
|
|
||||||
// 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.
|
// Averaging panel appears with one pass snapshot.
|
||||||
_ = waitFor("chartreadAveragingPanel", timeout: 20)
|
_ = waitFor("chartreadAveragingPanel", timeout: 20)
|
||||||
@@ -168,15 +154,34 @@ final class Milestone4UITests: XCTestCase {
|
|||||||
|
|
||||||
/// Runs the mock handheld chartread session to completion
|
/// Runs the mock handheld chartread session to completion
|
||||||
/// (start → calibrate → strip A → strip B → Done & Save).
|
/// (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) {
|
private func driveOnePass(startButton: String) {
|
||||||
let start = app.buttons[startButton]
|
let start = app.buttons[startButton]
|
||||||
XCTAssertTrue(start.waitForExistence(timeout: 10))
|
XCTAssertTrue(start.waitForExistence(timeout: 10), startButton)
|
||||||
let deadline = Date().addingTimeInterval(10)
|
let enabledBy = Date().addingTimeInterval(10)
|
||||||
while Date() < deadline, !start.isEnabled {
|
while Date() < enabledBy, !start.isEnabled {
|
||||||
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||||
}
|
}
|
||||||
XCTAssertTrue(start.isEnabled)
|
XCTAssertTrue(start.isEnabled, "\(startButton) never enabled")
|
||||||
|
|
||||||
|
let sessionBy = Date().addingTimeInterval(25)
|
||||||
|
while Date() < sessionBy, !chartreadSessionRunning {
|
||||||
|
if start.exists, start.isEnabled {
|
||||||
start.click()
|
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)"
|
||||||
|
)
|
||||||
|
|
||||||
_ = waitFor("btnCalibrate", timeout: 25)
|
_ = waitFor("btnCalibrate", timeout: 25)
|
||||||
app.buttons["btnCalibrate"].click()
|
app.buttons["btnCalibrate"].click()
|
||||||
driveStripsUntilDone()
|
driveStripsUntilDone()
|
||||||
@@ -184,6 +189,14 @@ final class Milestone4UITests: XCTestCase {
|
|||||||
app.buttons["btnDoneRead"].firstMatch.click()
|
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`
|
/// Clicks Trigger for each remaining strip until `btnDoneRead`
|
||||||
/// appears — the button is re-polled each pass so a click that
|
/// appears — the button is re-polled each pass so a click that
|
||||||
/// races a state transition isn't lost.
|
/// races a state transition isn't lost.
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ Keyboard: **R** resets gamut camera when Stage 5 is visible. Bind to a focusable
|
|||||||
| Dialog | Root id | Controls |
|
| Dialog | Root id | Controls |
|
||||||
|--------|---------|----------|
|
|--------|---------|----------|
|
||||||
| Settings | `settingsDialog` | `settingsDeltaEGood`, `settingsDeltaEWarning`, `settingsCalStaleDays`; other controls listed in [22](22-settings-presets.md) |
|
| Settings | `settingsDialog` | `settingsDeltaEGood`, `settingsDeltaEWarning`, `settingsCalStaleDays`; other controls listed in [22](22-settings-presets.md) |
|
||||||
| About | `aboutDialog` | `aboutVersion`, `aboutBuildDate` from `get_app_info`, `closeAboutBtn` |
|
| About | `aboutDialog` | `aboutVersion` (release tag + marketing, e.g. `v2.0.0-pre2 (2.0.0)`), `aboutBuildDate` from `get_app_info`, `closeAboutBtn` |
|
||||||
| Save preset | `savePresetDialog` | `savePresetName`, `savePresetDesc`, `btnConfirmSavePreset`, `btnCloseSavePresetDialog` |
|
| Save preset | `savePresetDialog` | `savePresetName`, `savePresetDesc`, `btnConfirmSavePreset`, `btnCloseSavePresetDialog` |
|
||||||
| Manage presets | `managePresetsDialog` | `managePresetsList`, `btnExportActivePreset`, `btnImportPreset`, `btnCloseManagePresetsDialog` |
|
| Manage presets | `managePresetsDialog` | `managePresetsList`, `btnExportActivePreset`, `btnImportPreset`, `btnCloseManagePresetsDialog` |
|
||||||
| Save media recipe | `saveMediaRecipeDialog` | `saveMediaName`, `saveMediaNotes`, `saveMediaPaper`, `saveMediaInk`, `saveMediaPrinter`, `saveMediaPreset`, `saveMediaColourSpace`, `saveMediaCal`, `saveMediaApplyCal`, `btnConfirmSaveMedia`, `btnCloseSaveMediaDialog` |
|
| Save media recipe | `saveMediaRecipeDialog` | `saveMediaName`, `saveMediaNotes`, `saveMediaPaper`, `saveMediaInk`, `saveMediaPrinter`, `saveMediaPreset`, `saveMediaColourSpace`, `saveMediaCal`, `saveMediaApplyCal`, `btnConfirmSaveMedia`, `btnCloseSaveMediaDialog` |
|
||||||
|
|||||||
+4
-1
@@ -43,6 +43,8 @@ Do not install Argyll to `$PATH`. ICCery never searches `$PATH` for binaries.
|
|||||||
| `NOTARIZE_APPLE_ID` | Apple ID for `notarytool` |
|
| `NOTARIZE_APPLE_ID` | Apple ID for `notarytool` |
|
||||||
| `NOTARIZE_PASSWORD` | App-specific password for `notarytool` |
|
| `NOTARIZE_PASSWORD` | App-specific password for `notarytool` |
|
||||||
| `APPLE_TEAM_ID` | Team ID 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:
|
Invocation for a signed, notarized DMG:
|
||||||
|
|
||||||
@@ -56,7 +58,8 @@ export APPLE_TEAM_ID="TEAMID"
|
|||||||
scripts/package-release.sh
|
scripts/package-release.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
The script will emit `ICCery-<version>-<build>.dmg`. After mounting, verify Gatekeeper acceptance:
|
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:
|
||||||
|
|
||||||
```
|
```
|
||||||
spctl -a -t open --context context:primary-signature -v ICCery-*.dmg
|
spctl -a -t open --context context:primary-signature -v ICCery-*.dmg
|
||||||
|
|||||||
+16
@@ -42,6 +42,15 @@ targets:
|
|||||||
echo "note: Vendor/Argyll absent — run scripts/fetch-argyll.sh"
|
echo "note: Vendor/Argyll absent — run scripts/fetch-argyll.sh"
|
||||||
fi
|
fi
|
||||||
basedOnDependencyAnalysis: false
|
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:
|
settings:
|
||||||
base:
|
base:
|
||||||
PRODUCT_BUNDLE_IDENTIFIER: com.gronod.iccery2
|
PRODUCT_BUNDLE_IDENTIFIER: com.gronod.iccery2
|
||||||
@@ -54,6 +63,13 @@ targets:
|
|||||||
INFOPLIST_KEY_NSHumanReadableCopyright: "Copyright © 2026 Gronod. AGPLv3."
|
INFOPLIST_KEY_NSHumanReadableCopyright: "Copyright © 2026 Gronod. AGPLv3."
|
||||||
MARKETING_VERSION: "2.0.0"
|
MARKETING_VERSION: "2.0.0"
|
||||||
CURRENT_PROJECT_VERSION: "1"
|
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
|
ENABLE_HARDENED_RUNTIME: YES
|
||||||
CODE_SIGN_ENTITLEMENTS: Resources/ICCery.entitlements
|
CODE_SIGN_ENTITLEMENTS: Resources/ICCery.entitlements
|
||||||
CODE_SIGN_IDENTITY: "-"
|
CODE_SIGN_IDENTITY: "-"
|
||||||
|
|||||||
@@ -106,9 +106,19 @@ fi
|
|||||||
|
|
||||||
mkdir -p "$DEST"
|
mkdir -p "$DEST"
|
||||||
cp -R "$BIN_DIR"/. "$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 {} +
|
find "$DEST" -type f -exec chmod 0755 {} +
|
||||||
# Downloads carry com.apple.quarantine; the app cannot spawn quarantined tools.
|
# Downloads carry com.apple.quarantine; the app cannot spawn quarantined tools.
|
||||||
xattr -dr com.apple.quarantine "$DEST" 2>/dev/null || true
|
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
|
# Ad-hoc sign every Mach-O (#165: unsigned arm64 → "Killed: 9"), then
|
||||||
# verify — an unsigned sidecar fails the script. The tree may nest
|
# verify — an unsigned sidecar fails the script. The tree may nest
|
||||||
|
|||||||
@@ -45,6 +45,13 @@ DEST="platform=macOS"
|
|||||||
IDENTITY="${CODESIGN_IDENTITY:--}"
|
IDENTITY="${CODESIGN_IDENTITY:--}"
|
||||||
DEVELOPMENT_TEAM="${DEVELOPMENT_TEAM:-}"
|
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"
|
echo "==> Building universal Release app"
|
||||||
BUILD_EXTRA=""
|
BUILD_EXTRA=""
|
||||||
if [ -n "$DEVELOPMENT_TEAM" ]; then
|
if [ -n "$DEVELOPMENT_TEAM" ]; then
|
||||||
@@ -60,6 +67,9 @@ xcodebuild \
|
|||||||
ONLY_ACTIVE_ARCH=NO \
|
ONLY_ACTIVE_ARCH=NO \
|
||||||
CODE_SIGNING_ALLOWED=YES \
|
CODE_SIGNING_ALLOWED=YES \
|
||||||
CODE_SIGN_IDENTITY="$IDENTITY" \
|
CODE_SIGN_IDENTITY="$IDENTITY" \
|
||||||
|
MARKETING_VERSION="$MARKETING_VERSION" \
|
||||||
|
CURRENT_PROJECT_VERSION="$CURRENT_PROJECT_VERSION" \
|
||||||
|
ICCERY_RELEASE_TAG="$ICCERY_RELEASE_TAG" \
|
||||||
$BUILD_EXTRA \
|
$BUILD_EXTRA \
|
||||||
build
|
build
|
||||||
|
|
||||||
@@ -142,7 +152,14 @@ fi
|
|||||||
echo "==> Building DMG"
|
echo "==> Building DMG"
|
||||||
VERSION="$(plutil -extract CFBundleShortVersionString raw "$APP/Contents/Info.plist" 2>/dev/null || printf '2.0.0')"
|
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')"
|
BUILD_NUM="$(plutil -extract CFBundleVersion raw "$APP/Contents/Info.plist" 2>/dev/null || printf '1')"
|
||||||
DMG="ICCery-${VERSION}-${BUILD_NUM}.dmg"
|
# 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
|
||||||
VOLUME_NAME="ICCery ${VERSION}"
|
VOLUME_NAME="ICCery ${VERSION}"
|
||||||
|
|
||||||
DMG_APP="$APP" \
|
DMG_APP="$APP" \
|
||||||
|
|||||||
Executable
+88
@@ -0,0 +1,88 @@
|
|||||||
|
#!/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