diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml
index 38f1c4a95..25533cfa3 100644
--- a/.github/workflows/e2e.yaml
+++ b/.github/workflows/e2e.yaml
@@ -27,6 +27,7 @@ jobs:
SQLX_OFFLINE: '1'
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: 'true'
+ DEFGUARD_CLIENT_WELCOME_SKIP: '1'
steps:
- name: Install system dependencies
run: |
diff --git a/new-ui/src/pages/welcome/WelcomePage.tsx b/new-ui/src/pages/welcome/WelcomePage.tsx
new file mode 100644
index 000000000..8b8b620b7
--- /dev/null
+++ b/new-ui/src/pages/welcome/WelcomePage.tsx
@@ -0,0 +1,38 @@
+import { Fragment } from 'react/jsx-runtime';
+import { FullPage } from '../../shared/layouts/FullPage/FullPage';
+import './style.scss';
+import { IconKind } from '../../shared/components/Icon';
+import { IconButton } from '../../shared/components/IconButton/IconButton';
+import { IconButtonVariant } from '../../shared/components/IconButton/types';
+import { Snackbar } from '../../shared/providers/snackbar/snackbar';
+import { ThemeSpacing } from '../../shared/types';
+import { WelcomeCarousel } from './components/WelcomeCarousel/WelcomeCarousel';
+import { welcomeSlides } from './config';
+
+export const WelcomePage = () => {
+ return (
+
+
+
+
+ {`What's new`}
+ {
+ Snackbar.default(`Close Welcome view`);
+ }}
+ />
+
+
+
+
+ );
+};
diff --git a/new-ui/src/pages/welcome/assets/test_frame.png b/new-ui/src/pages/welcome/assets/test_frame.png
new file mode 100644
index 000000000..c1c5d96d3
Binary files /dev/null and b/new-ui/src/pages/welcome/assets/test_frame.png differ
diff --git a/new-ui/src/pages/welcome/components/SlideImage.tsx b/new-ui/src/pages/welcome/components/SlideImage.tsx
new file mode 100644
index 000000000..0e7f96477
--- /dev/null
+++ b/new-ui/src/pages/welcome/components/SlideImage.tsx
@@ -0,0 +1,15 @@
+type Props = {
+ src: string;
+};
+
+export const SlideImage = ({ src }: Props) => {
+ return (
+
+ );
+};
diff --git a/new-ui/src/pages/welcome/components/SlideVideo.tsx b/new-ui/src/pages/welcome/components/SlideVideo.tsx
new file mode 100644
index 000000000..fc8d7ea71
--- /dev/null
+++ b/new-ui/src/pages/welcome/components/SlideVideo.tsx
@@ -0,0 +1,18 @@
+type Props = {
+ src: string;
+};
+
+export const SlideVideo = ({ src }: Props) => {
+ return (
+
+ );
+};
diff --git a/new-ui/src/pages/welcome/components/WelcomeCarousel/WelcomeCarousel.tsx b/new-ui/src/pages/welcome/components/WelcomeCarousel/WelcomeCarousel.tsx
new file mode 100644
index 000000000..dda3343e7
--- /dev/null
+++ b/new-ui/src/pages/welcome/components/WelcomeCarousel/WelcomeCarousel.tsx
@@ -0,0 +1,122 @@
+import './style.scss';
+import { openUrl } from '@tauri-apps/plugin-opener';
+import { AnimatePresence, motion } from 'motion/react';
+import { Fragment, useState } from 'react';
+import { Button } from '../../../../shared/components/Button/Button';
+import { ButtonVariant } from '../../../../shared/components/Button/types';
+import { Divider } from '../../../../shared/components/Divider/Divider';
+import { SizedBox } from '../../../../shared/components/SizedBox/SizedBox';
+import { motionTransitionStandard } from '../../../../shared/consts';
+import { ThemeSpacing } from '../../../../shared/types';
+import { isPresent } from '../../../../shared/utils/isPresent';
+import { SlideImage } from '../SlideImage';
+import { SlideVideo } from '../SlideVideo';
+import type { CarouselSlide } from '../types';
+import { CarouselControls } from './components/CarouselControls/CarouselControls';
+import { CarouselIndicators } from './components/CarouselIndicators/CarouselIndicators';
+import type { CarouselDirection, WelcomeCarouselProps } from './types';
+
+const slideVariants = {
+ enter: (direction: CarouselDirection) => ({
+ x: direction > 0 ? '100%' : '-100%',
+ opacity: 0,
+ }),
+ center: {
+ x: 0,
+ opacity: 1,
+ },
+ exit: (direction: CarouselDirection) => ({
+ x: direction > 0 ? '-100%' : '100%',
+ opacity: 0,
+ }),
+};
+
+const slideTransition = {
+ ...motionTransitionStandard,
+ duration: 0.32,
+};
+
+const renderSlide = (slide: CarouselSlide) => {
+ switch (slide.slideType) {
+ case 'image':
+ return ;
+ case 'video':
+ return ;
+ }
+};
+
+export const WelcomeCarousel = ({ slides }: WelcomeCarouselProps) => {
+ const [[activeIndex, direction], setSlideState] = useState<[number, CarouselDirection]>(
+ [0, 1],
+ );
+
+ const isFirst = activeIndex === 0;
+ const isLast = activeIndex === slides.length - 1;
+ const activeSlide = slides[activeIndex];
+
+ const goTo = (index: number) => {
+ if (index === activeIndex) return;
+ setSlideState([index, index > activeIndex ? 1 : -1]);
+ };
+ const goPrev = () => {
+ if (!isFirst) goTo(activeIndex - 1);
+ };
+ const goNext = () => {
+ if (!isLast) goTo(activeIndex + 1);
+ };
+
+ return (
+
+
+
+
+ {renderSlide(activeSlide)}
+
+
{activeSlide.title}
+
+
{activeSlide.description}
+ {isPresent(activeSlide.blogLink) && isPresent(activeSlide.blogLinkText) && (
+
+
+
+ )}
+
+
+
+
+ {slides.length > 1 && (
+
+
+
+
+
+
+
+
+ )}
+
+ );
+};
diff --git a/new-ui/src/pages/welcome/components/WelcomeCarousel/components/CarouselControls/CarouselControls.tsx b/new-ui/src/pages/welcome/components/WelcomeCarousel/components/CarouselControls/CarouselControls.tsx
new file mode 100644
index 000000000..15ee76bbc
--- /dev/null
+++ b/new-ui/src/pages/welcome/components/WelcomeCarousel/components/CarouselControls/CarouselControls.tsx
@@ -0,0 +1,66 @@
+import './style.scss';
+import clsx from 'clsx';
+import type { PropsWithChildren } from 'react';
+import {
+ Icon,
+ IconKind,
+ type IconKindValue,
+} from '../../../../../../shared/components/Icon';
+import { Direction, type DirectionValue } from '../../../../../../shared/types';
+
+type Props = {
+ onPrev: () => void;
+ onNext: () => void;
+ disablePrev: boolean;
+ disableNext: boolean;
+} & PropsWithChildren;
+
+export const CarouselControls = ({
+ onPrev,
+ onNext,
+ disablePrev,
+ disableNext,
+ children,
+}: Props) => {
+ return (
+
+
+ {children}
+
+
+ );
+};
+
+const ControlButton = ({
+ disabled,
+ onClick,
+ icon,
+ iconRotation,
+}: {
+ disabled: boolean;
+ onClick: () => void;
+ icon: IconKindValue;
+ iconRotation: DirectionValue;
+}) => {
+ return (
+
+ );
+};
diff --git a/new-ui/src/pages/welcome/components/WelcomeCarousel/components/CarouselControls/style.scss b/new-ui/src/pages/welcome/components/WelcomeCarousel/components/CarouselControls/style.scss
new file mode 100644
index 000000000..20a8b9783
--- /dev/null
+++ b/new-ui/src/pages/welcome/components/WelcomeCarousel/components/CarouselControls/style.scss
@@ -0,0 +1,41 @@
+.carousel-controls {
+ display: flex;
+ flex-flow: row;
+ column-gap: var(--spacing-md);
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+
+ > button {
+ --color: var(--fg-white-100);
+
+ border: solid 1px var(--color);
+ border-radius: 100px;
+ height: 28px;
+ width: 28px;
+ margin: 0;
+ padding: 0;
+ background-color: transparent;
+ cursor: pointer;
+ position: relative;
+ display: flex;
+ flex-flow: row;
+ align-items: center;
+ justify-content: center;
+ overflow: hidden;
+ user-select: none;
+
+ @include animate(border);
+
+ &:disabled,
+ &.disabled {
+ --color: var(--fg-white-20);
+
+ cursor: not-allowed;
+ }
+
+ .icon {
+ --icon-color: var(--color);
+ }
+ }
+}
diff --git a/new-ui/src/pages/welcome/components/WelcomeCarousel/components/CarouselIndicators/CarouselIndicators.tsx b/new-ui/src/pages/welcome/components/WelcomeCarousel/components/CarouselIndicators/CarouselIndicators.tsx
new file mode 100644
index 000000000..c7d10a43a
--- /dev/null
+++ b/new-ui/src/pages/welcome/components/WelcomeCarousel/components/CarouselIndicators/CarouselIndicators.tsx
@@ -0,0 +1,27 @@
+import './style.scss';
+import clsx from 'clsx';
+
+type Props = {
+ count: number;
+ activeIndex: number;
+ onSelect: (index: number) => void;
+};
+
+export const CarouselIndicators = ({ count, activeIndex, onSelect }: Props) => {
+ return (
+
+ {Array.from({ length: count }, (_, index) => (
+
+ ))}
+
+ );
+};
diff --git a/new-ui/src/pages/welcome/components/WelcomeCarousel/components/CarouselIndicators/style.scss b/new-ui/src/pages/welcome/components/WelcomeCarousel/components/CarouselIndicators/style.scss
new file mode 100644
index 000000000..5626a6082
--- /dev/null
+++ b/new-ui/src/pages/welcome/components/WelcomeCarousel/components/CarouselIndicators/style.scss
@@ -0,0 +1,40 @@
+.carousel-indicators {
+ display: flex;
+ flex-flow: row;
+ align-items: center;
+
+ .indicator {
+ --scale: 1;
+ --color: var(--fg-white-30);
+
+ border: none;
+ padding: 5px;
+ cursor: pointer;
+ box-sizing: border-box;
+ margin: 0;
+ background: transparent;
+ user-select: none;
+
+ &.active {
+ --color: var(--fg-white-100);
+ --scale: 1.25;
+ }
+
+ &:hover:not(.active) {
+ --color: var(--fg-white-60);
+ --scale: 1;
+ }
+
+ > .dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--color);
+ transform: scale(var(--scale));
+ content: ' ';
+ display: block;
+
+ @include animate(background-color, transform);
+ }
+ }
+}
diff --git a/new-ui/src/pages/welcome/components/WelcomeCarousel/style.scss b/new-ui/src/pages/welcome/components/WelcomeCarousel/style.scss
new file mode 100644
index 000000000..3bd0726fd
--- /dev/null
+++ b/new-ui/src/pages/welcome/components/WelcomeCarousel/style.scss
@@ -0,0 +1,47 @@
+.welcome-carousel {
+ display: flex;
+ flex-flow: column;
+ height: 100%;
+ min-height: 0;
+
+ .carousel-viewport {
+ position: relative;
+ flex: 1 1 auto;
+ min-height: 0;
+ overflow: hidden;
+ }
+
+ .carousel-slide {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ flex-flow: column;
+ align-items: center;
+
+ img,
+ video {
+ max-width: 100%;
+ object-fit: contain;
+ padding-bottom: var(--spacing-md);
+ }
+
+ > .content {
+ .title {
+ font: var(--t-h5);
+ color: var(--fg-white-100);
+ }
+
+ .description {
+ font: var(--t-body-sm-400);
+ color: var(--fg-white-80);
+ }
+ }
+ }
+
+ .carousel-footer {
+ display: flex;
+ flex-flow: row;
+ align-items: center;
+ justify-content: space-between;
+ }
+}
diff --git a/new-ui/src/pages/welcome/components/WelcomeCarousel/types.ts b/new-ui/src/pages/welcome/components/WelcomeCarousel/types.ts
new file mode 100644
index 000000000..a62987501
--- /dev/null
+++ b/new-ui/src/pages/welcome/components/WelcomeCarousel/types.ts
@@ -0,0 +1,8 @@
+import type { CarouselSlide } from '../types';
+
+export interface WelcomeCarouselProps {
+ slides: CarouselSlide[];
+}
+
+// +1 = advancing forward (next), -1 = going back (prev) — drives slide-in/out direction
+export type CarouselDirection = 1 | -1;
diff --git a/new-ui/src/pages/welcome/components/types.ts b/new-ui/src/pages/welcome/components/types.ts
new file mode 100644
index 000000000..00ede875f
--- /dev/null
+++ b/new-ui/src/pages/welcome/components/types.ts
@@ -0,0 +1,8 @@
+export interface CarouselSlide {
+ title: string;
+ slideSrc: string;
+ slideType: 'image' | 'video';
+ description: string;
+ blogLink?: string;
+ blogLinkText?: string;
+}
diff --git a/new-ui/src/pages/welcome/config.ts b/new-ui/src/pages/welcome/config.ts
new file mode 100644
index 000000000..ef6edac88
--- /dev/null
+++ b/new-ui/src/pages/welcome/config.ts
@@ -0,0 +1,35 @@
+import testSlide from './assets/test_frame.png';
+import type { CarouselSlide } from './components/types';
+
+export const welcomeSlides: CarouselSlide[] = [
+ {
+ slideSrc: testSlide,
+ slideType: 'image',
+ title: `New Defguard Desktop App`,
+ description: `Defguard now lives in your system tray, giving you faster access while staying out of your way. Launch it anytime directly from the tray.`,
+ blogLink: 'https://defguard.net/blog/',
+ blogLinkText: 'Check how it works',
+ },
+ {
+ slideSrc: testSlide,
+ slideType: 'image',
+ title: `Faster Connection Switching`,
+ description: `Switch between locations in a single click, right from the tray menu, without opening the full app window.`,
+ blogLink: 'https://defguard.net/blog/',
+ blogLinkText: 'See what changed',
+ },
+ {
+ slideSrc: testSlide,
+ slideType: 'image',
+ title: `Improved Security Posture Checks`,
+ description: `Posture checks now run silently in the background and surface only actionable issues, reducing noise while keeping you compliant.`,
+ },
+ {
+ slideSrc: testSlide,
+ slideType: 'image',
+ title: `Redesigned Notifications`,
+ description: `Native system notifications now match your OS theme and give you quick actions without switching windows.`,
+ blogLink: 'https://defguard.net/blog/',
+ blogLinkText: 'Read the release notes',
+ },
+];
diff --git a/new-ui/src/pages/welcome/style.scss b/new-ui/src/pages/welcome/style.scss
new file mode 100644
index 000000000..d5ce4fc4f
--- /dev/null
+++ b/new-ui/src/pages/welcome/style.scss
@@ -0,0 +1,30 @@
+#welcome-page {
+ box-sizing: border-box;
+ display: flex;
+ flex-flow: column;
+ padding: 0 var(--spacing-lg) var(--spacing-md);
+ height: calc(100dvh - var(--spacing-md));
+
+ > header {
+ display: flex;
+ flex-flow: row;
+ align-items: center;
+ justify-content: flex-start;
+ user-select: none;
+ padding-bottom: var(--spacing-md);
+
+ h1 {
+ font: var(--t-h5);
+ color: var(--fg-white-100);
+ }
+
+ > .icon-button {
+ margin-left: auto;
+ }
+ }
+
+ > .welcome-carousel {
+ flex: 1 1 auto;
+ min-height: 0;
+ }
+}
diff --git a/new-ui/src/routeTree.gen.ts b/new-ui/src/routeTree.gen.ts
index 60a604535..b603bac65 100644
--- a/new-ui/src/routeTree.gen.ts
+++ b/new-ui/src/routeTree.gen.ts
@@ -9,6 +9,7 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
+import { Route as WelcomeRouteImport } from './routes/welcome'
import { Route as FullRouteImport } from './routes/full'
import { Route as EmptyRouteImport } from './routes/empty'
import { Route as IndexRouteImport } from './routes/index'
@@ -29,6 +30,11 @@ import { Route as FullDefaultAddIndexRouteImport } from './routes/full/_default/
import { Route as FullDefaultAddTunnelRouteImport } from './routes/full/_default/add/tunnel'
import { Route as FullDefaultAddInstanceRouteImport } from './routes/full/_default/add/instance'
+const WelcomeRoute = WelcomeRouteImport.update({
+ id: '/welcome',
+ path: '/welcome',
+ getParentRoute: () => rootRouteImport,
+} as any)
const FullRoute = FullRouteImport.update({
id: '/full',
path: '/full',
@@ -129,6 +135,7 @@ export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/empty': typeof EmptyRoute
'/full': typeof FullRouteWithChildren
+ '/welcome': typeof WelcomeRoute
'/full/enrollment': typeof FullEnrollmentRoute
'/full/session-timeout': typeof FullSessionTimeoutRoute
'/full/tunnel-wizard': typeof FullTunnelWizardRoute
@@ -148,6 +155,7 @@ export interface FileRoutesByFullPath {
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/empty': typeof EmptyRoute
+ '/welcome': typeof WelcomeRoute
'/full': typeof FullIndexRoute
'/full/enrollment': typeof FullEnrollmentRoute
'/full/session-timeout': typeof FullSessionTimeoutRoute
@@ -169,6 +177,7 @@ export interface FileRoutesById {
'/': typeof IndexRoute
'/empty': typeof EmptyRoute
'/full': typeof FullRouteWithChildren
+ '/welcome': typeof WelcomeRoute
'/full/_default': typeof FullDefaultRouteWithChildren
'/full/enrollment': typeof FullEnrollmentRoute
'/full/session-timeout': typeof FullSessionTimeoutRoute
@@ -192,6 +201,7 @@ export interface FileRouteTypes {
| '/'
| '/empty'
| '/full'
+ | '/welcome'
| '/full/enrollment'
| '/full/session-timeout'
| '/full/tunnel-wizard'
@@ -211,6 +221,7 @@ export interface FileRouteTypes {
to:
| '/'
| '/empty'
+ | '/welcome'
| '/full'
| '/full/enrollment'
| '/full/session-timeout'
@@ -231,6 +242,7 @@ export interface FileRouteTypes {
| '/'
| '/empty'
| '/full'
+ | '/welcome'
| '/full/_default'
| '/full/enrollment'
| '/full/session-timeout'
@@ -253,12 +265,20 @@ export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
EmptyRoute: typeof EmptyRoute
FullRoute: typeof FullRouteWithChildren
+ WelcomeRoute: typeof WelcomeRoute
CompactIndexRoute: typeof CompactIndexRoute
PlaygroundIndexRoute: typeof PlaygroundIndexRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
+ '/welcome': {
+ id: '/welcome'
+ path: '/welcome'
+ fullPath: '/welcome'
+ preLoaderRoute: typeof WelcomeRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/full': {
id: '/full'
path: '/full'
@@ -445,6 +465,7 @@ const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
EmptyRoute: EmptyRoute,
FullRoute: FullRouteWithChildren,
+ WelcomeRoute: WelcomeRoute,
CompactIndexRoute: CompactIndexRoute,
PlaygroundIndexRoute: PlaygroundIndexRoute,
}
diff --git a/new-ui/src/routes/welcome.tsx b/new-ui/src/routes/welcome.tsx
new file mode 100644
index 000000000..83c782959
--- /dev/null
+++ b/new-ui/src/routes/welcome.tsx
@@ -0,0 +1,6 @@
+import { createFileRoute } from '@tanstack/react-router';
+import { WelcomePage } from '../pages/welcome/WelcomePage';
+
+export const Route = createFileRoute('/welcome')({
+ component: WelcomePage,
+});
diff --git a/new-ui/src/shared/components/IconButton/IconButton.tsx b/new-ui/src/shared/components/IconButton/IconButton.tsx
index 3ae3800a0..db35312b2 100644
--- a/new-ui/src/shared/components/IconButton/IconButton.tsx
+++ b/new-ui/src/shared/components/IconButton/IconButton.tsx
@@ -9,16 +9,20 @@ export const IconButton = ({
iconRotation,
className,
variant = IconButtonVariant.Big,
+ disabled = false,
onClick,
}: IconButtonProps) => {
return (
{
- onClick?.(e);
+ if (!disabled) {
+ onClick?.(e);
+ }
}}
role="button"
+ aria-disabled={disabled}
>
diff --git a/new-ui/src/shared/components/IconButton/style.scss b/new-ui/src/shared/components/IconButton/style.scss
index 1182e3679..93d90de6d 100644
--- a/new-ui/src/shared/components/IconButton/style.scss
+++ b/new-ui/src/shared/components/IconButton/style.scss
@@ -73,4 +73,10 @@
}
}
}
+
+ &.disabled {
+ cursor: not-allowed;
+ pointer-events: none;
+ --icon: var(--c-white-30);
+ }
}
diff --git a/new-ui/src/shared/components/IconButton/types.ts b/new-ui/src/shared/components/IconButton/types.ts
index 6b1efaf3f..d66697a47 100644
--- a/new-ui/src/shared/components/IconButton/types.ts
+++ b/new-ui/src/shared/components/IconButton/types.ts
@@ -16,6 +16,7 @@ export type IconButtonProps = {
variant: IconButtonVariantValue;
icon: IconKindValue;
iconRotation?: DirectionValue;
+ disabled?: boolean;
ref?: Ref;
className?: string;
onClick?: MouseEventHandler;
diff --git a/new-ui/src/shared/rust-api/api.ts b/new-ui/src/shared/rust-api/api.ts
index 9d2ca9373..d467d6350 100644
--- a/new-ui/src/shared/rust-api/api.ts
+++ b/new-ui/src/shared/rust-api/api.ts
@@ -126,12 +126,15 @@ const swapToTray = async () => invoke(TauriCommand.SwapToTray);
const closeTrayWindow = async () => invoke(TauriCommand.CloseTrayWindow);
+const closeWelcomeWindow = async () => invoke(TauriCommand.CloseWelcomeWindow);
+
const getSessionState = (): Promise => invoke(TauriCommand.GetSessionState);
const patchSessionState = (patch: SessionStatePatch): Promise =>
invoke(TauriCommand.PatchSessionState, { patch });
export const api = {
+ closeWelcomeWindow,
// Instances
getInstances,
deleteInstance,
diff --git a/new-ui/src/shared/rust-api/types.ts b/new-ui/src/shared/rust-api/types.ts
index 7ffb204b5..25b01b774 100644
--- a/new-ui/src/shared/rust-api/types.ts
+++ b/new-ui/src/shared/rust-api/types.ts
@@ -126,6 +126,7 @@ export const TauriCommand = {
// Session state
GetSessionState: 'get_session_state',
PatchSessionState: 'patch_session_state',
+ CloseWelcomeWindow: 'close_welcome_window',
} as const;
export type TauriCommand = (typeof TauriCommand)[keyof typeof TauriCommand];
diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json
index 0bc984fe0..97fc0b90c 100644
--- a/src-tauri/capabilities/default.json
+++ b/src-tauri/capabilities/default.json
@@ -6,7 +6,7 @@
"remote": {
"urls": ["http://localhost:5071/*", "http://localhost:5072/*"]
},
- "windows": ["compact-view", "full-view"],
+ "windows": ["compact-view", "full-view", "welcome"],
"permissions": [
"core:webview:allow-create-webview",
"core:webview:allow-create-webview-window",
diff --git a/src-tauri/core/src/version.rs b/src-tauri/core/src/version.rs
index 4f2d60fb0..59a154bdc 100644
--- a/src-tauri/core/src/version.rs
+++ b/src-tauri/core/src/version.rs
@@ -1,10 +1,22 @@
+use std::{
+ fs::{create_dir_all, File, OpenOptions},
+ path::{Path, PathBuf},
+};
+
+use serde::{Deserialize, Serialize};
+
pub use semver::Version;
+#[cfg(unix)]
+use crate::set_perms;
+
pub const MIN_CORE_VERSION: Version = Version::new(1, 6, 0);
pub const MIN_PROXY_VERSION: Version = Version::new(1, 6, 0);
pub const CLIENT_VERSION_HEADER: &str = "defguard-client-version";
pub const CLIENT_PLATFORM_HEADER: &str = "defguard-client-platform";
pub const LOG_FILENAME: &str = "defguard-client";
+pub const WELCOME_FORCE_ENV_VAR: &str = "DEFGUARD_CLIENT_WELCOME_FORCE";
+pub const WELCOME_SKIP_ENV_VAR: &str = "DEFGUARD_CLIENT_WELCOME_SKIP";
pub use defguard_client_common::VERSION as PKG_VERSION;
/// Selects the version string the client should report: the build-version override when present
@@ -19,9 +31,138 @@ pub fn select_reported_app_version(
.map_or_else(|| package_version.to_owned(), str::to_owned)
}
+static VERSION_STATE_FILE_NAME: &str = "version.json";
+
+fn get_version_state_file_path(config_dir: &Path) -> PathBuf {
+ let mut path = config_dir.to_path_buf();
+ if !path.exists() {
+ create_dir_all(&path).expect("Failed to create missing app data dir");
+ }
+ #[cfg(unix)]
+ set_perms(&path);
+ path.push(VERSION_STATE_FILE_NAME);
+ #[cfg(unix)]
+ set_perms(&path);
+ path
+}
+
+fn get_version_state_file(config_dir: &Path, for_write: bool) -> File {
+ let path = get_version_state_file_path(config_dir);
+ OpenOptions::new()
+ .create(true)
+ .read(true)
+ .truncate(for_write)
+ .write(true)
+ .open(path)
+ .expect("Failed to create and open version state file.")
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+struct VersionState {
+ version: Version,
+}
+
+impl VersionState {
+ fn save(&self, config_dir: &Path) {
+ let file = get_version_state_file(config_dir, true);
+ match serde_json::to_writer(file, &self) {
+ Ok(()) => debug!("Version state file has been saved."),
+ Err(err) => error!("Version state file couldn't be saved. Failed to serialize: {err}"),
+ }
+ }
+}
+
+/// Result of comparing the last known app version (persisted on disk) against the currently
+/// running version.
+#[derive(Clone, Debug, PartialEq)]
+pub enum VersionCheckResult {
+ /// No version state file existed on disk yet (fresh install, or first run of this check).
+ Init,
+ /// Stored version matches the current version. Also returned for a downgrade (current
+ /// version lower than the stored one) — the file is left untouched in that case so the
+ /// highest version ever seen isn't lost.
+ Unchanged,
+ /// Stored version is lower than the current version.
+ Upgraded { previous: Version, current: Version },
+}
+
+fn welcome_force_enabled() -> bool {
+ std::env::var(WELCOME_FORCE_ENV_VAR)
+ .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
+ .unwrap_or(false)
+}
+
+fn welcome_skip_enabled() -> bool {
+ std::env::var(WELCOME_SKIP_ENV_VAR)
+ .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
+ .unwrap_or(false)
+}
+
+/// Checks the last known app version (persisted in `config_dir`) against `current_version`,
+/// updating the on-disk state as needed.
+///
+/// Meant to be called exactly once, synchronously, during app setup.
+#[must_use]
+pub fn check_app_version(config_dir: &Path, current_version: &Version) -> VersionCheckResult {
+ if welcome_skip_enabled() {
+ return VersionCheckResult::Unchanged;
+ }
+
+ if welcome_force_enabled() {
+ return VersionCheckResult::Upgraded {
+ previous: current_version.clone(),
+ current: current_version.clone(),
+ };
+ }
+
+ let path = get_version_state_file_path(config_dir);
+ if !path.exists() {
+ VersionState {
+ version: current_version.clone(),
+ }
+ .save(config_dir);
+ return VersionCheckResult::Init;
+ }
+
+ let file = get_version_state_file(config_dir, false);
+ match serde_json::from_reader::<_, VersionState>(file) {
+ Ok(state) => match state.version.cmp(current_version) {
+ std::cmp::Ordering::Equal | std::cmp::Ordering::Greater => {
+ VersionCheckResult::Unchanged
+ }
+ std::cmp::Ordering::Less => {
+ let previous = state.version;
+ VersionState {
+ version: current_version.clone(),
+ }
+ .save(config_dir);
+ VersionCheckResult::Upgraded {
+ previous,
+ current: current_version.clone(),
+ }
+ }
+ },
+ Err(err) => {
+ error!("Failed to deserialize version state file: {err}. Treating as first run.");
+ VersionState {
+ version: current_version.clone(),
+ }
+ .save(config_dir);
+ VersionCheckResult::Init
+ }
+ }
+}
+
#[cfg(test)]
mod tests {
- use super::select_reported_app_version;
+ use std::fs;
+
+ use tempfile::tempdir;
+
+ use super::{
+ check_app_version, select_reported_app_version, Version, VersionCheckResult,
+ VERSION_STATE_FILE_NAME, WELCOME_FORCE_ENV_VAR, WELCOME_SKIP_ENV_VAR,
+ };
#[test]
fn test_reported_app_version_uses_override_when_present() {
@@ -40,4 +181,130 @@ mod tests {
fn test_reported_app_version_ignores_empty_override() {
assert_eq!(select_reported_app_version("1.6.8", Some(" ")), "1.6.8");
}
+
+ #[test]
+ fn test_check_app_version_init_when_missing() {
+ let dir = tempdir().unwrap();
+ let current = Version::new(1, 2, 0);
+
+ let result = check_app_version(dir.path(), ¤t);
+
+ assert_eq!(result, VersionCheckResult::Init);
+ assert!(dir.path().join(VERSION_STATE_FILE_NAME).exists());
+ }
+
+ #[test]
+ fn test_check_app_version_unchanged_when_same() {
+ let dir = tempdir().unwrap();
+ let current = Version::new(1, 2, 0);
+ let _ = check_app_version(dir.path(), ¤t);
+
+ let result = check_app_version(dir.path(), ¤t);
+
+ assert_eq!(result, VersionCheckResult::Unchanged);
+ }
+
+ #[test]
+ fn test_check_app_version_upgraded_when_current_is_newer() {
+ let dir = tempdir().unwrap();
+ let previous = Version::new(1, 2, 0);
+ let _ = check_app_version(dir.path(), &previous);
+
+ let current = Version::new(1, 3, 0);
+ let result = check_app_version(dir.path(), ¤t);
+
+ assert_eq!(
+ result,
+ VersionCheckResult::Upgraded {
+ previous: previous.clone(),
+ current: current.clone(),
+ }
+ );
+
+ // File should now reflect the new version.
+ let result = check_app_version(dir.path(), ¤t);
+ assert_eq!(result, VersionCheckResult::Unchanged);
+ }
+
+ #[test]
+ fn test_check_app_version_unchanged_on_downgrade() {
+ let dir = tempdir().unwrap();
+ let previous = Version::new(1, 3, 0);
+ let _ = check_app_version(dir.path(), &previous);
+
+ let older = Version::new(1, 2, 0);
+ let result = check_app_version(dir.path(), &older);
+
+ assert_eq!(result, VersionCheckResult::Unchanged);
+
+ // File should still hold the higher version, not the downgrade.
+ let contents = fs::read_to_string(dir.path().join(VERSION_STATE_FILE_NAME)).unwrap();
+ assert!(contents.contains("1.3.0"));
+ }
+
+ #[test]
+ fn test_check_app_version_corrupt_file_falls_back_to_init() {
+ let dir = tempdir().unwrap();
+ fs::write(
+ dir.path().join(VERSION_STATE_FILE_NAME),
+ b"{ not valid json",
+ )
+ .unwrap();
+
+ let current = Version::new(1, 2, 0);
+ let result = check_app_version(dir.path(), ¤t);
+
+ assert_eq!(result, VersionCheckResult::Init);
+ }
+
+ #[test]
+ fn test_check_app_version_force_upgraded_via_env_var() {
+ let dir = tempdir().unwrap();
+ let current = Version::new(1, 2, 0);
+ let _ = check_app_version(dir.path(), ¤t);
+
+ for value in ["1", "true", "TRUE"] {
+ std::env::set_var(WELCOME_FORCE_ENV_VAR, value);
+ let result = check_app_version(dir.path(), ¤t);
+ std::env::remove_var(WELCOME_FORCE_ENV_VAR);
+
+ assert_eq!(
+ result,
+ VersionCheckResult::Upgraded {
+ previous: current.clone(),
+ current: current.clone(),
+ }
+ );
+ }
+
+ // Flag unset: normal behavior resumes.
+ let result = check_app_version(dir.path(), ¤t);
+ assert_eq!(result, VersionCheckResult::Unchanged);
+ }
+
+ #[test]
+ fn test_check_app_version_skip_via_env_var() {
+ let dir = tempdir().unwrap();
+ let previous = Version::new(1, 2, 0);
+ let _ = check_app_version(dir.path(), &previous);
+
+ let current = Version::new(1, 3, 0);
+ for value in ["1", "true", "TRUE"] {
+ std::env::set_var(WELCOME_SKIP_ENV_VAR, value);
+ let result = check_app_version(dir.path(), ¤t);
+ std::env::remove_var(WELCOME_SKIP_ENV_VAR);
+
+ assert_eq!(result, VersionCheckResult::Unchanged);
+ }
+
+ // Flag unset: normal behavior resumes, upgrade is detected.
+ let result = check_app_version(dir.path(), ¤t);
+ assert_eq!(
+ result,
+ VersionCheckResult::Upgraded {
+ previous: previous.clone(),
+ current: current.clone(),
+ }
+ );
+ }
}
diff --git a/src-tauri/permissions/default.toml b/src-tauri/permissions/default.toml
index 18b028e8e..eb2987f67 100644
--- a/src-tauri/permissions/default.toml
+++ b/src-tauri/permissions/default.toml
@@ -36,6 +36,7 @@ commands.allow = [
"swap_to_tray",
"swap_to_full_view",
"close_tray_window",
+ "close_welcome_window",
"all_active_connections",
"disconnect_locations",
"get_posture_data",
diff --git a/src-tauri/src/bin/defguard-client.rs b/src-tauri/src/bin/defguard-client.rs
index 931d18e03..87a840dcd 100644
--- a/src-tauri/src/bin/defguard-client.rs
+++ b/src-tauri/src/bin/defguard-client.rs
@@ -46,6 +46,7 @@ use defguard_client::{
use defguard_client_core::connection::active_connections::close_all_connections;
#[cfg(target_os = "macos")]
use defguard_client_core::connection::sync_locations_and_tunnels;
+use defguard_client_core::version::{check_app_version, VersionCheckResult};
use log::{Level, LevelFilter};
use tauri::{async_runtime, AppHandle, Builder, Manager, RunEvent, WindowEvent};
use tauri_plugin_deep_link::DeepLinkExt;
@@ -54,6 +55,8 @@ use tauri_plugin_log::{Target, TargetKind};
#[macro_use]
extern crate log;
+const ENABLE_WELCOME_SCREEN: bool = false;
+
// For tauri logging plugin:
// if found in metadata target name it will ignore the log if it was below info level.
const LOGGING_TARGET_IGNORE_LIST: [&str; 5] = ["tauri", "sqlx", "hyper", "h2", "tower"];
@@ -205,6 +208,7 @@ fn main() {
swap_to_tray,
swap_to_full_view,
close_tray_window,
+ close_welcome_window,
all_active_connections,
disconnect_locations,
session_state::get_session_state,
@@ -311,6 +315,24 @@ fn main() {
.app_data_dir()
.expect("Failed to access app data");
let config = AppConfig::new(&config_dir);
+ let current_version = app_handle.package_info().version.clone();
+ let mut open_welcome_view = match check_app_version(&config_dir, ¤t_version) {
+ VersionCheckResult::Init => {
+ debug!("No previous version recorded; initializing at {current_version}.");
+ true
+ }
+ VersionCheckResult::Unchanged => {
+ debug!("Application version unchanged ({current_version}).");
+ false
+ }
+ VersionCheckResult::Upgraded { previous, current } => {
+ info!("Application upgraded from {previous} to {current}.");
+ true
+ }
+ };
+ if !ENABLE_WELCOME_SCREEN {
+ open_welcome_view = false;
+ }
// Setup logging.
@@ -389,13 +411,16 @@ fn main() {
let state = AppState::new(config, provisioning_config);
app.manage(state);
- // Pre-build both windows hidden so they can be shown/hidden without recreation.
+ // Pre-build windows hidden so they can be shown/hidden without recreation.
if let Err(e) = WindowManager::build_tray_window(app_handle) {
warn!("Failed to pre-build tray window: {e}");
}
if let Err(e) = WindowManager::build_full_view_window(app_handle) {
warn!("Failed to pre-build full window: {e}");
}
+ if let Err(e) = WindowManager::build_welcome_window(app_handle) {
+ warn!("Failed to pre-build welcome window: {e}");
+ }
// 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
@@ -409,6 +434,9 @@ fn main() {
if launched_by_deep_link {
info!("App launched via deep link, opening full view directly.");
let _ = WindowManager::open_full_view(app_handle);
+ } else if open_welcome_view {
+ info!("Opening welcome view.");
+ let _ = WindowManager::open_welcome_view(app_handle);
} else {
show_tray_or_full_view(app_handle);
}
diff --git a/src-tauri/src/window_manager/mod.rs b/src-tauri/src/window_manager/mod.rs
index 127735548..c4d1ce755 100644
--- a/src-tauri/src/window_manager/mod.rs
+++ b/src-tauri/src/window_manager/mod.rs
@@ -27,6 +27,9 @@ pub async fn has_tray_content() -> bool {
pub const COMPACT_WINDOW_ID: &str = "compact-view";
pub const FULL_VIEW_WINDOW_ID: &str = "full-view";
+pub const WELCOME_WINDOW_ID: &str = "welcome";
+pub const WELCOME_WINDOW_WIDTH: f64 = 640.0;
+pub const WELCOME_WINDOW_HEIGHT: f64 = 640.0;
pub const COMPACT_WINDOW_WIDTH: f64 = 380.0;
pub const COMPACT_WINDOW_HEIGHT: f64 = 680.0;
pub const FULL_VIEW_WINDOW_WIDTH: f64 = 800.0;
@@ -53,6 +56,24 @@ pub fn full_view_ui_url() -> WebviewUrl {
}
}
+#[must_use]
+pub fn welcome_ui_url() -> WebviewUrl {
+ if cfg!(any(defguard_client_dev)) {
+ WebviewUrl::External("http://localhost:5072/welcome/".parse().unwrap())
+ } else {
+ WebviewUrl::App("welcome/".into())
+ }
+}
+
+/// Hides any currently visible webview window other than `skip_label`.
+fn hide_shown_windows(app: &AppHandle, skip_label: &str) {
+ for (label, window) in app.webview_windows() {
+ if label != skip_label && window.is_visible().unwrap_or(false) {
+ let _ = window.hide();
+ }
+ }
+}
+
pub struct WindowManager;
impl WindowManager {
@@ -94,6 +115,29 @@ impl WindowManager {
Ok(window)
}
+
+ pub fn build_welcome_window(app: &AppHandle) -> tauri::Result {
+ let window = WebviewWindowBuilder::new(app, WELCOME_WINDOW_ID, welcome_ui_url())
+ .title(WINDOW_TITLE)
+ .inner_size(WELCOME_WINDOW_WIDTH, WELCOME_WINDOW_HEIGHT)
+ .resizable(false)
+ .maximizable(false)
+ .decorations(false)
+ .skip_taskbar(false)
+ .always_on_top(false)
+ .visible(false);
+ #[cfg(target_os = "macos")]
+ let window = window.hidden_title(true);
+
+ let window = window.build()?;
+
+ #[cfg(target_os = "macos")]
+ if let Err(err) = macos::enable_rounded_corners(&window, false) {
+ warn!("Failed to enable rounded corners on welcome window: {err}");
+ }
+
+ Ok(window)
+ }
}
#[cfg(not(windows))]
@@ -131,6 +175,24 @@ impl WindowManager {
let _ = window.set_focus();
Ok(window)
}
+
+ pub fn open_welcome_view(app: &AppHandle) -> tauri::Result {
+ hide_shown_windows(app, WELCOME_WINDOW_ID);
+
+ let window = if let Some(window) = app.get_webview_window(WELCOME_WINDOW_ID) {
+ let _ = window.unminimize();
+ window
+ } else {
+ Self::build_welcome_window(app)?
+ };
+ #[cfg(target_os = "macos")]
+ let _ = app.set_dock_visibility(true);
+ #[cfg(target_os = "macos")]
+ let _ = app.show();
+ let _ = window.show();
+ let _ = window.set_focus();
+ Ok(window)
+ }
}
#[cfg(windows)]
@@ -200,6 +262,21 @@ pub fn close_tray_window(app: AppHandle) {
}
}
+#[tauri::command]
+pub fn close_welcome_window(app: AppHandle) {
+ info!("close_welcome_window called");
+
+ if let Some(window) = app.get_webview_window(WELCOME_WINDOW_ID) {
+ if let Err(err) = window.hide() {
+ error!("close_welcome_window task: Failed to hide welcome window: {err:?}");
+ }
+ } else {
+ warn!("close_welcome_window task: welcome window not found");
+ }
+
+ show_tray_or_full_view(&app);
+}
+
#[tauri::command]
pub fn swap_to_tray(app: AppHandle) {
info!("swap_to_tray called");
diff --git a/src-tauri/src/window_manager/windows.rs b/src-tauri/src/window_manager/windows.rs
index 15056fa8b..2e6c29298 100644
--- a/src-tauri/src/window_manager/windows.rs
+++ b/src-tauri/src/window_manager/windows.rs
@@ -8,8 +8,9 @@ use windows::Win32::{
};
use crate::window_manager::{
- WindowManager, COMPACT_WINDOW_HEIGHT, COMPACT_WINDOW_ID, COMPACT_WINDOW_WIDTH,
- FULL_VIEW_WINDOW_HEIGHT, FULL_VIEW_WINDOW_ID, FULL_VIEW_WINDOW_WIDTH, WINDOW_GAP,
+ hide_shown_windows, WindowManager, COMPACT_WINDOW_HEIGHT, COMPACT_WINDOW_ID,
+ COMPACT_WINDOW_WIDTH, FULL_VIEW_WINDOW_HEIGHT, FULL_VIEW_WINDOW_ID, FULL_VIEW_WINDOW_WIDTH,
+ WELCOME_WINDOW_HEIGHT, WELCOME_WINDOW_ID, WELCOME_WINDOW_WIDTH, WINDOW_GAP,
};
#[derive(Debug, Clone, PartialEq)]
@@ -329,4 +330,40 @@ impl WindowManager {
info!("open_full_view: Window shown successfully");
Ok(window)
}
+
+ pub fn open_welcome_view(app: &tauri::AppHandle) -> tauri::Result {
+ hide_shown_windows(app, WELCOME_WINDOW_ID);
+
+ let monitors = Self::get_monitors();
+ let primary = monitors
+ .iter()
+ .find(|m| m.is_primary)
+ .unwrap_or(&monitors[0]);
+
+ let window = if let Some(window) = app.get_webview_window(WELCOME_WINDOW_ID) {
+ let _ = window.unminimize();
+ window
+ } else {
+ Self::build_welcome_window(app)?
+ };
+
+ let outer_size = window.outer_size().unwrap_or(tauri::PhysicalSize {
+ width: (WELCOME_WINDOW_WIDTH * primary.scale_factor) as u32,
+ height: (WELCOME_WINDOW_HEIGHT * primary.scale_factor) as u32,
+ });
+
+ let physical_width = outer_size.width as i32;
+ let physical_height = outer_size.height as i32;
+
+ let center_x = primary.physical_x + (primary.physical_width as i32 / 2);
+ let center_y = primary.physical_y + (primary.physical_height as i32 / 2);
+
+ let window_x = center_x - (physical_width / 2);
+ let window_y = center_y - (physical_height / 2);
+
+ window.set_position(tauri::PhysicalPosition::new(window_x, window_y))?;
+ window.show()?;
+ window.set_focus()?;
+ Ok(window)
+ }
}