From a5c227f6ed4d75979f127652a5170b6a61c823cd Mon Sep 17 00:00:00 2001 From: Eva Date: Tue, 26 May 2026 17:32:56 +0700 Subject: [PATCH 1/8] feat(macos): add beta distribution lane --- docs/OPENWORLDS_NATIVE_APP_ROADMAP.md | 31 +- macos/ClawDnDApp/Package.resolved | 14 + macos/ClawDnDApp/Package.swift | 10 +- .../Sources/ClawDnDApp/App/ClawDnDApp.swift | 3 + .../Services/AppProcessService.swift | 26 +- .../ClawDnDApp/Services/UpdaterService.swift | 60 +++ .../Sources/ClawDnDApp/Views/RootView.swift | 50 +++ macos/ClawDnDApp/SparklePublicKey.txt | 1 + script/package_macos_beta.sh | 357 ++++++++++++++++++ viewer/openworlds/chrome.jsx | 46 ++- viewer/openworlds/screen-settings.jsx | 32 ++ viewer/openworlds/styles.css | 12 + viewer/server.py | 17 +- viewer/tests/test_openworlds_static.py | 18 + 14 files changed, 660 insertions(+), 17 deletions(-) create mode 100644 macos/ClawDnDApp/Package.resolved create mode 100644 macos/ClawDnDApp/Sources/ClawDnDApp/Services/UpdaterService.swift create mode 100644 macos/ClawDnDApp/SparklePublicKey.txt create mode 100755 script/package_macos_beta.sh diff --git a/docs/OPENWORLDS_NATIVE_APP_ROADMAP.md b/docs/OPENWORLDS_NATIVE_APP_ROADMAP.md index 2d7e9ff4..b2f76d7b 100644 --- a/docs/OPENWORLDS_NATIVE_APP_ROADMAP.md +++ b/docs/OPENWORLDS_NATIVE_APP_ROADMAP.md @@ -60,16 +60,27 @@ 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. The local beta channel writes to +`/Volumes/LEXAR/Codex/clawdnd-beta-channel` and uses: + +- app bundle: `/Volumes/LEXAR/Codex/clawdnd-beta-channel/ClawDnD.app` +- update feed: `file:///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)` + +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 +93,9 @@ 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. ## Window Chrome Lane @@ -114,6 +128,9 @@ Supported request types: - `stopProvider` - `diagnostics` - `copyDiagnostics` +- `updaterStatus` +- `checkForUpdates` +- `windowCommand` - `openFallbackDashboard` Native replies: 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/Services/AppProcessService.swift b/macos/ClawDnDApp/Sources/ClawDnDApp/Services/AppProcessService.swift index 2e7bd6f9..d0be70c0 100644 --- a/macos/ClawDnDApp/Sources/ClawDnDApp/Services/AppProcessService.swift +++ b/macos/ClawDnDApp/Sources/ClawDnDApp/Services/AppProcessService.swift @@ -10,6 +10,7 @@ final class AppProcessService: ObservableObject { @Published var providerLog: String = "" @Published var lastError: String? @Published var providerLaunchMetadata: ProviderLaunchMetadata? + @Published var openWorldsAssetsPath: String? private var viewerProcess: ManagedProcess? private var providerProcess: ManagedProcess? @@ -22,6 +23,7 @@ final class AppProcessService: ObservableObject { ClawDnD Native App Diagnostics Viewer: \(viewerEndpoint?.url.absoluteString ?? "stopped") Viewer status: \(viewerEndpoint?.status.rawValue ?? "stopped") + OpenWorlds assets: \(openWorldsAssetsPath ?? "repo default") Active campaign: \(activeCampaignID ?? "none") Running provider: \(runningProvider?.rawValue ?? "none") Last error: \(lastError ?? "none") @@ -74,6 +76,7 @@ final class AppProcessService: ObservableObject { if !stateDir.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { env["CLAWDND_STATE_DIR"] = (stateDir as NSString).expandingTildeInPath } + attachBundledOpenWorldsAssets(to: &env) let args = ["python3", "viewer/server.py", campaignID ?? "", String(port)] let managed = try launchManagedProcess( name: "viewer", @@ -152,13 +155,15 @@ final class AppProcessService: ObservableObject { runningProvider = nil providerLaunchMetadata = nil providerLog = "" + var providerEnvironment = request.environment + attachBundledOpenWorldsAssets(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 +175,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 +336,23 @@ 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 throwAndRecord(_ message: String) throws -> Never { lastError = message append(message, stream: .supervisor) diff --git a/macos/ClawDnDApp/Sources/ClawDnDApp/Services/UpdaterService.swift b/macos/ClawDnDApp/Sources/ClawDnDApp/Services/UpdaterService.swift new file mode 100644 index 00000000..8a3f0115 --- /dev/null +++ b/macos/ClawDnDApp/Sources/ClawDnDApp/Services/UpdaterService.swift @@ -0,0 +1,60 @@ +import Foundation +import Sparkle + +@MainActor +final class UpdaterService: ObservableObject { + @Published private(set) var lastCheckRequestedAt: Date? + @Published private(set) var lastError: String? + + private let updaterController: SPUStandardUpdaterController? + + init() { + if Bundle.main.object(forInfoDictionaryKey: "SUFeedURL") as? String == nil { + updaterController = nil + lastError = "Sparkle feed URL is missing from Info.plist." + } else if Bundle.main.object(forInfoDictionaryKey: "SUPublicEDKey") as? String == nil { + updaterController = nil + lastError = "Sparkle public key is missing from Info.plist." + } else { + updaterController = SPUStandardUpdaterController( + startingUpdater: true, + updaterDelegate: nil, + userDriverDelegate: nil + ) + } + } + + 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": info["SUFeedURL"] as? String ?? "", + "channel": info["ClawDnDUpdateChannel"] as? String ?? "local-beta", + "canCheckForUpdates": updaterController?.updater.canCheckForUpdates ?? false, + "publicKeyConfigured": (info["SUPublicEDKey"] as? String)?.isEmpty == false, + "status": updaterController == nil ? "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 updaterController.updater.canCheckForUpdates else { + throw ProviderError.configuration("Sparkle updater is not ready to check for updates yet.") + } + lastCheckRequestedAt = Date() + lastError = nil + updaterController.checkForUpdates(nil) + return statusPayload + } + + 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..4b0893bf 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 @@ -53,6 +54,11 @@ struct RootView: View { .background(.black.opacity(0.82)) } } + .overlay(alignment: .top) { + OpenWorldsDragStrip() + .frame(width: 560, height: 36) + .accessibilityHidden(true) + } .onAppear { refresh() startOpenWorlds() @@ -171,6 +177,12 @@ struct RootView: View { 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) case "openFallbackDashboard": let dashboardURL = try await ensureDashboardURL() NSWorkspace.shared.open(dashboardURL) @@ -254,11 +266,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]) throws -> [String: Any] { + guard let rawCommand = stringPayload(payload, "command")?.lowercased(), !rawCommand.isEmpty else { + throw ProviderError.configuration("windowCommand requires a command.") + } + guard let window = NSApp.keyWindow ?? NSApp.mainWindow ?? NSApp.windows.first(where: { $0.isVisible }) else { + throw ProviderError.configuration("No active 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] @@ -370,6 +404,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/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/package_macos_beta.sh b/script/package_macos_beta.sh new file mode 100755 index 00000000..38ffa92f --- /dev/null +++ b/script/package_macos_beta.sh @@ -0,0 +1,357 @@ +#!/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" + +APP_NAME="ClawDnD" +EXECUTABLE_NAME="ClawDnDApp" +BUNDLE_ID="dev.clawdnd.app" +SIGNING_IDENTITY="Developer ID Application: Andrew Ryan (TC6MS3T6NN)" + +OUTPUT_ROOT="/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] + +Defaults: + --version 0.3.0 + --build 2026052601 + --channel local-beta + +Environment: + CLAWDND_FEED_URL Sparkle feed URL written into Info.plist. + Defaults to file:///Volumes/LEXAR/Codex/clawdnd-beta-channel/appcast.xml + CLAWDND_DOWNLOAD_URL_PREFIX Optional URL prefix passed to Sparkle generate_appcast. +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" +} + +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" +} + +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" + + 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") + 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" + + 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 + ;; + -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 shasum + require_command spctl + + local script_dir repo_root package_dir public_key_file feed_url + 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" + feed_url="${CLAWDND_FEED_URL:-file://${OUTPUT_ROOT}/appcast.xml}" + + [[ "$repo_root" == /Volumes/LEXAR/repos/* ]] || fail "repo must be under /Volumes/LEXAR/repos; found ${repo_root}" + [[ "$OUTPUT_ROOT" == /Volumes/LEXAR/Codex/* ]] || fail "output root must be under /Volumes/LEXAR/Codex" + + 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}-beta.1" + 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" + + 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" + 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" + + 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") + if [[ -n "${CLAWDND_DOWNLOAD_URL_PREFIX:-}" ]]; then + appcast_args+=(--download-url-prefix "$CLAWDND_DOWNLOAD_URL_PREFIX") + fi + 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 (
-