Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2fefe98434 | ||
|
|
90d034baab | ||
|
|
8cb79e8ffa | ||
|
|
c1c37bb7d7 | ||
|
|
d2466d9a66 | ||
|
|
4af2d96294 | ||
|
|
d17aa962ce | ||
|
|
93acd32b51 | ||
|
|
67cf452e59 | ||
|
|
83ed53c7fc | ||
|
|
cba9476af7 | ||
|
|
288b08e3d9 | ||
|
|
18f1d1ff33 | ||
|
|
9dfa79ebd0 |
@@ -0,0 +1,231 @@
|
|||||||
|
# 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
|
||||||
@@ -206,9 +206,13 @@ xcodebuild test -scheme ICCery -destination 'platform=macOS' \
|
|||||||
-only-testing:ICCeryUITests/Milestone5UITests
|
-only-testing:ICCeryUITests/Milestone5UITests
|
||||||
```
|
```
|
||||||
|
|
||||||
CI (`.gitea/workflows/macos.yml`) runs `build-and-test` then `package` on
|
CI (`.gitea/workflows/macos.yml` on Gitea, `.github/workflows/macos.yml` on
|
||||||
|
GitHub) runs `build-and-test` then `package` on
|
||||||
`develop` and on `v*` tags. Tags whose name contains `prerelease` skip the
|
`develop` and on `v*` tags. Tags whose name contains `prerelease` skip the
|
||||||
test job and still package. `pull_request` is wired for **`develop` only**.
|
test job and still package. `pull_request` is wired for **`develop` only**.
|
||||||
|
The GitHub file is the same pipeline on `macos-14` (github.com retired
|
||||||
|
`macos-12`), `actions/upload-artifact@v4`, and `gh release upload` for tag
|
||||||
|
DMGs.
|
||||||
|
|
||||||
UI tests need an unlocked console (`IOConsoleLocked=false`). Mock Argyll /
|
UI tests need an unlocked console (`IOConsoleLocked=false`). Mock Argyll /
|
||||||
CUPS fixtures live under the test bundles; they must not be treated as proof
|
CUPS fixtures live under the test bundles; they must not be treated as proof
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import ICCeryCore
|
|||||||
struct AboutView: View {
|
struct AboutView: View {
|
||||||
let onClose: () -> Void
|
let onClose: () -> Void
|
||||||
|
|
||||||
|
@State private var showingLicenses = false
|
||||||
|
|
||||||
private let info = ArtefactFiles.appInfo()
|
private let info = ArtefactFiles.appInfo()
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -54,6 +56,12 @@ struct AboutView: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
|
Button("View Licenses") {
|
||||||
|
showingLicenses = true
|
||||||
|
}
|
||||||
|
.controlSize(.large)
|
||||||
|
.accessibilityIdentifier("viewLicensesBtn")
|
||||||
|
|
||||||
Button("Close") {
|
Button("Close") {
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
@@ -66,5 +74,8 @@ struct AboutView: View {
|
|||||||
.background(Theme.panel)
|
.background(Theme.panel)
|
||||||
.accessibilityElement(children: .contain)
|
.accessibilityElement(children: .contain)
|
||||||
.accessibilityIdentifier("aboutDialog")
|
.accessibilityIdentifier("aboutDialog")
|
||||||
|
.sheet(isPresented: $showingLicenses) {
|
||||||
|
LicenseWindowView()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,10 +56,41 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// docs/21 §Shell: 1280×800 content, min 1100×700, centred.
|
/// docs/21 §Shell: 1280×800 content, min 1100×700, centred.
|
||||||
|
/// GitHub-hosted Macs (and any display smaller than 1280×800) must
|
||||||
|
/// not get a window that hangs off-screen — XCTest then reports
|
||||||
|
/// sidebar controls at negative x as not hittable (run 34864198118).
|
||||||
private func configureMainWindow(_ window: NSWindow) {
|
private func configureMainWindow(_ window: NSWindow) {
|
||||||
window.setContentSize(NSSize(width: 1280, height: 800))
|
let desired = NSSize(width: 1280, height: 800)
|
||||||
window.contentMinSize = NSSize(width: 1100, height: 700)
|
let minimum = NSSize(width: 1100, height: 700)
|
||||||
|
let visible = (window.screen ?? NSScreen.main)?.visibleFrame
|
||||||
|
?? NSRect(origin: .zero, size: desired)
|
||||||
|
|
||||||
|
window.contentMinSize = NSSize(
|
||||||
|
width: min(minimum.width, visible.width),
|
||||||
|
height: min(minimum.height, visible.height)
|
||||||
|
)
|
||||||
|
window.setContentSize(NSSize(
|
||||||
|
width: min(desired.width, visible.width),
|
||||||
|
height: min(desired.height, max(minimum.height, visible.height - 40))
|
||||||
|
))
|
||||||
window.center()
|
window.center()
|
||||||
|
|
||||||
|
var frame = window.frame
|
||||||
|
if frame.width > visible.width {
|
||||||
|
frame.size.width = visible.width
|
||||||
|
}
|
||||||
|
if frame.height > visible.height {
|
||||||
|
frame.size.height = visible.height
|
||||||
|
}
|
||||||
|
frame.origin.x = min(
|
||||||
|
max(frame.origin.x, visible.minX),
|
||||||
|
visible.maxX - frame.width
|
||||||
|
)
|
||||||
|
frame.origin.y = min(
|
||||||
|
max(frame.origin.y, visible.minY),
|
||||||
|
visible.maxY - frame.height
|
||||||
|
)
|
||||||
|
window.setFrame(frame, display: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Dock-click reopen: let the WindowGroup re-show or recreate the
|
/// Dock-click reopen: let the WindowGroup re-show or recreate the
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import AppKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Detailed license and attribution window (issue #31, docs/21 §Modals).
|
||||||
|
struct LicenseWindowView: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
private let icceryLicense: String
|
||||||
|
private let argyllLicense: String
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// ICCery license from LICENCE.md (embedded at compile time)
|
||||||
|
self.icceryLicense = """
|
||||||
|
# LICENCE
|
||||||
|
|
||||||
|
**Copyright (c) 2026 Gordon Bolton**
|
||||||
|
**All Rights Reserved.**
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), strictly to view the source code and execute the Software for the sole purpose of personal testing, evaluation, and providing feedback.
|
||||||
|
|
||||||
|
Under this licence, you may **not**:
|
||||||
|
|
||||||
|
* Modify, alter, or create derivative works of the Software.
|
||||||
|
* Distribute, publish, or sublicense the Software or any derivatives.
|
||||||
|
* Use the Software for any commercial or production purpose.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bundled ArgyllCMS sidecar binaries
|
||||||
|
|
||||||
|
This application bundles and invokes command-line binaries from the Gronod fork of ArgyllCMS. Those binaries are licensed separately under the **GNU Affero General Public License v3 (AGPLv3)**. They are executed strictly as independent subprocesses — they are never linked, loaded, or incorporated into this application — and a copy of `License.txt` is shipped beside the binaries in `Resources/Argyll/`. The terms above apply only to the ICCery application source code, not to the ArgyllCMS binaries.
|
||||||
|
"""
|
||||||
|
|
||||||
|
// ArgyllCMS license from bundled License.txt
|
||||||
|
if let url = Bundle.main.url(forResource: "License", withExtension: "txt", subdirectory: "Argyll"),
|
||||||
|
let content = try? String(contentsOf: url, encoding: .utf8) {
|
||||||
|
self.argyllLicense = content
|
||||||
|
} else {
|
||||||
|
self.argyllLicense = """
|
||||||
|
ArgyllCMS license not found — run `scripts/fetch-argyll.sh` to bundle binaries and license.
|
||||||
|
|
||||||
|
The ArgyllCMS binaries are licensed under the GNU Affero General Public License v3 (AGPLv3).
|
||||||
|
A copy of the license should be present at Resources/Argyll/License.txt.
|
||||||
|
|
||||||
|
See: https://git.i3omb.com/gronod/argyllcms/releases
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Title bar
|
||||||
|
HStack {
|
||||||
|
Text("Licenses & Attribution")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
Spacer()
|
||||||
|
Button("Close") {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.keyboardShortcut(.cancelAction)
|
||||||
|
.accessibilityIdentifier("closeLicenseBtn")
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(Theme.panel.opacity(0.9))
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 24) {
|
||||||
|
// Section 1: ICCery License
|
||||||
|
licenseSection(
|
||||||
|
title: "ICCery License",
|
||||||
|
content: icceryLicense,
|
||||||
|
identifier: "icceryLicenseSection"
|
||||||
|
)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// Section 2: ArgyllCMS License
|
||||||
|
licenseSection(
|
||||||
|
title: "ArgyllCMS License (AGPLv3)",
|
||||||
|
content: argyllLicense,
|
||||||
|
identifier: "argyllLicenseSection"
|
||||||
|
)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// Section 3: Attribution & Links
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Attribution & Links")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
.accessibilityIdentifier("attributionHeader")
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Link("ArgyllCMS by Graeme Gill → https://www.argyllcms.com/",
|
||||||
|
destination: URL(string: "https://www.argyllcms.com/")!)
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(Theme.accent)
|
||||||
|
.accessibilityIdentifier("argyllUpstreamLink")
|
||||||
|
|
||||||
|
Link("Gronod ArgyllCMS fork (v3.5.0-ICCery.1.x) → https://git.i3omb.com/gronod/argyllcms",
|
||||||
|
destination: URL(string: "https://git.i3omb.com/gronod/argyllcms")!)
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(Theme.accent)
|
||||||
|
.accessibilityIdentifier("argyllForkLink")
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("ICCery bundles and invokes ArgyllCMS binaries as isolated subprocesses per AGPLv3 isolation requirements. The ArgyllCMS binaries are never linked, loaded, or incorporated into the ICCery application binary.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.accessibilityIdentifier("agplIsolationNote")
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
.accessibilityElement(children: .contain)
|
||||||
|
.accessibilityIdentifier("attributionSection")
|
||||||
|
}
|
||||||
|
.padding(24)
|
||||||
|
}
|
||||||
|
.frame(minWidth: 600, minHeight: 500)
|
||||||
|
.background(Theme.panel)
|
||||||
|
}
|
||||||
|
.accessibilityElement(children: .contain)
|
||||||
|
.accessibilityIdentifier("licenseWindow")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func licenseSection(title: String, content: String, identifier: String) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text(title)
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
.accessibilityIdentifier(identifier + "Header")
|
||||||
|
|
||||||
|
Text(content)
|
||||||
|
.font(.system(.body, design: .monospaced))
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.accessibilityIdentifier(identifier + "Content")
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,13 @@ final class AboutHelpUITests: XCTestCase {
|
|||||||
private func element(_ id: String) -> XCUIElement {
|
private func element(_ id: String) -> XCUIElement {
|
||||||
let inApp = app.descendants(matching: .any)[id].firstMatch
|
let inApp = app.descendants(matching: .any)[id].firstMatch
|
||||||
if inApp.exists { return inApp }
|
if inApp.exists { return inApp }
|
||||||
|
// Search all sheets (including nested sheets) for the element.
|
||||||
|
// The license window is a nested sheet (sheet presented from AboutView).
|
||||||
|
for sheet in app.sheets.allElementsBoundByIndex {
|
||||||
|
let inSheet = sheet.descendants(matching: .any)[id].firstMatch
|
||||||
|
if inSheet.exists { return inSheet }
|
||||||
|
}
|
||||||
|
// Fallback to original behavior
|
||||||
return app.sheets.firstMatch.descendants(matching: .any)[id].firstMatch
|
return app.sheets.firstMatch.descendants(matching: .any)[id].firstMatch
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,4 +92,139 @@ final class AboutHelpUITests: XCTestCase {
|
|||||||
"Toggling global help must not reflow the sidebar.")
|
"Toggling global help must not reflow the sidebar.")
|
||||||
XCTAssertTrue(app.descendants(matching: .any)["openSettingsBtn"].exists)
|
XCTAssertTrue(app.descendants(matching: .any)["openSettingsBtn"].exists)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testAboutDialogShowsViewLicensesButton() throws {
|
||||||
|
launchApp()
|
||||||
|
|
||||||
|
let openAbout = app.buttons["openAboutBtn"]
|
||||||
|
XCTAssertTrue(openAbout.waitForExistence(timeout: 10))
|
||||||
|
openAbout.click()
|
||||||
|
|
||||||
|
_ = waitFor("aboutVersion", timeout: 10)
|
||||||
|
|
||||||
|
let viewLicensesBtn = waitFor("viewLicensesBtn", timeout: 10)
|
||||||
|
XCTAssertTrue(viewLicensesBtn.exists)
|
||||||
|
viewLicensesBtn.click()
|
||||||
|
|
||||||
|
// License window should open as a sheet
|
||||||
|
_ = waitFor("licenseWindow", timeout: 10)
|
||||||
|
XCTAssertTrue(element("licenseWindow").exists)
|
||||||
|
|
||||||
|
// Close license window
|
||||||
|
let closeLicenseBtn = waitFor("closeLicenseBtn", timeout: 5)
|
||||||
|
closeLicenseBtn.click()
|
||||||
|
|
||||||
|
// License window should be dismissed
|
||||||
|
XCTAssertFalse(element("licenseWindow").exists)
|
||||||
|
|
||||||
|
// Close about dialog
|
||||||
|
let closeAboutBtn = waitFor("closeAboutBtn", timeout: 5)
|
||||||
|
closeAboutBtn.click()
|
||||||
|
XCTAssertFalse(element("aboutDialog").exists)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLicenseWindowShowsICCeryLicense() throws {
|
||||||
|
launchApp()
|
||||||
|
|
||||||
|
let openAbout = app.buttons["openAboutBtn"]
|
||||||
|
XCTAssertTrue(openAbout.waitForExistence(timeout: 10))
|
||||||
|
openAbout.click()
|
||||||
|
|
||||||
|
let viewLicensesBtn = waitFor("viewLicensesBtn", timeout: 10)
|
||||||
|
viewLicensesBtn.click()
|
||||||
|
|
||||||
|
_ = waitFor("licenseWindow", timeout: 10)
|
||||||
|
|
||||||
|
// Verify ICCery license section exists
|
||||||
|
let icceryLicenseSection = waitFor("icceryLicenseSectionHeader", timeout: 5)
|
||||||
|
XCTAssertTrue(icceryLicenseSection.exists)
|
||||||
|
|
||||||
|
// Verify ICCery license content contains key phrases
|
||||||
|
let icceryLicenseContent = element("icceryLicenseSectionContent")
|
||||||
|
XCTAssertTrue(icceryLicenseContent.waitForExistence(timeout: 5))
|
||||||
|
let licenseText = icceryLicenseContent.value as? String ?? ""
|
||||||
|
XCTAssertTrue(licenseText.contains("Copyright (c) 2026 Gordon Bolton"))
|
||||||
|
XCTAssertTrue(licenseText.contains("All Rights Reserved"))
|
||||||
|
XCTAssertTrue(licenseText.contains("AGPLv3"))
|
||||||
|
|
||||||
|
// Close license window
|
||||||
|
let closeLicenseBtn = waitFor("closeLicenseBtn", timeout: 5)
|
||||||
|
closeLicenseBtn.click()
|
||||||
|
|
||||||
|
let closeAboutBtn = waitFor("closeAboutBtn", timeout: 5)
|
||||||
|
closeAboutBtn.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLicenseWindowShowsArgyllLicense() throws {
|
||||||
|
launchApp()
|
||||||
|
|
||||||
|
let openAbout = app.buttons["openAboutBtn"]
|
||||||
|
XCTAssertTrue(openAbout.waitForExistence(timeout: 10))
|
||||||
|
openAbout.click()
|
||||||
|
|
||||||
|
let viewLicensesBtn = waitFor("viewLicensesBtn", timeout: 10)
|
||||||
|
viewLicensesBtn.click()
|
||||||
|
|
||||||
|
_ = waitFor("licenseWindow", timeout: 10)
|
||||||
|
|
||||||
|
// Verify ArgyllCMS license section exists
|
||||||
|
let argyllLicenseSection = waitFor("argyllLicenseSectionHeader", timeout: 5)
|
||||||
|
XCTAssertTrue(argyllLicenseSection.exists)
|
||||||
|
|
||||||
|
// Verify Argyll license content exists (may be fallback if License.txt not bundled)
|
||||||
|
let argyllLicenseContent = element("argyllLicenseSectionContent")
|
||||||
|
XCTAssertTrue(argyllLicenseContent.waitForExistence(timeout: 5))
|
||||||
|
let licenseText = argyllLicenseContent.value as? String ?? ""
|
||||||
|
// Should contain either the actual AGPLv3 license or the fallback notice
|
||||||
|
XCTAssertTrue(licenseText.contains("AGPLv3") || licenseText.contains("GNU Affero General Public License") || licenseText.contains("fetch-argyll"))
|
||||||
|
|
||||||
|
// Close license window
|
||||||
|
let closeLicenseBtn = waitFor("closeLicenseBtn", timeout: 5)
|
||||||
|
closeLicenseBtn.click()
|
||||||
|
|
||||||
|
let closeAboutBtn = waitFor("closeAboutBtn", timeout: 5)
|
||||||
|
closeAboutBtn.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLicenseWindowShowsAttributionLinks() throws {
|
||||||
|
launchApp()
|
||||||
|
|
||||||
|
let openAbout = app.buttons["openAboutBtn"]
|
||||||
|
XCTAssertTrue(openAbout.waitForExistence(timeout: 10))
|
||||||
|
openAbout.click()
|
||||||
|
|
||||||
|
let viewLicensesBtn = waitFor("viewLicensesBtn", timeout: 10)
|
||||||
|
viewLicensesBtn.click()
|
||||||
|
|
||||||
|
_ = waitFor("licenseWindow", timeout: 10)
|
||||||
|
|
||||||
|
// Verify attribution section exists
|
||||||
|
let attributionHeader = waitFor("attributionHeader", timeout: 5)
|
||||||
|
XCTAssertTrue(attributionHeader.exists)
|
||||||
|
|
||||||
|
// Verify upstream link
|
||||||
|
let upstreamLink = element("argyllUpstreamLink")
|
||||||
|
XCTAssertTrue(upstreamLink.waitForExistence(timeout: 5))
|
||||||
|
let upstreamLabel = upstreamLink.label
|
||||||
|
XCTAssertTrue(upstreamLabel.contains("Graeme Gill") || upstreamLabel.contains("argyllcms.com"))
|
||||||
|
|
||||||
|
// Verify fork link
|
||||||
|
let forkLink = element("argyllForkLink")
|
||||||
|
XCTAssertTrue(forkLink.waitForExistence(timeout: 5))
|
||||||
|
let forkLabel = forkLink.label
|
||||||
|
XCTAssertTrue(forkLabel.contains("Gronod") || forkLabel.contains("git.i3omb.com"))
|
||||||
|
|
||||||
|
// Verify AGPL isolation note
|
||||||
|
let isolationNote = element("agplIsolationNote")
|
||||||
|
XCTAssertTrue(isolationNote.waitForExistence(timeout: 5))
|
||||||
|
let noteText = isolationNote.value as? String ?? ""
|
||||||
|
XCTAssertTrue(noteText.contains("isolated subprocesses") || noteText.contains("AGPLv3 isolation"))
|
||||||
|
|
||||||
|
// Close license window
|
||||||
|
let closeLicenseBtn = waitFor("closeLicenseBtn", timeout: 5)
|
||||||
|
closeLicenseBtn.click()
|
||||||
|
|
||||||
|
let closeAboutBtn = waitFor("closeAboutBtn", timeout: 5)
|
||||||
|
closeAboutBtn.click()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ final class Milestone2UITests: XCTestCase {
|
|||||||
"ICCERY_UI_TESTING": "1",
|
"ICCERY_UI_TESTING": "1",
|
||||||
"ICCERY_TEST_ROOT": testRoot.path,
|
"ICCERY_TEST_ROOT": testRoot.path,
|
||||||
"ICCERY_ARGYLL_BINARY_DIR": binDir.path,
|
"ICCERY_ARGYLL_BINARY_DIR": binDir.path,
|
||||||
|
"ICCERY_CUPS_BIN_DIR": binDir.path,
|
||||||
"ICCERY_TEST_SAVE_TARGET":
|
"ICCERY_TEST_SAVE_TARGET":
|
||||||
workDir.appendingPathComponent("mytarget.ti1").path,
|
workDir.appendingPathComponent("mytarget.ti1").path,
|
||||||
"ICCERY_TEST_WORKDIR": workDir.path,
|
"ICCERY_TEST_WORKDIR": workDir.path,
|
||||||
@@ -218,11 +219,12 @@ final class Milestone2UITests: XCTestCase {
|
|||||||
XCTAssertTrue(FileManager.default.fileExists(
|
XCTAssertTrue(FileManager.default.fileExists(
|
||||||
atPath: workDir.appendingPathComponent("mytarget.ti2").path))
|
atPath: workDir.appendingPathComponent("mytarget.ti2").path))
|
||||||
|
|
||||||
// Print panel is live from M3; a default printer is selected
|
// Print panel is live from M3. GitHub macos-14 has no system
|
||||||
// so both the all-pages and per-page print buttons are enabled.
|
// queues, so mock CUPS (`ICCERY_CUPS_BIN_DIR`) must enumerate
|
||||||
|
// before Print All / per-page enable (run 34867767434).
|
||||||
XCTAssertTrue(element("rawPrintPanel").exists)
|
XCTAssertTrue(element("rawPrintPanel").exists)
|
||||||
XCTAssertTrue(app.buttons["btnPrintAll"].isEnabled)
|
_ = waitUntilEnabled("btnPrintAll", timeout: 15)
|
||||||
XCTAssertTrue(app.buttons["btnPrintPage-0"].isEnabled)
|
_ = waitUntilEnabled("btnPrintPage-0", timeout: 15)
|
||||||
XCTAssertTrue(app.buttons["btnAdvanceToStage3"].isEnabled)
|
XCTAssertTrue(app.buttons["btnAdvanceToStage3"].isEnabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -98,23 +98,9 @@ final class Milestone4UITests: XCTestCase {
|
|||||||
app.buttons["btnDetectInstruments"].click()
|
app.buttons["btnDetectInstruments"].click()
|
||||||
_ = waitFor("chartreadInstrumentSelect", timeout: 20)
|
_ = waitFor("chartreadInstrumentSelect", timeout: 20)
|
||||||
|
|
||||||
// Keep Auto (port 1) and start the session.
|
// Wait until Start is enabled before clicking. Existence-only
|
||||||
XCTAssertTrue(app.buttons["btnStartRead"].waitForExistence(timeout: 5))
|
// clicks are no-ops on the disabled control (runs 35251, 35293).
|
||||||
app.buttons["btnStartRead"].click()
|
driveOnePass(startButton: "btnStartRead")
|
||||||
|
|
||||||
// Calibrate.
|
|
||||||
let calibrate = element("btnCalibrate")
|
|
||||||
if !calibrate.waitForExistence(timeout: 25) {
|
|
||||||
let error = element("chartreadLastError").label
|
|
||||||
let value = element("chartreadLastError").value as? String ?? "<nil>"
|
|
||||||
XCTFail("No calibrate button. lastError.label='\(error)' value='\(value)'")
|
|
||||||
}
|
|
||||||
app.buttons["btnCalibrate"].click()
|
|
||||||
|
|
||||||
// Trigger each strip until all are read → Done & Save appears.
|
|
||||||
driveStripsUntilDone()
|
|
||||||
XCTAssertTrue(element("btnDoneRead").exists)
|
|
||||||
app.buttons["btnDoneRead"].firstMatch.click()
|
|
||||||
|
|
||||||
// Averaging panel appears with one pass snapshot.
|
// Averaging panel appears with one pass snapshot.
|
||||||
_ = waitFor("chartreadAveragingPanel", timeout: 20)
|
_ = waitFor("chartreadAveragingPanel", timeout: 20)
|
||||||
@@ -168,15 +154,34 @@ final class Milestone4UITests: XCTestCase {
|
|||||||
|
|
||||||
/// Runs the mock handheld chartread session to completion
|
/// Runs the mock handheld chartread session to completion
|
||||||
/// (start → calibrate → strip A → strip B → Done & Save).
|
/// (start → calibrate → strip A → strip B → Done & Save).
|
||||||
|
///
|
||||||
|
/// `isEnabled` can be true in AX while the SwiftUI action is still
|
||||||
|
/// a no-op (runs 35251, 35443). Re-click until the session is
|
||||||
|
/// actually running (`btnCancel` is shown whenever
|
||||||
|
/// `isChartreadRunning`), then wait for Calibrate.
|
||||||
private func driveOnePass(startButton: String) {
|
private func driveOnePass(startButton: String) {
|
||||||
let start = app.buttons[startButton]
|
let start = app.buttons[startButton]
|
||||||
XCTAssertTrue(start.waitForExistence(timeout: 10))
|
XCTAssertTrue(start.waitForExistence(timeout: 10), startButton)
|
||||||
let deadline = Date().addingTimeInterval(10)
|
let enabledBy = Date().addingTimeInterval(10)
|
||||||
while Date() < deadline, !start.isEnabled {
|
while Date() < enabledBy, !start.isEnabled {
|
||||||
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||||
}
|
}
|
||||||
XCTAssertTrue(start.isEnabled)
|
XCTAssertTrue(start.isEnabled, "\(startButton) never enabled")
|
||||||
|
|
||||||
|
let sessionBy = Date().addingTimeInterval(25)
|
||||||
|
while Date() < sessionBy, !chartreadSessionRunning {
|
||||||
|
if start.exists, start.isEnabled {
|
||||||
start.click()
|
start.click()
|
||||||
|
}
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.4))
|
||||||
|
}
|
||||||
|
XCTAssertTrue(
|
||||||
|
chartreadSessionRunning,
|
||||||
|
"\(startButton) click never started chartread; "
|
||||||
|
+ "notice=\(element("noticeText").value as? String ?? "") "
|
||||||
|
+ "start.exists=\(start.exists)"
|
||||||
|
)
|
||||||
|
|
||||||
_ = waitFor("btnCalibrate", timeout: 25)
|
_ = waitFor("btnCalibrate", timeout: 25)
|
||||||
app.buttons["btnCalibrate"].click()
|
app.buttons["btnCalibrate"].click()
|
||||||
driveStripsUntilDone()
|
driveStripsUntilDone()
|
||||||
@@ -184,6 +189,14 @@ final class Milestone4UITests: XCTestCase {
|
|||||||
app.buttons["btnDoneRead"].firstMatch.click()
|
app.buttons["btnDoneRead"].firstMatch.click()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `btnCancel` is in the tree for the whole chartread session;
|
||||||
|
/// Calibrate/Trigger only appear after the first classified prompt.
|
||||||
|
private var chartreadSessionRunning: Bool {
|
||||||
|
element("btnCancel").exists
|
||||||
|
|| element("btnCalibrate").exists
|
||||||
|
|| element("btnTrigger").exists
|
||||||
|
}
|
||||||
|
|
||||||
/// Clicks Trigger for each remaining strip until `btnDoneRead`
|
/// Clicks Trigger for each remaining strip until `btnDoneRead`
|
||||||
/// appears — the button is re-polled each pass so a click that
|
/// appears — the button is re-polled each pass so a click that
|
||||||
/// races a state transition isn't lost.
|
/// races a state transition isn't lost.
|
||||||
|
|||||||
@@ -106,9 +106,19 @@ fi
|
|||||||
|
|
||||||
mkdir -p "$DEST"
|
mkdir -p "$DEST"
|
||||||
cp -R "$BIN_DIR"/. "$DEST"/
|
cp -R "$BIN_DIR"/. "$DEST"/
|
||||||
|
# Copy License.txt from archive root (same level as bin/) to Vendor/Argyll/ root
|
||||||
|
VENDOR_ROOT="$ROOT/Vendor/Argyll"
|
||||||
|
for license_src in "$EXTRACT"/Argyll_V*/License.txt "$EXTRACT"/License.txt; do
|
||||||
|
if [ -f "$license_src" ]; then
|
||||||
|
cp "$license_src" "$VENDOR_ROOT/License.txt"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
find "$DEST" -type f -exec chmod 0755 {} +
|
find "$DEST" -type f -exec chmod 0755 {} +
|
||||||
# Downloads carry com.apple.quarantine; the app cannot spawn quarantined tools.
|
# Downloads carry com.apple.quarantine; the app cannot spawn quarantined tools.
|
||||||
xattr -dr com.apple.quarantine "$DEST" 2>/dev/null || true
|
xattr -dr com.apple.quarantine "$DEST" 2>/dev/null || true
|
||||||
|
# Also remove quarantine from Vendor/Argyll root if License.txt was copied
|
||||||
|
xattr -dr com.apple.quarantine "$VENDOR_ROOT" 2>/dev/null || true
|
||||||
|
|
||||||
# Ad-hoc sign every Mach-O (#165: unsigned arm64 → "Killed: 9"), then
|
# Ad-hoc sign every Mach-O (#165: unsigned arm64 → "Killed: 9"), then
|
||||||
# verify — an unsigned sidecar fails the script. The tree may nest
|
# verify — an unsigned sidecar fails the script. The tree may nest
|
||||||
|
|||||||
Reference in New Issue
Block a user