diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml
index 886063d..7f03dcb 100644
--- a/.github/workflows/desktop-build.yml
+++ b/.github/workflows/desktop-build.yml
@@ -39,7 +39,7 @@ jobs:
working-directory: website
run: bun run build
macos:
- runs-on: macos-15
+ runs-on: macos-26
timeout-minutes: 45
defaults:
run:
@@ -68,11 +68,52 @@ jobs:
run: |
version="$(python3 scripts/release.py version)"
python3 scripts/keychain-smoke.py "dist/Umbod-$version.app"
+ - name: Prepare standalone acceptance harness
+ run: |
+ bash scripts/prepare-macos-acceptance.sh "$RUNNER_TEMP/macos-acceptance-kit"
+ tar -czf "$RUNNER_TEMP/macos-acceptance-kit.tar.gz" -C "$RUNNER_TEMP/macos-acceptance-kit" .
+ - uses: actions/upload-artifact@v4
+ with:
+ name: macos-acceptance-kit
+ path: ${{ runner.temp }}/macos-acceptance-kit.tar.gz
+ if-no-files-found: error
- uses: actions/upload-artifact@v4
with:
name: desktop-macos-arm64
path: desktop/dist/Umbod-*-arm64.dmg
if-no-files-found: error
+ macos-acceptance:
+ needs: macos
+ runs-on: macos-26
+ timeout-minutes: 20
+ steps:
+ # Deliberately no checkout or development-tool installation.
+ - uses: actions/download-artifact@v4
+ with:
+ name: desktop-macos-arm64
+ path: candidate
+ - uses: actions/download-artifact@v4
+ with:
+ name: macos-acceptance-kit
+ path: kit
+ - name: Accept the exact release DMG without a source checkout
+ shell: bash
+ run: |
+ set -euo pipefail
+ test "$(uname -m)" = arm64
+ mkdir harness
+ tar -xzf kit/macos-acceptance-kit.tar.gz -C harness
+ shasum -a 256 candidate/*.dmg > artifact-sha256.txt
+ env -i HOME="$HOME" TMPDIR="$(getconf DARWIN_USER_TEMP_DIR)" PATH=/usr/bin:/bin:/usr/sbin:/sbin GITHUB_ACTIONS=true RUNNER_ENVIRONMENT=github-hosted \
+ /bin/bash harness/scripts/accept-macos-artifact.sh "$PWD"/candidate/*.dmg 2>&1 | tee acceptance.log
+ - uses: actions/upload-artifact@v4
+ if: always()
+ with:
+ name: macos-acceptance-evidence
+ path: |
+ acceptance.log
+ artifact-sha256.txt
+ if-no-files-found: error
windows:
runs-on: windows-latest
timeout-minutes: 45
diff --git a/desktop/README.md b/desktop/README.md
index c69c654..13cb736 100644
--- a/desktop/README.md
+++ b/desktop/README.md
@@ -61,4 +61,4 @@ OAuth supports native public clients with loopback callbacks, discovery, PKCE an
The [upstream Windows run](https://github.com/kasperjunge/umbod-desktop/actions/runs/37907078176) is historical source evidence supplied for this import. It is **not** a CI result for this target repository or these integration changes.
-Release packaging collects dependency inventories and notices through `scripts/dependency-notices.py`; pinned supplemental sources are documented in `licenses/README.md`. `scripts/verify-python-bundle.py` rejects editable installs and imports outside the bundled interpreter before signing and again after installation. `scripts/install-smoke.py` installs the Mac DMG into disposable storage and verifies the packaged connector/agent flow, native window lifecycle, and signature integrity before and after execution. It never uses the login Keychain.
+Release packaging collects dependency inventories and notices through `scripts/dependency-notices.py`; pinned supplemental sources are documented in `licenses/README.md`. `scripts/verify-python-bundle.py` rejects editable installs and imports outside the bundled interpreter before signing and again after installation. `scripts/audit-macos-bundle.py` verifies ARM64 slices, the macOS 14 deployment floor, required resources, executable permissions, internal symlinks and relocatable native dependencies. `Resources/build-metadata.json` records source and toolchain inputs. `scripts/prepare-macos-acceptance.sh` packages a precompiled test harness and fixtures; the separate `macos-acceptance` job runs `scripts/accept-macos-artifact.sh` on current macOS ARM64 with no checkout, using the DMG’s interpreter and the unchanged release identity. This required job also runs in the manual release workflow before publication. Local UI tests retain disposable bundle identities to protect user preferences. `scripts/install-smoke.py` installs the Mac DMG into disposable storage and verifies the packaged connector/agent flow, native window lifecycle, and signature integrity before and after execution. It never uses the login Keychain.
diff --git a/desktop/docs/RELEASING.md b/desktop/docs/RELEASING.md
index 86a6a75..9a80133 100644
--- a/desktop/docs/RELEASING.md
+++ b/desktop/docs/RELEASING.md
@@ -6,7 +6,7 @@ Desktop is versioned independently of chart, images and SDK. Update `desktop/VER
1. Resolve the source-license and dependency-notice blockers in [provenance](PROVENANCE.md), review the changes, and merge through the normal process. Do not copy historical personal docs or evidence.
2. Configure the GitHub `desktop-release` environment with required reviewers and a deployment branch rule permitting only `main`. The workflow also validates repository, ref, event, version, rights attestation, and exact confirmation. No signing secret is required for unsigned builds. No credentials belong in website code or manifests.
-3. Run native desktop CI on the reviewed source. PR/push CI only uploads artifacts and has read-only permissions. Mac uses an Apple Silicon runner and Swift 6; Windows builds native x64 Rust and WPF, including its packaged smoke test. Both must pass; a local cross-build does not substitute for Windows tests.
+3. Run native desktop CI on the reviewed source. PR/push CI only uploads artifacts and has read-only permissions. Mac uses an Apple Silicon runner and Swift 6; Windows builds native x64 Rust and WPF, including its packaged smoke test. The Mac artifact-only acceptance job must also pass: it uses the exact DMG, no checkout or compiler, and an unchanged release app launched through LaunchServices on a fresh hosted runner. Both platform builds must pass; a local cross-build does not substitute for Windows tests.
4. From `main`, explicitly dispatch **Desktop manual prerelease** with version `X.Y.Z`, mode `unsigned-prerelease`, rights confirmation enabled, and confirmation text `release desktop-vX.Y.Z unsigned-prerelease`. The workflow rebuilds/tests the dispatch SHA. It creates a new desktop tag, an explicitly UNSIGNED GitHub prerelease, versioned assets, release notes, SHA256SUMS and desktop-downloads.json. It never promotes repository-wide latest or overwrites existing tags/releases/assets.
5. Download the `website-manifest-update` artifact and follow [APPLY-MANIFEST.md](APPLY-MANIFEST.md). This is a PR-ready file tree; publication does not automatically edit or deploy the website.
@@ -25,3 +25,14 @@ A realistic future setup is a protected release environment with a dedicated App
The macOS signing implementation must sign nested executables and the app with hardened runtime and timestamp, verify the designated identity and `codesign --verify --deep --strict`, submit the final distribution to `xcrun notarytool submit --wait`, require Accepted, staple, validate the staple, and run Gatekeeper assessment on the distribution. Windows must sign the gateway and UI executables using SHA-256 and an RFC 3161 timestamp, then require successful Authenticode chain/identity verification before packaging. Hashes/manifests must be generated after every signing/stapling mutation. Native clean-machine installation testing is still required. This is a setup specification, not a claim that a production signing path is implemented or verified.
Release notes and website labels must change only after that verified path exists; their schema currently accepts only unsigned prereleases.
+
+## Unsigned first-launch rehearsal
+
+Artifact-only CI checks application behavior and ad-hoc signature integrity. It does not claim notarization or browser/Gatekeeper acceptance. Before recommending an unsigned candidate, use a fresh Apple Silicon Mac account or a reset macOS VM:
+
+1. Download the exact candidate DMG through Safari so quarantine is applied. Record its SHA-256, app version, source commit and macOS version with the release evidence.
+2. Open the DMG, drag Umbod to Applications, eject the DMG, and open the installed app through Finder. Expect the unsigned-app warning; verify the system’s per-app Open Anyway flow works. Do not disable Gatekeeper or remove quarantine as a workaround.
+3. With disposable test data, verify backend startup, connector discovery and a tool call. Close/reopen, restart the backend and quit. Repeat launch without changing credentials or security settings; record any prompts.
+4. Check the installed bundle signature after execution. Preserve failure evidence and fix failures before recommending the candidate.
+
+Current CI runs on macOS 26 ARM64 and checks binary deployment requirements against macOS 14. This is not runtime verification on macOS 14; that lane remains follow-up work. Native dependencies built from source use MACOSX_DEPLOYMENT_TARGET=14.0 and do not reuse potentially incompatible cached build wheels.
diff --git a/desktop/native/Sources/Umbod/UmbodApp.swift b/desktop/native/Sources/Umbod/UmbodApp.swift
index 575eb66..1b412c8 100644
--- a/desktop/native/Sources/Umbod/UmbodApp.swift
+++ b/desktop/native/Sources/Umbod/UmbodApp.swift
@@ -54,7 +54,7 @@ import UmbodCore
window.makeKeyAndOrderFront(nil)
reopened = window.isVisible
}
- let report: [String: Any] = ["backgroundSurvivedClose":survived,"reopened":reopened].merging( ["backendRunning": backend.isRunning, "backendPID": backend.processID, "bundlePath": Bundle.main.bundlePath, "gateway": backend.gateway,
+ let report: [String: Any] = ["backgroundSurvivedClose":survived,"reopened":reopened].merging( ["backendRunning": backend.isRunning, "backendPID": backend.processID, "bundlePath": Bundle.main.bundlePath, "bundleIdentifier": Bundle.main.bundleIdentifier ?? "", "gateway": backend.gateway,
"serverCount": servers.count, "windowCount": NSApp.windows.count,
"windows": NSApp.windows.map { ["title": $0.title, "visible": $0.isVisible, "width": $0.frame.width, "height": $0.frame.height] as [String: Any] }]) { _, new in new }
try JSONSerialization.data(withJSONObject: report, options: [.prettyPrinted, .sortedKeys]).write(to: URL(fileURLWithPath: path))
diff --git a/desktop/native/Tests/UmbodTests/ContractTests.swift b/desktop/native/Tests/UmbodTests/ContractTests.swift
index 342e57c..198e056 100644
--- a/desktop/native/Tests/UmbodTests/ContractTests.swift
+++ b/desktop/native/Tests/UmbodTests/ContractTests.swift
@@ -29,7 +29,7 @@ import UmbodCore
precondition(!config.contains("alice"))
let directory = FileManager.default.temporaryDirectory.appendingPathComponent("umbod-test-" + UUID().uuidString)
let backend = Backend()
- let desktop = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent()
+ let desktop = ProcessInfo.processInfo.environment["UMBOD_TEST_RESOURCES"].map { URL(fileURLWithPath: $0) } ?? URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent()
let app = ProcessInfo.processInfo.environment["UMBOD_TEST_APP"].map { URL(fileURLWithPath: $0) } ?? desktop.appendingPathComponent("dist/Umbod Dev.app")
let executable = app.appendingPathComponent("Contents/MacOS/umbod-desktop")
let profile = try String(contentsOf: app.appendingPathComponent("Contents/Resources/umbod-profile"), encoding: .utf8).trimmingCharacters(in: .whitespacesAndNewlines)
diff --git a/desktop/native/Tests/UmbodTests/UpdateTests.swift b/desktop/native/Tests/UmbodTests/UpdateTests.swift
index 9816cb3..d30c7fa 100644
--- a/desktop/native/Tests/UmbodTests/UpdateTests.swift
+++ b/desktop/native/Tests/UmbodTests/UpdateTests.swift
@@ -2,7 +2,7 @@ import Foundation
import UmbodCore
func updateTests() throws {
- let desktop = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent()
+ let desktop = ProcessInfo.processInfo.environment["UMBOD_TEST_RESOURCES"].map { URL(fileURLWithPath: $0) } ?? URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent()
let data = try Data(contentsOf: desktop.appendingPathComponent("tests/fixtures/update-manifest.json"))
let update = try DesktopUpdate.parse(data, installed: "0.2.0")
precondition(update?.version == "0.10.0", "Compare numeric versions, not strings")
diff --git a/desktop/scripts/accept-macos-artifact.sh b/desktop/scripts/accept-macos-artifact.sh
new file mode 100755
index 0000000..80d204d
--- /dev/null
+++ b/desktop/scripts/accept-macos-artifact.sh
@@ -0,0 +1,38 @@
+#!/bin/bash
+set -euo pipefail
+# No checkout, compiler or externally installed Python is used here.
+MODE="${2:-release}"
+case "$MODE" in release|local-fixture) ;; *) exit 2 ;; esac
+KIT="$(cd "$(dirname "$0")/.." && pwd)"
+DMG="${1:?DMG required}"
+ROOT="$(mktemp -d "${TMPDIR:-/tmp}/umbod-test-acceptance.XXXXXX")"
+MOUNT="$ROOT/volume"
+APP="$ROOT/Applications/Umbod.app"
+cleanup() {
+ if mount | grep -Fq " on $MOUNT "; then hdiutil detach "$MOUNT" || true; fi
+ rm -rf "$ROOT"
+}
+trap cleanup EXIT
+shasum -a 256 "$DMG"
+hdiutil verify "$DMG"
+hdiutil attach -readonly -nobrowse -mountpoint "$MOUNT" "$DMG"
+mkdir -p "$ROOT/Applications"
+ditto "$MOUNT/Umbod.app" "$APP"
+hdiutil detach "$MOUNT"
+codesign --verify --deep --strict "$APP"
+PYTHON="$APP/Contents/Resources/python/bin/python3"
+cd "$ROOT"
+export PATH=/usr/bin:/bin:/usr/sbin:/sbin
+unset PYTHONHOME PYTHONPATH VIRTUAL_ENV CONDA_PREFIX DYLD_LIBRARY_PATH DYLD_FRAMEWORK_PATH
+"$PYTHON" -I -B "$KIT/scripts/audit-macos-bundle.py" "$APP"
+"$PYTHON" -I -B "$KIT/scripts/verify-python-bundle.py"
+"$APP/Contents/MacOS/umbod-desktop" --help > /dev/null
+UMBOD_TEST_APP="$APP" UMBOD_TEST_RESOURCES="$KIT" "$KIT/UmbodTests"
+# This mode refuses to run on a developer account. No bundle ID changes or re-signing.
+if [[ "$MODE" == release ]]; then
+ "$PYTHON" -I -B "$KIT/scripts/native-smoke.py" "$APP" --release-identity
+else
+ "$PYTHON" -I -B "$KIT/scripts/native-smoke.py" "$APP"
+fi
+codesign --verify --deep --strict "$APP"
+printf 'PASS: artifact-only acceptance (%s)\n' "$MODE"
diff --git a/desktop/scripts/audit-macos-bundle.py b/desktop/scripts/audit-macos-bundle.py
new file mode 100644
index 0000000..2ab214f
--- /dev/null
+++ b/desktop/scripts/audit-macos-bundle.py
@@ -0,0 +1,113 @@
+#!/usr/bin/env python3
+"""Audit release layout and Mach-O dependencies without loading application code."""
+import json
+import os
+from pathlib import Path
+import plistlib
+import subprocess
+import sys
+
+MAGICS = {bytes.fromhex(x) for x in ('cffaedfe', 'feedfacf', 'cafebabe', 'bebafeca', 'cafebabf', 'bfbafeca')}
+SYSTEM = ('/usr/lib/', '/System/Library/')
+
+
+def contained(path, root):
+ if not path.resolve().is_relative_to(root.resolve()):
+ raise ValueError(f'External bundle reference: {path}')
+ if not path.exists():
+ raise ValueError(f'Missing bundle dependency: {path}')
+
+
+def load_commands(text):
+ result = []
+ current = None
+ for line in text.splitlines():
+ fields = line.strip().split()
+ if fields[:1] == ['cmd']:
+ current = {'cmd': fields[1]}
+ result.append(current)
+ elif current is not None and fields and fields[0] in ('name', 'path', 'minos', 'version'):
+ current[fields[0]] = line.strip().split(' ', 1)[1].split(' (offset')[0]
+ return result
+
+
+def expand(value, loader, executable):
+ return Path(value.replace('@loader_path', str(loader)).replace('@executable_path', str(executable)))
+
+
+def check_reference(value, loader, executable, rpaths, root):
+ if value.startswith(SYSTEM):
+ return # System libraries can live only in dyld's shared cache.
+ if value.startswith('@rpath/'):
+ suffix = value[len('@rpath/'):]
+ for rpath in rpaths:
+ candidate = expand(rpath, loader, executable) / suffix
+ if str(candidate).startswith(SYSTEM):
+ # Swift's OS runtime is supplied by macOS, not the Xcode toolchain.
+ if str(candidate).startswith('/usr/lib/swift/libswift'):
+ return
+ elif candidate.exists():
+ contained(candidate, root)
+ return
+ raise ValueError(f'Unresolved library: {value} in {loader}')
+ if not value.startswith(('@loader_path/', '@executable_path/')):
+ raise ValueError(f'Non-relocatable library: {value}')
+ contained(expand(value, loader, executable), root)
+
+
+def audit(app):
+ app = app.resolve()
+ contents = app / 'Contents'
+ metadata = plistlib.loads((contents / 'Info.plist').read_bytes())
+ if metadata['LSMinimumSystemVersion'] != '14.0':
+ raise ValueError('Expected macOS 14 compatibility target')
+ required = ['MacOS/Umbod', 'MacOS/umbod-desktop', 'MacOS/umbod-gateway', 'Resources/python/bin/python3']
+ for name in required:
+ path = contents / name
+ contained(path, app)
+ if not os.access(path, os.X_OK):
+ raise ValueError(f'Missing executable permission: {name}')
+ for name in ['Resources/runtime-id', 'Resources/umbod-profile', 'Resources/UmbodBrand.icns', 'Resources/build-metadata.json']:
+ contained(contents / name, app)
+ identity = json.loads((contents / 'Resources/build-metadata.json').read_text())
+ if identity['version'] != metadata['CFBundleShortVersionString']:
+ raise ValueError('Build metadata and app versions disagree')
+ binaries = 0
+ for path in contents.rglob('*'):
+ if path.is_symlink():
+ contained(path, app)
+ continue
+ if not path.is_file():
+ continue
+ with path.open('rb') as stream:
+ if stream.read(4) not in MAGICS:
+ continue
+ binaries += 1
+ arches = subprocess.check_output(['/usr/bin/lipo', '-archs', str(path)], text=True).split()
+ if 'arm64' not in arches:
+ raise ValueError(f'No ARM64 slice: {path}')
+ commands = load_commands(subprocess.check_output(['/usr/bin/otool', '-arch', 'arm64', '-l', str(path)], text=True))
+ python = contents / 'Resources/python'
+ executable = python / 'bin' if path.is_relative_to(python) else contents / 'MacOS'
+ rpaths = [c['path'] for c in commands if c['cmd'] == 'LC_RPATH']
+ # Python extension libraries also inherit the interpreter's run path.
+ if path.is_relative_to(python):
+ rpaths += ['@executable_path/../lib']
+ for value in rpaths:
+ if not value.startswith(SYSTEM):
+ contained(expand(value, path.parent, executable), app)
+ for command in commands:
+ if command['cmd'] in ('LC_BUILD_VERSION', 'LC_VERSION_MIN_MACOSX'):
+ version = command.get('minos', command.get('version'))
+ parts = tuple(map(int, version.split('.')))
+ if (parts + (0, 0))[:2] > (14, 0):
+ raise ValueError(f'Requires macOS {version}, newer than target: {path}')
+ if command['cmd'] in ('LC_LOAD_DYLIB', 'LC_LOAD_WEAK_DYLIB', 'LC_REEXPORT_DYLIB', 'LC_LOAD_UPWARD_DYLIB'):
+ check_reference(command['name'], path.parent, executable, rpaths, app)
+ if not binaries:
+ raise ValueError('No native binaries found')
+ print(f'PASS: {binaries} ARM64 binaries, macOS 14 target, resources, symlinks and library references')
+
+
+if __name__ == '__main__':
+ audit(Path(sys.argv[1]))
diff --git a/desktop/scripts/build.sh b/desktop/scripts/build.sh
index 4d53b32..039c6f7 100755
--- a/desktop/scripts/build.sh
+++ b/desktop/scripts/build.sh
@@ -54,6 +54,8 @@ cat > "$APP/Contents/Info.plist" <NSPrincipalClassNSApplication
PLIST
+python3 scripts/record-macos-build.py "$APP"
+"$APP/Contents/Resources/python/bin/python3" -I -B scripts/audit-macos-bundle.py "$APP"
# Local/CI builds never search or use a developer's signing identity.
# Production publishing is disabled by release.py until verified signing exists.
while IFS= read -r -d '' binary; do
diff --git a/desktop/scripts/bundle-python.sh b/desktop/scripts/bundle-python.sh
index 85b93fb..8fbf6f5 100755
--- a/desktop/scripts/bundle-python.sh
+++ b/desktop/scripts/bundle-python.sh
@@ -12,8 +12,10 @@ printf '%s %s\n' '1a98bbe7e63547d2f764bc9119f6d604dfb333f21afab5f4885dc362b4f3a
tar -xzf "$ARCHIVE" -C "$DEST"
printf '20260310\n' > "$DEST/python/BUILD"
uv export --quiet --project "$ROOT/desktop/python" --frozen --no-dev --no-editable --no-hashes --output-file "$DEST/requirements.txt"
+# Select wheels for macOS 14 even when building on a newer OS.
+# Fresh source builds must not reuse wheels compiled for the build host.
# Export covers the full lock; development tool.uv.sources must not restore editable installs.
-(cd "$ROOT/desktop/python" && uv pip install --python "$DEST/python/bin/python3" --reinstall-package umbod-desktop --reinstall-package umbod-core --reinstall-package umbod --break-system-packages --no-editable --no-sources --requirements "$DEST/requirements.txt")
+(cd "$ROOT/desktop/python" && MACOSX_DEPLOYMENT_TARGET=14.0 uv pip install --no-cache --python-platform aarch64-apple-darwin --python "$DEST/python/bin/python3" --reinstall-package umbod-desktop --reinstall-package umbod-core --reinstall-package umbod --break-system-packages --no-editable --no-sources --requirements "$DEST/requirements.txt")
rm "$DEST/requirements.txt"
"$DEST/python/bin/python3" -I -B "$ROOT/desktop/scripts/verify-python-bundle.py"
"$DEST/python/bin/python3" -I -B "$ROOT/desktop/scripts/dependency-notices.py" python "$DEST"
diff --git a/desktop/scripts/native-smoke.py b/desktop/scripts/native-smoke.py
index 0a6c693..07c542c 100644
--- a/desktop/scripts/native-smoke.py
+++ b/desktop/scripts/native-smoke.py
@@ -18,17 +18,22 @@ def default_app():
def main():
+ release_identity = '--release-identity' in sys.argv
+ if release_identity and (os.environ.get('GITHUB_ACTIONS') != 'true' or os.environ.get('RUNNER_ENVIRONMENT') != 'github-hosted'):
+ raise SystemExit('Unchanged release identity tests require a disposable GitHub-hosted runner')
source = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else default_app()).resolve()
with tempfile.TemporaryDirectory(prefix='umbod-test-native-smoke-') as temp:
root = pathlib.Path(temp).resolve()
- # An isolated bundle ID avoids sharing SwiftUI window restoration with a user's app.
app = root / 'Umbod Smoke.app'
subprocess.run(['/usr/bin/ditto', str(source), str(app)], check=True)
- plist = app / 'Contents/Info.plist'
- metadata = plistlib.loads(plist.read_bytes())
- metadata['CFBundleIdentifier'] += '.fixture.' + uuid.uuid4().hex
- plist.write_bytes(plistlib.dumps(metadata))
- subprocess.run(['/usr/bin/codesign', '--force', '--sign', '-', str(app)], check=True, stderr=subprocess.DEVNULL)
+ if not release_identity:
+ # Local smoke tests must not share a user's window restoration identity.
+ plist = app / 'Contents/Info.plist'
+ metadata = plistlib.loads(plist.read_bytes())
+ metadata['CFBundleIdentifier'] += '.fixture.' + uuid.uuid4().hex
+ plist.write_bytes(plistlib.dumps(metadata))
+ subprocess.run(['/usr/bin/codesign', '--force', '--sign', '-', str(app)], check=True, stderr=subprocess.DEVNULL)
+ subprocess.run(['/usr/bin/codesign', '--verify', '--deep', '--strict', str(app)], check=True)
report = root / 'window.json'
directory = root / 'umbod-test-data'
environment = os.environ.copy()
@@ -66,6 +71,10 @@ def main():
raise AssertionError('Bundled backend survived normal app termination')
except ProcessLookupError:
pass
+ subprocess.run(['/usr/bin/codesign', '--verify', '--deep', '--strict', str(app)], check=True)
+ if release_identity:
+ assert state['bundleIdentifier'] == 'app.umbod.desktop'
+ print('PASS: unchanged release identity launched through LaunchServices')
print('PASS: isolated bundled SwiftUI window, authenticated backend, close/reopen and normal termination; no Keychain access')
diff --git a/desktop/scripts/prepare-macos-acceptance.sh b/desktop/scripts/prepare-macos-acceptance.sh
new file mode 100755
index 0000000..6334311
--- /dev/null
+++ b/desktop/scripts/prepare-macos-acceptance.sh
@@ -0,0 +1,9 @@
+#!/bin/bash
+set -euo pipefail
+cd "$(dirname "$0")/.."
+DEST="${1:?test kit output directory required}"
+mkdir -p "$DEST/scripts" "$DEST/tests/fixtures"
+swift build --package-path native --product UmbodTests
+cp native/.build/debug/UmbodTests "$DEST/UmbodTests"
+cp scripts/{accept-macos-artifact.sh,audit-macos-bundle.py,verify-python-bundle.py,native-smoke.py,fixture_stdio.py} "$DEST/scripts/"
+cp tests/fixtures/update-manifest.json "$DEST/tests/fixtures/"
diff --git a/desktop/scripts/record-macos-build.py b/desktop/scripts/record-macos-build.py
new file mode 100644
index 0000000..59af8f2
--- /dev/null
+++ b/desktop/scripts/record-macos-build.py
@@ -0,0 +1,37 @@
+#!/usr/bin/env python3
+"""Record build inputs and remove Swift's development-only toolchain search path."""
+import hashlib
+import json
+from pathlib import Path
+import subprocess
+import sys
+
+root = Path(__file__).resolve().parents[2]
+app = Path(sys.argv[1])
+resources = app / 'Contents/Resources'
+
+def output(*args):
+ return subprocess.check_output(args, text=True, cwd=root).strip()
+
+metadata = {
+ 'schemaVersion': 1,
+ 'version': (root / 'desktop/VERSION').read_text().strip(),
+ 'sourceCommit': output('git', 'rev-parse', 'HEAD'),
+ 'dirty': bool(output('git', 'status', '--porcelain', '--untracked-files=no')),
+ 'architecture': output('uname', '-m'),
+ 'deploymentTarget': '14.0',
+ 'python': output(str(resources / 'python/bin/python3'), '-I', '-B', '--version'),
+ 'pythonBuild': (resources / 'python/BUILD').read_text().strip(),
+ 'uv': output('uv', '--version'),
+ 'swift': output('swift', '--version'),
+ 'xcode': output('xcodebuild', '-version'),
+ 'lockSha256': hashlib.sha256((root / 'desktop/python/uv.lock').read_bytes()).hexdigest(),
+}
+(resources / 'build-metadata.json').write_text(json.dumps(metadata, indent=2) + '\n')
+binary = app / 'Contents/MacOS/Umbod'
+lines = output('/usr/bin/otool', '-l', str(binary)).splitlines()
+for i, line in enumerate(lines):
+ if line.strip() == 'cmd LC_RPATH':
+ path = lines[i + 2].strip().removeprefix('path ').split(' (offset')[0]
+ if path.startswith('/') and '.xctoolchain/' in path:
+ subprocess.run(['/usr/bin/install_name_tool', '-delete_rpath', path, str(binary)], check=True)
diff --git a/desktop/tests/test_macos_bundle.py b/desktop/tests/test_macos_bundle.py
new file mode 100644
index 0000000..79921b9
--- /dev/null
+++ b/desktop/tests/test_macos_bundle.py
@@ -0,0 +1,57 @@
+import importlib.util
+from pathlib import Path
+import tempfile
+import unittest
+
+SCRIPT = Path(__file__).resolve().parents[1] / 'scripts/audit-macos-bundle.py'
+spec = importlib.util.spec_from_file_location('macos_bundle', SCRIPT)
+bundle = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(bundle)
+
+
+class MacBundleTests(unittest.TestCase):
+ def test_rejects_external_and_missing_dependencies(self):
+ with tempfile.TemporaryDirectory() as directory:
+ root = Path(directory)
+ with self.assertRaisesRegex(ValueError, 'Non-relocatable'):
+ bundle.check_reference('/opt/homebrew/lib/example.dylib', root, root, [], root)
+ with self.assertRaisesRegex(ValueError, 'Unresolved'):
+ bundle.check_reference('@rpath/missing.dylib', root, root, ['@loader_path'], root)
+ (root / 'escape').symlink_to(SCRIPT)
+ with self.assertRaisesRegex(ValueError, 'External'):
+ bundle.contained(root / 'escape', root)
+
+ def test_accepts_bundled_and_system_dependencies(self):
+ with tempfile.TemporaryDirectory() as directory:
+ root = Path(directory)
+ (root / 'test.dylib').write_bytes(b'fixture')
+ bundle.check_reference('@rpath/test.dylib', root, root, ['@loader_path'], root)
+ bundle.check_reference('/usr/lib/libSystem.B.dylib', root, root, [], root)
+
+ def test_load_commands_distinguish_dylib_identity_from_dependencies(self):
+ result = bundle.load_commands('''Load command 1
+ cmd LC_ID_DYLIB
+ cmdsize 64
+ name bazel-out/build-path.so (offset 24)
+Load command 2
+ cmd LC_LOAD_DYLIB
+ cmdsize 64
+ name @rpath/required.dylib (offset 24)
+Load command 3
+ cmd LC_BUILD_VERSION
+ minos 14.0
+''')
+ self.assertEqual([c['cmd'] for c in result], ['LC_ID_DYLIB', 'LC_LOAD_DYLIB', 'LC_BUILD_VERSION'])
+ self.assertEqual(result[1]['name'], '@rpath/required.dylib')
+ self.assertEqual(result[2]['minos'], '14.0')
+
+ def test_acceptance_has_no_checkout_or_compilation(self):
+ workflow = (SCRIPT.parents[2] / '.github/workflows/desktop-build.yml').read_text()
+ job = workflow.split(' macos-acceptance:\n')[1].split(' windows:\n')[0]
+ self.assertIn('needs: macos', job)
+ self.assertNotIn('actions/checkout', job)
+ self.assertNotIn('setup-python', job)
+ self.assertIn('macos-acceptance-kit', job)
+ runner = (SCRIPT.parent / 'accept-macos-artifact.sh').read_text()
+ self.assertNotIn('swift run', runner)
+ self.assertIn('--release-identity', runner)
diff --git a/desktop/tests/test_release.py b/desktop/tests/test_release.py
index 435a996..c6ef8d5 100644
--- a/desktop/tests/test_release.py
+++ b/desktop/tests/test_release.py
@@ -123,7 +123,7 @@ def test_workflows_are_separate_and_guarded(self):
for required in ['release.py guard', 'needs: [guard, build]', '--prerelease', '--latest=false', '--verify-tag', 'desktop-release', 'website-manifest-update', 'rights_confirmed', 'persist-credentials: false']:
self.assertIn(required, workflow)
build = (ROOT/'.github/workflows/desktop-build.yml').read_text()
- for required in ['macos-15', 'windows-latest', 'cargo test --locked', 'bash scripts/verify.sh', '--smoke-test', 'upload-artifact@', 'bun run build', 'test_release_workflow.py']:
+ for required in ['macos-26', 'windows-latest', 'cargo test --locked', 'bash scripts/verify.sh', '--smoke-test', 'upload-artifact@', 'bun run build', 'test_release_workflow.py']:
self.assertIn(required, build)
verify = (ROOT/'desktop/scripts/verify.sh').read_text()
self.assertIn('UmbodTests', verify)
diff --git a/website/README.md b/website/README.md
index 6386c73..511777f 100644
--- a/website/README.md
+++ b/website/README.md
@@ -99,7 +99,7 @@ All Download CTAs navigate to `/download/`. That page uses
`public/desktop-downloads.json`, independently of root release metadata. The home
and enterprise pages never initialize desktop downloads. Only macOS Apple Silicon
is offered publicly; Windows, Linux and Intel Mac rows say Coming soon even when
-the release manifest contains Windows build artifacts. The initial Mac state is Release pending, with Enterprise
+the release manifest contains Windows build artifacts. Windows promotion is paused without changing the manifest schema or removing archived build artifacts. When no Mac release is available, its state is Release pending, with Enterprise
linked as the separate edition for teams;
do not add speculative URLs. `src/downloads.mjs` validates exact desktop tags, public
asset URLs, hashes and platform choices. Vite renders a no-JavaScript fallback from the
diff --git a/website/download/index.html b/website/download/index.html
index b95e235..6406959 100644
--- a/website/download/index.html
+++ b/website/download/index.html
@@ -3,11 +3,11 @@
-
+
-
+
@@ -23,7 +23,7 @@
Skip to content
{{ partial.header }}
- Get Umbod
Your tools.
On your computer.
Get Umbod for macOS on Apple Silicon or Windows on x64.
More platforms are on the way.
+ Get Umbod
Your tools.
On your computer.
Get Umbod for macOS on Apple Silicon.
More platforms are on the way.
@@ -64,7 +64,7 @@ Your tools. Your agents.
One place to connect them.
Good to know
A few questions,
answered.
What is Umbod?
A native app that connects your AI agents to MCP tools through a local gateway. Manage connections and choose which tools your agents can use.
-
Which computers does it support?
Unsigned prereleases support macOS 14 or later on Apple Silicon and Windows 10/11 on x64. Linux and Intel Mac downloads are coming soon. See the download page for availability.
+
Which computers does it support?
Unsigned prereleases support macOS 14 or later on Apple Silicon. Windows, Linux and Intel Mac downloads are coming soon. See the download page for availability.
Does everything stay on my computer?
Your gateway and configuration run locally, and credentials are stored in macOS Keychain. Your agents and remote MCP servers can still send data to their own services.
Which MCP connections can I use?
Local stdio servers and remote Streamable HTTP servers. Desktop currently supports tools; legacy standalone SSE, resources, prompts and sampling are not supported.
What if I’m setting this up for a team?
Explore Umbod Enterprise for shared connectors, group permissions and Kubernetes deployment on your infrastructure.
diff --git a/website/src/downloads.mjs b/website/src/downloads.mjs
index 2314fa2..e3c4275 100644
--- a/website/src/downloads.mjs
+++ b/website/src/downloads.mjs
@@ -39,8 +39,8 @@ export function selectDownload(manifest, platform) {
try {
const m = validateManifest(manifest);
if (m.status !== 'available') return { recommended: null, downloads: [] };
- return { recommended: Object.hasOwn(PLATFORMS, platform) ? platform : null,
- downloads: Object.entries(PLATFORMS).map(([id, info]) => ({id, ...info, ...m.assets[id], version: m.version})) };
+ return { recommended: platform === 'macos-arm64' ? platform : null,
+ downloads: Object.entries(PLATFORMS).filter(([id]) => id === 'macos-arm64').map(([id, info]) => ({id, ...info, ...m.assets[id], version: m.version})) };
} catch { return { recommended: null, downloads: [] }; }
}
export async function loadManifest(fetcher = globalThis.fetch) {
@@ -53,21 +53,18 @@ export async function loadManifest(fetcher = globalThis.fetch) {
export function renderDownloads(manifest, platform) {
const { downloads } = selectDownload(manifest, platform);
const mac = downloads.find(item => item.id === 'macos-arm64');
- const windows = downloads.find(item => item.id === 'windows-x64');
let state = 'Release pending';
try { if (validateManifest(manifest).status !== 'unavailable') state = 'Temporarily unavailable'; } catch { state = 'Temporarily unavailable'; }
const macRow = mac
? `
macOSApple Silicon · macOS 14 or laterDownload DMG ↓`
: `
macOSApple Silicon · macOS 14 or later${state}
`;
- const windowsRow = windows
- ? `
Windowsx64 · Windows 10/11Download ZIP ↓`
- : `
Windowsx64 · Windows 10/11${state}
`;
+ const windowsRow = '
Windowsx64 · Windows 10/11Coming soon
';
const upcoming = [['macOS Intel', 'Intel-based Macs'], ['Linux', 'Linux desktops']];
const grid = '
';
const guidance = platform === 'unsupported' || platform === 'macos-unknown'
- ? '
The Mac build requires an Apple M-series chip. Windows requires an x64 processor. Intel Mac and Linux builds are coming soon.
' : '';
+ ? '
The Mac build requires an Apple M-series chip. Windows, Intel Mac and Linux builds are coming soon.
' : '';
if (!mac) return grid + `
${state === 'Release pending' ? 'Desktop downloads are not available yet. They will appear here after the GitHub release is published and verified.' : 'Desktop downloads could not be verified. Please try again later.'}
` + guidance;
- return grid + `
Unsigned prerelease · ${mac.version}. Mac: ad-hoc signed, not notarized. Windows: unsigned. Your operating system may require approval to open the app. Do not disable system security.
Verify Mac download · SHA-256
${mac.sha256}Verify Windows download · SHA-256
${windows.sha256}` + guidance;
+ return grid + `
Unsigned prerelease · ${mac.version}. Mac: ad-hoc signed, not notarized. Your operating system may require approval to open the app. Do not disable system security.
Verify Mac download · SHA-256
${mac.sha256}` + guidance;
}
export async function initializeDownloads(doc = document, nav = navigator) {
const container = doc.getElementById('desktop-download-options');
diff --git a/website/tests/downloads.test.mjs b/website/tests/downloads.test.mjs
index efe6636..2ce3ae2 100644
--- a/website/tests/downloads.test.mjs
+++ b/website/tests/downloads.test.mjs
@@ -29,9 +29,9 @@ test('platform hints never guess Apple Silicon from MacIntel or select mobile AR
});
test('download selection preserves manual choices and truthful unavailability', () => {
- assert.equal(selectDownload(fixture(), 'windows-x64').recommended, 'windows-x64');
+ assert.equal(selectDownload(fixture(), 'windows-x64').recommended, null);
assert.equal(selectDownload(fixture(), 'macos-unknown').recommended, null);
- assert.equal(selectDownload(fixture(), 'unsupported').downloads.length, 2);
+ assert.equal(selectDownload(fixture(), 'unsupported').downloads.length, 1);
assert.equal(selectDownload({schemaVersion: 1, status: 'unavailable'}, 'macos-arm64').downloads.length, 0);
assert.equal(selectDownload(null, 'windows-x64').downloads.length, 0);
});
@@ -41,11 +41,13 @@ test('fetch fails closed for HTTP, network, malformed and redirected responses',
assert.equal((await loadManifest(async (url, options) => { assert.equal(url, '/desktop-downloads.json'); assert.equal(options.credentials, 'omit'); return {ok: true, json: async () => fixture()}; })).tag, 'desktop-v0.2.0');
});
-test('verified Mac and Windows downloads are available; unsupported platforms remain coming soon', () => {
+test('only Mac downloads are offered while Windows is paused', () => {
const html = renderDownloads(fixture(), 'windows-x64');
for (const text of ['Windows', 'Linux', 'macOS Intel', 'Apple Silicon', '0.2.0', 'Unsigned prerelease', 'SHA-256', 'b'.repeat(64), 'Coming soon']) assert.ok(html.includes(text), text);
assert.ok(html.includes(fixture().assets['macos-arm64'].url));
- assert.ok(html.includes(fixture().assets['windows-x64'].url));
+ assert.ok(!html.includes(fixture().assets['windows-x64'].url));
+ assert.ok(!html.includes('Download ZIP'));
+ assert.ok(!html.includes('Verify Windows download'));
assert.ok(!html.includes('Recommended'));
assert.ok(renderDownloads(null, 'unknown').includes('could not be verified'));
const absent = renderDownloads({schemaVersion: 1, status: 'unavailable'}, 'unknown');
diff --git a/website/tests/pages.test.mjs b/website/tests/pages.test.mjs
index 68f4566..719bc31 100644
--- a/website/tests/pages.test.mjs
+++ b/website/tests/pages.test.mjs
@@ -15,7 +15,7 @@ test('home and enterprise have separate audiences and truthful product evidence'
const home = read('index.html');
assert.match(home, /Your tools\. Your agents\./);
assert.match(home, /Apple Silicon/);
- assert.match(home, /Windows · x64/);
+ assert.match(home, /Windows · Coming soon/);
assert.match(home, /Connection illustration/i);
assert.doesNotMatch(home, /tree\/main\/desktop/, 'desktop is not on main before this PR merges');
assert.doesNotMatch(home, /kind create cluster|Grant access by group|umbod-overview\.mp4/);
@@ -66,7 +66,7 @@ for (const status of ['unavailable', 'available']) {
assert.doesNotMatch(pages[1], /desktop-download-options|releases\/download/);
if (status === 'available') {
assert.ok(pages[2].includes(manifest.assets['macos-arm64'].url));
- assert.ok(pages[2].includes(manifest.assets['windows-x64'].url));
+ assert.ok(!pages[2].includes(manifest.assets['windows-x64'].url));
assert.match(pages[2], /Unsigned prerelease/);
assert.match(pages[2], /SHA-256/);
} else {