Author SHA1 Message Date
gronod 1d056c26bf docs: use PlantUML maps so Kroki fits a phone width
macOS CI / build-and-test (push) Skipped
macOS CI / build-and-test (pull_request) Canceled after 0s
macOS CI / package (pull_request) Canceled after 0s
Class diagrams rendered 6556px wide; Gitea clipped that to a
black arrowhead and the UML C icon. Stacked maps are 560-780px
and render as labelled tables on public Kroki.
2026-09-14 15:04:22 +00:00
gronod c4f037535a docs: restore PlantUML UI maps; draw groups as rectangles
macOS CI / build-and-test (push) Skipped
macOS CI / build-and-test (pull_request) Canceled after 0s
macOS CI / package (pull_request) Canceled after 0s
Revert the maps to 368ae56 and fix the Kroki render: default
package style is a folder, which Gitea scaled to a black tab.
Groups are rectangles on a white canvas; shape stereotypes
(<<Button>>, <<*.swift>>) are folded into the title so C4/sprites
cannot retarget the box.
2026-09-14 14:56:52 +00:00
gronod 191bcb0280 docs: replace hairball graphs with small overviews and tables
macOS CI / build-and-test (push) Skipped
macOS CI / build-and-test (pull_request) Canceled after 0s
macOS CI / package (pull_request) Canceled after 0s
Gitea scaled the 50-node GraphViz maps to unreadably tiny black
boxes. Overviews are now ~12 nodes on an opaque white canvas;
enable/hide rules live in markdown tables that work in dark mode.
2026-09-14 14:44:59 +00:00
gronod 9c625ee21f docs: dark-mode GraphViz palette for Gitea Kroki
macOS CI / build-and-test (push) Skipped
macOS CI / build-and-test (pull_request) Canceled after 0s
macOS CI / package (pull_request) Canceled after 0s
Unfilled nodes and default black type sat on a transparent SVG,
so dark mode showed only tiny black boxes. Opaque GitHub-dark
fills, light labels, larger type.
2026-09-14 14:40:28 +00:00
gronod 40cc4ad701 docs: drop PlantUML UI maps that Kroki 0.30.1 cannot run without AVX2
macOS CI / build-and-test (push) Skipped
macOS CI / build-and-test (pull_request) Canceled after 0s
macOS CI / package (pull_request) Canceled after 0s
2026-09-14 14:25:52 +00:00
gronod 658d9813bd docs: render UI maps with GraphViz for Kroki 0.30.1
macOS CI / build-and-test (push) Skipped
macOS CI / package (pull_request) Canceled after 0s
macOS CI / build-and-test (pull_request) Canceled after 12m40s
Kroki 0.30.1 PlantUML is a GraalVM native image and requires AVX2.
This host has none, so the maps now use GraphViz (dot) — one
diagram per file, plus a markdown page with graphviz fences.
2026-09-14 14:25:35 +00:00
gronod 368ae56cf3 docs: split UI PlantUML map into one diagram per file
macOS CI / build-and-test (push) Skipped
macOS CI / build-and-test (pull_request) Failing after 40m37s
macOS CI / package (pull_request) Skipped
Kroki only renders the first @startuml block in a file.
2026-09-14 14:18:07 +00:00
gronod bd539df009 docs: PlantUML map of every interactive control
macOS CI / build-and-test (push) Skipped
macOS CI / build-and-test (pull_request) Failing after 3m58s
macOS CI / package (pull_request) Skipped
Identifiers, source files, and enable/hide/disable rules for the
wizard, sidebar, sheets, and File menu.
2026-09-14 14:03:26 +00:00
14 changed files with 378 additions and 621 deletions
-231
View File
@@ -1,231 +0,0 @@
# GitHub Actions twin of .gitea/workflows/macos.yml.
# Deltas from the Gitea file (everything else is the same jobs/steps):
# - runs-on macos-14: github.com retired macos-12. Do not use
# macos-latest — in 2026 that is macos-26-arm64, where a 1280×800
# window hangs off the virtual display and XCTest marks sidebar
# controls (x ≈ -116) as not hittable (run 34864198118).
# - actions/upload-artifact@v4: v3 is shut down on github.com. Gitea act_runner
# still uses v3.
# - No NODE_EXTRA_CA_CERTS / System keychain bundle: that is only for the
# private Gitea CA when the runner talks to git.i3omb.com.
# - Tag DMGs go to a GitHub Release via `gh` instead of
# scripts/attach-release-asset.sh (Gitea /api/v1).
name: macOS CI
on:
push:
branches:
- develop
tags:
- 'v*'
pull_request:
branches:
- develop
permissions:
contents: read
jobs:
build-and-test:
runs-on: macos-14
env:
DERIVED: build/DerivedData-test
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Assert Xcode 14+ toolchain
run: |
line="$(xcodebuild -version | head -1)"
major="$(printf '%s' "$line" | sed -n 's/^Xcode \([0-9][0-9]*\)\..*/\1/p')"
if [ -z "$major" ] || [ "$major" -lt 14 ]; then
echo "Unexpected Xcode version: $line" >&2; exit 1
fi
echo "$line"
# Tag pushes whose name contains "prerelease" skip the test build and both
# test legs: they exist to package a build already validated elsewhere.
# The job still succeeds quickly so `package`'s `needs:` stays satisfied.
# Homebrew's xcodegen formula requires Xcode 15.3, which cannot be
# installed on macOS 12 (#109). The script installs a pinned
# prebuilt release instead. dmgbuild is not installed here — the
# test job does not package (#95).
- name: Ensure host tools
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
run: scripts/ensure-host-tools.sh
- name: Generate Xcode project
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
run: xcodegen generate --spec project.yml
# Tests only ever run on the runner's own architecture; build
# just that slice. Packaging (scripts/package-release.sh) still
# produces the universal Release binary.
- name: Build for testing (host arch)
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
run: |
xcodebuild build-for-testing \
-scheme ICCery \
-destination 'platform=macOS' \
-derivedDataPath "$DERIVED" \
-configuration Debug \
ARCHS="$(uname -m)" \
ONLY_ACTIVE_ARCH=NO \
CODE_SIGNING_ALLOWED=YES \
CODE_SIGN_IDENTITY='-'
# Xcode embeds the shared ICCeryCore package framework into the app
# and the test bundle without signing it. Ad-hoc hosts still require
# every loaded dylib to carry a cdhash — dyld killed the test host at
# launch (run 31992) — so sign every embedded copy once the build is
# done (embed steps run after any build script phase) (#119).
- name: Sign package product frameworks
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
run: |
find "$DERIVED/Build/Products/Debug" -depth -name '*_PackageProduct.framework' -print0 \
| while IFS= read -r -d '' fw; do
echo "signing $fw"
codesign --force --sign - --timestamp=none "$fw"
done
- name: Test unit (ICCeryCoreTests)
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
run: |
XCTESTRUN="$(find "$DERIVED" -name 'ICCery*.xctestrun' | head -n 1)"
if [ -z "$XCTESTRUN" ] || [ ! -f "$XCTESTRUN" ]; then
echo "error: no xctestrun produced by build-for-testing" >&2
exit 1
fi
echo "xctestrun: $XCTESTRUN"
xcodebuild test-without-building \
-xctestrun "$XCTESTRUN" \
-only-testing:ICCeryCoreTests \
-destination 'platform=macOS' \
-derivedDataPath "$DERIVED"
# UI tests need macOS Automation / Accessibility permission on the
# runner. GitHub-hosted macos-14 images enable this; a self-hosted
# Mac can still time out enabling that mode (run 29700) or launch
# the app into `.runningBackground` (run 29804). Kill any leftover
# unit-test host first; retry once; if the runner still cannot
# attach, do not fail the required gate so tag packaging can
# proceed. Real XCTest assertion failures still fail the job.
- name: Test UI (ICCeryUITests)
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
run: |
set -o pipefail
XCTESTRUN="$(find "$DERIVED" -name 'ICCery*.xctestrun' | head -n 1)"
LOG="$DERIVED/ui-test.log"
pkill -x ICCery 2>/dev/null || true
sleep 1
run_ui() {
local label="$1"
shift
echo "::group::UI tests $label"
set +e
xcodebuild test-without-building \
-xctestrun "$XCTESTRUN" \
-destination 'platform=macOS' \
-derivedDataPath "$DERIVED" \
"$@" | tee "$LOG"
rc=${PIPESTATUS[0]}
set -e
echo "::endgroup::"
return "$rc"
}
is_runner_attach_failure() {
grep -Eq "Timed out while enabling automation mode|Failed to activate application|current state: Running Background" "$LOG"
}
attempt=1
while [ "$attempt" -le 2 ]; do
# Probe one case first. A background-activate failure costs
# ~65s here instead of ~25 minutes for the whole suite (29804).
if ! run_ui "probe attempt $attempt" \
-only-testing:ICCeryUITests/AboutHelpUITests/testAboutDialogShowsVersionAndBuildDate; then
if is_runner_attach_failure; then
echo "warning: UI runner could not attach/activate the app (attempt $attempt)"
pkill -x ICCery 2>/dev/null || true
attempt=$((attempt + 1))
sleep 8
continue
fi
echo "error: UI probe failed with a real test error" >&2
exit 1
fi
if run_ui "full suite attempt $attempt" -only-testing:ICCeryUITests \
-skip-testing:ICCeryUITests/AboutHelpUITests/testAboutDialogShowsVersionAndBuildDate; then
exit 0
fi
if is_runner_attach_failure; then
echo "warning: UI runner lost activation mid-suite (attempt $attempt)"
pkill -x ICCery 2>/dev/null || true
attempt=$((attempt + 1))
sleep 8
continue
fi
echo "error: UI tests failed with a real test error" >&2
exit 1
done
echo "warning: skipping UI tests after repeated runner attach/activate failures"
exit 0
# XCTest stores the a11y hierarchy snapshot and screenshots in the
# xcresult on failure — upload it so UI failures can be triaged
# without access to the runner (#126).
- name: Upload UI test xcresult
if: "failure() && !(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
uses: actions/upload-artifact@v4
with:
name: ui-test-xcresult
path: build/DerivedData-test/Logs/Test
package:
needs: build-and-test
runs-on: macos-14
if: github.ref == 'refs/heads/develop' || startsWith(github.ref, 'refs/tags/v')
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
# scripts/package-release.sh runs `xcodegen generate` and dmgbuild;
# see build-and-test for why brew is not used on macOS 12 (#109).
# INSTALL_DMGBUILD isolates dmgbuild in build/.venv-dmgbuild so
# the test job never pip-installs it (#95).
- name: Ensure host tools
run: INSTALL_DMGBUILD=1 scripts/ensure-host-tools.sh
- name: Package release
run: scripts/package-release.sh
env:
CODESIGN_IDENTITY: ${{ secrets.CODESIGN_IDENTITY }}
DEVELOPMENT_TEAM: ${{ secrets.DEVELOPMENT_TEAM }}
NOTARIZE_APPLE_ID: ${{ secrets.NOTARIZE_APPLE_ID }}
NOTARIZE_PASSWORD: ${{ secrets.NOTARIZE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
- name: Upload DMG artifact
uses: actions/upload-artifact@v4
with:
name: iccery-dmg
path: ICCery-*.dmg
- name: Attach DMG to GitHub release
if: startsWith(github.ref, 'refs/tags/v')
env:
GH_TOKEN: ${{ github.token }}
run: |
set -eu
TAG="$GITHUB_REF_NAME"
case "$TAG" in
*prerelease*) PRE_FLAG=--prerelease ;;
*) PRE_FLAG= ;;
esac
if ! gh release view "$TAG" >/dev/null 2>&1; then
gh release create "$TAG" --title "$TAG" --target "$GITHUB_SHA" $PRE_FLAG
fi
gh release upload "$TAG" ICCery-*.dmg --clobber
+1 -5
View File
@@ -206,13 +206,9 @@ xcodebuild test -scheme ICCery -destination 'platform=macOS' \
-only-testing:ICCeryUITests/Milestone5UITests
```
CI (`.gitea/workflows/macos.yml` on Gitea, `.github/workflows/macos.yml` on
GitHub) runs `build-and-test` then `package` on
CI (`.gitea/workflows/macos.yml`) runs `build-and-test` then `package` on
`develop` and on `v*` tags. Tags whose name contains `prerelease` skip the
test job and still package. `pull_request` is wired for **`develop` only**.
The GitHub file is the same pipeline on `macos-14` (github.com retired
`macos-12`), `actions/upload-artifact@v4`, and `gh release upload` for tag
DMGs.
UI tests need an unlocked console (`IOConsoleLocked=false`). Mock Argyll /
CUPS fixtures live under the test bundles; they must not be treated as proof
-11
View File
@@ -6,8 +6,6 @@ import ICCeryCore
struct AboutView: View {
let onClose: () -> Void
@State private var showingLicenses = false
private let info = ArtefactFiles.appInfo()
var body: some View {
@@ -56,12 +54,6 @@ struct AboutView: View {
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
Button("View Licenses") {
showingLicenses = true
}
.controlSize(.large)
.accessibilityIdentifier("viewLicensesBtn")
Button("Close") {
onClose()
}
@@ -74,8 +66,5 @@ struct AboutView: View {
.background(Theme.panel)
.accessibilityElement(children: .contain)
.accessibilityIdentifier("aboutDialog")
.sheet(isPresented: $showingLicenses) {
LicenseWindowView()
}
}
}
+2 -33
View File
@@ -56,41 +56,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}
/// docs/21 §Shell: 1280×800 content, min 1100×700, centred.
/// GitHub-hosted Macs (and any display smaller than 1280×800) must
/// not get a window that hangs off-screen XCTest then reports
/// sidebar controls at negative x as not hittable (run 34864198118).
private func configureMainWindow(_ window: NSWindow) {
let desired = NSSize(width: 1280, height: 800)
let minimum = NSSize(width: 1100, height: 700)
let visible = (window.screen ?? NSScreen.main)?.visibleFrame
?? NSRect(origin: .zero, size: desired)
window.contentMinSize = NSSize(
width: min(minimum.width, visible.width),
height: min(minimum.height, visible.height)
)
window.setContentSize(NSSize(
width: min(desired.width, visible.width),
height: min(desired.height, max(minimum.height, visible.height - 40))
))
window.setContentSize(NSSize(width: 1280, height: 800))
window.contentMinSize = NSSize(width: 1100, height: 700)
window.center()
var frame = window.frame
if frame.width > visible.width {
frame.size.width = visible.width
}
if frame.height > visible.height {
frame.size.height = visible.height
}
frame.origin.x = min(
max(frame.origin.x, visible.minX),
visible.maxX - frame.width
)
frame.origin.y = min(
max(frame.origin.y, visible.minY),
visible.maxY - frame.height
)
window.setFrame(frame, display: true)
}
/// Dock-click reopen: let the WindowGroup re-show or recreate the
-148
View File
@@ -1,148 +0,0 @@
import AppKit
import SwiftUI
/// Detailed license and attribution window (issue #31, docs/21 §Modals).
struct LicenseWindowView: View {
@Environment(\.dismiss) private var dismiss
private let icceryLicense: String
private let argyllLicense: String
init() {
// ICCery license from LICENCE.md (embedded at compile time)
self.icceryLicense = """
# LICENCE
**Copyright (c) 2026 Gordon Bolton**
**All Rights Reserved.**
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), strictly to view the source code and execute the Software for the sole purpose of personal testing, evaluation, and providing feedback.
Under this licence, you may **not**:
* Modify, alter, or create derivative works of the Software.
* Distribute, publish, or sublicense the Software or any derivatives.
* Use the Software for any commercial or production purpose.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
---
## Bundled ArgyllCMS sidecar binaries
This application bundles and invokes command-line binaries from the Gronod fork of ArgyllCMS. Those binaries are licensed separately under the **GNU Affero General Public License v3 (AGPLv3)**. They are executed strictly as independent subprocesses — they are never linked, loaded, or incorporated into this application — and a copy of `License.txt` is shipped beside the binaries in `Resources/Argyll/`. The terms above apply only to the ICCery application source code, not to the ArgyllCMS binaries.
"""
// ArgyllCMS license from bundled License.txt
if let url = Bundle.main.url(forResource: "License", withExtension: "txt", subdirectory: "Argyll"),
let content = try? String(contentsOf: url, encoding: .utf8) {
self.argyllLicense = content
} else {
self.argyllLicense = """
ArgyllCMS license not found — run `scripts/fetch-argyll.sh` to bundle binaries and license.
The ArgyllCMS binaries are licensed under the GNU Affero General Public License v3 (AGPLv3).
A copy of the license should be present at Resources/Argyll/License.txt.
See: https://git.i3omb.com/gronod/argyllcms/releases
"""
}
}
var body: some View {
VStack(spacing: 0) {
// Title bar
HStack {
Text("Licenses & Attribution")
.font(.headline)
.foregroundStyle(Theme.text)
Spacer()
Button("Close") {
dismiss()
}
.keyboardShortcut(.cancelAction)
.accessibilityIdentifier("closeLicenseBtn")
}
.padding(.horizontal, 20)
.padding(.vertical, 12)
.background(Theme.panel.opacity(0.9))
Divider()
ScrollView {
VStack(alignment: .leading, spacing: 24) {
// Section 1: ICCery License
licenseSection(
title: "ICCery License",
content: icceryLicense,
identifier: "icceryLicenseSection"
)
Divider()
// Section 2: ArgyllCMS License
licenseSection(
title: "ArgyllCMS License (AGPLv3)",
content: argyllLicense,
identifier: "argyllLicenseSection"
)
Divider()
// Section 3: Attribution & Links
VStack(alignment: .leading, spacing: 12) {
Text("Attribution & Links")
.font(.headline)
.foregroundStyle(Theme.text)
.accessibilityIdentifier("attributionHeader")
VStack(alignment: .leading, spacing: 8) {
Link("ArgyllCMS by Graeme Gill → https://www.argyllcms.com/",
destination: URL(string: "https://www.argyllcms.com/")!)
.font(.callout)
.foregroundStyle(Theme.accent)
.accessibilityIdentifier("argyllUpstreamLink")
Link("Gronod ArgyllCMS fork (v3.5.0-ICCery.1.x) → https://git.i3omb.com/gronod/argyllcms",
destination: URL(string: "https://git.i3omb.com/gronod/argyllcms")!)
.font(.callout)
.foregroundStyle(Theme.accent)
.accessibilityIdentifier("argyllForkLink")
}
Text("ICCery bundles and invokes ArgyllCMS binaries as isolated subprocesses per AGPLv3 isolation requirements. The ArgyllCMS binaries are never linked, loaded, or incorporated into the ICCery application binary.")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
.accessibilityIdentifier("agplIsolationNote")
}
.padding(.horizontal, 4)
.accessibilityElement(children: .contain)
.accessibilityIdentifier("attributionSection")
}
.padding(24)
}
.frame(minWidth: 600, minHeight: 500)
.background(Theme.panel)
}
.accessibilityElement(children: .contain)
.accessibilityIdentifier("licenseWindow")
}
private func licenseSection(title: String, content: String, identifier: String) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text(title)
.font(.headline)
.foregroundStyle(Theme.text)
.accessibilityIdentifier(identifier + "Header")
Text(content)
.font(.system(.body, design: .monospaced))
.foregroundStyle(Theme.text)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.accessibilityIdentifier(identifier + "Content")
}
.padding(.horizontal, 4)
}
}
-142
View File
@@ -20,13 +20,6 @@ final class AboutHelpUITests: XCTestCase {
private func element(_ id: String) -> XCUIElement {
let inApp = app.descendants(matching: .any)[id].firstMatch
if inApp.exists { return inApp }
// Search all sheets (including nested sheets) for the element.
// The license window is a nested sheet (sheet presented from AboutView).
for sheet in app.sheets.allElementsBoundByIndex {
let inSheet = sheet.descendants(matching: .any)[id].firstMatch
if inSheet.exists { return inSheet }
}
// Fallback to original behavior
return app.sheets.firstMatch.descendants(matching: .any)[id].firstMatch
}
@@ -92,139 +85,4 @@ final class AboutHelpUITests: XCTestCase {
"Toggling global help must not reflow the sidebar.")
XCTAssertTrue(app.descendants(matching: .any)["openSettingsBtn"].exists)
}
func testAboutDialogShowsViewLicensesButton() throws {
launchApp()
let openAbout = app.buttons["openAboutBtn"]
XCTAssertTrue(openAbout.waitForExistence(timeout: 10))
openAbout.click()
_ = waitFor("aboutVersion", timeout: 10)
let viewLicensesBtn = waitFor("viewLicensesBtn", timeout: 10)
XCTAssertTrue(viewLicensesBtn.exists)
viewLicensesBtn.click()
// License window should open as a sheet
_ = waitFor("licenseWindow", timeout: 10)
XCTAssertTrue(element("licenseWindow").exists)
// Close license window
let closeLicenseBtn = waitFor("closeLicenseBtn", timeout: 5)
closeLicenseBtn.click()
// License window should be dismissed
XCTAssertFalse(element("licenseWindow").exists)
// Close about dialog
let closeAboutBtn = waitFor("closeAboutBtn", timeout: 5)
closeAboutBtn.click()
XCTAssertFalse(element("aboutDialog").exists)
}
func testLicenseWindowShowsICCeryLicense() throws {
launchApp()
let openAbout = app.buttons["openAboutBtn"]
XCTAssertTrue(openAbout.waitForExistence(timeout: 10))
openAbout.click()
let viewLicensesBtn = waitFor("viewLicensesBtn", timeout: 10)
viewLicensesBtn.click()
_ = waitFor("licenseWindow", timeout: 10)
// Verify ICCery license section exists
let icceryLicenseSection = waitFor("icceryLicenseSectionHeader", timeout: 5)
XCTAssertTrue(icceryLicenseSection.exists)
// Verify ICCery license content contains key phrases
let icceryLicenseContent = element("icceryLicenseSectionContent")
XCTAssertTrue(icceryLicenseContent.waitForExistence(timeout: 5))
let licenseText = icceryLicenseContent.value as? String ?? ""
XCTAssertTrue(licenseText.contains("Copyright (c) 2026 Gordon Bolton"))
XCTAssertTrue(licenseText.contains("All Rights Reserved"))
XCTAssertTrue(licenseText.contains("AGPLv3"))
// Close license window
let closeLicenseBtn = waitFor("closeLicenseBtn", timeout: 5)
closeLicenseBtn.click()
let closeAboutBtn = waitFor("closeAboutBtn", timeout: 5)
closeAboutBtn.click()
}
func testLicenseWindowShowsArgyllLicense() throws {
launchApp()
let openAbout = app.buttons["openAboutBtn"]
XCTAssertTrue(openAbout.waitForExistence(timeout: 10))
openAbout.click()
let viewLicensesBtn = waitFor("viewLicensesBtn", timeout: 10)
viewLicensesBtn.click()
_ = waitFor("licenseWindow", timeout: 10)
// Verify ArgyllCMS license section exists
let argyllLicenseSection = waitFor("argyllLicenseSectionHeader", timeout: 5)
XCTAssertTrue(argyllLicenseSection.exists)
// Verify Argyll license content exists (may be fallback if License.txt not bundled)
let argyllLicenseContent = element("argyllLicenseSectionContent")
XCTAssertTrue(argyllLicenseContent.waitForExistence(timeout: 5))
let licenseText = argyllLicenseContent.value as? String ?? ""
// Should contain either the actual AGPLv3 license or the fallback notice
XCTAssertTrue(licenseText.contains("AGPLv3") || licenseText.contains("GNU Affero General Public License") || licenseText.contains("fetch-argyll"))
// Close license window
let closeLicenseBtn = waitFor("closeLicenseBtn", timeout: 5)
closeLicenseBtn.click()
let closeAboutBtn = waitFor("closeAboutBtn", timeout: 5)
closeAboutBtn.click()
}
func testLicenseWindowShowsAttributionLinks() throws {
launchApp()
let openAbout = app.buttons["openAboutBtn"]
XCTAssertTrue(openAbout.waitForExistence(timeout: 10))
openAbout.click()
let viewLicensesBtn = waitFor("viewLicensesBtn", timeout: 10)
viewLicensesBtn.click()
_ = waitFor("licenseWindow", timeout: 10)
// Verify attribution section exists
let attributionHeader = waitFor("attributionHeader", timeout: 5)
XCTAssertTrue(attributionHeader.exists)
// Verify upstream link
let upstreamLink = element("argyllUpstreamLink")
XCTAssertTrue(upstreamLink.waitForExistence(timeout: 5))
let upstreamLabel = upstreamLink.label
XCTAssertTrue(upstreamLabel.contains("Graeme Gill") || upstreamLabel.contains("argyllcms.com"))
// Verify fork link
let forkLink = element("argyllForkLink")
XCTAssertTrue(forkLink.waitForExistence(timeout: 5))
let forkLabel = forkLink.label
XCTAssertTrue(forkLabel.contains("Gronod") || forkLabel.contains("git.i3omb.com"))
// Verify AGPL isolation note
let isolationNote = element("agplIsolationNote")
XCTAssertTrue(isolationNote.waitForExistence(timeout: 5))
let noteText = isolationNote.value as? String ?? ""
XCTAssertTrue(noteText.contains("isolated subprocesses") || noteText.contains("AGPLv3 isolation"))
// Close license window
let closeLicenseBtn = waitFor("closeLicenseBtn", timeout: 5)
closeLicenseBtn.click()
let closeAboutBtn = waitFor("closeAboutBtn", timeout: 5)
closeAboutBtn.click()
}
}
+4 -6
View File
@@ -36,7 +36,6 @@ final class Milestone2UITests: XCTestCase {
"ICCERY_UI_TESTING": "1",
"ICCERY_TEST_ROOT": testRoot.path,
"ICCERY_ARGYLL_BINARY_DIR": binDir.path,
"ICCERY_CUPS_BIN_DIR": binDir.path,
"ICCERY_TEST_SAVE_TARGET":
workDir.appendingPathComponent("mytarget.ti1").path,
"ICCERY_TEST_WORKDIR": workDir.path,
@@ -219,12 +218,11 @@ final class Milestone2UITests: XCTestCase {
XCTAssertTrue(FileManager.default.fileExists(
atPath: workDir.appendingPathComponent("mytarget.ti2").path))
// Print panel is live from M3. GitHub macos-14 has no system
// queues, so mock CUPS (`ICCERY_CUPS_BIN_DIR`) must enumerate
// before Print All / per-page enable (run 34867767434).
// Print panel is live from M3; a default printer is selected
// so both the all-pages and per-page print buttons are enabled.
XCTAssertTrue(element("rawPrintPanel").exists)
_ = waitUntilEnabled("btnPrintAll", timeout: 15)
_ = waitUntilEnabled("btnPrintPage-0", timeout: 15)
XCTAssertTrue(app.buttons["btnPrintAll"].isEnabled)
XCTAssertTrue(app.buttons["btnPrintPage-0"].isEnabled)
XCTAssertTrue(app.buttons["btnAdvanceToStage3"].isEnabled)
}
+22 -35
View File
@@ -98,9 +98,23 @@ final class Milestone4UITests: XCTestCase {
app.buttons["btnDetectInstruments"].click()
_ = waitFor("chartreadInstrumentSelect", timeout: 20)
// Wait until Start is enabled before clicking. Existence-only
// clicks are no-ops on the disabled control (runs 35251, 35293).
driveOnePass(startButton: "btnStartRead")
// Keep Auto (port 1) and start the session.
XCTAssertTrue(app.buttons["btnStartRead"].waitForExistence(timeout: 5))
app.buttons["btnStartRead"].click()
// Calibrate.
let calibrate = element("btnCalibrate")
if !calibrate.waitForExistence(timeout: 25) {
let error = element("chartreadLastError").label
let value = element("chartreadLastError").value as? String ?? "<nil>"
XCTFail("No calibrate button. lastError.label='\(error)' value='\(value)'")
}
app.buttons["btnCalibrate"].click()
// Trigger each strip until all are read Done & Save appears.
driveStripsUntilDone()
XCTAssertTrue(element("btnDoneRead").exists)
app.buttons["btnDoneRead"].firstMatch.click()
// Averaging panel appears with one pass snapshot.
_ = waitFor("chartreadAveragingPanel", timeout: 20)
@@ -154,34 +168,15 @@ final class Milestone4UITests: XCTestCase {
/// Runs the mock handheld chartread session to completion
/// (start calibrate strip A strip B Done & Save).
///
/// `isEnabled` can be true in AX while the SwiftUI action is still
/// a no-op (runs 35251, 35443). Re-click until the session is
/// actually running (`btnCancel` is shown whenever
/// `isChartreadRunning`), then wait for Calibrate.
private func driveOnePass(startButton: String) {
let start = app.buttons[startButton]
XCTAssertTrue(start.waitForExistence(timeout: 10), startButton)
let enabledBy = Date().addingTimeInterval(10)
while Date() < enabledBy, !start.isEnabled {
XCTAssertTrue(start.waitForExistence(timeout: 10))
let deadline = Date().addingTimeInterval(10)
while Date() < deadline, !start.isEnabled {
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
}
XCTAssertTrue(start.isEnabled, "\(startButton) never enabled")
let sessionBy = Date().addingTimeInterval(25)
while Date() < sessionBy, !chartreadSessionRunning {
if start.exists, start.isEnabled {
start.click()
}
RunLoop.current.run(until: Date().addingTimeInterval(0.4))
}
XCTAssertTrue(
chartreadSessionRunning,
"\(startButton) click never started chartread; "
+ "notice=\(element("noticeText").value as? String ?? "") "
+ "start.exists=\(start.exists)"
)
XCTAssertTrue(start.isEnabled)
start.click()
_ = waitFor("btnCalibrate", timeout: 25)
app.buttons["btnCalibrate"].click()
driveStripsUntilDone()
@@ -189,14 +184,6 @@ final class Milestone4UITests: XCTestCase {
app.buttons["btnDoneRead"].firstMatch.click()
}
/// `btnCancel` is in the tree for the whole chartread session;
/// Calibrate/Trigger only appear after the first classified prompt.
private var chartreadSessionRunning: Bool {
element("btnCancel").exists
|| element("btnCalibrate").exists
|| element("btnTrigger").exists
}
/// Clicks Trigger for each remaining strip until `btnDoneRead`
/// appears the button is re-polled each pass so a click that
/// races a state transition isn't lost.
+89
View File
@@ -0,0 +1,89 @@
@startuml ICCery-UI-sheets
title Sheets Settings, presets, media, Spot Read, Gamut, project
!theme plain
skinparam backgroundColor white
skinparam defaultFontColor black
skinparam defaultFontSize 13
skinparam mapBackgroundColor white
skinparam mapBorderColor #222222
skinparam mapFontColor black
skinparam arrowColor #222222
skinparam shadowing false
hide circle
top to bottom direction
map "SettingsView.swift" as set {
Argyll dir => TextField+Browse : empty = bundled
Default instrument => Picker : seeds Stage 3 and Spot Read
Enable i1Pro 2 LEDs => Toggle
settingsDeltaEGood => TextField
settingsDeltaEWarning => TextField : must be greater than Good
settingsCalStaleDays => TextField
Install location => Picker User or System
Ask before overwriting => Toggle
Open ColorSync after install => Toggle
Log level => Picker
Open log folder => Button
Cancel / Save => Save stays if validation fails
}
map "Presets PresetDialogs.swift" as pre {
savePresetName => TextField
savePresetDesc => TextField
btnCloseSavePresetDialog => Button
btnConfirmSavePreset => Button : disabled if name empty
presetRow-id => Row : built-in cannot delete
btnImportPreset => Button
btnExportActivePreset => Button : custom selected
btnCloseManagePresetsDialog => Button
}
map "Media MediaLibraryDialogs.swift" as media {
saveMediaName/Paper/Ink => TextField
saveMediaApplyCal => Toggle : disabled if CAL_ or missing file
btnConfirmSaveMedia => Button : name+paper+ink required
mediaLibraryList => List
btnMediaLibraryApply-id => Button per row
manageMediaNotice => Caption : failed Apply in-sheet
btnMediaLibraryApply => Button : disabled if no selection
btnCloseManageMediaDialog => Button
}
map "spotReadView SpotReadView.swift" as spot {
btnSpotDetectInstruments => Button
spotInstrumentSelect => Picker : disabled while running
spotSetDefault => Toggle
btnSpotStart => Button : needs sidecar, cwd, not chartread
btnSpotCalibrate => Button : calibrating
btnSpotTrigger => Button : Read, awaitingStrip
btnSpotStop => Button : while running
btnSpotCopyLab => Button : disabled if no sample
btnSpotExportCsv => Button : disabled if no history
btnCloseSpotRead => Button Esc : dismiss = Stop
}
map "gamutView GamutView.swift" as gam {
gamutLayer-srgb => Toggle : can hide, cannot remove
gamutLayer-profile => Toggle : disabled if no .gam
gamutLayer-compare => Toggle : disabled if no compare
btnGamutAddCompare => Menu
btnGamutRemoveCompare => Button
btnGamutSampleTiff => Button
btnResetGamutCamera => Button R
gamutLabEntryL/A/B => TextField
btnGamutInspectLab => Button
btnCloseGamut => Button Esc
}
map "Project alerts" as proj {
projectNewAlert => Alert : New Cancel / Confirm
Dirty save => Alert : Save / Don't Save / Cancel
projectRelocateSheet => Sheet : cwd missing on Open
}
set -[hidden]down- pre
pre -[hidden]down- media
media -[hidden]down- spot
spot -[hidden]down- gam
gam -[hidden]down- proj
@enduml
+81
View File
@@ -0,0 +1,81 @@
@startuml ICCery-UI-shell
title Shell, sidebar, stepper, File menu
!theme plain
skinparam backgroundColor white
skinparam defaultFontColor black
skinparam defaultFontSize 13
skinparam mapBackgroundColor white
skinparam mapBorderColor #222222
skinparam mapFontColor black
skinparam arrowColor #222222
skinparam shadowing false
hide circle
top to bottom direction
map "Window" as win {
WindowGroup => ICCeryApp.swift : 1280x800 min 1100x700
RootView => RootView.swift : hosts every sheet
noticeText => NoticeBanner : shown if wizard.notice set; auto-hide 6s
}
map "Sidebar header" as hdr {
openSettingsBtn => Button : always / Settings sheet
openAboutBtn => Button : always / About
btnToggleAllHelp => Button : toggles yellow help dots
}
map "Presets" as pre {
presetSelect => Picker.menu : none + preset id; none is not factory reset
btnSavePresetModal => Button : always
btnOpenPresetsDialog => Button : always
}
map "Media library" as med {
mediaSelect => Picker.menu : never reuses presetSelect
mediaRecipeStale => Caption : shown if staleReasons nonempty
btnMediaLibraryCapture => Button : disabled if no Stage 2 printer
btnMediaLibraryManage => Button : always
}
map "Studio (not stepper)" as stu {
btnCalibratePrinter => Button : always; Stage 0
btnViewGamut => Button : sidebar always on; same id as Stage 5
btnSpotRead => Button : disabled if no cwd or chartread running
}
map "Stepper 1-5 disk is truth" as stp {
Stage1 Generate => always
Stage2 Lay out => .ti1
Stage3 Measure => .ti1 AND .ti2
Stage4 Build => .ti3 (not .ti2 alone)
Stage5 Verify => .ti3 AND .icc/.icm
Calibrate => not a stepper row; always available
}
map "Project chip" as chip {
projectChipName => Text : bound name or No project
projectChipPath => Text : shown if bound
projectChipStale => Caption : diskBehindNotes
btnProjectReveal => Button : shown if bound
btnProjectSave => Button : canSave = bound + basename + cwd
btnProjectOpen => Button : shown if not bound
}
map "File menu" as menu {
menuProjectNew => Button Cmd-N
menuProjectOpen => Button Cmd-O
menuProjectRecents => Menu : disabled if recents empty
menuProjectSave => Button Cmd-S : !canSave
menuProjectSaveAs => Button Shift-Cmd-S : basename + cwd
menuProjectReport => Button : !canReport
menuProjectClose => Button : !isBound
}
win -[hidden]down- hdr
hdr -[hidden]down- pre
pre -[hidden]down- med
med -[hidden]down- stu
stu -[hidden]down- stp
stp -[hidden]down- chip
chip -[hidden]down- menu
@enduml
+83
View File
@@ -0,0 +1,83 @@
@startuml ICCery-UI-stage1-2
title Stages 1-2 Generate Target / Lay Out and Print
!theme plain
skinparam backgroundColor white
skinparam defaultFontColor black
skinparam defaultFontSize 13
skinparam mapBackgroundColor white
skinparam mapBorderColor #222222
skinparam mapFontColor black
skinparam arrowColor #222222
skinparam shadowing false
hide circle
top to bottom direction
map "stage-1 Stage1View.swift" as s1 {
colourSpace => Picker.segmented : RGB or CMYK
patchCountPreset => Picker
patchCountCustom => TextField : shown if preset is custom
whitePatches => Stepper 0-50
blackPatches => Stepper 0-50
targetBasename => TextField
btnBrowse => Button
btnSelectWorkDir => Button
btnOpenExisting => Button : .ti1 or .ti2
btn-import-dataset => Button : unlocks stage 4
selectedPathDisplay => Text
targenAdvancedDetails => DisclosureGroup : UI tests pre-expand
btnGenerate => Button : needs basename AND directory
targenLog => ProcessLogView
}
map "Stage 1 Advanced (inside disclosure)" as adv {
targenGreySteps => Toggle+field : field iff on
targenSingleChannelSteps => Toggle+field
targenNeutralSteps => Toggle+field
targenNeutralConcentration => Toggle+Slider 0-1
targenAdaptation => Toggle+Slider 0-1
targenPrecondProfile => TextField
btnBrowsePrecondProfile => Button
targenHighQuality => Toggle
targenAlgorithm => Picker
targenInkLimitGroup => Toggle+field : CMYK only
targenDarkEmphasis => Toggle+Slider 0-3
targenDevicePower => Toggle+Slider 0-3
}
map "stage-2 Stage2View.swift" as s2 {
cmWarningBanner => Banner : always on stage 2
instrumentSelect => Picker : chart code, not USB port
pageSizeSelect => Picker
customPageW / customPageH => TextField : pageSize custom
tiffDpi => Stepper 72-600
printtargLayoutOrder => Picker
printtargCustomSeed => TextField : custom seed order
btnToggleLabelEdit => Button
targetMetadataPrinter => TextField
targetMetadataInkSet => TextField
targetMetadataDriverPaper => TextField
targetMetadataActualPaper => TextField
targetLabelPreview => TextField or Text
btnCreateLayout => Button : disabled if no basename
tiffGallery => Grid : after printtarg
btnPrintPage-N => Button : disabled if no printer
}
map "rawPrintPanel" as prn {
printerSelect => Picker CUPS
printerStatusBadge => Caption
btnRefreshPrinters => Button
btnPrinterProperties => Button : disabled if no printer
printerTraySelect => Picker : if trays exist
printerMediaTypeSelect => Picker : if media types exist
btnOrientPortrait => Button
btnOrientLandscape => Button
btnPrintAll => Button : needs layout AND printer
btnAdvanceToStage3 => Button : needs .ti2 unlock
printNotificationText => Caption : if printNotice set
}
s1 -[hidden]down- adv
adv -[hidden]down- s2
s2 -[hidden]down- prn
@enduml
+84
View File
@@ -0,0 +1,84 @@
@startuml ICCery-UI-stage3-5-cal
title Stages 3-5 and Calibrate Printer
!theme plain
skinparam backgroundColor white
skinparam defaultFontColor black
skinparam defaultFontSize 13
skinparam mapBackgroundColor white
skinparam mapBorderColor #222222
skinparam mapFontColor black
skinparam arrowColor #222222
skinparam shadowing false
hide circle
top to bottom direction
map "stage-3 Stage3View.swift" as s3 {
btnDetectInstruments => Button : disabled while detecting
chartreadInstrumentSelect => Picker.menu
xyTableHint => Caption : if XY instrument
xyTablePanel => Place/Align/Scan/Remove
chartreadPrompt => Text
chartreadLastError => Caption : if notice set
btnStartRead => Button : shown if not running; needs basename+cwd
btnCalibrate => Button : running and calibrating
btnTrigger => Button : running and awaitingStrip
btnDoneReadEarly => Button : awaitingStrip
btnAccept => Button : place / align / continue / warning
btnRetry => Button : error state
btnDoneRead => Button : allStripsRead
btnCancel => Button : while running
swatchGrid => swatch-rowId-loc
btnMeasureAnotherSheet => Button : after a finished pass
btnFinishAndAverage => Button : finished AND at least one pass
}
map "stage-4 Stage4View.swift" as s4 {
colprofAlgorithm => Picker
colprofQuality => Picker
colprofFwa => Picker
colprofFwaCustomPath => TextField : custom spectrum
btnBrowseFwaSp => Button
colprofIlluminant => TextField
colprofObserver => TextField
colprofInputViewCond => TextField
colprofOutputViewCond => TextField
colprofDescription => TextField
colprofCopyright => TextField
colprofApplyCalibration => Toggle
colprofCalibrationFile => TextField : if applyCalibration
btnBrowseCalibrationFile => Button : if applyCalibration
btnCreateProfile => Button : basename + cwd, not running
}
map "stage-5 Stage5View.swift" as s5 {
btnVerifyProfile => Button : needs created profile
btnViewGamut => Button : disabled if no .gam (same id as sidebar)
btnInstallProfile => Button : needs created profile
driftPrinterFilter => Picker
btnExportHistory => Button
btnClearHistory => Button
verificationHistoryTable => Table
driftChart => not interactive
profileOverwriteBtn => Alert : install collision
profileRenameBtn => Alert
profileCancelCollisionBtn => Alert
}
map "stage-cal CalibrationView.swift" as cal {
Colour space => Picker.segmented : no a11y id
calSteps => TextField
White patches => TextField : no a11y id
calInkExplore => TextField : CMYK only
calNeutralEmphasis => Toggle
btnCalGenerate => Button : needs basename + cwd
btnCalLayout => Button : same
btnCalMeasure => Button : needs .ti3
btnCalCompute => Button : .ti3 and not computing
calApplyToggle => Toggle : after curves exist
btnCalReturn => Button Esc : restores original basename
}
s3 -[hidden]down- s4
s4 -[hidden]down- s5
s5 -[hidden]down- cal
@enduml
+12
View File
@@ -0,0 +1,12 @@
# Interactive UI map
PlantUML **maps** (not class diagrams). Class diagrams laid out 6500px wide, so Gitea on a phone showed a sliver of a black arrow and the UML “C” icon.
Each file is one stacked column (~560780px) that Kroki actually fits in the page.
| Diagram | File | Kroki size |
| --- | --- | --- |
| Shell, sidebar, stepper, File menu | [ui-interactive-map-shell.puml](ui-interactive-map-shell.puml) | 563 × 1367 |
| Stages 12 | [ui-interactive-map-stage1-2.puml](ui-interactive-map-stage1-2.puml) | 625 × 1366 |
| Stages 35 + Calibrate | [ui-interactive-map-stage3-5-cal.puml](ui-interactive-map-stage3-5-cal.puml) | 568 × 1386 |
| Sheets | [ui-interactive-map-sheets.puml](ui-interactive-map-sheets.puml) | 782 × 1502 |
-10
View File
@@ -106,19 +106,9 @@ fi
mkdir -p "$DEST"
cp -R "$BIN_DIR"/. "$DEST"/
# Copy License.txt from archive root (same level as bin/) to Vendor/Argyll/ root
VENDOR_ROOT="$ROOT/Vendor/Argyll"
for license_src in "$EXTRACT"/Argyll_V*/License.txt "$EXTRACT"/License.txt; do
if [ -f "$license_src" ]; then
cp "$license_src" "$VENDOR_ROOT/License.txt"
break
fi
done
find "$DEST" -type f -exec chmod 0755 {} +
# Downloads carry com.apple.quarantine; the app cannot spawn quarantined tools.
xattr -dr com.apple.quarantine "$DEST" 2>/dev/null || true
# Also remove quarantine from Vendor/Argyll root if License.txt was copied
xattr -dr com.apple.quarantine "$VENDOR_ROOT" 2>/dev/null || true
# Ad-hoc sign every Mach-O (#165: unsigned arm64 → "Killed: 9"), then
# verify — an unsigned sidecar fails the script. The tree may nest