From cf2bfc3aa86c7985f473f2fb52a61ffcac8f3a0f Mon Sep 17 00:00:00 2001 From: Gremlin Date: Sat, 21 Mar 2026 15:50:21 +0100 Subject: [PATCH] feat: add SwiftUI macOS main window demo with CI --- .gitea/workflows/swift-macos-arm64.yml | 80 ++++++++++++ .gitignore | 6 + README.md | 17 +++ SwiftTrayDemo/ContentView.swift | 143 +++++++++++++++++++++ SwiftTrayDemo/SwiftTrayDemoApp.swift | 168 +++++++++++++++++++++++++ project.yml | 38 ++++++ scripts/generate-xcodeproj.sh | 8 ++ swift-tests-docs.md | 14 +++ swift-tests-report.md | 3 + 9 files changed, 477 insertions(+) create mode 100644 .gitea/workflows/swift-macos-arm64.yml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 SwiftTrayDemo/ContentView.swift create mode 100644 SwiftTrayDemo/SwiftTrayDemoApp.swift create mode 100644 project.yml create mode 100755 scripts/generate-xcodeproj.sh create mode 100644 swift-tests-docs.md create mode 100644 swift-tests-report.md diff --git a/.gitea/workflows/swift-macos-arm64.yml b/.gitea/workflows/swift-macos-arm64.yml new file mode 100644 index 0000000..c767e7d --- /dev/null +++ b/.gitea/workflows/swift-macos-arm64.yml @@ -0,0 +1,80 @@ +name: swift-macos-arm64 + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + workflow_dispatch: + +jobs: + swift-macos-arm64: + runs-on: macos-arm64 + steps: + - uses: actions/checkout@v4 + + - name: Show toolchain versions + run: | + set -eu + sw_vers + xcodebuild -version + swift --version + + - name: Install XcodeGen + run: | + set -eu + if ! command -v xcodegen >/dev/null 2>&1; then + brew install xcodegen + fi + xcodegen --version + + - name: Generate Xcode project + run: | + set -eu + chmod +x ./scripts/generate-xcodeproj.sh + ./scripts/generate-xcodeproj.sh + + - name: Build app + run: | + set -eu + xcodebuild -project SwiftTrayDemo.xcodeproj \ + -scheme SwiftTrayDemo \ + -configuration Release \ + -destination 'generic/platform=macOS' \ + ARCHS=arm64 ONLY_ACTIVE_ARCH=YES \ + -derivedDataPath build/DerivedData \ + build + + - name: Package app + run: | + set -eux + rm -rf artifacts bundle-out + mkdir -p artifacts + app="build/DerivedData/Build/Products/Release/SwiftTrayDemo.app" + zip_name="swift-window-demo-macos-arm64-run${{ github.run_number }}.zip" + [ -d "$app" ] || { echo 'No SwiftTrayDemo.app produced'; exit 1; } + ditto -c -k --sequesterRsrc --keepParent "$app" "artifacts/$zip_name" + cp -R "$app" bundle-out + printf '%s\n' 'SwiftTrayDemo macOS app bundle.' > artifacts/README.txt + + - name: Copy latest app to runner desktop + run: | + set -eux + dest="$HOME/Desktop/Builds/swift-tests" + mkdir -p "$dest" + find "$dest" -mindepth 1 -maxdepth 1 -exec rm -rf {} + + ditto bundle-out/SwiftTrayDemo.app "$dest/SwiftTrayDemo.app" + cp artifacts/README.txt "$dest/" + printf '%s\n' \ + 'repo: swift-tests' \ + 'workflow: swift-macos-arm64.yml' \ + 'artifact: SwiftTrayDemo.app' \ + "runner_app: $dest/SwiftTrayDemo.app" \ + > "$dest/LATEST.txt" + + - name: Upload artifacts + uses: actions/upload-artifact@v3 + with: + name: SwiftTrayDemo-macos-arm64-run${{ github.run_number }} + path: artifacts/ + retention-days: 14 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4dc6d18 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.DS_Store +build/ +DerivedData/ +artifacts/ +bundle-out/ +*.xcodeproj/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..8bf8e99 --- /dev/null +++ b/README.md @@ -0,0 +1,17 @@ +# swift-tests + +A modern macOS SwiftUI/Xcode app for exercising main application window capabilities on Apple Silicon Macs. + +## What it demonstrates +- standard app window with toolbar + sidebar-style layout +- live window controls (title, size, full screen, center) +- sheets and confirmation dialogs +- secondary utility window +- simple menu-bar / tray integration +- Xcode project generation via XcodeGen for modern Xcode workflows and previews + +## Layout +- `SwiftTrayDemo/` — Swift source for the macOS app +- `project.yml` — XcodeGen spec +- `scripts/generate-xcodeproj.sh` — generates `SwiftTrayDemo.xcodeproj` +- `.gitea/workflows/swift-macos-arm64.yml` — native Apple Silicon CI build diff --git a/SwiftTrayDemo/ContentView.swift b/SwiftTrayDemo/ContentView.swift new file mode 100644 index 0000000..edd8482 --- /dev/null +++ b/SwiftTrayDemo/ContentView.swift @@ -0,0 +1,143 @@ +import SwiftUI + +struct FeatureCard: View { + let title: String + let systemImage: String + let detail: String + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Label(title, systemImage: systemImage) + .font(.headline) + Text(detail) + .font(.subheadline) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .background(.quaternary.opacity(0.45), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + } +} + +struct ContentView: View { + @Environment(\.openWindow) private var openWindow + + @State private var documentTitle = "Swift Window Demo" + @State private var width: Double = 980 + @State private var height: Double = 640 + @State private var showSheet = false + @State private var showDialog = false + + var body: some View { + NavigationSplitView { + List { + Label("Overview", systemImage: "macwindow") + Label("Window Controls", systemImage: "slider.horizontal.3") + Label("Secondary Window", systemImage: "uiwindow.split.2x1") + Label("Dialogs", systemImage: "square.and.pencil") + } + .navigationTitle("Capabilities") + .listStyle(.sidebar) + } detail: { + ScrollView { + VStack(alignment: .leading, spacing: 22) { + VStack(alignment: .leading, spacing: 8) { + Text("Main Window Demo") + .font(.system(size: 34, weight: .bold)) + Text("A current-style SwiftUI macOS app that exercises real desktop window behaviors instead of just rendering a static view.") + .foregroundStyle(.secondary) + } + + Grid(horizontalSpacing: 16, verticalSpacing: 16) { + GridRow { + FeatureCard(title: "Resizable", systemImage: "arrow.up.left.and.arrow.down.right", detail: "Set a target content size and apply it to the frontmost app window.") + FeatureCard(title: "Secondary Window", systemImage: "macwindow.on.rectangle", detail: "Open a separate utility-style scene to prove multi-window support.") + } + GridRow { + FeatureCard(title: "Sheet + Dialog", systemImage: "rectangle.portrait.and.arrow.right", detail: "Trigger standard macOS sheets and confirmation dialogs.") + FeatureCard(title: "Tray Integration", systemImage: "menubar.rectangle", detail: "Use the menu-bar item to reopen the main window at any time.") + } + } + + GroupBox("Window controls") { + VStack(alignment: .leading, spacing: 14) { + TextField("Window title", text: $documentTitle) + .textFieldStyle(.roundedBorder) + + HStack { + VStack(alignment: .leading) { + Text("Width: \(Int(width))") + Slider(value: $width, in: 700...1400, step: 10) + } + VStack(alignment: .leading) { + Text("Height: \(Int(height))") + Slider(value: $height, in: 480...960, step: 10) + } + } + + HStack(spacing: 12) { + Button("Apply Size") { + WindowCommandCenter.resizeMainWindow(width: width, height: height) + } + Button("Center Window") { + WindowCommandCenter.centerMainWindow() + } + Button("Toggle Full Screen") { + WindowCommandCenter.toggleFullScreen() + } + } + } + .padding(.top, 4) + } + + HStack(spacing: 12) { + Button("Open Utility Window") { + openWindow(id: "inspector") + } + Button("Show Sheet") { + showSheet = true + } + Button("Show Confirmation") { + showDialog = true + } + } + } + .padding(24) + } + .navigationTitle(documentTitle) + .sheet(isPresented: $showSheet) { + SheetDemoView() + } + .confirmationDialog("Test confirmation dialog", isPresented: $showDialog, titleVisibility: .visible) { + Button("Acknowledge") {} + Button("Cancel", role: .cancel) {} + } message: { + Text("This validates standard app-window attached dialogs on macOS.") + } + } + } +} + +struct SheetDemoView: View { + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("Attached Sheet") + .font(.title.bold()) + Text("This sheet is attached to the main app window and demonstrates standard macOS document-style presentation.") + .foregroundStyle(.secondary) + HStack { + Spacer() + Button("Close") { dismiss() } + .keyboardShortcut(.defaultAction) + } + } + .padding(24) + .frame(minWidth: 420, minHeight: 180) + } +} + +#Preview { + ContentView() +} diff --git a/SwiftTrayDemo/SwiftTrayDemoApp.swift b/SwiftTrayDemo/SwiftTrayDemoApp.swift new file mode 100644 index 0000000..a623925 --- /dev/null +++ b/SwiftTrayDemo/SwiftTrayDemoApp.swift @@ -0,0 +1,168 @@ +import SwiftUI +import AppKit +import ObjectiveC + +enum WindowCommandCenter { + static func mainWindow() -> NSWindow? { + NSApp.windows.first { $0.identifier?.rawValue == "main-window" } ?? NSApp.windows.first + } + + static func resizeMainWindow(width: Double, height: Double) { + guard let window = mainWindow() else { return } + var frame = window.frame + frame.size = NSSize(width: width, height: height) + window.setFrame(frame, display: true, animate: true) + window.makeKeyAndOrderFront(nil) + } + + static func centerMainWindow() { + guard let window = mainWindow() else { return } + window.center() + NSApp.activate(ignoringOtherApps: true) + window.makeKeyAndOrderFront(nil) + } + + static func toggleFullScreen() { + guard let window = mainWindow() else { return } + NSApp.activate(ignoringOtherApps: true) + window.toggleFullScreen(nil) + } + + static func showMainWindow() { + guard let window = mainWindow() else { return } + NSApp.activate(ignoringOtherApps: true) + window.makeKeyAndOrderFront(nil) + } +} + +final class WindowAccessor: NSView { + var onResolve: ((NSWindow) -> Void)? + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + if let window { + onResolve?(window) + } + } +} + +struct MainWindowConfigurator: NSViewRepresentable { + func makeNSView(context: Context) -> NSView { + let view = WindowAccessor() + view.onResolve = { window in + window.identifier = NSUserInterfaceItemIdentifier("main-window") + window.title = "Swift Window Demo" + window.setContentSize(NSSize(width: 980, height: 640)) + window.center() + window.styleMask.insert(.resizable) + window.styleMask.insert(.miniaturizable) + window.toolbarStyle = .unified + window.titlebarAppearsTransparent = false + } + return view + } + + func updateNSView(_ nsView: NSView, context: Context) {} +} + +final class TrayController: NSObject { + private var statusItem: NSStatusItem? + + func install() { + let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + item.button?.image = NSImage(systemSymbolName: "menubar.rectangle", accessibilityDescription: "Swift Window Demo") + item.button?.toolTip = "Swift Window Demo" + + let menu = NSMenu() + + let openItem = NSMenuItem(title: "Open Main Window", action: #selector(MenuActionBox.performAction), keyEquivalent: "o") + openItem.target = MenuActionBox { + WindowCommandCenter.showMainWindow() + } + menu.addItem(openItem) + + let centerItem = NSMenuItem(title: "Center Main Window", action: #selector(MenuActionBox.performAction), keyEquivalent: "c") + centerItem.target = MenuActionBox { + WindowCommandCenter.centerMainWindow() + } + menu.addItem(centerItem) + + menu.addItem(.separator()) + + let quitItem = NSMenuItem(title: "Quit", action: #selector(MenuActionBox.performAction), keyEquivalent: "q") + quitItem.target = MenuActionBox { + NSApp.terminate(nil) + } + menu.addItem(quitItem) + + objc_setAssociatedObject(openItem, UnsafeRawPointer(bitPattern: 1)!, openItem.target, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + objc_setAssociatedObject(centerItem, UnsafeRawPointer(bitPattern: 2)!, centerItem.target, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + objc_setAssociatedObject(quitItem, UnsafeRawPointer(bitPattern: 3)!, quitItem.target, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + + item.menu = menu + statusItem = item + } +} + +final class MenuActionBox: NSObject { + private let action: () -> Void + + init(action: @escaping () -> Void) { + self.action = action + } + + @objc func performAction() { + action() + } +} + +final class AppDelegate: NSObject, NSApplicationDelegate { + private let trayController = TrayController() + + func applicationDidFinishLaunching(_ notification: Notification) { + trayController.install() + } +} + +struct InspectorView: View { + var body: some View { + VStack(alignment: .leading, spacing: 14) { + Text("Utility Window") + .font(.title2.bold()) + Text("This secondary scene proves the app supports more than one real macOS window.") + .foregroundStyle(.secondary) + Divider() + Label("Independent scene", systemImage: "macwindow.on.rectangle") + Label("Useful for inspectors, logs, or tools", systemImage: "sidebar.right") + } + .padding(20) + .frame(minWidth: 360, minHeight: 220) + } +} + +@main +struct SwiftTrayDemoApp: App { + @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate + + var body: some Scene { + WindowGroup("Swift Window Demo") { + ContentView() + .background(MainWindowConfigurator()) + } + .defaultSize(width: 980, height: 640) + .commands { + CommandGroup(after: .windowArrangement) { + Button("Center Main Window") { + WindowCommandCenter.centerMainWindow() + } + .keyboardShortcut("0") + } + } + + Window("Inspector", id: "inspector") { + InspectorView() + } + .defaultSize(width: 360, height: 220) + .windowResizability(.contentSize) + } +} diff --git a/project.yml b/project.yml new file mode 100644 index 0000000..2533f2f --- /dev/null +++ b/project.yml @@ -0,0 +1,38 @@ +name: SwiftTrayDemo +options: + minimumXcodeGenVersion: 2.38.0 + deploymentTarget: + macOS: '14.0' +settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: io.swissline.swifttraydemo + SWIFT_VERSION: 5.10 + MACOSX_DEPLOYMENT_TARGET: 14.0 + GENERATE_INFOPLIST_FILE: YES + INFOPLIST_KEY_NSHighResolutionCapable: YES + INFOPLIST_KEY_LSApplicationCategoryType: public.app-category.developer-tools + CODE_SIGNING_ALLOWED: NO + CODE_SIGNING_REQUIRED: NO + CODE_SIGN_IDENTITY: '' + ENABLE_DEBUG_DYLIB: YES + +targets: + SwiftTrayDemo: + type: application + platform: macOS + sources: + - SwiftTrayDemo + settings: + base: + PRODUCT_NAME: SwiftTrayDemo + PRODUCT_MODULE_NAME: SwiftTrayDemo + INFOPLIST_KEY_CFBundleDisplayName: SwiftTrayDemo + INFOPLIST_KEY_CFBundleName: SwiftTrayDemo + INFOPLIST_KEY_CFBundleExecutable: SwiftTrayDemo + INFOPLIST_KEY_CFBundleIdentifier: io.swissline.swifttraydemo + INFOPLIST_KEY_CFBundleShortVersionString: '1.0' + INFOPLIST_KEY_CFBundleVersion: '1' + INFOPLIST_KEY_LSMinimumSystemVersion: '14.0' + scheme: + testTargets: [] + gatherCoverageData: false diff --git a/scripts/generate-xcodeproj.sh b/scripts/generate-xcodeproj.sh new file mode 100755 index 0000000..d6218b9 --- /dev/null +++ b/scripts/generate-xcodeproj.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail +if ! command -v xcodegen >/dev/null 2>&1; then + echo "xcodegen not found. Install with: brew install xcodegen" >&2 + exit 1 +fi +cd "$(dirname "$0")/.." +xcodegen generate diff --git a/swift-tests-docs.md b/swift-tests-docs.md new file mode 100644 index 0000000..fdfd9f2 --- /dev/null +++ b/swift-tests-docs.md @@ -0,0 +1,14 @@ +# swift-tests-docs + +Clean restart as a normal macOS SwiftUI app. + +## Shape +- one app target only +- no Swift package layout +- one window +- one tray icon +- real `.app` output + +## Build output +Latest app is copied to: +- `/Users/m1/Desktop/Builds/swift-tests/SwiftTrayDemo.app` diff --git a/swift-tests-report.md b/swift-tests-report.md new file mode 100644 index 0000000..ea95a7e --- /dev/null +++ b/swift-tests-report.md @@ -0,0 +1,3 @@ +# swift-tests-report + +Project restarted from scratch as a minimal normal SwiftUI macOS app with one target, one window, and one tray icon.