Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/desktop-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ jobs:
path: |
acceptance.log
artifact-sha256.txt
ui-evidence/
if-no-files-found: error
windows:
runs-on: windows-latest
Expand Down
2 changes: 1 addition & 1 deletion desktop/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion desktop/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "umbod-gateway"
version = "0.2.1"
version = "0.2.2"
edition = "2024"

[lib]
Expand Down
13 changes: 12 additions & 1 deletion desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ OAuth supports native public clients with loopback callbacks, discovery, PKCE an

- [`python/`](python/README.md): macOS host, transport adapters, Keychain, migration and tests; reuses `umbod/api` and `packages/umbod-sdk`.
- `backend/src/`: Windows Rust gateway, profiles, platform adapters, client setup and OAuth; `backend/tests/`: integration tests.
- `native/`: SwiftUI app and Swift contract/lifecycle tests; `UmbodCore/DesktopUpdate.swift` validates update metadata and `Umbod/UpdatesView.swift` owns update preferences and the install guidance.
- `native/`: SwiftUI app and Swift contract/lifecycle tests; `UmbodCore/ConnectorEditor.swift` owns the actual editor submission and retry state; `UITests/` contains the XCUITest project for packaged-app user journeys; `UmbodCore/DesktopUpdate.swift` validates update metadata and `Umbod/UpdatesView.swift` owns update preferences and the install guidance.
- `windows/`: shared .NET Core contracts, Core tests, and WPF app; `Umbod.Core/DesktopUpdate.cs` validates update metadata and `Umbod.Windows/UpdatesView.cs` provides update checks and install guidance.
- `scripts/`: local builds, packaging, isolated fixtures, and release policy/metadata.
- `VERSION`: independent desktop version; keep Cargo.toml and the gateway entry in Cargo.lock synchronized. Bundle, package and .NET versions derive from it.
Expand All @@ -62,3 +62,14 @@ 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/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.

The acceptance kit also includes a precompiled XCUITest runner. `scripts/run-macos-ui-tests.py` runs it with `xcodebuild test-without-building` on the disposable hosted Mac. It clicks Add connector, saves a local fixture, discovers and enables its echo tool, calls it through the packaged agent bridge, edits the connector, and verifies persistence after relaunch. A second journey adds a remote HTTPS MCP fixture, opens a real browser for synthetic consent, completes discovery/registration and the PKCE callback, enables and calls a tool, refreshes the token, and verifies authenticated calls after relaunch. `scripts/ui_fixture.py` owns the local OAuth server and runs the packaged agent outside the sandboxed XCTest runner; its control endpoints require an ephemeral capability. Its temporary certificate is trusted only by the test app process, with no Keychain or system trust changes. No API shortcut creates or enables connectors. This tests the standards-based sign-in flow rather than Wispr Flow’s live account service. The required gate rejects failed or skipped journeys and preserves `.xcresult` diagnostics and screenshots. The runner uses temporary data and fixture credentials, never the login Keychain. Local UI testing uses `--local-fixture` to copy and re-sign the app with a unique bundle identity; release-identity execution remains restricted to disposable CI. The app is still built with SwiftPM, and the separate test project does not build or modify it.

To run the same click-through journey locally without touching installed apps or profiles:

```sh
bash desktop/scripts/prepare-macos-acceptance.sh /tmp/umbod-ui-kit
python3 desktop/scripts/run-macos-ui-tests.py "desktop/dist/Umbod Dev.app" /tmp/umbod-ui-kit /tmp/umbod-ui-evidence --local-fixture
```

Build the dev bundle first. Use a fresh kit directory per build and a new evidence directory per run. Xcode UI testing may request macOS automation/accessibility access on first use; do not bypass system controls. The CI gate still tests the unchanged release artifact.
2 changes: 1 addition & 1 deletion desktop/VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.2.1
0.2.2
2 changes: 1 addition & 1 deletion desktop/docs/RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 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.
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 precompiled XCUITest journeys must pass without skipped tests: local connector creation/edit/relaunch and browser OAuth sign-in/PKCE, token refresh, and credential persistence. Both must enable tools through the UI and make real calls through the packaged agent bridge; inspect the uploaded `.xcresult` on failure. 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.

