diff --git a/docs/OPENWORLDS_APP_SURFACE_MAP.md b/docs/OPENWORLDS_APP_SURFACE_MAP.md new file mode 100644 index 00000000..b71e7433 --- /dev/null +++ b/docs/OPENWORLDS_APP_SURFACE_MAP.md @@ -0,0 +1,74 @@ +# OpenWorlds App Surface Map + +Date: 2026-05-26 + +This document is the app-side handoff map for the OpenWorlds macOS shell. It +describes what is wired today, what is display-only, and which GitHub issue owns +the next implementation. The native app remains a supervisor and read/action +surface; the engine/viewer remain the game-state authority. + +## Capability Legend + +- **Wired**: usable against current viewer/native bridge APIs. +- **Read-only**: backed by real data, but no mutation path yet. +- **Partial**: some real backing exists, but the screen still contains local or + prototype-only behavior. +- **Display-only**: retained for design fidelity and roadmap shape, not backed + by ClawDnD data yet. +- **Engine-needed**: needs a new engine/viewer read model or engine-owned action. +- **Bridge-needed**: needs native supervisor bridge work. + +## Product Shell + +| Area | Current state | Evidence | Owner issue | Next action | +| --- | --- | --- | --- | --- | +| Native app host | Wired | `RootView` launches viewer and hosts `/openworlds/` in `WKWebView` | #82, #113 | Keep as product shell; leave old SwiftUI shell as debug only | +| Bundled OpenWorlds assets | Wired | App packages `viewer/openworlds` and sets `CLAWDND_OPENWORLDS_DIR` | #134 | Keep UI assets app-bundled; engine/viewer stay repo-backed | +| Sparkle local beta | Partial | `UpdaterService`, `checkForUpdates`, local appcast | #134 | Manual update smoke; notarization remains separate | +| Window controls | Partial | OpenWorlds traffic buttons call `windowCommand`; AppKit drag strip exists | #136 | Manual close/minimize/zoom/drag smoke; tune drag hit box if needed | +| Native settings/provider/log bridge | Partial | Settings screen calls `appStatus`, provider actions, diagnostics, Sparkle | #132, #133 | Move remaining Swift debug-only controls into OpenWorlds or mark unavailable | + +## OpenWorlds Screens + +| Screen | Current state | Backing today | Owner issue | Handoff notes | +| --- | --- | --- | --- | --- | +| Chronicles / launcher | Partial | `/openworlds/campaigns.json`; local selected campaign state | #114 | Resume/view navigates to Table. New chronicle modal currently creates local-only demo rows and must be replaced with native/provider start flow before treating it as real. | +| Session / table | Wired/partial | `/session-surface`; enabled actions post `/move` | #115 | Best real gameplay surface today. Continue enriching read model fields rather than writing local state. | +| Battle / combat | Wired/partial | `/combat-surface`; action bar posts `/move` | #116 | Tactical board is engine-owned projection. Improve legality, event cards, targeting, and refresh behavior through viewer read models. | +| Atlas / map | Wired/partial | `/atlas-surface`; available travel posts `/move` | #117 | Strategic map is partially real. Camp/rest UI is still mostly informational until rest/camp actions are engine-owned. | +| Settings / native app | Partial | Native bridge app status, dependencies, providers, Sparkle | #132, #134 | `ClawDnD` section is real. Audio/display/gameplay/controls/accessibility/saves are display-only until preferences are bridged. | +| Quest journal | Display-only/read-model-needed | `state.quests` demo/store fallback only | #120 or new journal projection issue | Should consume player-known quest projection from viewer, not static seed data. | +| Character / heroes | Display-only/engine-needed | `state.party` only | #80/#81 if build planner, or new character projection issue | Needs character sheet projection, level-up/build planner actions, rest-prep through engine. | +| Inventory / stash | Display-only/engine-needed | `state.stash` only; equip/use/drop are local toasts | #119 | Replace local interactions with engine-owned item action model. | +| Merchant / market | Display-only/engine-needed | static `MERCHANTS`, local cart/coins | #119 | Needs economy/merchant read models and buy/sell/haggle player-intent actions. | +| Forge | Display-only/engine-needed | static recipe list and local random roll | #119 | Must not keep local crafting resolution; needs engine-owned craft preview/action. | +| Relations / camp | Display-only/engine-needed | static factions/NPCs | #118 | Needs companion/camp/faction/NPC disposition projection and player-known filtering. | +| Bestiary / codex | Display-only/engine-needed | static `BESTIARY`, `PEOPLE`, `LORE` | #121 | Must consume player-known lore only; never expose hidden/private lore. | +| Acts | Display-only/engine-needed | static `ACTS` | #120 | Needs campaign-director projection and player-visible chronology/payoff markers. | +| Dialogue / parley | Display-only/provider-needed | static `DIALOGUE`; local choice tree | #118 or provider dialogue issue | Needs provider/engine dialogue read model; choices should post player intent, not mutate local branch only. | +| Creation Plane | Display-only/engine-needed | local character-creation wizard | #79/#81 or new onboarding issue | Can remain design reference until character creation/start-game contract exists. | +| World Seed | Display-only/engine-needed | local seed settings and toasts | #114/#79 | Needs read-only campaign seed projection first; destructive reseed must be unavailable until engine-owned. | + +## Highest-Value App Follow-Ups + +1. **Finish manual beta smoke for PR #150**: verify OpenWorlds loads inside the + packaged app, Sparkle reads the local appcast, traffic buttons control the + real native window, and the drag strip behaves well. +2. **Replace local-only launcher creation**: `NewCampaignModal` must call the + native/provider start flow or be marked `Display-only`. +3. **Bridge Settings preferences**: persist real native prefs for repo path, + port, state dir, provider commands, budgets, voice backend, and update state. +4. **Complete Session/Combat/Atlas before new surfaces**: these already have + viewer read models and `/move` lanes, so they compound fastest. +5. **Keep all inventory/crafting/merchant actions disabled until engine-owned**: + the current UI is visually useful but must not resolve item/economy state in + browser-local code. +6. **Add notarization as a separate release-trust issue**: current beta is + Developer ID signed but intentionally not notarized. + +## Non-Negotiable Boundary + +The macOS app and OpenWorlds browser code may supervise, display, and submit +player intent. They must not directly write `snapshot.json`, `play-state`, +`qa/state`, inventory, quests, XP, clocks, companion state, or private lore. + diff --git a/docs/OPENWORLDS_NATIVE_APP_ROADMAP.md b/docs/OPENWORLDS_NATIVE_APP_ROADMAP.md index 2d7e9ff4..051b6406 100644 --- a/docs/OPENWORLDS_NATIVE_APP_ROADMAP.md +++ b/docs/OPENWORLDS_NATIVE_APP_ROADMAP.md @@ -60,16 +60,46 @@ Every OpenWorlds screen should expose one of these labels: 5. Sprint 4: make Chronicles the real app home with live/stale run state. 6. Sprint 5+: finish gameplay surfaces in impact order: table, combat, atlas, relations/camp, inventory/economy, acts, bestiary/codex. -7. Release trust: add a Sparkle-backed update channel (#134) after the local `.app`, - signing, and bundle identity are stable. This should let owners update the - native app without repeated manual rebuild/download cycles, while keeping the - viewer/engine state directories outside the app bundle. +7. Release trust: add a Sparkle-backed local beta channel (#134) after the local + `.app`, signing, and bundle identity are stable. This lets owners update the + native app and bundled OpenWorlds UI without repeated manual rebuild/download + cycles, while keeping the viewer/engine state directories outside the app + bundle. ## Sparkle Update Lane -Sparkle is intentionally out of the correction PR's runtime scope. Track it as a -release-trust feature in #134 after the OpenWorlds host and native bridge are -stable. +Sparkle is the beta distribution lane for #134. Maintainers currently use a +Lexar-backed local channel at `/Volumes/LEXAR/Codex/clawdnd-beta-channel`. +Contributors can use a different local channel by setting `BETA_OUTPUT_DIR` and, +when needed, `CLAWDND_FEED_URL` before running the packaging script. Sparkle +must fetch appcasts over HTTP(S), so the app points Sparkle at the running +loopback viewer, and the viewer serves the local channel artifacts without +moving game state into the app bundle. + +Local maintainer setup: + +- app bundle: `/Volumes/LEXAR/Codex/clawdnd-beta-channel/ClawDnD.app` +- update feed: `http://127.0.0.1:/appcast.xml` +- local channel files: `/Volumes/LEXAR/Codex/clawdnd-beta-channel/appcast.xml` +- release script: `script/package_macos_beta.sh` +- bundle id: `dev.clawdnd.app` +- version/build: `0.3.0` / `2026052601` for `0.3.0-beta.1` +- signing identity: `Developer ID Application: Andrew Ryan (TC6MS3T6NN)` + +Contributor override example: + +```bash +BETA_OUTPUT_DIR="/Volumes/LEXAR/Codex/my-clawdnd-beta-channel" \ +CLAWDND_FEED_URL="http://127.0.0.1:8765/appcast.xml" \ +./script/package_macos_beta.sh --version 0.3.0 --build 2026052601 --channel local-beta +``` + +The beta packaging script intentionally rejects output paths outside +`/Volumes/LEXAR/Codex` and release identifiers containing path separators. + +The Sparkle private key lives only under +`/Volumes/LEXAR/Codex/clawdnd-release-secrets/`. The repo stores only the public +key in `macos/ClawDnDApp/SparklePublicKey.txt`. Implementation goals: @@ -82,6 +112,12 @@ Implementation goals: bridge, not through a second visible SwiftUI settings shell. - Keep local dev builds working without Sparkle so contributors can still use `./script/build_and_run.sh --verify`. +- Package `viewer/openworlds/` into `ClawDnD.app/Contents/Resources/openworlds` + and launch the repo-backed Python viewer with `CLAWDND_OPENWORLDS_DIR` pointing + at those bundled assets when available. +- Launch the viewer with `CLAWDND_BETA_CHANNEL_DIR` when a local beta channel is + configured, so `/appcast.xml` and `ClawDnD-*` artifacts are served from the + same loopback host/port as the OpenWorlds shell. ## Window Chrome Lane @@ -114,8 +150,33 @@ Supported request types: - `stopProvider` - `diagnostics` - `copyDiagnostics` +- `updaterStatus` +- `checkForUpdates` +- `windowCommand` - `openFallbackDashboard` +Update and window request payloads: + +```json +{ "type": "updaterStatus", "payload": {} } +``` + +Returns `{ "updater": { "version": "0.3.0", "build": "2026052601", "feedURL": "...", "channel": "local-beta", "canCheckForUpdates": true, "status": "ready", "lastError": "" } }`. + +```json +{ "type": "checkForUpdates", "payload": {} } +``` + +Triggers Sparkle's user-facing update check and returns the same updater status +shape. + +```json +{ "type": "windowCommand", "payload": { "command": "close" } } +``` + +`command` must be one of `close`, `minimize`, or `zoom`; the native reply +includes `{ "command": "...", "performed": true }`. + Native replies: ```json diff --git a/macos/ClawDnDApp/Package.resolved b/macos/ClawDnDApp/Package.resolved new file mode 100644 index 00000000..d4c21882 --- /dev/null +++ b/macos/ClawDnDApp/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins" : [ + { + "identity" : "sparkle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/sparkle-project/Sparkle", + "state" : { + "revision" : "6276ba2b404829d139c45ff98427cf90e2efc59b", + "version" : "2.9.2" + } + } + ], + "version" : 2 +} diff --git a/macos/ClawDnDApp/Package.swift b/macos/ClawDnDApp/Package.swift index 881e6fc1..8c257631 100644 --- a/macos/ClawDnDApp/Package.swift +++ b/macos/ClawDnDApp/Package.swift @@ -10,7 +10,15 @@ let package = Package( products: [ .executable(name: "ClawDnDApp", targets: ["ClawDnDApp"]) ], + dependencies: [ + .package(url: "https://github.com/sparkle-project/Sparkle", exact: "2.9.2") + ], targets: [ - .executableTarget(name: "ClawDnDApp") + .executableTarget( + name: "ClawDnDApp", + dependencies: [ + .product(name: "Sparkle", package: "Sparkle") + ] + ) ] ) diff --git a/macos/ClawDnDApp/Sources/ClawDnDApp/App/ClawDnDApp.swift b/macos/ClawDnDApp/Sources/ClawDnDApp/App/ClawDnDApp.swift index b3a21910..34e15de9 100644 --- a/macos/ClawDnDApp/Sources/ClawDnDApp/App/ClawDnDApp.swift +++ b/macos/ClawDnDApp/Sources/ClawDnDApp/App/ClawDnDApp.swift @@ -7,18 +7,21 @@ struct ClawDnDApp: App { @Environment(\.openWindow) private var openWindow @StateObject private var processService = AppProcessService() @StateObject private var campaignStore = CampaignStore() + @StateObject private var updaterService = UpdaterService() var body: some Scene { WindowGroup { RootView() .environmentObject(processService) .environmentObject(campaignStore) + .environmentObject(updaterService) .frame(minWidth: 1120, minHeight: 720) } WindowGroup("Debug Control Center", id: "debug-control-center") { DebugControlCenterView() .environmentObject(processService) .environmentObject(campaignStore) + .environmentObject(updaterService) .frame(minWidth: 1120, minHeight: 720) } .commands { diff --git a/macos/ClawDnDApp/Sources/ClawDnDApp/Models/LocalEndpoint.swift b/macos/ClawDnDApp/Sources/ClawDnDApp/Models/LocalEndpoint.swift index 9019eb1f..65530912 100644 --- a/macos/ClawDnDApp/Sources/ClawDnDApp/Models/LocalEndpoint.swift +++ b/macos/ClawDnDApp/Sources/ClawDnDApp/Models/LocalEndpoint.swift @@ -28,6 +28,10 @@ struct LocalEndpoint: Identifiable, Equatable { return components?.url ?? url.appendingPathComponent("openworlds/") } + var appcastURL: URL { + url.appendingPathComponent("appcast.xml") + } + var monitorURL: URL { url.appendingPathComponent("monitor") } diff --git a/macos/ClawDnDApp/Sources/ClawDnDApp/Services/AppProcessService.swift b/macos/ClawDnDApp/Sources/ClawDnDApp/Services/AppProcessService.swift index 2e7bd6f9..d4c96e17 100644 --- a/macos/ClawDnDApp/Sources/ClawDnDApp/Services/AppProcessService.swift +++ b/macos/ClawDnDApp/Sources/ClawDnDApp/Services/AppProcessService.swift @@ -10,6 +10,8 @@ final class AppProcessService: ObservableObject { @Published var providerLog: String = "" @Published var lastError: String? @Published var providerLaunchMetadata: ProviderLaunchMetadata? + @Published var openWorldsAssetsPath: String? + @Published var localBetaChannelPath: String? private var viewerProcess: ManagedProcess? private var providerProcess: ManagedProcess? @@ -22,6 +24,8 @@ final class AppProcessService: ObservableObject { ClawDnD Native App Diagnostics Viewer: \(viewerEndpoint?.url.absoluteString ?? "stopped") Viewer status: \(viewerEndpoint?.status.rawValue ?? "stopped") + OpenWorlds assets: \(openWorldsAssetsPath ?? "repo default") + Local beta channel: \(localBetaChannelPath ?? "not configured") Active campaign: \(activeCampaignID ?? "none") Running provider: \(runningProvider?.rawValue ?? "none") Last error: \(lastError ?? "none") @@ -60,6 +64,11 @@ final class AppProcessService: ObservableObject { guard RepositoryLocator.looksLikeRepo(repoURL) else { try throwAndRecord("Repo path is not a ClawDnD checkout: \(repoPath)") } + guard RepositoryLocator.supportsOpenWorldsViewer(repoURL) else { + try throwAndRecord( + "Repo checkout is missing OpenWorlds viewer routes. Update the checkout or choose an OpenWorlds-capable ClawDnD worktree: \(repoPath)" + ) + } guard Shell.which("python3") != nil else { try throwAndRecord("python3 is missing. Install Python 3 before launching the viewer.") } @@ -74,6 +83,8 @@ final class AppProcessService: ObservableObject { if !stateDir.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { env["CLAWDND_STATE_DIR"] = (stateDir as NSString).expandingTildeInPath } + attachBundledOpenWorldsAssets(to: &env) + attachLocalBetaChannel(to: &env) let args = ["python3", "viewer/server.py", campaignID ?? "", String(port)] let managed = try launchManagedProcess( name: "viewer", @@ -119,6 +130,11 @@ final class AppProcessService: ObservableObject { guard RepositoryLocator.looksLikeRepo(repoURL) else { try throwAndRecord("Repo path is not a ClawDnD checkout: \(repoPath)") } + guard RepositoryLocator.supportsOpenWorldsViewer(repoURL) else { + try throwAndRecord( + "Repo checkout is missing OpenWorlds viewer routes. Update the checkout or choose an OpenWorlds-capable ClawDnD worktree: \(repoPath)" + ) + } guard let port = PortFinder.firstFreePort(startingAt: preferredPort) else { try throwAndRecord("Could not find a free provider viewer port near \(preferredPort).") @@ -152,13 +168,16 @@ final class AppProcessService: ObservableObject { runningProvider = nil providerLaunchMetadata = nil providerLog = "" + var providerEnvironment = request.environment + attachBundledOpenWorldsAssets(to: &providerEnvironment) + attachLocalBetaChannel(to: &providerEnvironment) let metadata = ProviderLaunchMetadata( kind: kind, processName: request.name, executable: request.executable, arguments: request.arguments, workingDirectory: request.workingDirectory, - environment: request.environment, + environment: providerEnvironment, world: world, runId: runId, port: port, @@ -170,7 +189,7 @@ final class AppProcessService: ObservableObject { executable: request.executable, arguments: request.arguments, workingDirectory: request.workingDirectory, - environment: request.environment, + environment: providerEnvironment, stream: .provider, providerMetadata: metadata ) @@ -331,6 +350,49 @@ final class AppProcessService: ObservableObject { return (trimmed as NSString).expandingTildeInPath } + private func attachBundledOpenWorldsAssets(to environment: inout [String: String]) { + guard let bundledAssets = bundledOpenWorldsAssets() else { + openWorldsAssetsPath = nil + return + } + environment["CLAWDND_OPENWORLDS_DIR"] = bundledAssets.path + openWorldsAssetsPath = bundledAssets.path + } + + private func bundledOpenWorldsAssets() -> URL? { + guard let resourceURL = Bundle.main.resourceURL else { return nil } + let assetsURL = resourceURL.appendingPathComponent("openworlds", isDirectory: true) + let indexURL = assetsURL.appendingPathComponent("index.html") + guard FileManager.default.fileExists(atPath: indexURL.path) else { return nil } + return assetsURL + } + + private func attachLocalBetaChannel(to environment: inout [String: String]) { + guard let channelURL = localBetaChannel() else { + localBetaChannelPath = nil + return + } + environment["CLAWDND_BETA_CHANNEL_DIR"] = channelURL.path + localBetaChannelPath = channelURL.path + } + + private func localBetaChannel() -> URL? { + let fileManager = FileManager.default + if let rawPath = Bundle.main.object(forInfoDictionaryKey: "ClawDnDLocalBetaChannelPath") as? String { + let url = URL(fileURLWithPath: (rawPath as NSString).expandingTildeInPath, isDirectory: true) + if fileManager.fileExists(atPath: url.appendingPathComponent("appcast.xml").path) { + return url + } + } + + let appDirectory = Bundle.main.bundleURL.deletingLastPathComponent() + if fileManager.fileExists(atPath: appDirectory.appendingPathComponent("appcast.xml").path) { + return appDirectory + } + + return nil + } + private func throwAndRecord(_ message: String) throws -> Never { lastError = message append(message, stream: .supervisor) diff --git a/macos/ClawDnDApp/Sources/ClawDnDApp/Services/RepositoryLocator.swift b/macos/ClawDnDApp/Sources/ClawDnDApp/Services/RepositoryLocator.swift index a159d04f..56e0c515 100644 --- a/macos/ClawDnDApp/Sources/ClawDnDApp/Services/RepositoryLocator.swift +++ b/macos/ClawDnDApp/Sources/ClawDnDApp/Services/RepositoryLocator.swift @@ -2,34 +2,84 @@ import Foundation enum RepositoryLocator { static func defaultRepoPath() -> String? { - if let env = ProcessInfo.processInfo.environment["CLAWDND_REPO_ROOT"] { - let expanded = (env as NSString).expandingTildeInPath - if looksLikeRepo(URL(fileURLWithPath: expanded)) { - return expanded - } + if let openWorldsRepo = defaultOpenWorldsRepoPath() { + return openWorldsRepo + } + + for candidate in candidateURLs() where looksLikeRepo(candidate) { + return candidate.standardizedFileURL.path + } + + return nil + } + + static func defaultOpenWorldsRepoPath() -> String? { + for candidate in candidateURLs() where looksLikeRepo(candidate) && supportsOpenWorldsViewer(candidate) { + return candidate.standardizedFileURL.path + } + return nil + } + + static func validRepoPath(_ rawPath: String) -> String? { + let trimmed = rawPath.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + let expanded = (trimmed as NSString).expandingTildeInPath + let url = URL(fileURLWithPath: expanded) + return looksLikeRepo(url) ? url.standardizedFileURL.path : nil + } + + static func openWorldsRepoPath(_ rawPath: String) -> String? { + let trimmed = rawPath.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + let expanded = (trimmed as NSString).expandingTildeInPath + let url = URL(fileURLWithPath: expanded) + return looksLikeRepo(url) && supportsOpenWorldsViewer(url) ? url.standardizedFileURL.path : nil + } + + private static func candidateURLs() -> [URL] { + var candidates: [URL] = [] + + func append(_ url: URL?) { + guard let url else { return } + let standardized = url.standardizedFileURL + guard !candidates.contains(standardized) else { return } + candidates.append(standardized) + } + + if let env = ProcessInfo.processInfo.environment["CLAWDND_REPO_ROOT"], + let valid = validRepoPath(env) { + append(URL(fileURLWithPath: valid)) } let bundleURL = Bundle.main.bundleURL var cursor = bundleURL.deletingLastPathComponent() for _ in 0..<8 { - if looksLikeRepo(cursor) { - return cursor.path - } + append(cursor) cursor.deleteLastPathComponent() } - let homeRepo = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent("repos/ClawDnD") - if looksLikeRepo(homeRepo) { - return homeRepo.path + append(FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("repos/ClawDnD")) + + let lexarRepos = URL(fileURLWithPath: "/Volumes/LEXAR/repos", isDirectory: true) + if let children = try? FileManager.default.contentsOfDirectory( + at: lexarRepos, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + ) { + let clawRepos = children + .filter { $0.lastPathComponent.localizedCaseInsensitiveContains("ClawDnD") } + .sorted { lhs, rhs in + if lhs.lastPathComponent == "ClawDnD" { return true } + if rhs.lastPathComponent == "ClawDnD" { return false } + return lhs.lastPathComponent < rhs.lastPathComponent + } + clawRepos.forEach { append($0) } } let cwd = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) - if looksLikeRepo(cwd) { - return cwd.path - } + append(cwd) - return nil + return candidates } static func looksLikeRepo(_ url: URL) -> Bool { @@ -38,4 +88,9 @@ enum RepositoryLocator { return FileManager.default.fileExists(atPath: viewer) && FileManager.default.fileExists(atPath: plugin) } + + static func supportsOpenWorldsViewer(_ url: URL) -> Bool { + let index = url.appendingPathComponent("viewer/openworlds/index.html").path + return FileManager.default.fileExists(atPath: index) + } } diff --git a/macos/ClawDnDApp/Sources/ClawDnDApp/Services/UpdaterService.swift b/macos/ClawDnDApp/Sources/ClawDnDApp/Services/UpdaterService.swift new file mode 100644 index 00000000..88d77135 --- /dev/null +++ b/macos/ClawDnDApp/Sources/ClawDnDApp/Services/UpdaterService.swift @@ -0,0 +1,116 @@ +import Foundation +import Sparkle + +@MainActor +final class UpdaterService: NSObject, ObservableObject, SPUUpdaterDelegate { + @Published private(set) var lastCheckRequestedAt: Date? + @Published private(set) var lastError: String? + + private var updaterController: SPUStandardUpdaterController? + private var overrideFeedURL: URL? + private var feedAvailable = false + + override init() { + super.init() + + let feedURL = (Bundle.main.object(forInfoDictionaryKey: "SUFeedURL") as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let publicKey = (Bundle.main.object(forInfoDictionaryKey: "SUPublicEDKey") as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + + if feedURL.isEmpty { + updaterController = nil + feedAvailable = false + lastError = "Sparkle feed URL is missing or empty in Info.plist." + } else if publicKey.isEmpty { + updaterController = nil + feedAvailable = false + lastError = "Sparkle public key is missing or empty in Info.plist." + } else { + feedAvailable = true + updaterController = SPUStandardUpdaterController( + startingUpdater: true, + updaterDelegate: self, + userDriverDelegate: nil + ) + } + } + + func setFeedURL(_ feedURL: URL?, available: Bool = false) { + overrideFeedURL = feedURL + feedAvailable = available + lastError = available ? nil : "Local beta appcast is unavailable." + } + + var statusPayload: [String: Any] { + let info = Bundle.main.infoDictionary ?? [:] + var payload: [String: Any] = [ + "version": info["CFBundleShortVersionString"] as? String ?? "0.0.0", + "build": info["CFBundleVersion"] as? String ?? "0", + "bundleIdentifier": info["CFBundleIdentifier"] as? String ?? "", + "feedURL": currentFeedURLString, + "feedAvailable": feedAvailable, + "channel": info["ClawDnDUpdateChannel"] as? String ?? "local-beta", + "canCheckForUpdates": feedAvailable && (updaterController?.updater.canCheckForUpdates ?? false), + "publicKeyConfigured": ((info["SUPublicEDKey"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false), + "status": updaterController == nil || !feedAvailable ? "unavailable" : "ready", + "lastError": lastError ?? "", + ] + if let lastCheckRequestedAt { + payload["lastCheckRequestedAt"] = Self.isoDateFormatter.string(from: lastCheckRequestedAt) + } + return payload + } + + func checkForUpdates() throws -> [String: Any] { + guard let updaterController else { + throw ProviderError.configuration(lastError ?? "Sparkle updater is unavailable.") + } + guard feedAvailable else { + lastError = "Local beta appcast is unavailable. Start the viewer from a packaged beta channel." + throw ProviderError.configuration(lastError ?? "Local beta appcast is unavailable.") + } + guard updaterController.updater.canCheckForUpdates else { + throw ProviderError.configuration("Sparkle updater is not ready to check for updates yet.") + } + guard let feedURL = currentFeedURL, ["http", "https"].contains(feedURL.scheme?.lowercased() ?? "") else { + lastError = "Sparkle feed is not reachable over HTTP yet. Start the viewer first." + throw ProviderError.configuration(lastError ?? "Sparkle feed is not reachable over HTTP yet.") + } + lastCheckRequestedAt = Date() + lastError = nil + updaterController.checkForUpdates(nil) + return statusPayload + } + + @objc(feedURLStringForUpdater:) + func feedURLString(for updater: SPUUpdater) -> String? { + overrideFeedURL?.absoluteString + } + + @objc(updater:didAbortWithError:) + func updater(_ updater: SPUUpdater, didAbortWithError error: Error) { + lastError = error.localizedDescription + } + + @objc(updater:didFinishUpdateCycleForUpdateCheck:error:) + func updater( + _ updater: SPUUpdater, + didFinishUpdateCycleFor updateCheck: SPUUpdateCheck, + error: Error? + ) { + lastError = error?.localizedDescription + } + + private var currentFeedURLString: String { + overrideFeedURL?.absoluteString + ?? (Bundle.main.object(forInfoDictionaryKey: "SUFeedURL") as? String ?? "") + } + + private var currentFeedURL: URL? { + URL(string: currentFeedURLString.trimmingCharacters(in: .whitespacesAndNewlines)) + } + + private static let isoDateFormatter = ISO8601DateFormatter() +} diff --git a/macos/ClawDnDApp/Sources/ClawDnDApp/Views/RootView.swift b/macos/ClawDnDApp/Sources/ClawDnDApp/Views/RootView.swift index 59d3a876..f2f1d5fb 100644 --- a/macos/ClawDnDApp/Sources/ClawDnDApp/Views/RootView.swift +++ b/macos/ClawDnDApp/Sources/ClawDnDApp/Views/RootView.swift @@ -5,6 +5,7 @@ import SwiftUI struct RootView: View { @EnvironmentObject private var processService: AppProcessService @EnvironmentObject private var campaignStore: CampaignStore + @EnvironmentObject private var updaterService: UpdaterService @AppStorage("repoPath") private var repoPath: String = RepositoryLocator.defaultRepoPath() ?? "" @AppStorage("preferredPort") private var preferredPort: Int = 8765 @@ -35,6 +36,7 @@ struct RootView: View { navigationError: $webViewErrorMessage, nativeRequestHandler: handleNativeRequest ) + .frame(maxWidth: .infinity, maxHeight: .infinity) .ignoresSafeArea() } else { OpenWorldsLaunchOverlay( @@ -53,6 +55,11 @@ struct RootView: View { .background(.black.opacity(0.82)) } } + .overlay(alignment: .top) { + OpenWorldsDragStrip() + .frame(width: 560, height: 36) + .accessibilityHidden(true) + } .onAppear { refresh() startOpenWorlds() @@ -69,7 +76,11 @@ struct RootView: View { private func refresh() { processService.refreshDependencies() - campaignStore.reload(repoPath: repoPath) + if let resolved = resolveAndPersistRepoPath() { + campaignStore.reload(repoPath: resolved) + } else { + campaignStore.reload(repoPath: repoPath) + } } private func startOpenWorlds() { @@ -81,11 +92,13 @@ struct RootView: View { webURL = nil launchMessage = "Starting the local viewer" do { + let resolvedRepoPath = try requireRepoPath() let url = try processService.startViewer( - repoPath: repoPath, + repoPath: resolvedRepoPath, preferredPort: preferredPort, stateDir: stateDir ) + updateAppcastFeedURL() launchMessage = "Waiting for OpenWorlds" try await waitForOpenWorlds(url) guard !Task.isCancelled else { return } @@ -137,16 +150,16 @@ struct RootView: View { throw ProviderError.configuration("Viewer did not become ready at \(url.absoluteString): \(lastError)") } - private func handleNativeRequest(_ request: NativeBridgeRequest) async -> NativeBridgeReply { + private func handleNativeRequest(_ request: NativeBridgeRequest, sourceWindow: NSWindow?) async -> NativeBridgeReply { do { - let payload = try await nativePayload(for: request) + let payload = try await nativePayload(for: request, sourceWindow: sourceWindow) return .success(request: request, payload: payload) } catch { return .failure(request: request, error: error.localizedDescription) } } - private func nativePayload(for request: NativeBridgeRequest) async throws -> [String: Any] { + private func nativePayload(for request: NativeBridgeRequest, sourceWindow: NSWindow?) async throws -> [String: Any] { switch request.type { case "appStatus": return appStatusPayload() @@ -159,18 +172,26 @@ struct RootView: View { return try await startViewerFromBridge(request.payload) case "stopViewer": processService.stopViewer() + updaterService.setFeedURL(nil) webURL = nil return appStatusPayload() case "startProviderSession": return try await startProviderFromBridge(request.payload) case "stopProvider": processService.stopProvider() + updaterService.setFeedURL(nil) return appStatusPayload() case "diagnostics": return ["diagnostics": processService.diagnostics] case "copyDiagnostics": Diagnostics.copy(processService: processService) return ["copied": true] + case "updaterStatus": + return ["updater": updaterService.statusPayload] + case "checkForUpdates": + return ["updater": try updaterService.checkForUpdates()] + case "windowCommand": + return try performWindowCommand(request.payload, sourceWindow: sourceWindow) case "openFallbackDashboard": let dashboardURL = try await ensureDashboardURL() NSWorkspace.shared.open(dashboardURL) @@ -182,12 +203,14 @@ struct RootView: View { private func startViewerFromBridge(_ payload: [String: Any]) async throws -> [String: Any] { let campaignID = stringPayload(payload, "campaignID").flatMap { $0.isEmpty ? nil : $0 } + let resolvedRepoPath = try requireRepoPath() let url = try processService.startViewer( - repoPath: repoPath, + repoPath: resolvedRepoPath, preferredPort: preferredPort, stateDir: stateDir, campaignID: campaignID ) + updateAppcastFeedURL() try await waitForOpenWorlds(url) webURL = url return appStatusPayload(extra: ["url": url.absoluteString]) @@ -199,9 +222,10 @@ struct RootView: View { let world = stringPayload(payload, "world") ?? defaultWorld let runId = stringPayload(payload, "runId").flatMap { $0.isEmpty ? nil : $0 } ?? Self.newRunID() let companions = stringPayload(payload, "companions") ?? "" + let resolvedRepoPath = try requireRepoPath() let url = try processService.startProviderSession( kind: provider, - repoPath: repoPath, + repoPath: resolvedRepoPath, world: world, runId: runId, preferredPort: preferredPort, @@ -209,6 +233,7 @@ struct RootView: View { stateDir: stateDir, preferences: providerPreferences ) + updateAppcastFeedURL() try await waitForOpenWorlds(url) webURL = url return appStatusPayload(extra: ["url": url.absoluteString, "runId": runId]) @@ -218,19 +243,27 @@ struct RootView: View { if let endpoint = processService.viewerEndpoint { return endpoint.dashboardURL } + let resolvedRepoPath = try requireRepoPath() let url = try processService.startViewer( - repoPath: repoPath, + repoPath: resolvedRepoPath, preferredPort: preferredPort, stateDir: stateDir ) + updateAppcastFeedURL() try await waitForOpenWorlds(url) webURL = url return processService.viewerEndpoint?.dashboardURL ?? url } + private func updateAppcastFeedURL() { + let appcastURL = processService.localBetaChannelPath == nil ? nil : processService.viewerEndpoint?.appcastURL + updaterService.setFeedURL(appcastURL, available: appcastURL != nil) + } + private func appStatusPayload(extra: [String: Any] = [:]) -> [String: Any] { + let currentRepoPath = resolvedRepoPath() ?? repoPath var payload: [String: Any] = [ - "repoPath": repoPath, + "repoPath": currentRepoPath, "stateDir": stateDir.isEmpty ? "default" : stateDir, "preferredPort": preferredPort, "defaultWorld": defaultWorld, @@ -241,7 +274,7 @@ struct RootView: View { "runningProvider": processService.runningProvider?.rawValue ?? "", "lastError": processService.lastError ?? "", "preferences": [ - "repoPath": repoPath, + "repoPath": currentRepoPath, "preferredPort": preferredPort, "stateDir": stateDir, "selectedProvider": selectedProviderRaw, @@ -254,11 +287,33 @@ struct RootView: View { "providers": providerStatusesPayload(), "dependencies": dependencyPayload(), "providerDiagnostics": Diagnostics.providerLaunchSummary(processService.providerLaunchMetadata), + "openWorldsAssetsPath": processService.openWorldsAssetsPath ?? "", + "updater": updaterService.statusPayload, ] extra.forEach { payload[$0.key] = $0.value } return payload } + private func performWindowCommand(_ payload: [String: Any], sourceWindow: NSWindow?) throws -> [String: Any] { + guard let rawCommand = stringPayload(payload, "command")?.lowercased(), !rawCommand.isEmpty else { + throw ProviderError.configuration("windowCommand requires a command.") + } + guard let window = sourceWindow, window.isVisible else { + throw ProviderError.configuration("No OpenWorlds app window is available.") + } + switch rawCommand { + case "close": + DispatchQueue.main.async { window.performClose(nil) } + case "minimize": + DispatchQueue.main.async { window.miniaturize(nil) } + case "zoom": + DispatchQueue.main.async { window.zoom(nil) } + default: + throw ProviderError.configuration("Unsupported window command: \(rawCommand)") + } + return ["command": rawCommand, "performed": true] + } + private func endpointPayload(_ endpoint: LocalEndpoint?) -> [String: Any] { guard let endpoint else { return ["status": EndpointStatus.stopped.rawValue] @@ -287,7 +342,8 @@ struct RootView: View { } private func providerStatusesPayload() -> [[String: Any]] { - processService.providerStatuses(repoPath: repoPath, preferences: providerPreferences).map { + let currentRepoPath = resolvedRepoPath() ?? repoPath + return processService.providerStatuses(repoPath: currentRepoPath, preferences: providerPreferences).map { [ "kind": $0.kind.rawValue, "displayName": $0.kind.displayName, @@ -299,6 +355,33 @@ struct RootView: View { } } + private func requireRepoPath() throws -> String { + if let resolved = resolveAndPersistRepoPath() { + return resolved + } + throw ProviderError.configuration( + "Repo path is not a ClawDnD checkout. Choose a checkout in Settings or set CLAWDND_REPO_ROOT." + ) + } + + private func resolveAndPersistRepoPath() -> String? { + guard let resolved = resolvedRepoPath() else { return nil } + if resolved != repoPath { + repoPath = resolved + } + return resolved + } + + private func resolvedRepoPath() -> String? { + if let compatible = RepositoryLocator.openWorldsRepoPath(repoPath) { + return compatible + } + if let discovered = RepositoryLocator.defaultOpenWorldsRepoPath() { + return discovered + } + return nil + } + private var providerPreferences: ProviderPreferences { ProviderPreferences( codexCommand: codexProviderCommand, @@ -352,7 +435,7 @@ private final class OpenWorldsChromeHostView: NSView { window.titleVisibility = .hidden window.titlebarAppearsTransparent = true window.styleMask.insert(.fullSizeContentView) - window.styleMask.remove(.titled) + window.styleMask.insert(.titled) window.styleMask.insert(.resizable) window.toolbar = nil window.backgroundColor = .black @@ -370,6 +453,22 @@ private final class OpenWorldsChromeHostView: NSView { } } +private struct OpenWorldsDragStrip: NSViewRepresentable { + func makeNSView(context: Context) -> NSView { + OpenWorldsDragStripView(frame: .zero) + } + + func updateNSView(_ view: NSView, context: Context) {} +} + +private final class OpenWorldsDragStripView: NSView { + override var acceptsFirstResponder: Bool { false } + + override func mouseDown(with event: NSEvent) { + window?.performDrag(with: event) + } +} + private struct OpenWorldsLaunchOverlay: View { let message: String let isError: Bool diff --git a/macos/ClawDnDApp/Sources/ClawDnDApp/Views/WebView.swift b/macos/ClawDnDApp/Sources/ClawDnDApp/Views/WebView.swift index dd4ad971..7fcc5c3c 100644 --- a/macos/ClawDnDApp/Sources/ClawDnDApp/Views/WebView.swift +++ b/macos/ClawDnDApp/Sources/ClawDnDApp/Views/WebView.swift @@ -1,3 +1,4 @@ +import AppKit import Foundation import SwiftUI import WebKit @@ -54,7 +55,7 @@ struct NativeBridgeReply { } struct WebView: NSViewRepresentable { - typealias NativeRequestHandler = @MainActor (NativeBridgeRequest) async -> NativeBridgeReply + typealias NativeRequestHandler = @MainActor (NativeBridgeRequest, NSWindow?) async -> NativeBridgeReply let url: URL? @Binding var navigationError: String? @@ -179,8 +180,9 @@ struct WebView: NSViewRepresentable { send(.failure(request: request, error: "Native bridge is unavailable.")) return } + let sourceWindow = webView?.window Task { @MainActor in - let reply = await nativeRequestHandler(request) + let reply = await nativeRequestHandler(request, sourceWindow) self.send(reply) } } diff --git a/macos/ClawDnDApp/SparklePublicKey.txt b/macos/ClawDnDApp/SparklePublicKey.txt new file mode 100644 index 00000000..3e2b5a0a --- /dev/null +++ b/macos/ClawDnDApp/SparklePublicKey.txt @@ -0,0 +1 @@ +nEtnlcHYwAug72XlWg6Li9poAbUzN2CwFy94WmJ23E0= diff --git a/script/build_and_run.sh b/script/build_and_run.sh index b4f51c00..c3eb618d 100755 --- a/script/build_and_run.sh +++ b/script/build_and_run.sh @@ -13,6 +13,8 @@ DIST_DIR="$ROOT_DIR/dist" APP_BUNDLE="$DIST_DIR/$DISPLAY_NAME.app" APP_CONTENTS="$APP_BUNDLE/Contents" APP_MACOS="$APP_CONTENTS/MacOS" +APP_FRAMEWORKS="$APP_CONTENTS/Frameworks" +APP_RESOURCES="$APP_CONTENTS/Resources" APP_BINARY="$APP_MACOS/$APP_NAME" INFO_PLIST="$APP_CONTENTS/Info.plist" @@ -30,9 +32,12 @@ build_bundle() { bin_path="$(swift build --package-path "$PACKAGE_DIR" --show-bin-path)/$APP_NAME" rm -rf "$APP_BUNDLE" - mkdir -p "$APP_MACOS" + mkdir -p "$APP_MACOS" "$APP_FRAMEWORKS" "$APP_RESOURCES" cp "$bin_path" "$APP_BINARY" chmod +x "$APP_BINARY" + copy_sparkle_framework + copy_openworlds_resources + add_app_framework_rpath cat >"$INFO_PLIST" < @@ -67,6 +72,29 @@ PLIST fi } +copy_sparkle_framework() { + local sparkle_framework + sparkle_framework="$(find "$PACKAGE_DIR/.build/artifacts" -path '*/Sparkle.framework' -type d -print -quit 2>/dev/null || true)" + if [[ -z "$sparkle_framework" ]]; then + sparkle_framework="$(find "$PACKAGE_DIR/.build" -path '*/Sparkle.framework' -type d -print -quit 2>/dev/null || true)" + fi + if [[ -n "$sparkle_framework" ]]; then + ditto "$sparkle_framework" "$APP_FRAMEWORKS/Sparkle.framework" + fi +} + +copy_openworlds_resources() { + if [[ -d "$ROOT_DIR/viewer/openworlds" ]]; then + ditto "$ROOT_DIR/viewer/openworlds" "$APP_RESOURCES/openworlds" + fi +} + +add_app_framework_rpath() { + if command -v install_name_tool >/dev/null 2>&1 && ! otool -l "$APP_BINARY" | grep -q '@executable_path/../Frameworks'; then + install_name_tool -add_rpath '@executable_path/../Frameworks' "$APP_BINARY" + fi +} + open_app() { CLAWDND_REPO_ROOT="$ROOT_DIR" /usr/bin/open -n "$APP_BUNDLE" } diff --git a/script/package_macos_beta.sh b/script/package_macos_beta.sh new file mode 100755 index 00000000..c344ba0a --- /dev/null +++ b/script/package_macos_beta.sh @@ -0,0 +1,425 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Build and package the ClawDnD macOS beta channel. +# +# This script intentionally keeps every generated artifact under +# /Volumes/LEXAR/Codex/clawdnd-beta-channel and reads release signing material +# from /Volumes/LEXAR/Codex/clawdnd-release-secrets. It never prints key +# contents and does not mutate source files. + +DEFAULT_VERSION="0.3.0" +DEFAULT_BUILD="2026052601" +DEFAULT_CHANNEL="local-beta" +DEFAULT_PRERELEASE="beta.1" + +APP_NAME="ClawDnD" +EXECUTABLE_NAME="ClawDnDApp" +BUNDLE_ID="dev.clawdnd.app" +SIGNING_IDENTITY="Developer ID Application: Andrew Ryan (TC6MS3T6NN)" + +OUTPUT_ROOT="${BETA_OUTPUT_DIR:-/Volumes/LEXAR/Codex/clawdnd-beta-channel}" +SECRETS_DIR="/Volumes/LEXAR/Codex/clawdnd-release-secrets" +PRIVATE_KEY_FILE="${SECRETS_DIR}/sparkle-ed25519-private-key.base64" + +usage() { + cat <<'USAGE' +Usage: script/package_macos_beta.sh [--version VERSION] [--build BUILD] [--channel CHANNEL] [--prerelease SUFFIX] + +Defaults: + --version 0.3.0 + --build 2026052601 + --channel local-beta + --prerelease beta.1 + + Environment: + BETA_OUTPUT_DIR Output directory for the local beta channel. + Defaults to /Volumes/LEXAR/Codex/clawdnd-beta-channel + Must resolve under /Volumes/LEXAR/Codex. + CLAWDND_FEED_URL Sparkle feed URL written into Info.plist. + Defaults to http://127.0.0.1:8765/appcast.xml + CLAWDND_DOWNLOAD_URL_PREFIX Optional URL prefix passed to Sparkle generate_appcast. + Defaults to http://127.0.0.1:8765/ +USAGE +} + +log() { + printf '[package_macos_beta] %s\n' "$*" +} + +fail() { + printf '[package_macos_beta] error: %s\n' "$*" >&2 + exit 1 +} + +require_file() { + local path="$1" + local label="$2" + [[ -f "$path" ]] || fail "missing ${label}: ${path}" +} + +require_dir() { + local path="$1" + local label="$2" + [[ -d "$path" ]] || fail "missing ${label}: ${path}" +} + +require_command() { + command -v "$1" >/dev/null 2>&1 || fail "required command not found: $1" +} + +realpath_compat() { + python3 -c 'import os, sys; print(os.path.realpath(os.path.expanduser(sys.argv[1])))' "$1" +} + +validate_release_token() { + local label="$1" + local value="$2" + [[ "$value" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || \ + fail "${label} must be non-empty and may contain only letters, numbers, dot, underscore, and dash" +} + +assert_under_root() { + local root="$1" + local path="$2" + local label="$3" + local resolved + resolved="$(realpath_compat "$path")" + case "$resolved" in + "$root"|"$root"/*) ;; + *) fail "${label} must stay under ${root}; ${path} resolves to ${resolved}" ;; + esac +} + +xml_escape() { + local value="$1" + value="${value//&/&}" + value="${value///>}" + value="${value//\"/"}" + value="${value//\'/'}" + printf '%s' "$value" +} + +copy_sparkle_framework() { + local package_dir="$1" + local frameworks_dir="$2" + local sparkle_framework="" + + sparkle_framework="$(find "${package_dir}/.build/artifacts" -path '*/Sparkle.framework' -type d -print -quit 2>/dev/null || true)" + [[ -n "$sparkle_framework" ]] || fail "Sparkle.framework was not found under SwiftPM artifacts; run SwiftPM resolution/build first" + + mkdir -p "$frameworks_dir" + ditto "$sparkle_framework" "${frameworks_dir}/Sparkle.framework" +} + +add_app_framework_rpath() { + local binary_path="$1" + if otool -l "$binary_path" | grep -q '@executable_path/../Frameworks'; then + return + fi + install_name_tool -add_rpath '@executable_path/../Frameworks' "$binary_path" +} + +find_swiftpm_executable() { + local package_dir="$1" + local executable_path="" + + if [[ -x "${package_dir}/.build/release/${EXECUTABLE_NAME}" ]]; then + executable_path="${package_dir}/.build/release/${EXECUTABLE_NAME}" + fi + + if [[ -z "$executable_path" ]]; then + executable_path="$(find "${package_dir}/.build" -type f -path "*/release/${EXECUTABLE_NAME}" -perm -111 -print 2>/dev/null | head -n 1 || true)" + fi + + [[ -n "$executable_path" ]] || fail "release executable was not found in ${package_dir}/.build" + printf '%s' "$executable_path" +} + +find_generate_appcast() { + local package_dir="$1" + local tool_path="" + + tool_path="$(find "${package_dir}/.build/artifacts" -type f -path '*/bin/generate_appcast' -perm -111 -print -quit 2>/dev/null || true)" + if [[ -z "$tool_path" ]]; then + tool_path="$(find "${package_dir}/.build/checkouts" -type f -name generate_appcast -perm -111 -print -quit 2>/dev/null || true)" + fi + + [[ -n "$tool_path" ]] || fail "Sparkle generate_appcast was not found in SwiftPM build artifacts" + printf '%s' "$tool_path" +} + +write_info_plist() { + local plist_path="$1" + local version="$2" + local build="$3" + local channel="$4" + local public_key="$5" + local feed_url="$6" + local beta_channel_root="$7" + + cat >"$plist_path" < + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + ${APP_NAME} + CFBundleExecutable + ${EXECUTABLE_NAME} + CFBundleIdentifier + ${BUNDLE_ID} + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + ${APP_NAME} + CFBundlePackageType + APPL + CFBundleShortVersionString + $(xml_escape "$version") + CFBundleVersion + $(xml_escape "$build") + ClawDnDUpdateChannel + $(xml_escape "$channel") + ClawDnDLocalBetaChannelPath + $(xml_escape "$beta_channel_root") + LSMinimumSystemVersion + 13.0 + NSHighResolutionCapable + + NSSupportsAutomaticGraphicsSwitching + + SUEnableInstallerLauncherService + + SUFeedURL + $(xml_escape "$feed_url") + SUPublicEDKey + $(xml_escape "$public_key") + + +PLIST +} + +sign_bundle_contents() { + local app_path="$1" + + log "Signing nested Sparkle helpers, framework, executable, and app bundle" + while IFS= read -r nested; do + codesign --force --timestamp --options runtime --sign "$SIGNING_IDENTITY" "$nested" + done < <(find "${app_path}/Contents/Frameworks/Sparkle.framework" \ + \( -name '*.xpc' -o -name '*.app' -o -name '*.dylib' -o -name Autoupdate \) -print 2>/dev/null | sort) + + codesign --force --timestamp --options runtime --sign "$SIGNING_IDENTITY" \ + "${app_path}/Contents/Frameworks/Sparkle.framework" + codesign --force --timestamp --options runtime --sign "$SIGNING_IDENTITY" \ + "${app_path}/Contents/MacOS/${EXECUTABLE_NAME}" + codesign --force --timestamp --options runtime --sign "$SIGNING_IDENTITY" "$app_path" +} + +write_release_notes() { + local notes_path="$1" + local version="$2" + local build="$3" + local channel="$4" + + cat >"$notes_path" <"$checksums_path" + for artifact in "$@"; do + shasum -a 256 "$artifact" >>"$checksums_path" + done +} + +write_validation_report() { + local report_path="$1" + local app_path="$2" + local zip_path="$3" + local dmg_path="$4" + local appcast_path="$5" + local version="$6" + local build="$7" + local channel="$8" + + { + printf '# ClawDnD Beta Packaging Validation\n\n' + printf -- '- Version: `%s`\n' "$version" + printf -- '- Build: `%s`\n' "$build" + printf -- '- Channel: `%s`\n' "$channel" + printf -- '- App bundle: `%s`\n' "$app_path" + printf -- '- ZIP: `%s`\n' "$zip_path" + printf -- '- DMG: `%s`\n' "$dmg_path" + printf -- '- Appcast: `%s`\n\n' "$appcast_path" + printf '## Checks\n\n' + printf '```text\n' + codesign --verify --deep --strict --verbose=2 "$app_path" 2>&1 + spctl --assess --type execute --verbose=2 "$app_path" 2>&1 || true + printf '```\n' + } >"$report_path" +} + +main() { + local version="$DEFAULT_VERSION" + local build="$DEFAULT_BUILD" + local channel="$DEFAULT_CHANNEL" + local prerelease="${PRERELEASE:-$DEFAULT_PRERELEASE}" + + while (($#)); do + case "$1" in + --version) + [[ $# -ge 2 ]] || fail "--version requires a value" + version="$2" + shift 2 + ;; + --build) + [[ $# -ge 2 ]] || fail "--build requires a value" + build="$2" + shift 2 + ;; + --channel) + [[ $# -ge 2 ]] || fail "--channel requires a value" + channel="$2" + shift 2 + ;; + --prerelease) + [[ $# -ge 2 ]] || fail "--prerelease requires a value" + prerelease="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + fail "unknown argument: $1" + ;; + esac + done + + require_command swift + require_command ditto + require_command codesign + require_command hdiutil + require_command install_name_tool + require_command otool + require_command python3 + require_command shasum + require_command spctl + prerelease="$(printf '%s' "$prerelease" | tr -d '[:space:]')" + validate_release_token "version" "$version" + validate_release_token "build" "$build" + validate_release_token "channel" "$channel" + validate_release_token "pre-release suffix" "$prerelease" + + OUTPUT_ROOT="$(realpath_compat "$OUTPUT_ROOT")" + [[ "$OUTPUT_ROOT" == /Volumes/LEXAR/Codex/* ]] || fail "output root must resolve under /Volumes/LEXAR/Codex; found ${OUTPUT_ROOT}" + mkdir -p "$OUTPUT_ROOT" + + local script_dir repo_root package_dir public_key_file feed_url download_url_prefix + script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + repo_root="$(cd "${script_dir}/.." && pwd)" + package_dir="${repo_root}/macos/ClawDnDApp" + public_key_file="${package_dir}/SparklePublicKey.txt" + download_url_prefix="${CLAWDND_DOWNLOAD_URL_PREFIX:-http://127.0.0.1:8765/}" + feed_url="${CLAWDND_FEED_URL:-${download_url_prefix%/}/appcast.xml}" + + [[ "$repo_root" == /Volumes/LEXAR/repos/* ]] || fail "repo must be under /Volumes/LEXAR/repos; found ${repo_root}" + + require_dir "$package_dir" "SwiftPM package" + require_dir "${repo_root}/viewer/openworlds" "OpenWorlds viewer resources" + require_file "$public_key_file" "Sparkle public key" + require_file "$PRIVATE_KEY_FILE" "Sparkle private key file" + + local public_key + public_key="$(tr -d '\r\n[:space:]' <"$public_key_file")" + [[ -n "$public_key" ]] || fail "Sparkle public key file is empty" + + log "Building SwiftPM release package at ${package_dir}" + swift build -c release --package-path "$package_dir" + + local executable_path generate_appcast_path + executable_path="$(find_swiftpm_executable "$package_dir")" + generate_appcast_path="$(find_generate_appcast "$package_dir")" + + local staging_dir app_path channel_app_path zip_path dmg_path dmg_src appcast_src release_notes_path + local appcast_path checksums_path validation_report_path artifact_stem + staging_dir="${OUTPUT_ROOT}/staging" + app_path="${staging_dir}/${APP_NAME}.app" + channel_app_path="${OUTPUT_ROOT}/${APP_NAME}.app" + artifact_stem="${APP_NAME}-${version}-${prerelease}" + zip_path="${OUTPUT_ROOT}/${artifact_stem}.zip" + dmg_path="${OUTPUT_ROOT}/${artifact_stem}.dmg" + dmg_src="${staging_dir}/dmg" + appcast_src="${staging_dir}/appcast" + release_notes_path="${OUTPUT_ROOT}/RELEASE_NOTES.md" + appcast_path="${OUTPUT_ROOT}/appcast.xml" + checksums_path="${OUTPUT_ROOT}/CHECKSUMS.txt" + validation_report_path="${OUTPUT_ROOT}/validation-report.md" + + assert_under_root "$OUTPUT_ROOT" "$staging_dir" "staging directory" + assert_under_root "$OUTPUT_ROOT" "$channel_app_path" "channel app bundle" + assert_under_root "$OUTPUT_ROOT" "$zip_path" "zip artifact" + assert_under_root "$OUTPUT_ROOT" "$dmg_path" "dmg artifact" + assert_under_root "$OUTPUT_ROOT" "$dmg_src" "dmg staging directory" + assert_under_root "$OUTPUT_ROOT" "$appcast_src" "appcast staging directory" + assert_under_root "$OUTPUT_ROOT" "$release_notes_path" "release notes" + assert_under_root "$OUTPUT_ROOT" "$appcast_path" "appcast" + assert_under_root "$OUTPUT_ROOT" "$checksums_path" "checksums" + assert_under_root "$OUTPUT_ROOT" "$validation_report_path" "validation report" + + log "Assembling ${app_path}" + rm -rf "$staging_dir" + mkdir -p "${app_path}/Contents/MacOS" "${app_path}/Contents/Resources" "${app_path}/Contents/Frameworks" + install -m 755 "$executable_path" "${app_path}/Contents/MacOS/${EXECUTABLE_NAME}" + write_info_plist "${app_path}/Contents/Info.plist" "$version" "$build" "$channel" "$public_key" "$feed_url" "$OUTPUT_ROOT" + plutil -lint "${app_path}/Contents/Info.plist" >/dev/null + copy_sparkle_framework "$package_dir" "${app_path}/Contents/Frameworks" + ditto "${repo_root}/viewer/openworlds" "${app_path}/Contents/Resources/openworlds" + add_app_framework_rpath "${app_path}/Contents/MacOS/${EXECUTABLE_NAME}" + + sign_bundle_contents "$app_path" + rm -rf "$channel_app_path" + ditto "$app_path" "$channel_app_path" + + log "Writing ZIP, DMG, release notes, appcast, checksums, and validation report" + rm -f "$zip_path" "$dmg_path" "$release_notes_path" "${zip_path%.zip}.md" "${dmg_path%.dmg}.md" "$appcast_path" "$checksums_path" "$validation_report_path" + ditto -c -k --keepParent "$app_path" "$zip_path" + + mkdir -p "$dmg_src" + ditto "$app_path" "${dmg_src}/${APP_NAME}.app" + hdiutil create -volname "${APP_NAME} ${version}" -srcfolder "$dmg_src" -ov -format UDZO "$dmg_path" >/dev/null + + write_release_notes "$release_notes_path" "$version" "$build" "$channel" + cp "$release_notes_path" "${zip_path%.zip}.md" + cp "$release_notes_path" "${dmg_path%.dmg}.md" + mkdir -p "$appcast_src" + cp "$zip_path" "$appcast_src/" + cp "${zip_path%.zip}.md" "$appcast_src/" + + local appcast_args=("$generate_appcast_path" --ed-key-file "$PRIVATE_KEY_FILE" -o "$appcast_path") + appcast_args+=(--download-url-prefix "$download_url_prefix") + appcast_args+=("$appcast_src") + "${appcast_args[@]}" >/dev/null + + write_checksums "$checksums_path" "$zip_path" "$dmg_path" "$appcast_path" "$release_notes_path" + write_validation_report "$validation_report_path" "$channel_app_path" "$zip_path" "$dmg_path" "$appcast_path" "$version" "$build" "$channel" + + log "Done: ${OUTPUT_ROOT}" +} + +main "$@" diff --git a/viewer/openworlds/chrome.jsx b/viewer/openworlds/chrome.jsx index 29a3db8d..c0cae814 100644 --- a/viewer/openworlds/chrome.jsx +++ b/viewer/openworlds/chrome.jsx @@ -366,12 +366,50 @@ function CapabilityBadge({ capability, nativeStatus }) { } function TitleBar({ campaign, location, day, capability, nativeStatus }) { + const bridgeReady = Boolean(nativeStatus?.bridge && window.OpenWorldsNative?.hasBridge?.()); + const commandWindow = (command) => { + if (!bridgeReady) return; + window.OpenWorldsNative.request("windowCommand", { command }).catch((error) => { + window.dispatchEvent(new CustomEvent("openworlds:toast", { + detail: { + kind: "danger", + title: "Window command failed", + body: error?.message || String(error), + }, + })); + }); + }; + const controlTitle = bridgeReady + ? "Native window control" + : "Unavailable outside the ClawDnD macOS app"; + return (
-