diff --git a/Sources/ICCery/AboutView.swift b/Sources/ICCery/AboutView.swift index a24a8e5..8fd604a 100644 --- a/Sources/ICCery/AboutView.swift +++ b/Sources/ICCery/AboutView.swift @@ -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() + } } } diff --git a/Sources/ICCery/LicenseWindowView.swift b/Sources/ICCery/LicenseWindowView.swift new file mode 100644 index 0000000..531ada4 --- /dev/null +++ b/Sources/ICCery/LicenseWindowView.swift @@ -0,0 +1,146 @@ +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) + .accessibilityIdentifier("attributionSection") + } + .padding(24) + } + .frame(minWidth: 600, minHeight: 500) + .background(Theme.panel) + } + .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) + } +} \ No newline at end of file diff --git a/Tests/ICCeryUITests/AboutHelpUITests.swift b/Tests/ICCeryUITests/AboutHelpUITests.swift index b44cd0d..a842abd 100644 --- a/Tests/ICCeryUITests/AboutHelpUITests.swift +++ b/Tests/ICCeryUITests/AboutHelpUITests.swift @@ -85,4 +85,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() + } } diff --git a/scripts/fetch-argyll.sh b/scripts/fetch-argyll.sh index 0918c37..61296c6 100755 --- a/scripts/fetch-argyll.sh +++ b/scripts/fetch-argyll.sh @@ -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