Expand Down
20 changes: 14 additions & 6 deletions desktop/native/Sources/Umbod/ConnectorsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,21 @@ struct ServersView: View {
@State private var sheet: ConnectorSheet?
@Binding var selectedConnector: String?
@State private var removal: String?
// An offline draft receives its backend ID after first successful discovery.
// Keep the selected detail and sheet attached through its stable host identity.
private func connector(_ id: String) -> [String: Any]? {
model.servers.first {
$0["id"] as? String == id ||
(id.hasPrefix("pending:") && $0["host_id"] as? String == String(id.dropFirst("pending:".count)))
}
}
var body: some View {
VStack(alignment: .leading, spacing: 24) {
if let id = selectedConnector, let connector = model.servers.first(where: { $0["id"] as? String == id }) {
if let id = selectedConnector, let connector = connector(id) {
Button { selectedConnector = nil } label: { Label("All connectors", systemImage: "chevron.left") }
ConnectorToolsView(connector: connector, manage: { sheet = .manage(id) }).id(id)
} else {
Button { sheet = .add } label: { Label("Add connector", systemImage: "plus") }.buttonStyle(BrandButtonStyle(primary: true))
Button { sheet = .add } label: { Label("Add connector", systemImage: "plus") }.buttonStyle(BrandButtonStyle(primary: true)).accessibilityIdentifier("connectors.add")
if model.servers.isEmpty {
ContentUnavailableView("No connectors yet", systemImage: "externaldrive.badge.plus", description: Text("Add a connector to make its tools available to your apps."))
} else {
Expand All @@ -39,7 +47,7 @@ struct ServersView: View {
Text("\(tools.filter { model.allowedTools.contains($0["name"] as? String ?? "") }.count) / \(tools.count) tools enabled").font(.caption).foregroundStyle(Brand.muted)
Image(systemName: "chevron.right").foregroundStyle(Brand.muted)
}.padding(.vertical, 6).contentShape(Rectangle())
}.buttonStyle(.plain)
}.buttonStyle(.plain).accessibilityIdentifier("connector.row.\(server["name"] as? String ?? "")")
Menu {
Button("Connection settings") { sheet = .manage(id) }
Button("Connect / rediscover") { model.act(["action":"connect", "id":id]) }.disabled(model.busy)
Expand All @@ -58,9 +66,9 @@ struct ServersView: View {
switch route {
case .add: ServerEditor(draft: ServerDraft())
case .edit(let id):
if let server = model.servers.first(where: { $0["id"] as? String == id }) { ServerEditor(draft: ServerDraft(server)) }
if let server = connector(id) { ServerEditor(draft: ServerDraft(server)) }
case .manage(let id):
if let server = model.servers.first(where: { $0["id"] as? String == id }) {
if let server = connector(id) {
ConnectorManagementView(server: server, edit: { sheet = .edit(id) })
}
}
Expand Down Expand Up @@ -191,6 +199,6 @@ struct ConnectorToolsView: View {
}
private func permissionToggle(_ name: String) -> some View {
Toggle("Allow \(String(name.dropFirst(id.count + 1)))", isOn: Binding(get: { model.allowedTools.contains(name) }, set: { model.act(["action":"grant", "tool":name, "allow":$0]) }))
.labelsHidden().toggleStyle(.switch).controlSize(.small).disabled(model.busy)
.accessibilityIdentifier("tool.allow.\(String(name.dropFirst(id.count + 1)))").labelsHidden().toggleStyle(.switch).controlSize(.small).disabled(model.busy)
}
}
48 changes: 20 additions & 28 deletions desktop/native/Sources/Umbod/UmbodApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -224,48 +224,40 @@ struct Panel<Content: View>: View {
struct ServerEditor: View {
@EnvironmentObject var model: AppModel
@Environment(\.dismiss) var dismiss
@State var draft: ServerDraft
@State private var credentialName = "Authorization"
@State private var credential = ""
@StateObject private var editor: ConnectorEditor
init(draft: ServerDraft) { _editor = StateObject(wrappedValue: ConnectorEditor(draft: draft)) }
@State private var localError = ""
@State private var saving = false
var body: some View {
VStack(alignment: .leading, spacing: 18) {
Text(draft.id.isEmpty ? "Add connector" : "Edit connector").font(.title2.bold())
Text(editor.draft.id.isEmpty ? "Add connector" : "Edit connector").font(.title2.bold())
ScrollView { VStack(alignment: .leading, spacing: 16) {
TextField("Name", text: $draft.name)
Picker("Connection", selection: $draft.transport) { Text("Local process · stdio").tag("stdio"); Text("Remote · Streamable HTTP").tag("http") }
if draft.transport == "stdio" {
TextField("Executable (absolute path)", text: $draft.command)
TextField("Name", text: $editor.draft.name).accessibilityIdentifier("connector.name")
Picker("Connection", selection: $editor.draft.transport) { Text("Local process · stdio").tag("stdio"); Text("Remote · Streamable HTTP").tag("http") }.accessibilityIdentifier("connector.transport")
if editor.draft.transport == "stdio" {
TextField("Executable (absolute path)", text: $editor.draft.command).accessibilityIdentifier("connector.command")
Text("Arguments · one per line, no shell quoting").font(.caption).foregroundStyle(Brand.muted)
TextEditor(text: $draft.arguments).font(.system(.body, design: .monospaced)).frame(height: 65).padding(8).background(Brand.canvas).overlay(RoundedRectangle(cornerRadius: 8).stroke(Brand.border))
} else { TextField("Endpoint URL", text: $draft.url) }
TextEditor(text: $editor.draft.arguments).accessibilityIdentifier("connector.arguments").font(.system(.body, design: .monospaced)).frame(height: 65).padding(8).background(Brand.canvas).overlay(RoundedRectangle(cornerRadius: 8).stroke(Brand.border).allowsHitTesting(false))
} else { TextField("Endpoint URL", text: $editor.draft.url).accessibilityIdentifier("connector.url") }
VStack(alignment: .leading, spacing: 12) {
Text("Optional credential · stored only in Keychain").font(.system(size: 14, weight: .semibold)).padding(.top, 8)
TextField(draft.transport == "stdio" ? "Environment variable name" : "Header name (Authorization, X-API-Key…)", text: $credentialName)
SecureField(draft.transport == "stdio" ? "Secret environment value" : "Header value (include Bearer prefix for a bearer token)", text: $credential)
TextField(editor.draft.transport == "stdio" ? "Environment variable name" : "Header name (Authorization, X-API-Key…)", text: $editor.credentialName)
SecureField(editor.draft.transport == "stdio" ? "Secret environment value" : "Header value (include Bearer prefix for a bearer token)", text: $editor.credential)
Text("Leave blank for no change. Use browser sign-in after saving for OAuth.").font(.caption).foregroundStyle(Brand.muted)
}
DisclosureGroup("Advanced credential references") { Text("JSON maps names to upstream: Keychain references. Never put secret values here.").font(.caption); TextField("Headers", text: $draft.headerReferences); TextField("Environment", text: $draft.environmentReferences) }
}.padding(2) }.textFieldStyle(BrandTextFieldStyle())
if !draft.id.isEmpty { Text("Saving edits disconnects this connector and resets its shared tool permissions. Reconnect and enable the tools you want.").font(.caption).foregroundStyle(Brand.muted) }
DisclosureGroup("Advanced credential references") { Text("JSON maps names to upstream: Keychain references. Never put secret values here.").font(.caption); TextField("Headers", text: $editor.draft.headerReferences); TextField("Environment", text: $editor.draft.environmentReferences) }
}.padding(2) }.textFieldStyle(BrandTextFieldStyle()).disabled(editor.saving)
if !editor.draft.id.isEmpty { Text("Saving edits disconnects this connector and resets its shared tool permissions. Reconnect and enable the tools you want.").font(.caption).foregroundStyle(Brand.muted) }
if !localError.isEmpty { Text(localError).foregroundStyle(Brand.danger) }
HStack { Spacer(); Button("Cancel") { dismiss() }; Button(saving ? "Saving…" : "Save connector") { save() }.buttonStyle(BrandButtonStyle(primary: true)).disabled(saving || draft.name.isEmpty) }
HStack { Spacer(); Button("Cancel") { dismiss() }; Button(editor.saving ? "Saving…" : "Save connector") { save() }.buttonStyle(BrandButtonStyle(primary: true)).disabled(editor.saving || editor.draft.name.isEmpty).accessibilityIdentifier("connector.save") }
}.padding(24).frame(width: 620, height: 660).modifier(BrandAppearance())
}
func save() {
Task { saving = true; defer { saving = false }
Task {
do {
if draft.id.isEmpty { draft.id = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased() }
var payload = try draft.payload()
if !credential.isEmpty {
guard !credentialName.isEmpty else { throw ContractError.message("Enter a credential header or environment name") }
let reference = "upstream:\(draft.id):\(credentialName)"
_ = try await model.backend.request(["action": "secret_set", "reference": reference, "value": credential]); credential = ""
let key = draft.transport == "stdio" ? "env" : "headers"
var references = payload[key] as? [String: String] ?? [:]; references[credentialName] = reference; payload[key] = references
}
_ = try await model.backend.request(["action": "server_save", "server": payload]); try await model.refresh(); dismiss()
localError = ""
try await editor.save { try await model.backend.request($0) }
try await model.refresh()
dismiss()
} catch { localError = error.localizedDescription }
}
}
Expand Down
Loading
Loading