Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79209eb911 | ||
|
|
51c30737c7 | ||
|
|
37d1f7eb24 | ||
|
|
bb3cc3c858 | ||
|
|
eb73bf0106 | ||
|
|
736fd080cb | ||
|
|
14cd544e05 |
@@ -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 }}
|
||||||
|
|||||||
@@ -30,9 +30,16 @@ jobs:
|
|||||||
runs-on: macos-14
|
runs-on: macos-14
|
||||||
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: |
|
||||||
@@ -64,6 +71,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' \
|
||||||
@@ -72,7 +83,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 }}
|
||||||
CODESIGN_IDENTITY: ${{ secrets.CODESIGN_IDENTITY }}
|
CODESIGN_IDENTITY: ${{ secrets.CODESIGN_IDENTITY }}
|
||||||
DEVELOPMENT_TEAM: ${{ secrets.DEVELOPMENT_TEAM }}
|
DEVELOPMENT_TEAM: ${{ secrets.DEVELOPMENT_TEAM }}
|
||||||
NOTARIZE_APPLE_ID: ${{ secrets.NOTARIZE_APPLE_ID }}
|
NOTARIZE_APPLE_ID: ${{ secrets.NOTARIZE_APPLE_ID }}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -268,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`
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: "-"
|
||||||
|
|||||||
@@ -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