Author SHA1 Message Date
gronod c9b423b229 Merge pull request 'fix(print): enumerate all EPIJ_Qual quality options (#180)' (#194) from feat/180-epson-qual-options into milestone/m11-print-settings
macOS CI / build-and-test (push) Skipped
2026-09-15 19:07:18 +01:00
gronodandDevin 79209eb911 fix(print): enumerate all EPIJ_Qual quality options (#180)
macOS CI / build-and-test (push) Skipped
The Phase 1 (#183) quality plumbing already covers detection,
extraction, capture, and lp emission — this change proves the Epson
path end-to-end with recorded fixtures.

- CupsParserTests: replace the synthetic cupsPrintQuality listing with
  the recorded XP-55 line (301 302 *303 308 304 305 307 — driver order,
  308 between 303 and 304); assert all seven ids enumerate unsorted,
  the * default is 303, all seven PPD labels resolve, and EPIJ_Qual
  wins detection over OutputMode/Resolution (R11).
- CupsOptionsFilterTests: captured EPIJ_Qual + canonical quality keys
  survive the filter (roster verified complete post-#183 — no source
  change needed; EPIJ_Quality kept as harmless alias, R16).
- LpArgsTests: explicit quality 305 emits -o EPIJ_Qual=305.
- PrintSessionViewModelTests + UI lpoptions fixture: recorded
  seven-choice line feeds the picker; Stage 2 picker lists all seven
  entries in driver order (new UI test).

Devin-AI: Devin <devin@cognition.ai>
Co-authored-by: Devin <devin@cognition.ai>
2026-09-15 19:06:47 +01:00
gronod 51c30737c7 Merge pull request 'feat(print): Stage 2 paper size + quality selection (#183)' (#193) from feat/183-stage2-print-settings into milestone/m11-print-settings
macOS CI / build-and-test (push) Skipped
2026-09-15 18:43:19 +01:00
gronodandDevin 37d1f7eb24 feat(print): Stage 2 paper size + quality selection (#183)
macOS CI / build-and-test (push) Skipped
Devin-AI: Devin <devin@cognition.ai>
Co-authored-by: Devin <devin@cognition.ai>
2026-09-15 18:42:49 +01:00
gronod bb3cc3c858 Merge pull request 'docs(m11): record printer-settings milestone tracking' (#191) from chore/m11-tracking into milestone/m11-print-settings
macOS CI / build-and-test (push) Skipped
macOS CI / build-and-test (pull_request) Successful in 39m2s
macOS CI / package (pull_request) Skipped
2026-09-15 18:07:23 +01:00
gronod eb73bf0106 docs(m11): record printer-settings milestone tracking
macOS CI / build-and-test (push) Skipped
- BUILD-PLAN: M11 row (issues 183/180/181/186) above Later/#16; source
  line names milestone id 34; note line points at
  milestone/m11-print-settings from 736fd08; #16 not an M11 exit gate
- README: M10 row reflects shipped state (milestone 32 closed); new M11
  row in flight on milestone/m11-print-settings; branch taxonomy names
  the announced integration branch
2026-09-15 18:07:07 +01:00
gronod 736fd080cb docs: ignore local set-version helper; document gitea MCP dependency methods
macOS CI / package (push) Successful in 3m31s
macOS CI / build-and-test (push) Skipped
- .gitignore: /set-version.sh is a local-only developer helper, never committed
- AGENTS.md: gitea MCP issue_write/issue_read expose dependency methods
  (add_dependency, block_issue, list_dependencies, ...) — replaces the
  raw REST notes now that the MCP build supports them
2026-09-15 16:49:25 +01:00
gronod 14cd544e05 feat(#189): release-driven versioning — tag-stamped About + monotonic build
macOS CI / build-and-test (pull_request) Successful in 38m2s
macOS CI / package (pull_request) Skipped
macOS CI / build-and-test (push) Failing after 37m37s
macOS CI / package (push) Skipped
- scripts/version.sh resolves ICCERY_RELEASE_TAG / MARKETING_VERSION /
  CURRENT_PROJECT_VERSION from RELEASE_TAG env or `git describe`; tag
  builds hard-fail when the tag's X.Y.Z != project.yml MARKETING_VERSION
- build number = `git rev-list --count HEAD` (Apple: macOS
  CFBundleVersion must monotonically increase, no per-version reset)
- tag ships as a bundled ICCeryReleaseTag resource — a generated
  Info.plist can't carry custom keys (INFOPLIST_KEY_* allowlist, and
  ProcessInfoPlistFile runs after script phases); About shows
  "tag (marketing)", e.g. v2.0.0-pre2-grok (2.0.0)
- DMG named ICCery-<tag>-<build>.dmg for tagged/described builds
- CI twins: fetch-depth 0 + RELEASE_TAG env + stamped test builds
2026-09-15 13:10:19 +01:00
30 changed files with 1266 additions and 36 deletions
+20 -1
View File
@@ -17,9 +17,16 @@ jobs:
runs-on: macos-12
env:
DERIVED: build/DerivedData-test
# Tag name on tag pushes, branch name otherwise — scripts/version.sh
# only honours values matching 'v[0-9]*'.
RELEASE_TAG: ${{ github.ref_name }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
# Full history + tags: scripts/version.sh needs `git describe` and
# `git rev-list --count`; the default depth-1 clone has neither.
fetch-depth: 0
- name: Assert Xcode 14+ toolchain
run: |
@@ -51,6 +58,10 @@ jobs:
- name: Build for testing (host arch)
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
run: |
# Stamp the test build with the same version triple packaging uses
# so CI-built apps are traceable (and mis-tags fail early) (#189).
eval "$(scripts/version.sh)"
echo "version=$MARKETING_VERSION build=$CURRENT_PROJECT_VERSION tag=$ICCERY_RELEASE_TAG"
xcodebuild build-for-testing \
-scheme ICCery \
-destination 'platform=macOS' \
@@ -59,7 +70,10 @@ jobs:
ARCHS="$(uname -m)" \
ONLY_ACTIVE_ARCH=NO \
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
# and the test bundle without signing it. Ad-hoc hosts still require
@@ -191,6 +205,10 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
with:
# Full history + tags: scripts/version.sh needs `git describe` and
# `git rev-list --count`; the default depth-1 clone has neither.
fetch-depth: 0
# scripts/package-release.sh runs `xcodegen generate` and dmgbuild;
# see build-and-test for why brew is not used on macOS 12 (#109).
@@ -202,6 +220,7 @@ jobs:
- name: Package release
run: scripts/package-release.sh
env:
RELEASE_TAG: ${{ github.ref_name }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
CODESIGN_IDENTITY: ${{ secrets.CODESIGN_IDENTITY }}
DEVELOPMENT_TEAM: ${{ secrets.DEVELOPMENT_TEAM }}
+20 -1
View File
@@ -30,9 +30,16 @@ jobs:
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: |
@@ -64,6 +71,10 @@ jobs:
- name: Build for testing (host arch)
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
run: |
# Stamp the test build with the same version triple packaging uses
# so CI-built apps are traceable (and mis-tags fail early) (#189).
eval "$(scripts/version.sh)"
echo "version=$MARKETING_VERSION build=$CURRENT_PROJECT_VERSION tag=$ICCERY_RELEASE_TAG"
xcodebuild build-for-testing \
-scheme ICCery \
-destination 'platform=macOS' \
@@ -72,7 +83,10 @@ jobs:
ARCHS="$(uname -m)" \
ONLY_ACTIVE_ARCH=NO \
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
# and the test bundle without signing it. Ad-hoc hosts still require
@@ -191,6 +205,10 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
with:
# Full history + tags: scripts/version.sh needs `git describe` and
# `git rev-list --count`; the default depth-1 clone has neither.
fetch-depth: 0
# scripts/package-release.sh runs `xcodegen generate` and dmgbuild;
# see build-and-test for why brew is not used on macOS 12 (#109).
@@ -202,6 +220,7 @@ jobs:
- 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 }}
+3
View File
@@ -18,6 +18,9 @@ Vendor/Argyll/
# XcodeGen output (regenerate with `make gen`)
ICCery.xcodeproj/
# Local-only developer helpers (never committed)
/set-version.sh
# macOS
.DS_Store
+18
View File
@@ -39,6 +39,13 @@ Empty cwd illegal (#59). Atomic writes = `.tmp` + rename (#213).
User-supplied strings via SwiftUI `Text` only (#114).
TIFF never rendered directly — host-side PNG preview (#58).
## Versioning
`scripts/version.sh` is the single source: tag/describe → `ICCERY_RELEASE_TAG`
(About shows `tag (marketing)`), `MARKETING_VERSION` = strict `X.Y.Z`,
`CURRENT_PROJECT_VERSION` = `git rev-list --count HEAD` (#189).
`v*` tag builds hard-fail if tag's X.Y.Z ≠ `project.yml` MARKETING_VERSION —
bump `project.yml` on `develop` before tagging. CI needs `fetch-depth: 0`.
## Branching
`develop``milestone/mN-<name>``feat/<issue#>-<slug>`.
PRs via Gitea MCP. Every issue/PR: `Project/ICCery-v2` + `Feature/*` or `Bug/*` + `Priority/*`.
@@ -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`.
Modes: `AP_ApplicationColorMatching` then `ApplicationColorMatching`.
`lp` path and Quartz/`ICCeryPrintKit` path use **different** ColorSync dictionaries. Never mix.
## Gitea issue dependencies
Use the `gitea` MCP (custom build with blocking support — verified working):
- `issue_write` methods:
- `add_dependency``blocking_issue` blocks `issue_number`.
- `remove_dependency` — removes `blocking_issue` from `issue_number`'s blockers.
- `block_issue` / `unblock_issue``issue_number` blocks/unblocks `blocked_issue`.
- `issue_read` methods: `list_dependencies` (issues blocking N),
`list_blocks` (issues N blocks).
- All issue numbers are *display numbers*, not db ids.
+4 -3
View File
@@ -1,6 +1,6 @@
# BUILD-PLAN.md — ICCery v2 Mac
Spec snapshot: `docs/`. Source of tickets: Gitea milestones M1M6 + M10 (id 32) + Later.
Spec snapshot: `docs/`. Source of tickets: Gitea milestones M1M6 + M10 (id 32) + M11 (id 34) + Later.
## Sprint rule
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 | 7986 | (already shipped on `develop`) | N/A |
| M9 | macOS 12 / Xcode 14.2 retarget | (milestone/m9-monterey, PR #145) | XCTest + ObservableObject + macos-12 CI | N/A |
| M10 | Studio workflow | 146149 | Media library + spot-read + gamut compare + project file unit/UI smoke | Real printer+paper+.cal; live spot-read; two `.gam`; reopen `.icceryproj` |
| M11 | Printer settings completeness & dialog binding | 183, 180, 181, 186 | Quality/media/paper caps parse + `LpArgs` emit + picker UI smoke + panel capture-return | Real Epson: 7 `EPIJ_Qual` options; real Canon Pro9500: English media labels; dialog bound to non-default queue with paper/quality/media/orientation preselected; captured settings reach `lp` argv |
| Later | Quartz / TargetPrint | 16 | `ICCeryPrintKit` standalone + seam test | 1:1 on paper vs TIFF |
M8 and M9 merged to `develop` via PR #104 / #145; M10 starts from `800c980`.
M8M10 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
@@ -20,8 +20,14 @@ public enum ArtefactFiles {
bundle: Bundle = .main
) -> (version: String, build: String, buildDate: String) {
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"
var tag = ""
if let url = bundle.url(forResource: "ICCeryReleaseTag", withExtension: nil),
let raw = try? String(contentsOf: url, encoding: .utf8) {
tag = raw
}
let version = displayVersion(shortVersion: short, releaseTag: tag)
let url = bundle.executableURL ?? bundle.bundleURL
let buildDate: String
@@ -37,4 +43,15 @@ public enum ArtefactFiles {
return (version, build, buildDate)
}
/// About "Version:" string from the release tag + marketing version
/// (#189): `v2.0.0-pre2 (2.0.0)`; dedupes to `v2.0.0` on an exact
/// release tag; falls back to the short version when untagged.
static func displayVersion(shortVersion: String, releaseTag: String) -> String {
let tag = releaseTag.trimmingCharacters(in: .whitespacesAndNewlines)
if tag.isEmpty || tag == shortVersion || tag == "v\(shortVersion)" {
return tag.isEmpty ? shortVersion : tag
}
return "\(tag) (\(shortVersion))"
}
}
@@ -25,8 +25,9 @@ public enum CupsOptionsFilter {
"EPIJ_OSColMat", "ColorCorrection", "StpColorCorrection",
"EpsonColorMode", "ColorModel",
// Quality
"Resolution", "cupsPrintQuality", "Quality", "EPIJ_Quality",
"CNIJQuality", "StpQuality", "OutputMode",
"Resolution", "cupsPrintQuality", "Quality", "EPIJ_Qual",
"EPIJ_Quality", "CNIJQuality", "CNIJPrintQuality",
"PrintQuality", "StpQuality", "OutputMode",
// Duplex
"Duplex", "sides",
]
@@ -232,6 +232,47 @@ public enum CupsParsers {
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
/// (docs/11 layer ): Canon `CNIJIntent2=4` else `CNIJIntent=4`;
/// Epson `EPIJ_CCor=0` when the key exists else `EPIJ_CMat=3`;
@@ -132,8 +132,28 @@ public struct CupsService: Sendable {
continue
}
}
// Print quality the detected roster key's listing maps to
// `PrinterQuality` with PPD labels and the `*` default (#183).
var qualities: [PrinterQuality] = []
var qualityDefault: String?
let qualityKey = CupsParsers.detectQualityKey(
optionKeys: Set(listings.map(\.key)))
if let qualityKey,
let listing = listings.first(where: { $0.key == qualityKey }) {
let labels = ppd.map {
CupsParsers.ppdChoiceLabels($0, key: qualityKey)
} ?? [:]
qualities = listing.choices.map {
PrinterQuality(id: $0, name: labels[$0] ?? $0)
}
qualityDefault = listing.defaultChoice
}
return PrinterCapabilities(
trays: trays, paperSizes: sizes, mediaTypes: media)
trays: trays, paperSizes: sizes, mediaTypes: media,
qualityKey: qualityKey, qualities: qualities,
qualityDefault: qualityDefault)
}
/// The set of option keys a queue advertises input to
@@ -20,6 +20,7 @@ public enum LpArgsError: LocalizedError, Equatable {
/// -o AP.ColorMatchingMode=AP_ApplicationColorMatching
/// <captured cups_options>
/// <media_type, if no media key already captured>
/// <quality, if no quality key already captured> (#183)
/// <driver bypass, if no bypass key captured>
/// <orientation-requested=3|4, unless captured>
/// <PageSize, unless captured>
@@ -82,6 +83,15 @@ public enum LpArgs {
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
// gated on ppdUncorrectedPassthrough (macOS always bypasses).
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 var trays: [PrinterTray]
public var paperSizes: [PrinterPaperSize]
@@ -73,17 +85,29 @@ public struct PrinterCapabilities: Codable, Equatable, Sendable {
/// Always `true` on macOS (spec parity CUPS honours
/// `orientation-requested`).
public var supportsOrientation: Bool
/// The queue's detected quality enumeration key
/// (`CupsParsers.detectQualityKey`), e.g. `EPIJ_Qual` (#183).
public var qualityKey: String?
public var qualities: [PrinterQuality]
/// The `*`-marked default choice from `lpoptions -l`, if any.
public var qualityDefault: String?
public init(
trays: [PrinterTray] = [],
paperSizes: [PrinterPaperSize] = [],
mediaTypes: [PrinterMediaType] = [],
supportsOrientation: Bool = true
supportsOrientation: Bool = true,
qualityKey: String? = nil,
qualities: [PrinterQuality] = [],
qualityDefault: String? = nil
) {
self.trays = trays
self.paperSizes = paperSizes
self.mediaTypes = mediaTypes
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?
/// `"portrait"` / `"landscape"` `orientation-requested=3|4`.
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 mediaType: String?
/// Print-quality token `-o <detectedQualityKey>=` (skipped if
/// captured, #183).
public var quality: String?
public var ppdUncorrectedPassthrough: Bool?
/// Space-separated `key=value` captured from
/// `PMPrintSettingsToOptions` and filtered (docs/11 layer ).
@@ -109,6 +136,7 @@ public struct PrintOptions: Codable, Equatable, Sendable {
orientation: String? = nil,
paperSize: String? = nil,
mediaType: String? = nil,
quality: String? = nil,
ppdUncorrectedPassthrough: Bool? = nil,
cupsOptions: String? = nil
) {
@@ -116,6 +144,7 @@ public struct PrintOptions: Codable, Equatable, Sendable {
self.orientation = orientation
self.paperSize = paperSize
self.mediaType = mediaType
self.quality = quality
self.ppdUncorrectedPassthrough = ppdUncorrectedPassthrough
self.cupsOptions = cupsOptions
}
+36 -5
View File
@@ -25,7 +25,8 @@ as AGPLv3 child processes. The GUI never `dlopen`s or links Argyll.
| M7 | Pre-UAT hardening — shipped |
| M8 | Deduplication contracts & UAT-ready hardening (#79#86) — shipped |
| M9 | macOS 12 / Xcode 14.2 retarget (PR #145) — shipped |
| M10 | Studio workflow: 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 |
## 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
bundled Mach-O sidecar (`scripts/verify-sidecar-signatures.sh`), builds a
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
`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 |
| `DEVELOPMENT_TEAM` | team ID passed to `xcodebuild` when signing |
| `NOTARIZE_APPLE_ID` / `NOTARIZE_PASSWORD` / `APPLE_TEAM_ID` | `notarytool` + staple when all three are set |
| `RELEASE_TAG` | release tag string (CI sets `github.ref_name`); stamped into the bundle + DMG name |
| `BUILD_NUMBER` | `CFBundleVersion` override; default `git rev-list --count HEAD` |
## Layout
@@ -268,9 +299,9 @@ fix/<issue>-<slug>
```
Open feature/fix PRs against **`develop`**. A `milestone/m…` integration
branch is used only while that milestone is assembling; `milestone/m10-studio`
has been merged and deleted. Do not open umbrella “bugfix” branches that mix
tickets.
branch is used only while that milestone is assembling — currently
`milestone/m11-print-settings` (`milestone/m10-studio` was merged and
deleted). Do not open umbrella “bugfix” branches that mix tickets.
`main` is push-protected and requires status check
`macOS CI / build-and-test (push)`. Protected **file** patterns on `main`
+10
View File
@@ -136,9 +136,19 @@ enum UITestHooks {
$0.isEmpty ? nil : $0
} ?? queue,
options: PrintOptions(
orientation: options.flatMap {
CupsParsers.extractOrientation(fromOptionsString: $0)
},
paperSize: options.flatMap {
CupsParsers.extractOption(
named: "PageSize", fromOptionsString: $0)
},
mediaType: options.flatMap {
CupsParsers.extractMediaType(fromOptionsString: $0)
},
quality: options.flatMap {
CupsParsers.extractQuality(fromOptionsString: $0)
},
ppdUncorrectedPassthrough: true,
cupsOptions: options))
default:
+53 -4
View File
@@ -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
/// queue (issue 13, docs/11).
///
@@ -41,7 +56,9 @@ struct PrintPanelService {
func showProperties(
queue: String,
displayName: String?,
cupsService: CupsService
cupsService: CupsService,
initialSelections: PrintPanelInitialSelections =
PrintPanelInitialSelections()
) async throws -> PrintPropertiesResult? {
#if DEBUG
if UITestHooks.printPanelStubbed {
@@ -56,7 +73,8 @@ struct PrintPanelService {
let optionKeys = (try? await cupsService.optionKeys(for: queue))
?? []
return try runNativePanel(
queue: queue, displayName: display, optionKeys: optionKeys)
queue: queue, displayName: display, optionKeys: optionKeys,
initialSelections: initialSelections)
}
// MARK: - Panel
@@ -64,7 +82,8 @@ struct PrintPanelService {
private func runNativePanel(
queue: String,
displayName: String?,
optionKeys: Set<String>
optionKeys: Set<String>,
initialSelections: PrintPanelInitialSelections
) throws -> PrintPropertiesResult? {
let printInfo = NSPrintInfo()
var pmPrinter: PMPrinter?
@@ -89,6 +108,9 @@ struct PrintPanelService {
// queue but are not fatal when they fail.
_ = PMSessionDefaultPrintSettings(session, settings)
_ = PMSessionDefaultPageFormat(session, pageFormat)
// Initial selections after `PMSessionDefault*`, before
// ColorSync suppression (locked write order, #183).
applyInitialSelections(initialSelections, to: settings)
boundViaPM = true
} else {
// Fallback: NSPrinter by display name (docs/11 §binding).
@@ -138,7 +160,9 @@ struct PrintPanelService {
// Capture the user's choices filtered replay options plus
// the media type they picked. Re-fetch the settings handle so
// we read back what the modal wrote.
// 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 mediaType: String?
if boundViaPM {
@@ -148,6 +172,7 @@ struct PrintPanelService {
cupsOptions = captured.cupsOptions
mediaType = captured.mediaType
}
let capturedOptions = cupsOptions ?? ""
return PrintPropertiesResult(
selectedPrinter: boundViaPM
? Self.currentPrinterID(
@@ -156,11 +181,35 @@ struct PrintPanelService {
fallback: queue)
: nil,
options: PrintOptions(
orientation: CupsParsers.extractOrientation(
fromOptionsString: capturedOptions),
paperSize: CupsParsers.extractOption(
named: "PageSize", fromOptionsString: capturedOptions),
mediaType: mediaType,
quality: CupsParsers.extractQuality(
fromOptionsString: capturedOptions),
ppdUncorrectedPassthrough: true,
cupsOptions: cupsOptions))
}
/// 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
/// `PMPrinter` `PMObject` for `PMRelease` the Carbon API wants
@@ -7,12 +7,20 @@ import ICCeryCore
final class PrintSessionViewModel: ObservableObject {
let wizard: WizardViewModel
let environment: AppEnvironment
/// Stage 1/2 form state read for paper seeding/mirroring only;
/// `workflow.pageSize` is the printtarg layout and is never written
/// back from the print side (#183).
weak var workflow: TargetWorkflowViewModel?
@Published var printers: [Printer] = []
@Published var selectedPrinter = ""
@Published var printerCaps = PrinterCapabilities()
@Published var selectedTray: Int?
@Published var selectedMediaType: String?
/// `PrinterPaperSize.id` `0` is the synthetic custom entry (#183).
@Published var selectedPaperSize: Int?
/// Print-quality option token, e.g. `"303"` (#183).
@Published var selectedQuality: String?
@Published var printOrientation = "portrait"
@Published var capturedCupsOptions: [String: String] = [:]
@Published var printNotice: Notice?
@@ -75,22 +83,79 @@ final class PrintSessionViewModel: ObservableObject {
if selectedTray == nil {
selectedTray = printerCaps.trays.first?.id
}
if selectedQuality == nil {
selectedQuality = printerCaps.qualityDefault
?? printerCaps.qualities.first?.id
}
// Caps reload is a re-mirror trigger for the paper picker
// (#183 E4) pageSize + printer changes route here too.
seedPaperSelection()
} catch {
printerCaps = PrinterCapabilities()
}
}
// MARK: - Paper / quality selection (#183)
/// Seed `selectedPaperSize` from Stage 1's `workflow.pageSize`:
/// a capability whose name matches `pageSize.rawValue` its id;
/// `.custom` the synthetic `Custom.<pt>x<pt>` entry (`id: 0`);
/// no match nil (never guess). Called only on pageSize / printer /
/// caps triggers never on unrelated publishes (R14).
func seedPaperSelection() {
guard let pageSize = workflow?.pageSize else { return }
if pageSize == .custom {
let token = customPaperToken()
if let index = printerCaps.paperSizes.firstIndex(where: { $0.id == 0 }) {
printerCaps.paperSizes[index].name = token
} else {
printerCaps.paperSizes.append(
PrinterPaperSize(id: 0, name: token))
}
selectedPaperSize = 0
return
}
selectedPaperSize = printerCaps.paperSizes
.first { $0.name == pageSize.rawValue }?.id
}
/// `Custom.<w>x<h>` in **points** mm × 72/25.4 (#183 E5/R8). The
/// PPD template token `Custom.WIDTHxHEIGHT` is never emitted verbatim.
func customPaperToken() -> String {
let w = workflow?.customPageW ?? 0
let h = workflow?.customPageH ?? 0
let wPt = (w * 72.0 / 25.4).rounded()
let hPt = (h * 72.0 / 25.4).rounded()
return "Custom.\(Int(wPt))x\(Int(hPt))"
}
/// The CUPS `PageSize` token for the current Stage 2 pick live
/// `Custom.<pt>x<pt>` for the synthetic entry, else the capability
/// name. 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() {
guard !selectedPrinter.isEmpty else { return }
let queue = selectedPrinter
let displayName = printers.first { $0.name == queue }?.displayName
let cups = environment.cupsService
let selections = PrintPanelInitialSelections(
paperSize: selectedPaperSizeToken,
qualityKey: printerCaps.qualityKey,
quality: selectedQuality,
mediaType: nil,
orientation: nil)
Task { @MainActor in
do {
guard let result = try await PrintPanelService()
.showProperties(
queue: queue, displayName: displayName,
cupsService: cups)
cupsService: cups,
initialSelections: selections)
else {
printNotice = Notice(
kind: .info,
@@ -111,6 +176,17 @@ final class PrintSessionViewModel: ObservableObject {
if let media = result.options.mediaType {
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(
kind: .info,
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 }
isPrinting = true
let task = Task { @MainActor [weak self] in
@@ -132,7 +208,7 @@ final class PrintSessionViewModel: ObservableObject {
var printed = 0
for page in result.pages {
do {
try await spool(page, index: page.index, pageSize: pageSize)
try await spool(page, index: page.index)
printed += 1
} catch {
printNotice = Notice(
@@ -156,13 +232,13 @@ final class PrintSessionViewModel: ObservableObject {
printTask = task
}
func printPage(_ page: GalleryPage, pageSize: PageSize) {
func printPage(_ page: GalleryPage) {
guard !isPrinting else { return }
isPrinting = true
let task = Task { @MainActor [weak self] in
guard let self else { return }
do {
try await spool(page, index: page.index, pageSize: pageSize)
try await spool(page, index: page.index)
printNotice = Notice(
kind: .info,
text: "Sent \(page.page.filename) to \(selectedPrinter).",
@@ -180,14 +256,18 @@ final class PrintSessionViewModel: ObservableObject {
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 {
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(
orientation: printOrientation,
paperSize: pageSize == .custom ? nil : pageSize.rawValue,
paperSize: selectedPaperSizeToken,
mediaType: selectedMediaType,
quality: selectedQuality,
ppdUncorrectedPassthrough: true,
cupsOptions: capturedCupsOptions[selectedPrinter])
try await environment.cupsService.printTarget(
+58 -3
View File
@@ -255,6 +255,7 @@ struct Stage2View: View {
.onChange(of: workflow.print.selectedPrinter) { _ in
workflow.print.selectedTray = nil
workflow.print.selectedMediaType = nil
workflow.print.selectedQuality = nil
Task { @MainActor in await workflow.print.reloadSelectedCapabilities() }
}
if let selected = workflow.print.printers
@@ -279,7 +280,9 @@ struct Stage2View: View {
.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) {
if !workflow.print.printerCaps.trays.isEmpty {
Picker("Tray", selection: $workflow.print.selectedTray) {
@@ -301,6 +304,12 @@ struct Stage2View: View {
.accessibilityIdentifier("mediaTypeGroup")
.accessibilityIdentifier("printerMediaTypeSelect")
}
if !workflow.print.printerCaps.paperSizes.isEmpty {
paperSizeGroup
}
if !workflow.print.printerCaps.qualities.isEmpty {
qualityGroup
}
HStack(spacing: 0) {
Button("Portrait") { workflow.print.printOrientation = "portrait" }
.buttonStyle(.bordered)
@@ -313,11 +322,13 @@ struct Stage2View: View {
}
Spacer()
}
// Stage 1 owns the custom dimensions the caption lives
// inside `paperSizeGroup` (#183).
HStack(spacing: 8) {
Button(action: {
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",
@@ -344,6 +355,50 @@ struct Stage2View: View {
.onChange(of: workflow.printtargResult?.pages.count) { _ in
schedulePrinterRefresh()
}
// Editable picker that re-mirrors Stage 1's pageSize (#183 E4).
.onChange(of: workflow.pageSize) { _ in
workflow.print.seedPaperSelection()
}
}
/// 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
@@ -392,7 +447,7 @@ private struct GalleryPageView: View {
Text("\(page.page.patches) patches · " +
"\(Int(page.page.widthMm))×\(Int(page.page.heightMm)) mm")
.font(.caption2).foregroundStyle(.secondary)
Button("Print") { workflow.print.printPage(page, pageSize: workflow.pageSize) }
Button("Print") { workflow.print.printPage(page) }
.disabled(workflow.print.isPrinting
|| workflow.print.selectedPrinter.isEmpty)
.accessibilityIdentifier("btnPrintPage-\(page.index)")
@@ -137,6 +137,9 @@ final class TargetWorkflowViewModel: ObservableObject {
environment: environment
)
self.print = PrintSessionViewModel(wizard: wizard, environment: environment)
// Paper-size seeding reads the Stage 1 form through this weak
// back-reference; the print side never writes it (#183).
self.print.workflow = self
self.calibration = nil
self.calibration = CalibrationViewModel(
workflow: self,
@@ -102,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 {
func testBase64RoundTrip() throws {
let url = tempURL("a.txt")
@@ -24,6 +24,18 @@ final class CupsOptionsFilterTests: XCTestCase {
XCTAssertEqual(CupsOptionsFilter.filter(raw), raw)
}
/// #180 a captured `EPIJ_Qual` (and the other canonical quality
/// keys) survives the filter so it 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() {
let raw = "VendorFooBar=baz MediaType=Plain"
XCTAssertEqual(CupsOptionsFilter.filter(raw), raw)
+117 -2
View File
@@ -4,7 +4,7 @@ import Foundation
/// Issue 12 CUPS enumeration parsers on recorded fixtures
/// (docs/1011). No live `lpstat`/`lpoptions` is spawned here.
final class CupsParsersTests: XCTestCase {
final class CupsParserTests: XCTestCase {
// Recorded on an Epson XP-55 + Canon Pro9500 host.
private let lpstatE = """
@@ -30,7 +30,7 @@ final class CupsParsersTests: XCTestCase {
MediaType/Media Type: *Stationery PhotographicHighGloss Photographic PhotographicMatte Envelope
ColorModel/Output Mode: *RGB Gray
Duplex/Duplex: *None DuplexNoTumble DuplexTumble
cupsPrintQuality/cupsPrintQuality: Draft *Normal High
EPIJ_Qual/Print Quality: 301 302 *303 308 304 305 307
"""
func testDestinations() {
@@ -117,6 +117,121 @@ final class CupsParsersTests: XCTestCase {
XCTAssertNil(CupsParsers.detectMediaTypeKey(optionKeys: ["PageSize"]))
}
// MARK: - #183 quality key + option extraction
func testQualityKeyRosterOrder() {
// Vendor keys beat the generic ones; OutputMode/Resolution sit
// last (they are colour-ish keys on some drivers #183/#180).
XCTAssertEqual(CupsParsers.detectQualityKey(
optionKeys: ["EPIJ_Qual", "Quality", "OutputMode"]), "EPIJ_Qual")
// A full Epson key set EPIJ_Qual wins over the colour-mode
// key, the generic keys, and Resolution (#180, R11).
XCTAssertEqual(CupsParsers.detectQualityKey(
optionKeys: ["EPIJ_Qual", "OutputMode", "Resolution",
"cupsPrintQuality", "PrintQuality",
"ColorModel"]), "EPIJ_Qual")
XCTAssertEqual(CupsParsers.detectQualityKey(
optionKeys: ["Quality", "OutputMode", "Resolution"]), "Quality")
XCTAssertEqual(CupsParsers.detectQualityKey(
optionKeys: ["cupsPrintQuality", "CNIJQuality"]),
"CNIJQuality")
XCTAssertEqual(CupsParsers.detectQualityKey(
optionKeys: ["OutputMode", "Resolution"]), "OutputMode")
XCTAssertEqual(CupsParsers.detectQualityKey(
optionKeys: ["Resolution"]), "Resolution")
XCTAssertNil(CupsParsers.detectQualityKey(optionKeys: ["PageSize"]))
}
func testCapabilitiesQuality() {
let service = CupsService()
let listings = CupsParsers.lpoptionsList(lpoptionsL)
let caps = service.capabilities(from: listings, ppd: nil)
// EPIJ_Qual is the roster member all seven Epson codes
// enumerate in the driver's own (non-sorted) order (#180).
XCTAssertEqual(caps.qualityKey, "EPIJ_Qual")
XCTAssertEqual(caps.qualities.map(\.id),
["301", "302", "303", "308", "304", "305", "307"])
XCTAssertEqual(caps.qualityDefault, "303")
}
func testCapabilitiesQualityPpdLabels() {
// Epson XP-55 PPD fragment the seven `*EPIJ_Qual id/Label`
// lines in the driver's own order (#180).
let ppd = """
*OpenUI *EPIJ_Qual/Print Quality: PickOne
*DefaultEPIJ_Qual: 303
*EPIJ_Qual 301/Fast Economy: ""
*EPIJ_Qual 302/Economy: ""
*EPIJ_Qual 303/Normal: ""
*EPIJ_Qual 308/Draft: ""
*EPIJ_Qual 304/Fine: ""
*EPIJ_Qual 305/Quality: ""
*EPIJ_Qual 307/Best Quality: ""
*CloseUI: *EPIJ_Qual
"""
let service = CupsService()
let listings = CupsParsers.lpoptionsList(
"EPIJ_Qual/Print Quality: 301 302 *303 308 304 305 307\n")
let caps = service.capabilities(from: listings, ppd: ppd)
XCTAssertEqual(caps.qualityKey, "EPIJ_Qual")
XCTAssertEqual(caps.qualities, [
PrinterQuality(id: "301", name: "Fast Economy"),
PrinterQuality(id: "302", name: "Economy"),
PrinterQuality(id: "303", name: "Normal"),
PrinterQuality(id: "308", name: "Draft"),
PrinterQuality(id: "304", name: "Fine"),
PrinterQuality(id: "305", name: "Quality"),
PrinterQuality(id: "307", name: "Best Quality"),
])
XCTAssertEqual(caps.qualityDefault, "303")
}
/// #180 the Epson listing also carries `OutputMode` (a colour
/// mode) and `Resolution`; detection must still pick `EPIJ_Qual`.
func testCapabilitiesQualityEpsonDetection() {
let service = CupsService()
let listings = CupsParsers.lpoptionsList("""
PageSize/Media Size: *A4 Letter
EPIJ_Qual/Print Quality: 301 302 *303 308 304 305 307
OutputMode/Color Mode: *Color Mono
Resolution/Resolution: *360dpi 720dpi
""")
let caps = service.capabilities(from: listings, ppd: nil)
XCTAssertEqual(caps.qualityKey, "EPIJ_Qual")
XCTAssertEqual(caps.qualities.count, 7)
XCTAssertEqual(caps.qualityDefault, "303")
XCTAssertFalse(caps.qualities.contains { $0.id == "Color" })
}
func testExtractOption() {
let options = "PageSize=A4 EPIJ_Qual=303 printer-info='EPSON XP-55'"
XCTAssertEqual(CupsParsers.extractOption(
named: "PageSize", fromOptionsString: options), "A4")
// Case-insensitive key match.
XCTAssertEqual(CupsParsers.extractOption(
named: "epij_qual", fromOptionsString: options), "303")
// Quoted values come back unquoted.
XCTAssertEqual(CupsParsers.extractOption(
named: "printer-info", fromOptionsString: options), "EPSON XP-55")
XCTAssertNil(CupsParsers.extractOption(
named: "InputSlot", fromOptionsString: options))
}
func testExtractQualityAndOrientation() {
let options = "orientation-requested=4 OutputMode=Gray EPIJ_Qual=305"
XCTAssertEqual(CupsParsers.extractQuality(
fromOptionsString: options), "305")
XCTAssertEqual(CupsParsers.extractOrientation(
fromOptionsString: options), "landscape")
XCTAssertNil(CupsParsers.extractQuality(
fromOptionsString: "PageSize=A4"))
XCTAssertNil(CupsParsers.extractOrientation(
fromOptionsString: "PageSize=A4"))
}
func testDriverBypass() {
func pair(_ keys: Set<String>) -> String? {
CupsParsers.detectDriverColorBypass(optionKeys: keys)
+55
View File
@@ -101,6 +101,61 @@ final class LpArgsTests: XCTestCase {
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 {
XCTAssertThrowsError(try build(options: PrintOptions(
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 {
try withEnv([
"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")
}
}
+4 -1
View File
@@ -1,6 +1,8 @@
#!/bin/sh
# 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=""
list=0
for arg in "$@"; do
@@ -15,6 +17,7 @@ if [ "$list" = "1" ]; then
printf 'PageSize/Media Size: 4x6 5x7 *A4 Letter Legal\n'
printf 'InputSlot/Media Source: Auto *Main Rear\n'
printf 'MediaType/Media Type: *Stationery PhotographicGlossy PhotographicMatte\n'
printf 'EPIJ_Qual/Print Quality: 301 302 *303 308 304 305 307\n'
printf 'EPIJ_CMat/Color Adjust: *0 1 2 3\n'
printf 'ColorModel/Output Mode: *RGB Gray\n'
exit 0
@@ -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()
}
}
+1 -1
View File
@@ -69,7 +69,7 @@ Keyboard: **R** resets gamut camera when Stage 5 is visible. Bind to a focusable
| Dialog | Root id | Controls |
|--------|---------|----------|
| 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` |
| Manage presets | `managePresetsDialog` | `managePresetsList`, `btnExportActivePreset`, `btnImportPreset`, `btnCloseManagePresetsDialog` |
| Save media recipe | `saveMediaRecipeDialog` | `saveMediaName`, `saveMediaNotes`, `saveMediaPaper`, `saveMediaInk`, `saveMediaPrinter`, `saveMediaPreset`, `saveMediaColourSpace`, `saveMediaCal`, `saveMediaApplyCal`, `btnConfirmSaveMedia`, `btnCloseSaveMediaDialog` |
+4 -1
View File
@@ -43,6 +43,8 @@ Do not install Argyll to `$PATH`. ICCery never searches `$PATH` for binaries.
| `NOTARIZE_APPLE_ID` | Apple ID for `notarytool` |
| `NOTARIZE_PASSWORD` | App-specific password for `notarytool` |
| `APPLE_TEAM_ID` | Team ID for `notarytool` |
| `RELEASE_TAG` | Optional. Simulates a tag build (`vX.Y.Z[-suffix]`); stamped into the bundle and DMG name |
| `BUILD_NUMBER` | Optional `CFBundleVersion` override; default `git rev-list --count HEAD` |
Invocation for a signed, notarized DMG:
@@ -56,7 +58,8 @@ export APPLE_TEAM_ID="TEAMID"
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
+16
View File
@@ -42,6 +42,15 @@ targets:
echo "note: Vendor/Argyll absent — run scripts/fetch-argyll.sh"
fi
basedOnDependencyAnalysis: false
- name: Stamp release tag
# GENERATE_INFOPLIST_FILE writes Info.plist after script phases and
# only honours Apple's INFOPLIST_KEY_* allowlist — so the tag ships as
# a bundled resource read by ArtefactFiles.appInfo (#189).
script: |
DEST="$TARGET_BUILD_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH"
mkdir -p "$DEST"
printf '%s' "${ICCERY_RELEASE_TAG:-}" > "$DEST/ICCeryReleaseTag"
basedOnDependencyAnalysis: false
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.gronod.iccery2
@@ -54,6 +63,13 @@ targets:
INFOPLIST_KEY_NSHumanReadableCopyright: "Copyright © 2026 Gronod. AGPLv3."
MARKETING_VERSION: "2.0.0"
CURRENT_PROJECT_VERSION: "1"
# Stamped by scripts/version.sh at packaging/CI time (release tag or
# `git describe` string); About shows it next to the marketing version.
# Written to the bundled ICCeryReleaseTag resource by the
# "Stamp release tag" phase — GENERATE_INFOPLIST_FILE drops custom
# INFOPLIST_KEY_* names and generates the plist after script phases.
# Tag builds hard-fail when the tag's X.Y.Z != MARKETING_VERSION above.
ICCERY_RELEASE_TAG: ""
ENABLE_HARDENED_RUNTIME: YES
CODE_SIGN_ENTITLEMENTS: Resources/ICCery.entitlements
CODE_SIGN_IDENTITY: "-"
+18 -1
View File
@@ -45,6 +45,13 @@ DEST="platform=macOS"
IDENTITY="${CODESIGN_IDENTITY:--}"
DEVELOPMENT_TEAM="${DEVELOPMENT_TEAM:-}"
# Resolve the version triple from the release tag / git state. On tag builds
# (RELEASE_TAG=v*) this hard-fails if the tag's X.Y.Z != project.yml
# MARKETING_VERSION, so a mis-tag cannot ship mismatched metadata (#189).
echo "==> Resolving version"
eval "$(scripts/version.sh)"
echo "version=$MARKETING_VERSION build=$CURRENT_PROJECT_VERSION tag=$ICCERY_RELEASE_TAG"
echo "==> Building universal Release app"
BUILD_EXTRA=""
if [ -n "$DEVELOPMENT_TEAM" ]; then
@@ -60,6 +67,9 @@ xcodebuild \
ONLY_ACTIVE_ARCH=NO \
CODE_SIGNING_ALLOWED=YES \
CODE_SIGN_IDENTITY="$IDENTITY" \
MARKETING_VERSION="$MARKETING_VERSION" \
CURRENT_PROJECT_VERSION="$CURRENT_PROJECT_VERSION" \
ICCERY_RELEASE_TAG="$ICCERY_RELEASE_TAG" \
$BUILD_EXTRA \
build
@@ -142,7 +152,14 @@ fi
echo "==> Building DMG"
VERSION="$(plutil -extract CFBundleShortVersionString raw "$APP/Contents/Info.plist" 2>/dev/null || printf '2.0.0')"
BUILD_NUM="$(plutil -extract CFBundleVersion raw "$APP/Contents/Info.plist" 2>/dev/null || printf '1')"
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}"
DMG_APP="$APP" \
+88
View File
@@ -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"