From 46a9d19a638aaed3cff490aa34c6690f4aa943c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Mon, 12 Oct 2026 10:11:08 +0200 Subject: [PATCH 01/15] add redirect helper --- .../-utils/resolveFullIndexRedirect.test.ts | 75 +++++++++++++++++++ .../full/-utils/resolveFullIndexRedirect.ts | 32 ++++++++ 2 files changed, 107 insertions(+) create mode 100644 new-ui/src/routes/full/-utils/resolveFullIndexRedirect.test.ts create mode 100644 new-ui/src/routes/full/-utils/resolveFullIndexRedirect.ts diff --git a/new-ui/src/routes/full/-utils/resolveFullIndexRedirect.test.ts b/new-ui/src/routes/full/-utils/resolveFullIndexRedirect.test.ts new file mode 100644 index 00000000..e15f4b46 --- /dev/null +++ b/new-ui/src/routes/full/-utils/resolveFullIndexRedirect.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; +import { resolveFullIndexRedirect } from './resolveFullIndexRedirect'; + +describe('resolveFullIndexRedirect', () => { + it('adds an empty client when no provisioning config exists', () => { + expect( + resolveFullIndexRedirect({ + instanceCount: 0, + tunnelCount: 0, + provisioningConfig: null, + }), + ).toEqual({ to: '/full/add' }); + }); + + it('opens the prefilled add-instance form for an empty client with config', () => { + expect( + resolveFullIndexRedirect({ + instanceCount: 0, + tunnelCount: 0, + provisioningConfig: { + enrollment_url: 'https://enroll.example', + enrollment_token: 'provisioning-token', + }, + }), + ).toEqual({ + to: '/full/add/instance', + search: { + url: 'https://enroll.example', + token: 'provisioning-token', + }, + }); + }); + + it('opens the prefilled form even if tunnels exist', () => { + expect( + resolveFullIndexRedirect({ + instanceCount: 0, + tunnelCount: 1, + provisioningConfig: { + enrollment_url: 'https://enroll.example', + enrollment_token: 'provisioning-token', + }, + }), + ).toEqual({ + to: '/full/add/instance', + search: { + url: 'https://enroll.example', + token: 'provisioning-token', + }, + }); + }); + + it('shows overview when an instance exists despite config', () => { + expect( + resolveFullIndexRedirect({ + instanceCount: 1, + tunnelCount: 0, + provisioningConfig: { + enrollment_url: 'https://enroll.example', + enrollment_token: 'provisioning-token', + }, + }), + ).toEqual({ to: '/full/overview' }); + }); + + it('shows overview for an empty client with tunnels and no config', () => { + expect( + resolveFullIndexRedirect({ + instanceCount: 0, + tunnelCount: 1, + provisioningConfig: null, + }), + ).toEqual({ to: '/full/overview' }); + }); +}); diff --git a/new-ui/src/routes/full/-utils/resolveFullIndexRedirect.ts b/new-ui/src/routes/full/-utils/resolveFullIndexRedirect.ts new file mode 100644 index 00000000..dc46b126 --- /dev/null +++ b/new-ui/src/routes/full/-utils/resolveFullIndexRedirect.ts @@ -0,0 +1,32 @@ +import type { ProvisioningConfig } from '../../../shared/rust-api/types'; + +type ResolveFullIndexRedirectArgs = { + instanceCount: number; + tunnelCount: number; + provisioningConfig: ProvisioningConfig | null; +}; + +type FullIndexRedirect = + | { to: '/full/add/instance'; search: { url: string; token: string } } + | { to: '/full/add' } + | { to: '/full/overview' }; + +export const resolveFullIndexRedirect = ({ + instanceCount, + tunnelCount, + provisioningConfig, +}: ResolveFullIndexRedirectArgs): FullIndexRedirect => { + if (instanceCount === 0 && provisioningConfig) { + return { + to: '/full/add/instance', + search: { + url: provisioningConfig.enrollment_url, + token: provisioningConfig.enrollment_token, + }, + }; + } + if (instanceCount === 0 && tunnelCount === 0) { + return { to: '/full/add' }; + } + return { to: '/full/overview' }; +}; From fa450fc0b0bc4edf075beb9d4dc76a8e33fc3585 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Mon, 12 Oct 2026 12:41:03 +0200 Subject: [PATCH 02/15] use helper to determine route redirect --- new-ui/src/routes/full/index.tsx | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/new-ui/src/routes/full/index.tsx b/new-ui/src/routes/full/index.tsx index 1ad5e6e8..038cff63 100644 --- a/new-ui/src/routes/full/index.tsx +++ b/new-ui/src/routes/full/index.tsx @@ -1,21 +1,32 @@ import { createFileRoute, Navigate, redirect } from '@tanstack/react-router'; +import { error as logError } from '@tauri-apps/plugin-log'; import { getInstancesQueryOptions, + getProvisioningConfigQueryOptions, getTunnelsQueryOptions, } from '../../shared/rust-api/query'; +import { resolveFullIndexRedirect } from './-utils/resolveFullIndexRedirect'; export const Route = createFileRoute('/full/')({ beforeLoad: async ({ context }) => { - const [instances, tunnels] = await Promise.all([ + const [instances, tunnels, provisioningConfig] = await Promise.all([ context.queryClient.fetchQuery(getInstancesQueryOptions), context.queryClient.fetchQuery(getTunnelsQueryOptions), + context.queryClient + .fetchQuery({ ...getProvisioningConfigQueryOptions, staleTime: 0 }) + .catch(() => { + void logError('Failed to fetch provisioning config.'); + return null; + }), ]); - if (instances.length === 0 && tunnels.length === 0) { - throw redirect({ to: '/full/add' }); - } else { - throw redirect({ to: '/full/overview' }); - } + throw redirect( + resolveFullIndexRedirect({ + instanceCount: instances.length, + tunnelCount: tunnels.length, + provisioningConfig, + }), + ); }, component: RouteComponent, }); From e117c48beea8728cb01012d2da8638f85b507c42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Tue, 13 Oct 2026 09:33:38 +0200 Subject: [PATCH 03/15] cleanup provisioning config on instance save --- src-tauri/enterprise/provisioning/src/lib.rs | 22 +++++++++++++++- src-tauri/src/commands.rs | 27 +++++++++++++++++++- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src-tauri/enterprise/provisioning/src/lib.rs b/src-tauri/enterprise/provisioning/src/lib.rs index 221c30d3..4324665d 100644 --- a/src-tauri/enterprise/provisioning/src/lib.rs +++ b/src-tauri/enterprise/provisioning/src/lib.rs @@ -1,4 +1,4 @@ -use std::{fmt, fs, path::Path}; +use std::{fmt, fs, io, path::Path}; use log::{debug, warn}; use serde::{Deserialize, Serialize}; @@ -66,6 +66,15 @@ pub fn try_get_provisioning_config(app_data_dir: &Path) -> Option io::Result<()> { + match fs::remove_file(app_data_dir.join(CONFIG_FILE_NAME)) { + Ok(()) => Ok(()), + Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()), + Err(err) => Err(err), + } +} + #[cfg(test)] mod tests { use std::fs; @@ -94,6 +103,17 @@ mod tests { assert!(try_get_provisioning_config(dir.path()).is_none()); } + #[test] + fn test_clear_provisioning_config_removes_file_and_is_idempotent() { + let dir = tempdir().unwrap(); + let config_file = dir.path().join(CONFIG_FILE_NAME); + fs::write(&config_file, "{}").unwrap(); + + clear_provisioning_config(dir.path()).expect("config file should be removed"); + assert!(!config_file.exists()); + clear_provisioning_config(dir.path()).expect("missing config file should be ignored"); + } + #[test] fn test_malformed_json_returns_none() { let dir = tempdir().unwrap(); diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index bb86f29c..c838c38d 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -29,7 +29,7 @@ use defguard_client_proto::defguard::{ }, enterprise::posture::v2::DevicePostureData, }; -use defguard_client_provisioning::ProvisioningConfig; +use defguard_client_provisioning::{clear_provisioning_config, ProvisioningConfig}; use reqwest::Url; use serde::{Deserialize, Serialize}; use struct_patch::Patch; @@ -612,6 +612,31 @@ pub async fn save_device_config( ); } transaction.commit().await?; + + let state = handle.state::(); + let has_provisioning_config = match state.provisioning_config.lock() { + Ok(mut provisioning_config) => provisioning_config.take().is_some(), + Err(_) => { + warn!("Failed to clear in-memory provisioning config"); + false + } + }; + if has_provisioning_config { + match handle.path().app_data_dir() { + Ok(app_data_dir) => { + if let Err(err) = clear_provisioning_config(&app_data_dir) { + warn!( + "Failed to remove provisioning config from {}: {err}", + app_data_dir.display() + ); + } + } + Err(err) => { + warn!("Failed to resolve app data directory for provisioning cleanup: {err}"); + } + } + } + info!("New instance {instance} created."); trace!("Created following instance: {instance:#?}"); From d7100583f269b61ba22101b178e21e9940068e89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Tue, 13 Oct 2026 12:13:25 +0200 Subject: [PATCH 04/15] add helper to pass launch options --- e2e/helpers/launchConfig.ts | 27 ++++++++++++++++++++++++ e2e/wdio.conf.ts | 41 +++++++++++++++++++++++++++++-------- 2 files changed, 59 insertions(+), 9 deletions(-) create mode 100644 e2e/helpers/launchConfig.ts diff --git a/e2e/helpers/launchConfig.ts b/e2e/helpers/launchConfig.ts new file mode 100644 index 00000000..9233e563 --- /dev/null +++ b/e2e/helpers/launchConfig.ts @@ -0,0 +1,27 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +export type LaunchConfig = { + files?: Record; + args?: string[]; +}; + +export const launchConfigs: Record = {}; + +export const getLaunchConfig = (specs: string[]): LaunchConfig | undefined => { + for (const spec of specs) { + const specName = path.basename(spec); + if (Object.hasOwn(launchConfigs, specName)) return launchConfigs[specName]; + } +}; + +export const writeLaunchFiles = ( + dataDir: string, + files: Record, +): void => { + const appDataDir = path.join(dataDir, 'share', 'net.defguard'); + fs.mkdirSync(appDataDir, { recursive: true }); + for (const [fileName, contents] of Object.entries(files)) { + fs.writeFileSync(path.join(appDataDir, fileName), contents); + } +}; diff --git a/e2e/wdio.conf.ts b/e2e/wdio.conf.ts index bf8c3cb5..d5739adb 100644 --- a/e2e/wdio.conf.ts +++ b/e2e/wdio.conf.ts @@ -2,6 +2,7 @@ import { type ChildProcess, spawn, spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { getLaunchConfig, writeLaunchFiles } from "./helpers/launchConfig.js"; const here = import.meta.dirname; @@ -87,22 +88,44 @@ export const config: WebdriverIO.Config = { } }, - beforeSession: () => { + beforeSession: (_config, caps, specs) => { killLeftoverClients(); cleanupWireguardInterfaces(); - dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "defguard-e2e-")); + const sessionDataDir = fs.mkdtempSync( + path.join(os.tmpdir(), "defguard-e2e-"), + ); + dataDir = sessionDataDir; + const launchConfig = getLaunchConfig(specs); + const sessionEnv = { + CLIENT_BINARY: clientBinary, + DEFGUARD_CLIENT_WELCOME_SKIP: "1", + E2E_DATA_DIR: sessionDataDir, + XDG_DATA_HOME: path.join(sessionDataDir, "share"), + XDG_CONFIG_HOME: path.join(sessionDataDir, "config"), + XDG_CACHE_HOME: path.join(sessionDataDir, "cache"), + }; + Object.assign(process.env, sessionEnv); + + if (launchConfig?.files) { + writeLaunchFiles(sessionDataDir, launchConfig.files); + } + + if (launchConfig?.args) { + const tauriOptions = caps["tauri:options"] as + | { args?: string[] } + | undefined; + if (!tauriOptions) { + throw new Error("Missing tauri:options capability for launch arguments"); + } + tauriOptions.args = launchConfig.args; + } + tauriDriver = spawn( tauriDriverBinary, nativeDriver ? ["--native-driver", nativeDriver] : [], { stdio: ["ignore", "inherit", "inherit"], - env: { - ...process.env, - DEFGUARD_CLIENT_WELCOME_SKIP: "1", - XDG_DATA_HOME: path.join(dataDir, "share"), - XDG_CONFIG_HOME: path.join(dataDir, "config"), - XDG_CACHE_HOME: path.join(dataDir, "cache"), - }, + env: { ...process.env }, }, ); tauriDriver.on("error", (error) => { From 7a773ac15a725e73e30959832f6de6b77f3f2788 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Tue, 13 Oct 2026 13:56:29 +0200 Subject: [PATCH 05/15] formatting --- e2e/helpers/launchConfig.ts | 30 +++++++++++++++--------------- e2e/wdio.conf.ts | 4 +++- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/e2e/helpers/launchConfig.ts b/e2e/helpers/launchConfig.ts index 9233e563..fac11275 100644 --- a/e2e/helpers/launchConfig.ts +++ b/e2e/helpers/launchConfig.ts @@ -1,27 +1,27 @@ -import fs from 'node:fs'; -import path from 'node:path'; +import fs from "node:fs"; +import path from "node:path"; export type LaunchConfig = { - files?: Record; - args?: string[]; + files?: Record; + args?: string[]; }; export const launchConfigs: Record = {}; export const getLaunchConfig = (specs: string[]): LaunchConfig | undefined => { - for (const spec of specs) { - const specName = path.basename(spec); - if (Object.hasOwn(launchConfigs, specName)) return launchConfigs[specName]; - } + for (const spec of specs) { + const specName = path.basename(spec); + if (Object.hasOwn(launchConfigs, specName)) return launchConfigs[specName]; + } }; export const writeLaunchFiles = ( - dataDir: string, - files: Record, + dataDir: string, + files: Record, ): void => { - const appDataDir = path.join(dataDir, 'share', 'net.defguard'); - fs.mkdirSync(appDataDir, { recursive: true }); - for (const [fileName, contents] of Object.entries(files)) { - fs.writeFileSync(path.join(appDataDir, fileName), contents); - } + const appDataDir = path.join(dataDir, "share", "net.defguard"); + fs.mkdirSync(appDataDir, { recursive: true }); + for (const [fileName, contents] of Object.entries(files)) { + fs.writeFileSync(path.join(appDataDir, fileName), contents); + } }; diff --git a/e2e/wdio.conf.ts b/e2e/wdio.conf.ts index d5739adb..1c8d9781 100644 --- a/e2e/wdio.conf.ts +++ b/e2e/wdio.conf.ts @@ -115,7 +115,9 @@ export const config: WebdriverIO.Config = { | { args?: string[] } | undefined; if (!tauriOptions) { - throw new Error("Missing tauri:options capability for launch arguments"); + throw new Error( + "Missing tauri:options capability for launch arguments", + ); } tauriOptions.args = launchConfig.args; } From 0e77fef3c3360d4003b5745777c81c38f1fbfac3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Tue, 13 Oct 2026 16:13:13 +0200 Subject: [PATCH 06/15] add new e2e dependencies --- .github/workflows/e2e.yaml | 4 ++-- e2e/README.md | 4 ++-- justfile | 5 +++-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index a0cdcc57..f49b0076 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -35,7 +35,7 @@ jobs: - name: Install system dependencies run: | apt-get update - apt-get install -y build-essential ca-certificates desktop-file-utils curl git \ + apt-get install -y build-essential ca-certificates dbus desktop-file-utils curl git \ iproute2 iputils-ping libayatana-appindicator3-dev libgtk-3-dev libprotobuf-dev \ librsvg2-dev libssl-dev libudev-dev libwebkit2gtk-4.1-dev libxdo-dev patchelf procps \ protobuf-compiler webkit2gtk-driver xvfb wireguard-tools xclip xdg-utils @@ -106,7 +106,7 @@ jobs: working-directory: e2e - name: Run e2e tests - run: xvfb-run -a pnpm test + run: dbus-run-session -- xvfb-run -a pnpm test working-directory: e2e - name: Collect logs diff --git a/e2e/README.md b/e2e/README.md index 7f64b652..a931a1ad 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -12,7 +12,7 @@ Defguard services to be running separately. - An administrator account whose login does not require MFA. The password is supplied through `e2e/.env`. - A dedicated deployment with exactly one test network named `e2e`. The connection checks use the first visible connect button. - Root `defguard-service` running with `/var/run/defguard.socket` available. -- Rust/Cargo with the Tauri CLI, Node.js, `pnpm`, `just`, `WebKitWebDriver`, and `xvfb-run` available. +- Rust/Cargo with the Tauri CLI, Node.js, `pnpm`, `just`, `WebKitWebDriver`, `xvfb-run`, and `dbus-run-session` available. - `tauri-driver` available at `~/.cargo/bin/tauri-driver`, or at the path set by `TAURI_DRIVER`. ## Configure the test environment @@ -58,6 +58,6 @@ To run one spec directly: ```bash cd e2e NATIVE_DRIVER="$(command -v WebKitWebDriver)" \ - xvfb-run -a pnpm exec wdio run ./wdio.conf.ts \ + dbus-run-session -- xvfb-run -a pnpm exec wdio run ./wdio.conf.ts \ --spec=./tests/enrollment.spec.ts ``` diff --git a/justfile b/justfile index 794212b5..06c1fc63 100644 --- a/justfile +++ b/justfile @@ -34,9 +34,10 @@ e2e-test: test "$(uname -s)" = "Linux" || { echo "Client E2E tests require Linux." >&2; exit 1; } test -f e2e/.env || { echo "Missing e2e/.env; copy e2e/.env.example and fill it locally." >&2; exit 1; } test -S /var/run/defguard.socket || { echo "defguard-service is not running at /var/run/defguard.socket." >&2; exit 1; } - test -x "${TAURI_DRIVER:-$HOME/.cargo/bin/tauri-driver}" || { echo "tauri-driver is missing; install it with: cargo install tauri-driver --locked" >&2; exit 1; } + test -x "${TAURI_DRIVER:-$HOME/.cargo/bin/tauri-driver}" || { echo "tauri-driver is missing; set TAURI_DRIVER to its location." >&2; exit 1; } command -v WebKitWebDriver >/dev/null || { echo "WebKitWebDriver is missing; enter nix develop." >&2; exit 1; } command -v xvfb-run >/dev/null || { echo "xvfb-run is missing; enter nix develop." >&2; exit 1; } - cd e2e && NATIVE_DRIVER="${NATIVE_DRIVER:-$(command -v WebKitWebDriver)}" xvfb-run -a pnpm test + command -v dbus-run-session >/dev/null || { echo "dbus-run-session is missing; enter nix develop." >&2; exit 1; } + cd e2e && NATIVE_DRIVER="${NATIVE_DRIVER:-$(command -v WebKitWebDriver)}" dbus-run-session -- xvfb-run -a pnpm test e2e: e2e-build e2e-provision e2e-test From ffe046f846ed22df01bee3d4da8dd2ffb7b5ec8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Tue, 13 Oct 2026 17:40:58 +0200 Subject: [PATCH 07/15] test provisioning config support --- e2e/helpers/launchConfig.ts | 11 ++++++- e2e/tests/provisioning.spec.ts | 58 ++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 e2e/tests/provisioning.spec.ts diff --git a/e2e/helpers/launchConfig.ts b/e2e/helpers/launchConfig.ts index fac11275..601cf0fa 100644 --- a/e2e/helpers/launchConfig.ts +++ b/e2e/helpers/launchConfig.ts @@ -6,7 +6,16 @@ export type LaunchConfig = { args?: string[]; }; -export const launchConfigs: Record = {}; +export const launchConfigs: Record = { + "provisioning.spec.ts": { + files: { + "provisioning.json": JSON.stringify({ + enrollment_url: "https://enroll.example", + enrollment_token: "e2e-provisioning-token", + }), + }, + }, +}; export const getLaunchConfig = (specs: string[]): LaunchConfig | undefined => { for (const spec of specs) { diff --git a/e2e/tests/provisioning.spec.ts b/e2e/tests/provisioning.spec.ts new file mode 100644 index 00000000..bcd37419 --- /dev/null +++ b/e2e/tests/provisioning.spec.ts @@ -0,0 +1,58 @@ +import { $, browser, expect } from "@wdio/globals"; +import { invoke } from "../helpers/client.js"; +import { switchToWindowLabel } from "../helpers/windows.js"; + +const enrollmentToken = "e2e-provisioning-token"; +const enrollmentUrl = "https://enroll.example"; + +describe("provisioning", () => { + it("opens the prefilled instance form from launch config", async () => { + await switchToWindowLabel("full-view"); + await browser.waitUntil( + async () => + new URL(await browser.getUrl()).pathname === "/full/add/instance", + { timeout: 10_000, timeoutMsg: "Provisioning form did not open" }, + ); + + const currentUrl = new URL(await browser.getUrl()); + expect(currentUrl.searchParams.get("token")).toBe(enrollmentToken); + expect(currentUrl.searchParams.get("url")).toBe(enrollmentUrl); + await expect($("#add-instance-view")).toBeDisplayed(); + await expect($('[data-testid="field-url"]')).not.toBeDisplayed(); + }); + + it("does not restore provisioning after the saved instance is deleted", async () => { + await switchToWindowLabel("full-view"); + const saved = await invoke<{ instance: { id: number } }>( + "save_device_config", + { + privateKey: "e2e-placeholder-private-key", + response: { + device: { + id: 1, + name: "e2e-device", + pubkey: "e2e-placeholder-public-key", + user_id: 1, + created_at: 0, + }, + configs: [], + instance: { + id: "e2e-provisioning-instance", + name: "e2e-instance", + url: "https://core.example", + proxy_url: "https://edge.example", + username: "e2e", + }, + }, + }, + ); + await invoke("delete_instance", { instanceId: saved.instance.id }); + + await browser.url("tauri://localhost/full/"); + await browser.waitUntil( + async () => new URL(await browser.getUrl()).pathname === "/full/add", + { timeout: 10_000, timeoutMsg: "Expected the empty-instance add page" }, + ); + await expect($("#add-instance-view")).not.toBeDisplayed(); + }); +}); From 6538b0e44bae36a05331b91e63189ba9849f809c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Tue, 13 Oct 2026 18:21:50 +0200 Subject: [PATCH 08/15] fix fixture --- e2e/tests/provisioning.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/tests/provisioning.spec.ts b/e2e/tests/provisioning.spec.ts index bcd37419..6c1d358d 100644 --- a/e2e/tests/provisioning.spec.ts +++ b/e2e/tests/provisioning.spec.ts @@ -37,7 +37,7 @@ describe("provisioning", () => { }, configs: [], instance: { - id: "e2e-provisioning-instance", + id: "00000000-0000-4000-8000-000000000001", name: "e2e-instance", url: "https://core.example", proxy_url: "https://edge.example", From 19966be605b11cbea2354697f750e35bbbb66933 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Wed, 14 Oct 2026 11:58:58 +0200 Subject: [PATCH 09/15] test deep links --- e2e/helpers/launchConfig.ts | 5 +++ e2e/tests/deep_link_cold_start.spec.ts | 22 ++++++++++++ e2e/tests/deep_link_running_app.spec.ts | 48 +++++++++++++++++++++++++ 3 files changed, 75 insertions(+) create mode 100644 e2e/tests/deep_link_cold_start.spec.ts create mode 100644 e2e/tests/deep_link_running_app.spec.ts diff --git a/e2e/helpers/launchConfig.ts b/e2e/helpers/launchConfig.ts index 601cf0fa..86d934d6 100644 --- a/e2e/helpers/launchConfig.ts +++ b/e2e/helpers/launchConfig.ts @@ -7,6 +7,11 @@ export type LaunchConfig = { }; export const launchConfigs: Record = { + "deep_link_cold_start.spec.ts": { + args: [ + "defguard://addinstance?token=e2e-cold-start-token&url=https%3A%2F%2Fcold-start.example", + ], + }, "provisioning.spec.ts": { files: { "provisioning.json": JSON.stringify({ diff --git a/e2e/tests/deep_link_cold_start.spec.ts b/e2e/tests/deep_link_cold_start.spec.ts new file mode 100644 index 00000000..042c24f1 --- /dev/null +++ b/e2e/tests/deep_link_cold_start.spec.ts @@ -0,0 +1,22 @@ +import { $, browser, expect } from "@wdio/globals"; +import { switchToWindowLabel } from "../helpers/windows.js"; + +const token = "e2e-cold-start-token"; +const url = "https://cold-start.example"; + +describe("cold-start deep link", () => { + it("opens the prefilled instance form", async () => { + await switchToWindowLabel("full-view"); + await browser.waitUntil( + async () => + new URL(await browser.getUrl()).pathname === "/full/add/instance", + { timeout: 30_000, timeoutMsg: "Cold-start deep link was not handled" }, + ); + + const currentUrl = new URL(await browser.getUrl()); + expect(currentUrl.searchParams.get("token")).toBe(token); + expect(currentUrl.searchParams.get("url")).toBe(url); + await expect($("#add-instance-view")).toBeDisplayed(); + await expect($('[data-testid="field-url"]')).not.toBeDisplayed(); + }); +}); diff --git a/e2e/tests/deep_link_running_app.spec.ts b/e2e/tests/deep_link_running_app.spec.ts new file mode 100644 index 00000000..49c7ed70 --- /dev/null +++ b/e2e/tests/deep_link_running_app.spec.ts @@ -0,0 +1,48 @@ +import { spawnSync } from "node:child_process"; +import { $, browser, expect } from "@wdio/globals"; +import { switchToWindowLabel } from "../helpers/windows.js"; + +const token = "e2e-running-app-token"; +const url = "https://running.example"; + +describe("running-app deep link", () => { + it("forwards the link to the running client", async () => { + await switchToWindowLabel("full-view"); + await browser.waitUntil( + async () => new URL(await browser.getUrl()).pathname === "/full/add", + { timeout: 30_000, timeoutMsg: "Full view did not open" }, + ); + await expect($("#add-page-view")).toBeDisplayed(); + + const clientBinary = process.env.CLIENT_BINARY; + if (!clientBinary) { + throw new Error("CLIENT_BINARY was not set by the e2e session"); + } + if (!process.env.DBUS_SESSION_BUS_ADDRESS) { + throw new Error("The e2e process has no D-Bus session"); + } + + const secondInstance = spawnSync( + clientBinary, + [`defguard://addinstance?token=${token}&url=${encodeURIComponent(url)}`], + { env: { ...process.env }, stdio: "ignore", timeout: 15_000 }, + ); + if (secondInstance.error) throw secondInstance.error; + expect(secondInstance.status).toBe(0); + expect(secondInstance.signal).toBeNull(); + + await browser.waitUntil( + async () => + new URL(await browser.getUrl()).pathname === "/full/add/instance", + { + timeout: 15_000, + timeoutMsg: "Running client did not handle the deep link", + }, + ); + const currentUrl = new URL(await browser.getUrl()); + expect(currentUrl.searchParams.get("token")).toBe(token); + expect(currentUrl.searchParams.get("url")).toBe(url); + await expect($("#add-instance-view")).toBeDisplayed(); + await expect($('[data-testid="field-url"]')).not.toBeDisplayed(); + }); +}); From 820cc999762649455dc570a2815886ffe20e6f78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Thu, 15 Oct 2026 09:37:58 +0200 Subject: [PATCH 10/15] buffer add-instance events sent before the UI starts --- e2e/helpers/launchConfig.ts | 6 +++ e2e/tests/deep_link_cold_start.spec.ts | 2 +- .../-utils/resolveFullIndexRedirect.test.ts | 28 +++++++++++++ .../full/-utils/resolveFullIndexRedirect.ts | 41 ++++++++++++++----- new-ui/src/routes/full/index.tsx | 10 +++++ .../shared/providers/TauriEventProvider.tsx | 20 +++++---- new-ui/src/shared/rust-api/api.ts | 5 +++ new-ui/src/shared/rust-api/types.ts | 1 + src-tauri/permissions/default.toml | 1 + src-tauri/src/appstate.rs | 3 ++ src-tauri/src/commands.rs | 18 +++++++- src-tauri/src/events.rs | 33 ++++++++------- src-tauri/src/gui.rs | 1 + 13 files changed, 133 insertions(+), 36 deletions(-) diff --git a/e2e/helpers/launchConfig.ts b/e2e/helpers/launchConfig.ts index 86d934d6..9cf62699 100644 --- a/e2e/helpers/launchConfig.ts +++ b/e2e/helpers/launchConfig.ts @@ -8,6 +8,12 @@ export type LaunchConfig = { export const launchConfigs: Record = { "deep_link_cold_start.spec.ts": { + files: { + "provisioning.json": JSON.stringify({ + enrollment_url: "https://provisioning.example", + enrollment_token: "e2e-provisioning-token", + }), + }, args: [ "defguard://addinstance?token=e2e-cold-start-token&url=https%3A%2F%2Fcold-start.example", ], diff --git a/e2e/tests/deep_link_cold_start.spec.ts b/e2e/tests/deep_link_cold_start.spec.ts index 042c24f1..dcf2e02d 100644 --- a/e2e/tests/deep_link_cold_start.spec.ts +++ b/e2e/tests/deep_link_cold_start.spec.ts @@ -5,7 +5,7 @@ const token = "e2e-cold-start-token"; const url = "https://cold-start.example"; describe("cold-start deep link", () => { - it("opens the prefilled instance form", async () => { + it("prioritizes the deep link over provisioning config", async () => { await switchToWindowLabel("full-view"); await browser.waitUntil( async () => diff --git a/new-ui/src/routes/full/-utils/resolveFullIndexRedirect.test.ts b/new-ui/src/routes/full/-utils/resolveFullIndexRedirect.test.ts index e15f4b46..b56141d3 100644 --- a/new-ui/src/routes/full/-utils/resolveFullIndexRedirect.test.ts +++ b/new-ui/src/routes/full/-utils/resolveFullIndexRedirect.test.ts @@ -2,12 +2,36 @@ import { describe, expect, it } from 'vitest'; import { resolveFullIndexRedirect } from './resolveFullIndexRedirect'; describe('resolveFullIndexRedirect', () => { + it('prioritizes a pending deep link over provisioning and existing instances', () => { + expect( + resolveFullIndexRedirect({ + instanceCount: 1, + tunnelCount: 0, + provisioningConfig: { + enrollment_url: 'https://enroll.example', + enrollment_token: 'provisioning-token', + }, + pendingAddInstance: { + token: 'deep-link-token', + url: 'https://deep-link.example', + }, + }), + ).toEqual({ + to: '/full/add/instance', + search: { + url: 'https://deep-link.example', + token: 'deep-link-token', + }, + }); + }); + it('adds an empty client when no provisioning config exists', () => { expect( resolveFullIndexRedirect({ instanceCount: 0, tunnelCount: 0, provisioningConfig: null, + pendingAddInstance: null, }), ).toEqual({ to: '/full/add' }); }); @@ -21,6 +45,7 @@ describe('resolveFullIndexRedirect', () => { enrollment_url: 'https://enroll.example', enrollment_token: 'provisioning-token', }, + pendingAddInstance: null, }), ).toEqual({ to: '/full/add/instance', @@ -40,6 +65,7 @@ describe('resolveFullIndexRedirect', () => { enrollment_url: 'https://enroll.example', enrollment_token: 'provisioning-token', }, + pendingAddInstance: null, }), ).toEqual({ to: '/full/add/instance', @@ -59,6 +85,7 @@ describe('resolveFullIndexRedirect', () => { enrollment_url: 'https://enroll.example', enrollment_token: 'provisioning-token', }, + pendingAddInstance: null, }), ).toEqual({ to: '/full/overview' }); }); @@ -69,6 +96,7 @@ describe('resolveFullIndexRedirect', () => { instanceCount: 0, tunnelCount: 1, provisioningConfig: null, + pendingAddInstance: null, }), ).toEqual({ to: '/full/overview' }); }); diff --git a/new-ui/src/routes/full/-utils/resolveFullIndexRedirect.ts b/new-ui/src/routes/full/-utils/resolveFullIndexRedirect.ts index dc46b126..1c1a2e8e 100644 --- a/new-ui/src/routes/full/-utils/resolveFullIndexRedirect.ts +++ b/new-ui/src/routes/full/-utils/resolveFullIndexRedirect.ts @@ -1,21 +1,40 @@ -import type { ProvisioningConfig } from '../../../shared/rust-api/types'; +import type { + AddInstanceEventPayload, + ProvisioningConfig, +} from '../../../shared/rust-api/types'; -type ResolveFullIndexRedirectArgs = { - instanceCount: number; - tunnelCount: number; - provisioningConfig: ProvisioningConfig | null; -}; +type ResolveFullIndexRedirectArgs = + | { + pendingAddInstance: AddInstanceEventPayload; + instanceCount?: number; + tunnelCount?: number; + provisioningConfig?: ProvisioningConfig | null; + } + | { + instanceCount: number; + tunnelCount: number; + provisioningConfig: ProvisioningConfig | null; + pendingAddInstance: null; + }; type FullIndexRedirect = | { to: '/full/add/instance'; search: { url: string; token: string } } | { to: '/full/add' } | { to: '/full/overview' }; -export const resolveFullIndexRedirect = ({ - instanceCount, - tunnelCount, - provisioningConfig, -}: ResolveFullIndexRedirectArgs): FullIndexRedirect => { +export const resolveFullIndexRedirect = ( + args: ResolveFullIndexRedirectArgs, +): FullIndexRedirect => { + if (args.pendingAddInstance) { + return { + to: '/full/add/instance', + search: { + url: args.pendingAddInstance.url, + token: args.pendingAddInstance.token, + }, + }; + } + const { instanceCount, tunnelCount, provisioningConfig } = args; if (instanceCount === 0 && provisioningConfig) { return { to: '/full/add/instance', diff --git a/new-ui/src/routes/full/index.tsx b/new-ui/src/routes/full/index.tsx index 038cff63..125c6e58 100644 --- a/new-ui/src/routes/full/index.tsx +++ b/new-ui/src/routes/full/index.tsx @@ -1,5 +1,6 @@ import { createFileRoute, Navigate, redirect } from '@tanstack/react-router'; import { error as logError } from '@tauri-apps/plugin-log'; +import { api } from '../../shared/rust-api/api'; import { getInstancesQueryOptions, getProvisioningConfigQueryOptions, @@ -9,6 +10,14 @@ import { resolveFullIndexRedirect } from './-utils/resolveFullIndexRedirect'; export const Route = createFileRoute('/full/')({ beforeLoad: async ({ context }) => { + const pendingAddInstance = await api.takePendingAddInstance().catch(() => { + void logError('Failed to fetch pending add-instance deep link.'); + return null; + }); + if (pendingAddInstance) { + throw redirect(resolveFullIndexRedirect({ pendingAddInstance })); + } + const [instances, tunnels, provisioningConfig] = await Promise.all([ context.queryClient.fetchQuery(getInstancesQueryOptions), context.queryClient.fetchQuery(getTunnelsQueryOptions), @@ -25,6 +34,7 @@ export const Route = createFileRoute('/full/')({ instanceCount: instances.length, tunnelCount: tunnels.length, provisioningConfig, + pendingAddInstance: null, }), ); }, diff --git a/new-ui/src/shared/providers/TauriEventProvider.tsx b/new-ui/src/shared/providers/TauriEventProvider.tsx index 8e353a2b..fa5b3cef 100644 --- a/new-ui/src/shared/providers/TauriEventProvider.tsx +++ b/new-ui/src/shared/providers/TauriEventProvider.tsx @@ -36,18 +36,22 @@ export const TauriEventProvider = ({ children }: PropsWithChildren) => { useEffect(() => { const unlisteners = Promise.all([ - listen(TauriEvent.AddInstance, (event) => { - void debug(`UI Received event AddInstance (${event.payload.url})`); - const windowLabel = getCurrentWindow().label; - if (windowLabel === WindowId.FullView) { - const { token, url } = event.payload; - navigate({ + listen(TauriEvent.AddInstance, async () => { + if (getCurrentWindow().label !== WindowId.FullView) return; + + try { + const pendingAddInstance = await api.takePendingAddInstance(); + if (!pendingAddInstance) return; + void debug(`UI Received event AddInstance (${pendingAddInstance.url})`); + void navigate({ to: '/full/add/instance', search: { - token, - url, + token: pendingAddInstance.token, + url: pendingAddInstance.url, }, }); + } catch (err) { + void logError(`Failed to retrieve pending add-instance deep link: ${err}`); } }), // Backend requests the MFA flow (e.g. connecting to an MFA location from the diff --git a/new-ui/src/shared/rust-api/api.ts b/new-ui/src/shared/rust-api/api.ts index bb14ec54..7b4b9d8c 100644 --- a/new-ui/src/shared/rust-api/api.ts +++ b/new-ui/src/shared/rust-api/api.ts @@ -2,6 +2,7 @@ import { invoke } from '@tauri-apps/api/core'; import { mfaToApi } from '../utils/mfa'; import type { ActiveConnectionSummary, + AddInstanceEventPayload, AppConfig, AppConfigPatch, Connection, @@ -115,6 +116,9 @@ const setAutostartEnabled = (enabled: boolean): Promise => const getProvisioningConfig = (): Promise => invoke(TauriCommand.GetProvisioningConfig); +const takePendingAddInstance = (): Promise => + invoke(TauriCommand.TakePendingAddInstance); + const getPlatformHeader = (): Promise => invoke(TauriCommand.GetPlatformHeader); const getLatestAppVersion = (): Promise => @@ -365,6 +369,7 @@ export const api = { setAutostartEnabled, // Misc getProvisioningConfig, + takePendingAddInstance, getPlatformHeader, getLatestAppVersion, openLink, diff --git a/new-ui/src/shared/rust-api/types.ts b/new-ui/src/shared/rust-api/types.ts index e496f8f3..0fd90285 100644 --- a/new-ui/src/shared/rust-api/types.ts +++ b/new-ui/src/shared/rust-api/types.ts @@ -142,6 +142,7 @@ export const TauriCommand = { SetAutostartEnabled: 'set_autostart_enabled', // Misc GetProvisioningConfig: 'get_provisioning_config', + TakePendingAddInstance: 'take_pending_add_instance', GetPlatformHeader: 'get_platform_header', GetLatestAppVersion: 'get_latest_app_version', OpenLink: 'open_link', diff --git a/src-tauri/permissions/default.toml b/src-tauri/permissions/default.toml index 2f1d0305..fa8aa07b 100644 --- a/src-tauri/permissions/default.toml +++ b/src-tauri/permissions/default.toml @@ -55,6 +55,7 @@ commands.allow = [ "get_autostart_enabled", "set_autostart_enabled", "get_provisioning_config", + "take_pending_add_instance", "get_platform_header", "set_location_mfa_step_plan", "open_tray_window", diff --git a/src-tauri/src/appstate.rs b/src-tauri/src/appstate.rs index 5fc4309b..b0523bc1 100644 --- a/src-tauri/src/appstate.rs +++ b/src-tauri/src/appstate.rs @@ -18,6 +18,7 @@ use uuid::Uuid; use crate::{ app_config::AppConfig, database::models::{connection::ActiveConnection, Id}, + events::AddInstancePayload, session_state::SessionState, utils::stats_handler, ConnectionType, @@ -43,6 +44,7 @@ pub struct AppState { pub tray_click_position: Mutex>>, stat_threads: Mutex>>, // location ID is the key pub provisioning_config: Mutex>, + pub pending_add_instance: Mutex>, pub session_state: Mutex, } @@ -60,6 +62,7 @@ impl AppState { tray_click_position: Mutex::new(None), stat_threads: Mutex::new(HashMap::new()), provisioning_config: Mutex::new(provisioning_config), + pending_add_instance: Mutex::new(None), session_state: Mutex::new(SessionState::default()), } } diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index c838c38d..d0a9a23e 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -55,7 +55,7 @@ use crate::{ DB_POOL, }, error::Error, - events::{EventKey, TunnelsDisabledPayload, TunnelsEnabledPayload}, + events::{AddInstancePayload, EventKey, TunnelsDisabledPayload, TunnelsEnabledPayload}, fido2_window::{platform_context, WindowLevelGuard}, into_location, log_watcher::{ @@ -1536,6 +1536,22 @@ pub fn get_provisioning_config( Ok(res) } +#[tauri::command] +pub fn take_pending_add_instance( + app_state: State<'_, AppState>, +) -> Result, Error> { + debug!("Running command take_pending_add_instance."); + let pending = app_state + .pending_add_instance + .lock() + .map_err(|_err| { + error!("Failed to acquire lock on pending add-instance deep link"); + Error::StateLockFail + })? + .take(); + Ok(pending) +} + #[tauri::command] #[must_use] pub fn get_platform_header() -> String { diff --git a/src-tauri/src/events.rs b/src-tauri/src/events.rs index e01c6d4b..023b56f3 100644 --- a/src-tauri/src/events.rs +++ b/src-tauri/src/events.rs @@ -4,6 +4,7 @@ use tauri::{AppHandle, Emitter, Manager, Url}; use tauri_plugin_notification::NotificationExt; use crate::{ + appstate::AppState, commands::LocationInfo, database::models::{instance::InstanceInfo, location::LocationMfaMethod, Id}, window_manager::{WindowManager, COMPACT_WINDOW_ID}, @@ -65,9 +66,9 @@ impl DeadConnReconnected { } #[derive(Clone, Serialize)] -pub struct AddInstancePayload<'a> { - pub token: &'a str, - pub url: &'a str, +pub struct AddInstancePayload { + pub token: String, + pub url: String, } /// Payload for the `configure-factors-trigger` event. Mirrors `ConfigureFactorsPayload` in @@ -129,28 +130,30 @@ pub fn handle_deep_link(app_handle: &AppHandle, urls: &[Url]) { let mut token = None; let mut url = None; for (key, value) in link.query_pairs() { - if key == "token" { - token = Some(value.clone()); - } - if key == "url" { - url = Some(value.clone()); + match key.as_ref() { + "token" => token = Some(value.into_owned()), + "url" => url = Some(value.into_owned()), + _ => {} } } if let (Some(token), Some(url)) = (token, url) { info!("Valid Deep link received."); + let payload = AddInstancePayload { token, url }; + let app_state = app_handle.state::(); + if let Ok(mut pending) = app_state.pending_add_instance.lock() { + *pending = Some(payload.clone()); + } else { + error!("Failed to store pending add-instance deep link."); + } if let Some(tray_win) = app_handle.get_webview_window(COMPACT_WINDOW_ID) { let _ = tray_win.hide(); } if let Err(e) = WindowManager::open_full_view(app_handle) { warn!("Deep link: failed to open main window: {e}"); } - let _ = app_handle.emit( - EventKey::AddInstance.into(), - AddInstancePayload { - token: &token, - url: &url, - }, - ); + if let Err(err) = app_handle.emit(EventKey::AddInstance.into(), payload) { + error!("Event AddInstance was not emitted. Reason: {err}"); + } } } } diff --git a/src-tauri/src/gui.rs b/src-tauri/src/gui.rs index 382fdbbc..2d1207f8 100644 --- a/src-tauri/src/gui.rs +++ b/src-tauri/src/gui.rs @@ -192,6 +192,7 @@ pub fn run_app() { command_get_app_config, command_set_app_config, get_provisioning_config, + take_pending_add_instance, get_platform_header, get_posture_data, set_location_mfa_step_plan, From 1ba80ad83a1949cd40ef1eeba75b6a7896ce02a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Thu, 15 Oct 2026 16:37:25 +0200 Subject: [PATCH 11/15] review fixes --- e2e/tests/provisioning.spec.ts | 17 ++++- src-tauri/src/appstate.rs | 2 + src-tauri/src/commands.rs | 134 ++++++++++++++++++++++++--------- 3 files changed, 117 insertions(+), 36 deletions(-) diff --git a/e2e/tests/provisioning.spec.ts b/e2e/tests/provisioning.spec.ts index 6c1d358d..5cf8399f 100644 --- a/e2e/tests/provisioning.spec.ts +++ b/e2e/tests/provisioning.spec.ts @@ -1,3 +1,5 @@ +import fs from "node:fs"; +import path from "node:path"; import { $, browser, expect } from "@wdio/globals"; import { invoke } from "../helpers/client.js"; import { switchToWindowLabel } from "../helpers/windows.js"; @@ -46,8 +48,21 @@ describe("provisioning", () => { }, }, ); - await invoke("delete_instance", { instanceId: saved.instance.id }); + const dataDir = process.env.E2E_DATA_DIR; + if (!dataDir) throw new Error("E2E_DATA_DIR is required"); + expect( + fs.existsSync( + path.join(dataDir, "share", "net.defguard", "provisioning.json"), + ), + ).toBe(true); + + await browser.url("tauri://localhost/full/"); + await browser.waitUntil( + async () => new URL(await browser.getUrl()).pathname === "/full/overview", + { timeout: 10_000, timeoutMsg: "Expected the saved-instance overview" }, + ); + await invoke("delete_instance", { instanceId: saved.instance.id }); await browser.url("tauri://localhost/full/"); await browser.waitUntil( async () => new URL(await browser.getUrl()).pathname === "/full/add", diff --git a/src-tauri/src/appstate.rs b/src-tauri/src/appstate.rs index b0523bc1..447f9f70 100644 --- a/src-tauri/src/appstate.rs +++ b/src-tauri/src/appstate.rs @@ -44,6 +44,7 @@ pub struct AppState { pub tray_click_position: Mutex>>, stat_threads: Mutex>>, // location ID is the key pub provisioning_config: Mutex>, + pub provisioning_instance_id: Mutex>, pub pending_add_instance: Mutex>, pub session_state: Mutex, } @@ -62,6 +63,7 @@ impl AppState { tray_click_position: Mutex::new(None), stat_threads: Mutex::new(HashMap::new()), provisioning_config: Mutex::new(provisioning_config), + provisioning_instance_id: Mutex::new(None), pending_add_instance: Mutex::new(None), session_state: Mutex::new(SessionState::default()), } diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index d0a9a23e..114918b7 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -555,6 +555,54 @@ pub struct SaveDeviceConfigResponse { instance: Instance, } +fn should_remove_provisioning_file( + saved_instance_uuid: &str, + provisioning_instance_id: Option<&str>, +) -> bool { + provisioning_instance_id == Some(saved_instance_uuid) +} + +fn clear_provisioning_after_instance_save(handle: &AppHandle, saved_instance_uuid: &str) { + let app_state = handle.state::(); + match app_state.provisioning_config.lock() { + Ok(mut provisioning_config) => { + if provisioning_config.take().is_some() { + debug!("Cleared in-memory provisioning config after saving an instance"); + } + } + Err(_) => warn!("Failed to clear in-memory provisioning config"), + } + + let mut provisioning_instance_id = match app_state.provisioning_instance_id.lock() { + Ok(provisioning_instance_id) => provisioning_instance_id, + Err(_) => { + warn!("Failed to check provisioning instance id"); + return; + } + }; + if !should_remove_provisioning_file(saved_instance_uuid, provisioning_instance_id.as_deref()) { + return; + } + + match handle.path().app_data_dir() { + Ok(app_data_dir) => { + let provisioning_file = app_data_dir.join("provisioning.json"); + match clear_provisioning_config(&app_data_dir) { + Ok(()) => debug!( + "Removed provisioning config file at {}", + provisioning_file.display() + ), + Err(err) => warn!( + "Failed to remove provisioning config file at {}: {err}", + provisioning_file.display() + ), + } + } + Err(err) => warn!("Failed to resolve app data directory for provisioning cleanup: {err}"), + } + *provisioning_instance_id = None; +} + #[tauri::command(async)] pub async fn save_device_config( private_key: String, @@ -613,29 +661,7 @@ pub async fn save_device_config( } transaction.commit().await?; - let state = handle.state::(); - let has_provisioning_config = match state.provisioning_config.lock() { - Ok(mut provisioning_config) => provisioning_config.take().is_some(), - Err(_) => { - warn!("Failed to clear in-memory provisioning config"); - false - } - }; - if has_provisioning_config { - match handle.path().app_data_dir() { - Ok(app_data_dir) => { - if let Err(err) = clear_provisioning_config(&app_data_dir) { - warn!( - "Failed to remove provisioning config from {}: {err}", - app_data_dir.display() - ); - } - } - Err(err) => { - warn!("Failed to resolve app data directory for provisioning cleanup: {err}"); - } - } - } + clear_provisioning_after_instance_save(&handle, &instance.uuid); info!("New instance {instance} created."); trace!("Created following instance: {instance:#?}"); @@ -1614,6 +1640,15 @@ pub async fn enrollment_start( state: State<'_, AppState>, ) -> Result { debug!("Starting enrollment at {proxy_url}"); + let uses_provisioning_token = match state.provisioning_config.lock() { + Ok(provisioning_config) => provisioning_config + .as_ref() + .is_some_and(|config| config.enrollment_token == token), + Err(_) => { + warn!("Failed to read in-memory provisioning config"); + false + } + }; let url = Url::parse(&proxy_url).map_err(|e| format!("Invalid proxy URL: {e}"))?; let (session, response) = enrollment::enrollment_start(url, token) .await @@ -1630,20 +1665,36 @@ pub async fn enrollment_start( .as_ref() .map_or("", |u| u.login.as_str()); info!("Enrollment started for user {login}, session {session_id}"); + let provisioning_instance_id = response + .instance + .as_ref() + .map(|instance| instance.id.clone()); + let user = response + .user + .ok_or_else(|| "Proxy did not return user info".to_string())?; + let admin = response + .admin + .ok_or_else(|| "Proxy did not return admin info".to_string())?; + let settings = response + .settings + .ok_or_else(|| "Proxy did not return enrollment settings".to_string())?; + let instance = response + .instance + .ok_or_else(|| "Proxy did not return instance info".to_string())?; + if uses_provisioning_token { + if let Some(instance_id) = provisioning_instance_id { + match state.provisioning_instance_id.lock() { + Ok(mut provisioning_instance_id) => *provisioning_instance_id = Some(instance_id), + Err(_) => warn!("Failed to remember provisioning instance id"), + } + } + } Ok(EnrollmentStartResult { session_id, - user: response - .user - .ok_or_else(|| "Proxy did not return user info".to_string())?, - admin: response - .admin - .ok_or_else(|| "Proxy did not return admin info".to_string())?, - settings: response - .settings - .ok_or_else(|| "Proxy did not return enrollment settings".to_string())?, - instance: response - .instance - .ok_or_else(|| "Proxy did not return instance info".to_string())?, + user, + admin, + settings, + instance, deadline_timestamp: response.deadline_timestamp, final_page_content: response.final_page_content, }) @@ -3190,6 +3241,19 @@ mod tests { Url::parse(&server.uri()).expect("MockServer URI should be valid") } + #[test] + fn test_provisioning_file_is_removed_only_for_matching_instance() { + assert!(should_remove_provisioning_file( + "core-instance", + Some("core-instance") + )); + assert!(!should_remove_provisioning_file( + "saved-instance", + Some("core-instance") + )); + assert!(!should_remove_provisioning_file("saved-instance", None)); + } + fn start_request() -> mfa::MfaStartRequest { start_request_with_method(MfaMethod::Totp) } From e0efda351ba780fe8881683c75ba18f63f624422 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Thu, 15 Oct 2026 18:04:11 +0200 Subject: [PATCH 12/15] don't hardcode file name --- src-tauri/enterprise/provisioning/src/lib.rs | 11 +++++++++-- src-tauri/src/commands.rs | 15 +++++---------- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/src-tauri/enterprise/provisioning/src/lib.rs b/src-tauri/enterprise/provisioning/src/lib.rs index 4324665d..6fcac805 100644 --- a/src-tauri/enterprise/provisioning/src/lib.rs +++ b/src-tauri/enterprise/provisioning/src/lib.rs @@ -68,8 +68,15 @@ pub fn try_get_provisioning_config(app_data_dir: &Path) -> Option io::Result<()> { - match fs::remove_file(app_data_dir.join(CONFIG_FILE_NAME)) { - Ok(()) => Ok(()), + let config_file_path = app_data_dir.join(CONFIG_FILE_NAME); + match fs::remove_file(&config_file_path) { + Ok(()) => { + debug!( + "Removed provisioning config file at {}", + config_file_path.display() + ); + Ok(()) + } Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()), Err(err) => Err(err), } diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 114918b7..c4652ae1 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -586,16 +586,11 @@ fn clear_provisioning_after_instance_save(handle: &AppHandle, saved_instance_uui match handle.path().app_data_dir() { Ok(app_data_dir) => { - let provisioning_file = app_data_dir.join("provisioning.json"); - match clear_provisioning_config(&app_data_dir) { - Ok(()) => debug!( - "Removed provisioning config file at {}", - provisioning_file.display() - ), - Err(err) => warn!( - "Failed to remove provisioning config file at {}: {err}", - provisioning_file.display() - ), + if let Err(err) = clear_provisioning_config(&app_data_dir) { + warn!( + "Failed to remove provisioning config file from {}: {err}", + app_data_dir.display() + ); } } Err(err) => warn!("Failed to resolve app data directory for provisioning cleanup: {err}"), From eb7f08da4b00b53021918ca9ae3ef6cab060a3c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Fri, 16 Oct 2026 16:01:54 +0200 Subject: [PATCH 13/15] cleanup --- e2e/helpers/launchConfig.ts | 58 ++++++++++++++----- e2e/tests/deep_link_cold_start.spec.ts | 4 +- e2e/tests/deep_link_running_app.spec.ts | 3 +- e2e/tests/provisioning.spec.ts | 4 +- e2e/wdio.conf.ts | 27 +++++---- .../-utils/resolveFullIndexRedirect.test.ts | 28 --------- .../full/-utils/resolveFullIndexRedirect.ts | 41 ++++--------- new-ui/src/routes/full/index.tsx | 6 +- src-tauri/src/commands.rs | 49 +++++----------- 9 files changed, 95 insertions(+), 125 deletions(-) diff --git a/e2e/helpers/launchConfig.ts b/e2e/helpers/launchConfig.ts index 9cf62699..940e29c6 100644 --- a/e2e/helpers/launchConfig.ts +++ b/e2e/helpers/launchConfig.ts @@ -6,25 +6,49 @@ export type LaunchConfig = { args?: string[]; }; +type ProvisioningFixture = { enrollmentUrl: string; enrollmentToken: string }; +type DeepLinkFixture = { url: string; token: string }; + +export const provisioningFixture: ProvisioningFixture = { + enrollmentUrl: "https://enroll.example", + enrollmentToken: "e2e-provisioning-token", +}; + +export const coldStartFixture: { + provisioning: ProvisioningFixture; + deepLink: DeepLinkFixture; +} = { + provisioning: { + enrollmentUrl: "https://provisioning.example", + enrollmentToken: "e2e-provisioning-token", + }, + deepLink: { + url: "https://cold-start.example", + token: "e2e-cold-start-token", + }, +}; + +export const provisioningFile = ({ + enrollmentUrl, + enrollmentToken, +}: ProvisioningFixture): string => + JSON.stringify({ + enrollment_url: enrollmentUrl, + enrollment_token: enrollmentToken, + }); + +export const deepLinkArg = ({ url, token }: DeepLinkFixture): string => + `defguard://addinstance?token=${encodeURIComponent(token)}&url=${encodeURIComponent(url)}`; + export const launchConfigs: Record = { "deep_link_cold_start.spec.ts": { files: { - "provisioning.json": JSON.stringify({ - enrollment_url: "https://provisioning.example", - enrollment_token: "e2e-provisioning-token", - }), + "provisioning.json": provisioningFile(coldStartFixture.provisioning), }, - args: [ - "defguard://addinstance?token=e2e-cold-start-token&url=https%3A%2F%2Fcold-start.example", - ], + args: [deepLinkArg(coldStartFixture.deepLink)], }, "provisioning.spec.ts": { - files: { - "provisioning.json": JSON.stringify({ - enrollment_url: "https://enroll.example", - enrollment_token: "e2e-provisioning-token", - }), - }, + files: { "provisioning.json": provisioningFile(provisioningFixture) }, }, }; @@ -35,6 +59,14 @@ export const getLaunchConfig = (specs: string[]): LaunchConfig | undefined => { } }; +export const assertLaunchConfigSpecsExist = (testsDir: string): void => { + for (const specName of Object.keys(launchConfigs)) { + if (!fs.existsSync(path.join(testsDir, specName))) { + throw new Error(`Launch config refers to missing spec tests/${specName}`); + } + } +}; + export const writeLaunchFiles = ( dataDir: string, files: Record, diff --git a/e2e/tests/deep_link_cold_start.spec.ts b/e2e/tests/deep_link_cold_start.spec.ts index dcf2e02d..f0bc8e8e 100644 --- a/e2e/tests/deep_link_cold_start.spec.ts +++ b/e2e/tests/deep_link_cold_start.spec.ts @@ -1,8 +1,8 @@ import { $, browser, expect } from "@wdio/globals"; +import { coldStartFixture } from "../helpers/launchConfig.js"; import { switchToWindowLabel } from "../helpers/windows.js"; -const token = "e2e-cold-start-token"; -const url = "https://cold-start.example"; +const { token, url } = coldStartFixture.deepLink; describe("cold-start deep link", () => { it("prioritizes the deep link over provisioning config", async () => { diff --git a/e2e/tests/deep_link_running_app.spec.ts b/e2e/tests/deep_link_running_app.spec.ts index 49c7ed70..f3a56316 100644 --- a/e2e/tests/deep_link_running_app.spec.ts +++ b/e2e/tests/deep_link_running_app.spec.ts @@ -1,5 +1,6 @@ import { spawnSync } from "node:child_process"; import { $, browser, expect } from "@wdio/globals"; +import { deepLinkArg } from "../helpers/launchConfig.js"; import { switchToWindowLabel } from "../helpers/windows.js"; const token = "e2e-running-app-token"; @@ -24,7 +25,7 @@ describe("running-app deep link", () => { const secondInstance = spawnSync( clientBinary, - [`defguard://addinstance?token=${token}&url=${encodeURIComponent(url)}`], + [deepLinkArg({ token, url })], { env: { ...process.env }, stdio: "ignore", timeout: 15_000 }, ); if (secondInstance.error) throw secondInstance.error; diff --git a/e2e/tests/provisioning.spec.ts b/e2e/tests/provisioning.spec.ts index 5cf8399f..fd84b29c 100644 --- a/e2e/tests/provisioning.spec.ts +++ b/e2e/tests/provisioning.spec.ts @@ -2,10 +2,10 @@ import fs from "node:fs"; import path from "node:path"; import { $, browser, expect } from "@wdio/globals"; import { invoke } from "../helpers/client.js"; +import { provisioningFixture } from "../helpers/launchConfig.js"; import { switchToWindowLabel } from "../helpers/windows.js"; -const enrollmentToken = "e2e-provisioning-token"; -const enrollmentUrl = "https://enroll.example"; +const { enrollmentToken, enrollmentUrl } = provisioningFixture; describe("provisioning", () => { it("opens the prefilled instance form from launch config", async () => { diff --git a/e2e/wdio.conf.ts b/e2e/wdio.conf.ts index 1c8d9781..1c5e6375 100644 --- a/e2e/wdio.conf.ts +++ b/e2e/wdio.conf.ts @@ -2,7 +2,11 @@ import { type ChildProcess, spawn, spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { getLaunchConfig, writeLaunchFiles } from "./helpers/launchConfig.js"; +import { + assertLaunchConfigSpecsExist, + getLaunchConfig, + writeLaunchFiles, +} from "./helpers/launchConfig.js"; const here = import.meta.dirname; @@ -86,28 +90,26 @@ export const config: WebdriverIO.Config = { "`pnpm tauri build` or set CLIENT_BINARY.", ); } + assertLaunchConfigSpecsExist(path.join(here, "tests")); }, beforeSession: (_config, caps, specs) => { killLeftoverClients(); cleanupWireguardInterfaces(); - const sessionDataDir = fs.mkdtempSync( - path.join(os.tmpdir(), "defguard-e2e-"), - ); - dataDir = sessionDataDir; + dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "defguard-e2e-")); const launchConfig = getLaunchConfig(specs); const sessionEnv = { CLIENT_BINARY: clientBinary, DEFGUARD_CLIENT_WELCOME_SKIP: "1", - E2E_DATA_DIR: sessionDataDir, - XDG_DATA_HOME: path.join(sessionDataDir, "share"), - XDG_CONFIG_HOME: path.join(sessionDataDir, "config"), - XDG_CACHE_HOME: path.join(sessionDataDir, "cache"), + E2E_DATA_DIR: dataDir, + XDG_DATA_HOME: path.join(dataDir, "share"), + XDG_CONFIG_HOME: path.join(dataDir, "config"), + XDG_CACHE_HOME: path.join(dataDir, "cache"), }; Object.assign(process.env, sessionEnv); if (launchConfig?.files) { - writeLaunchFiles(sessionDataDir, launchConfig.files); + writeLaunchFiles(dataDir, launchConfig.files); } if (launchConfig?.args) { @@ -125,10 +127,7 @@ export const config: WebdriverIO.Config = { tauriDriver = spawn( tauriDriverBinary, nativeDriver ? ["--native-driver", nativeDriver] : [], - { - stdio: ["ignore", "inherit", "inherit"], - env: { ...process.env }, - }, + { stdio: ["ignore", "inherit", "inherit"] }, ); tauriDriver.on("error", (error) => { console.error("tauri-driver failed to start:", error); diff --git a/new-ui/src/routes/full/-utils/resolveFullIndexRedirect.test.ts b/new-ui/src/routes/full/-utils/resolveFullIndexRedirect.test.ts index b56141d3..e15f4b46 100644 --- a/new-ui/src/routes/full/-utils/resolveFullIndexRedirect.test.ts +++ b/new-ui/src/routes/full/-utils/resolveFullIndexRedirect.test.ts @@ -2,36 +2,12 @@ import { describe, expect, it } from 'vitest'; import { resolveFullIndexRedirect } from './resolveFullIndexRedirect'; describe('resolveFullIndexRedirect', () => { - it('prioritizes a pending deep link over provisioning and existing instances', () => { - expect( - resolveFullIndexRedirect({ - instanceCount: 1, - tunnelCount: 0, - provisioningConfig: { - enrollment_url: 'https://enroll.example', - enrollment_token: 'provisioning-token', - }, - pendingAddInstance: { - token: 'deep-link-token', - url: 'https://deep-link.example', - }, - }), - ).toEqual({ - to: '/full/add/instance', - search: { - url: 'https://deep-link.example', - token: 'deep-link-token', - }, - }); - }); - it('adds an empty client when no provisioning config exists', () => { expect( resolveFullIndexRedirect({ instanceCount: 0, tunnelCount: 0, provisioningConfig: null, - pendingAddInstance: null, }), ).toEqual({ to: '/full/add' }); }); @@ -45,7 +21,6 @@ describe('resolveFullIndexRedirect', () => { enrollment_url: 'https://enroll.example', enrollment_token: 'provisioning-token', }, - pendingAddInstance: null, }), ).toEqual({ to: '/full/add/instance', @@ -65,7 +40,6 @@ describe('resolveFullIndexRedirect', () => { enrollment_url: 'https://enroll.example', enrollment_token: 'provisioning-token', }, - pendingAddInstance: null, }), ).toEqual({ to: '/full/add/instance', @@ -85,7 +59,6 @@ describe('resolveFullIndexRedirect', () => { enrollment_url: 'https://enroll.example', enrollment_token: 'provisioning-token', }, - pendingAddInstance: null, }), ).toEqual({ to: '/full/overview' }); }); @@ -96,7 +69,6 @@ describe('resolveFullIndexRedirect', () => { instanceCount: 0, tunnelCount: 1, provisioningConfig: null, - pendingAddInstance: null, }), ).toEqual({ to: '/full/overview' }); }); diff --git a/new-ui/src/routes/full/-utils/resolveFullIndexRedirect.ts b/new-ui/src/routes/full/-utils/resolveFullIndexRedirect.ts index 1c1a2e8e..dc46b126 100644 --- a/new-ui/src/routes/full/-utils/resolveFullIndexRedirect.ts +++ b/new-ui/src/routes/full/-utils/resolveFullIndexRedirect.ts @@ -1,40 +1,21 @@ -import type { - AddInstanceEventPayload, - ProvisioningConfig, -} from '../../../shared/rust-api/types'; +import type { ProvisioningConfig } from '../../../shared/rust-api/types'; -type ResolveFullIndexRedirectArgs = - | { - pendingAddInstance: AddInstanceEventPayload; - instanceCount?: number; - tunnelCount?: number; - provisioningConfig?: ProvisioningConfig | null; - } - | { - instanceCount: number; - tunnelCount: number; - provisioningConfig: ProvisioningConfig | null; - pendingAddInstance: null; - }; +type ResolveFullIndexRedirectArgs = { + instanceCount: number; + tunnelCount: number; + provisioningConfig: ProvisioningConfig | null; +}; type FullIndexRedirect = | { to: '/full/add/instance'; search: { url: string; token: string } } | { to: '/full/add' } | { to: '/full/overview' }; -export const resolveFullIndexRedirect = ( - args: ResolveFullIndexRedirectArgs, -): FullIndexRedirect => { - if (args.pendingAddInstance) { - return { - to: '/full/add/instance', - search: { - url: args.pendingAddInstance.url, - token: args.pendingAddInstance.token, - }, - }; - } - const { instanceCount, tunnelCount, provisioningConfig } = args; +export const resolveFullIndexRedirect = ({ + instanceCount, + tunnelCount, + provisioningConfig, +}: ResolveFullIndexRedirectArgs): FullIndexRedirect => { if (instanceCount === 0 && provisioningConfig) { return { to: '/full/add/instance', diff --git a/new-ui/src/routes/full/index.tsx b/new-ui/src/routes/full/index.tsx index 125c6e58..7dee0530 100644 --- a/new-ui/src/routes/full/index.tsx +++ b/new-ui/src/routes/full/index.tsx @@ -15,7 +15,10 @@ export const Route = createFileRoute('/full/')({ return null; }); if (pendingAddInstance) { - throw redirect(resolveFullIndexRedirect({ pendingAddInstance })); + throw redirect({ + to: '/full/add/instance', + search: { url: pendingAddInstance.url, token: pendingAddInstance.token }, + }); } const [instances, tunnels, provisioningConfig] = await Promise.all([ @@ -34,7 +37,6 @@ export const Route = createFileRoute('/full/')({ instanceCount: instances.length, tunnelCount: tunnels.length, provisioningConfig, - pendingAddInstance: null, }), ); }, diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index c4652ae1..d7e96b9b 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -555,13 +555,9 @@ pub struct SaveDeviceConfigResponse { instance: Instance, } -fn should_remove_provisioning_file( - saved_instance_uuid: &str, - provisioning_instance_id: Option<&str>, -) -> bool { - provisioning_instance_id == Some(saved_instance_uuid) -} - +/// Drops the in-memory provisioning config after any instance is saved, and removes +/// `provisioning.json` only when the saved instance was enrolled with its token. +/// Errors are logged, never returned, because the instance is already committed. fn clear_provisioning_after_instance_save(handle: &AppHandle, saved_instance_uuid: &str) { let app_state = handle.state::(); match app_state.provisioning_config.lock() { @@ -580,22 +576,22 @@ fn clear_provisioning_after_instance_save(handle: &AppHandle, saved_instance_uui return; } }; - if !should_remove_provisioning_file(saved_instance_uuid, provisioning_instance_id.as_deref()) { - return; - } - - match handle.path().app_data_dir() { - Ok(app_data_dir) => { - if let Err(err) = clear_provisioning_config(&app_data_dir) { - warn!( - "Failed to remove provisioning config file from {}: {err}", - app_data_dir.display() - ); + if provisioning_instance_id.as_deref() == Some(saved_instance_uuid) { + match handle.path().app_data_dir() { + Ok(app_data_dir) => { + if let Err(err) = clear_provisioning_config(&app_data_dir) { + warn!( + "Failed to remove provisioning config file from {}: {err}", + app_data_dir.display() + ); + } + } + Err(err) => { + warn!("Failed to resolve app data directory for provisioning cleanup: {err}"); } } - Err(err) => warn!("Failed to resolve app data directory for provisioning cleanup: {err}"), + *provisioning_instance_id = None; } - *provisioning_instance_id = None; } #[tauri::command(async)] @@ -3236,19 +3232,6 @@ mod tests { Url::parse(&server.uri()).expect("MockServer URI should be valid") } - #[test] - fn test_provisioning_file_is_removed_only_for_matching_instance() { - assert!(should_remove_provisioning_file( - "core-instance", - Some("core-instance") - )); - assert!(!should_remove_provisioning_file( - "saved-instance", - Some("core-instance") - )); - assert!(!should_remove_provisioning_file("saved-instance", None)); - } - fn start_request() -> mfa::MfaStartRequest { start_request_with_method(MfaMethod::Totp) } From 51ad8bf5a17c0fe44e4fb9f195fc698a61741b4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Mon, 19 Oct 2026 10:19:21 +0200 Subject: [PATCH 14/15] review cleanup --- e2e/helpers/launchConfig.ts | 11 ++-- .../shared/providers/TauriEventProvider.tsx | 51 ++++++++++++------- src-tauri/src/gui.rs | 16 ++---- 3 files changed, 44 insertions(+), 34 deletions(-) diff --git a/e2e/helpers/launchConfig.ts b/e2e/helpers/launchConfig.ts index 940e29c6..fd0f477c 100644 --- a/e2e/helpers/launchConfig.ts +++ b/e2e/helpers/launchConfig.ts @@ -53,10 +53,15 @@ export const launchConfigs: Record = { }; export const getLaunchConfig = (specs: string[]): LaunchConfig | undefined => { - for (const spec of specs) { - const specName = path.basename(spec); - if (Object.hasOwn(launchConfigs, specName)) return launchConfigs[specName]; + const configured = specs + .map((spec) => path.basename(spec)) + .filter((specName) => Object.hasOwn(launchConfigs, specName)); + if (configured.length > 0 && specs.length > 1) { + throw new Error( + `Specs with a launch config must run in their own session: ${configured.join(", ")}`, + ); } + return configured.length > 0 ? launchConfigs[configured[0]] : undefined; }; export const assertLaunchConfigSpecsExist = (testsDir: string): void => { diff --git a/new-ui/src/shared/providers/TauriEventProvider.tsx b/new-ui/src/shared/providers/TauriEventProvider.tsx index fa5b3cef..f35050c9 100644 --- a/new-ui/src/shared/providers/TauriEventProvider.tsx +++ b/new-ui/src/shared/providers/TauriEventProvider.tsx @@ -17,7 +17,6 @@ import { useAppData } from '../providers/AppDataContext'; import { api } from '../rust-api/api'; import { getInstancesQueryOptions } from '../rust-api/query'; import { - type AddInstanceEventPayload, type ConfigureFactorsPayload, ConnectionType, type DeadConnectionDroppedPayload, @@ -29,31 +28,43 @@ import { import { useAppStore } from '../store/useAppStore'; import { resolveMfaStepPlan } from '../utils/mfa'; +const urlOrigin = (url: string): string => { + try { + return new URL(url).origin; + } catch { + return ''; + } +}; + export const TauriEventProvider = ({ children }: PropsWithChildren) => { const navigate = useNavigate(); const queryClient = useQueryClient(); const { setViewSelection } = useAppData(); useEffect(() => { - const unlisteners = Promise.all([ - listen(TauriEvent.AddInstance, async () => { - if (getCurrentWindow().label !== WindowId.FullView) return; + const openPendingAddInstance = async () => { + if (getCurrentWindow().label !== WindowId.FullView) return; - try { - const pendingAddInstance = await api.takePendingAddInstance(); - if (!pendingAddInstance) return; - void debug(`UI Received event AddInstance (${pendingAddInstance.url})`); - void navigate({ - to: '/full/add/instance', - search: { - token: pendingAddInstance.token, - url: pendingAddInstance.url, - }, - }); - } catch (err) { - void logError(`Failed to retrieve pending add-instance deep link: ${err}`); - } - }), + try { + const pendingAddInstance = await api.takePendingAddInstance(); + if (!pendingAddInstance) return; + void debug( + `Opening pending add-instance deep link (${urlOrigin(pendingAddInstance.url)})`, + ); + void navigate({ + to: '/full/add/instance', + search: { + token: pendingAddInstance.token, + url: pendingAddInstance.url, + }, + }); + } catch (err) { + void logError(`Failed to retrieve pending add-instance deep link: ${err}`); + } + }; + + const unlisteners = Promise.all([ + listen(TauriEvent.AddInstance, () => void openPendingAddInstance()), // Backend requests the MFA flow (e.g. connecting to an MFA location from the // tray or system settings). The location is emitted as the payload directly. listen>( @@ -227,6 +238,8 @@ export const TauriEventProvider = ({ children }: PropsWithChildren) => { void queryClient.invalidateQueries({ queryKey: ['tunnels'] }); }), ]); + // A link stored before the listener existed sent its event to nobody. + void unlisteners.then(() => openPendingAddInstance()); return () => { void unlisteners.then((fns) => fns.forEach((fn) => void fn())); diff --git a/src-tauri/src/gui.rs b/src-tauri/src/gui.rs index 2d1207f8..654af479 100644 --- a/src-tauri/src/gui.rs +++ b/src-tauri/src/gui.rs @@ -456,14 +456,11 @@ pub fn run_app() { // Decide which window to show based on available locations. // If the app was cold-launched by a deep-link, the full view must open, not the // tray. - let launched_by_deep_link = app_handle - .deep_link() - .get_current() - .ok() - .flatten() - .is_some(); - if launched_by_deep_link { + let startup_deep_link = app_handle.deep_link().get_current().ok().flatten(); + if let Some(urls) = startup_deep_link { info!("App launched via deep link, opening full view directly."); + // Store the link before the full view loads, so its first route load takes it. + handle_deep_link(app_handle, &urls); let _ = WindowManager::open_full_view(app_handle); } else if open_welcome_view { info!("Opening welcome view."); @@ -513,11 +510,6 @@ pub fn run_app() { ); async_runtime::block_on(startup(app_handle)); - // Handle a deep link that launched the app (startup case). - if let Ok(Some(urls)) = app_handle.deep_link().get_current() { - handle_deep_link(app_handle, &urls); - } - // Handle Ctrl-C. debug!("Setting up Ctrl-C handler."); let app_handle_clone = app_handle.clone(); From 78accdd7acc1969587eb923c655eca85abdcfb58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Mon, 19 Oct 2026 11:22:30 +0200 Subject: [PATCH 15/15] update deny config --- src-tauri/deny.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src-tauri/deny.toml b/src-tauri/deny.toml index e08cbb52..1c08a184 100644 --- a/src-tauri/deny.toml +++ b/src-tauri/deny.toml @@ -96,6 +96,11 @@ ignore = [ { id = "RUSTSEC-2025-0081", reason = "Tauri v2 dependency (unmaintained)" }, { id = "RUSTSEC-2025-0098", reason = "Tauri v2 dependency (unmaintained)" }, { id = "RUSTSEC-2025-0100", reason = "Tauri v2 dependency (unmaintained)" }, + # zbus 5 support crates, merged into zbus 6; pulled by tauri-plugin-notification + # (notify-rust), tauri-plugin-opener and tauri-plugin-single-instance. + { id = "RUSTSEC-2026-0370", reason = "zvariant via zbus 5 (Tauri plugins); no zbus 6 upstream yet" }, + { id = "RUSTSEC-2026-0371", reason = "zbus_names via zbus 5 (Tauri plugins); no zbus 6 upstream yet" }, + { id = "RUSTSEC-2026-0372", reason = "zvariant_derive via zbus 5 (Tauri plugins); no zbus 6 upstream yet" }, ] # If this is true, then cargo deny will use the git executable to fetch advisory database. # If this is false, then it uses a built-in git library.