Compare commits

..
4 Commits
Author SHA1 Message Date
gronod 2fefe98434 fix: UI test helpers for nested license window sheet
macOS CI / package (pull_request) Canceled after 0s
macOS CI / build-and-test (pull_request) Canceled after 21s
macOS CI / build-and-test (push) Successful in 37m17s
macOS CI / package (push) Successful in 3m29s
- Update element() helper in AboutHelpUITests to search all sheets (nested sheets)
- Add .accessibilityElement(children: .contain) to LicenseWindowView root and attribution section
- All 6 AboutHelpUITests now pass (2 existing + 4 new license window tests)
2026-09-15 01:21:54 +01:00
gronod 90d034baab fix: LicenseWindowView compatibility with macOS 12.0 (remove NavigationStack)
macOS CI / build-and-test (pull_request) Canceled after 0s
macOS CI / package (pull_request) Canceled after 0s
macOS CI / build-and-test (push) Failing after 41m0s
macOS CI / package (push) Skipped
2026-09-14 23:40:08 +01:00
gronod 8cb79e8ffa feat: add license window to About dialog (issue #31)
macOS CI / build-and-test (push) Skipped
- Add LicenseWindowView.swift with ICCery license, ArgyllCMS AGPLv3 license (from bundled License.txt), and attribution links to upstream and Gronod fork
- Add "View Licenses" button to AboutView.swift that opens license window as sheet
- Update fetch-argyll.sh to copy License.txt to Vendor/Argyll/ root
- Add UI tests for license window content, links, and AGPL isolation note

The license window includes:
- ICCery license text (embedded from LICENCE.md)
- ArgyllCMS AGPLv3 license (dynamically loaded from bundled Resources/Argyll/License.txt)
- Attribution to Graeme Gill with link to argyllcms.com
- Attribution to Gronod fork with link to git.i3omb.com/gronod/argyllcms
- AGPL subprocess isolation note
2026-09-14 23:35:43 +01:00
gronod c1c37bb7d7 test(m4): retry Start Read until the chartread session is running
macOS CI / package (pull_request) Canceled after 0s
macOS CI / build-and-test (pull_request) Canceled after 1m52s
macOS CI / build-and-test (push) Successful in 37m49s
macOS CI / package (push) Successful in 4m39s
testTwoPassAverageFailurePromotesFirstPass died on Gitea run 35443
waiting for btnCalibrate after a single Start click. Handheld in the
same job succeeded: AX reported enabled and synthesized the click, but
SwiftUI startRead() was still a no-op (same class as 35251).

Re-click until btnCancel/Calibrate/Trigger exist — Cancel is in the
tree for the whole session — then wait for Calibrate.
2026-09-14 18:40:27 +00:00
5 changed files with 343 additions and 5 deletions
+11
View File
@@ -6,6 +6,8 @@ import ICCeryCore
struct AboutView: View {
let onClose: () -> Void
@State private var showingLicenses = false
private let info = ArtefactFiles.appInfo()
var body: some View {
@@ -54,6 +56,12 @@ struct AboutView: View {
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
Button("View Licenses") {
showingLicenses = true
}
.controlSize(.large)
.accessibilityIdentifier("viewLicensesBtn")
Button("Close") {
onClose()
}
@@ -66,5 +74,8 @@ struct AboutView: View {
.background(Theme.panel)
.accessibilityElement(children: .contain)
.accessibilityIdentifier("aboutDialog")
.sheet(isPresented: $showingLicenses) {
LicenseWindowView()
}
}
}
+148
View File
@@ -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)
}
}
+142
View File
@@ -20,6 +20,13 @@ 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
}
@@ -85,4 +92,139 @@ 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()
}
}
+32 -5
View File
@@ -154,15 +154,34 @@ 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))
let deadline = Date().addingTimeInterval(10)
while Date() < deadline, !start.isEnabled {
XCTAssertTrue(start.waitForExistence(timeout: 10), startButton)
let enabledBy = Date().addingTimeInterval(10)
while Date() < enabledBy, !start.isEnabled {
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
}
XCTAssertTrue(start.isEnabled)
start.click()
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)"
)
_ = waitFor("btnCalibrate", timeout: 25)
app.buttons["btnCalibrate"].click()
driveStripsUntilDone()
@@ -170,6 +189,14 @@ 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.
+10
View File
@@ -106,9 +106,19 @@ 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