feat(vpn-desktop): support screen UI and routing (#4279)

* add base setting screen and routing

* add version

* add logs icons

* fix comments

* refactor to nested routes

* add settingslayout

* remove useless note

* fix typecheck

* fix exports and optionals

* fix icon

---------

Co-authored-by: pierre <dommerc.pierre@gmail.com>
This commit is contained in:
Zane Schepke
2024-01-04 09:55:46 -05:00
committed by GitHub
parent cd9d4eebd3
commit 685019884f
24 changed files with 558 additions and 49 deletions
@@ -121,3 +121,47 @@ pub async fn set_entry_location_selector(
.map_err(|e| CmdError::new(CmdErrorSource::InternalError, e.to_string()))?;
Ok(())
}
#[instrument(skip(data_state))]
#[tauri::command]
pub async fn set_auto_connect(
data_state: State<'_, SharedAppData>,
entry_selector: bool,
) -> Result<(), CmdError> {
debug!("set_auto_connect");
let mut app_data_store = data_state.lock().await;
let mut app_data = app_data_store
.read()
.await
.map_err(|e| CmdError::new(CmdErrorSource::InternalError, e.to_string()))?;
app_data.autoconnect = Some(entry_selector);
app_data_store.data = app_data;
app_data_store
.write()
.await
.map_err(|e| CmdError::new(CmdErrorSource::InternalError, e.to_string()))?;
Ok(())
}
#[instrument(skip(data_state))]
#[tauri::command]
pub async fn set_monitoring(
data_state: State<'_, SharedAppData>,
entry_selector: bool,
) -> Result<(), CmdError> {
debug!("set_monitoring");
let mut app_data_store = data_state.lock().await;
let mut app_data = app_data_store
.read()
.await
.map_err(|e| CmdError::new(CmdErrorSource::InternalError, e.to_string()))?;
app_data.monitoring = Some(entry_selector);
app_data_store.data = app_data;
app_data_store
.write()
.await
.map_err(|e| CmdError::new(CmdErrorSource::InternalError, e.to_string()))?;
Ok(())
}
+2
View File
@@ -108,6 +108,8 @@ async fn main() -> Result<()> {
app_data::set_app_data,
app_data::set_ui_theme,
app_data::set_entry_location_selector,
app_data::set_monitoring,
app_data::set_auto_connect,
app_data::get_node_countries,
app_data::set_root_font_size,
node_location::set_node_location,
+1 -1
View File
@@ -8,7 +8,7 @@
},
"package": {
"productName": "nym-vpn",
"version": "0.0.0"
"version": "0.0.1"
},
"tauri": {
"updater": {
+4
View File
@@ -1,6 +1,10 @@
export const routes = {
root: '/',
settings: '/settings',
display: '/settings/display',
logs: '/settings/logs',
feedback: '/settings/feedback',
legal: '/settings/legal',
entryNodeLocation: '/entry-node-location',
exitNodeLocation: '/exit-node-location',
} as const;
+5 -1
View File
@@ -3,5 +3,9 @@
"disconnect": "Disconnect",
"first-hop-selection": "First hop selection",
"last-hop-selection": "Last hop selection",
"settings": "Settings"
"settings": "Settings",
"display-theme": "Display theme",
"logs": "Logs",
"feedback": "Feedback",
"legal": "Legal"
}
+18 -1
View File
@@ -1,3 +1,20 @@
{
"entry-selector": "Entry location selector"
"auto-connect": {
"title": "Auto-connect",
"desc": "Auto connect at app startup"
},
"entry-selector": {
"title": "Entry location selector",
"desc": "Manually select your entry hop"
},
"feedback": "Feedback",
"error-reporting": {
"title": "Error reporting",
"desc": "Help us improve NymVPN"
},
"faq": "FAQ",
"display-theme": "Display theme",
"logs": "Logs",
"legal": "Legal",
"quit": "Quit NymVPN"
}
+19 -2
View File
@@ -12,8 +12,14 @@ import ConnectionStatus from './ConnectionStatus';
import HopSelect from './HopSelect';
function Home() {
const { state, loading, exitNodeLocation, defaultNodeLocation } =
useMainState();
const {
state,
loading,
entryNodeLocation,
exitNodeLocation,
entrySelector,
defaultNodeLocation,
} = useMainState();
const dispatch = useMainDispatch() as StateDispatch;
const navigate = useNavigate();
const { t } = useTranslation('home');
@@ -77,6 +83,17 @@ function Home() {
<div className="mt-3 font-semibold text-lg">
{t('select-node-title')}
</div>
{entrySelector && (
<HopSelect
country={entryNodeLocation || defaultNodeLocation}
onClick={() => {
if (state === 'Disconnected') {
navigate(routes.entryNodeLocation);
}
}}
nodeHop="entry"
/>
)}
<HopSelect
country={exitNodeLocation || defaultNodeLocation}
onClick={() => {
+195 -40
View File
@@ -2,61 +2,216 @@ import { useEffect, useState } from 'react';
import clsx from 'clsx';
import { invoke } from '@tauri-apps/api';
import { Switch } from '@headlessui/react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { routes } from '../../constants';
import { useMainDispatch, useMainState } from '../../contexts';
import { StateDispatch } from '../../types';
import UiScaler from './UiScaler';
import SettingsGroup from './SettingsGroup';
function Settings() {
const state = useMainState();
const navigate = useNavigate();
const dispatch = useMainDispatch() as StateDispatch;
const { t } = useTranslation('settings');
const [darkModeEnabled, setDarkModeEnabled] = useState(
state.uiTheme === 'Dark',
);
const [entrySelector, setEntrySelector] = useState(state.entrySelector);
const [autoConnect, setAutoConnect] = useState(state.autoConnect);
const [monitoring, setMonitoring] = useState(state.monitoring);
useEffect(() => {
setDarkModeEnabled(state.uiTheme === 'Dark');
setEntrySelector(state.entrySelector);
setAutoConnect(state.autoConnect);
setMonitoring(state.monitoring);
}, [state]);
const handleThemeChange = async (darkMode: boolean) => {
if (darkMode && state.uiTheme === 'Light') {
dispatch({ type: 'set-ui-theme', theme: 'Dark' });
} else if (!darkMode && state.uiTheme === 'Dark') {
dispatch({ type: 'set-ui-theme', theme: 'Light' });
}
invoke<void>('set_ui_theme', { theme: darkMode ? 'Dark' : 'Light' }).catch(
(e) => {
console.log(e);
},
);
const handleEntrySelectorChange = async () => {
const isSelected = !state.entrySelector;
dispatch({ type: 'set-entry-selector', entrySelector: isSelected });
invoke<void>('set_entry_location_selector', {
entrySelector: isSelected,
}).catch((e) => {
console.log(e);
});
};
const handleAutoConnectChanged = async () => {
const isSelected = !state.autoConnect;
dispatch({ type: 'set-auto-connect', autoConnect: isSelected });
invoke<void>('set_auto_connect', { autoConnect: isSelected }).catch((e) => {
console.log(e);
});
};
const handleMonitoringChanged = async () => {
const isSelected = !state.monitoring;
dispatch({ type: 'set-monitoring', monitoring: isSelected });
invoke<void>('set_monitoring', { monitoring: isSelected }).catch((e) => {
console.log(e);
});
};
return (
<div className="h-full flex flex-col p-4 gap-4">
<div className="flex flex-row justify-between items-center">
<p className="text-lg text-baltic-sea dark:text-mercury-pinkish">
Dark Mode
</p>
<Switch
checked={darkModeEnabled}
onChange={handleThemeChange}
className={clsx([
darkModeEnabled
? 'bg-melon'
: 'bg-mercury-pinkish dark:bg-gun-powder',
'relative inline-flex h-6 w-11 items-center rounded-full',
])}
>
<span className="sr-only">Dark mode</span>
<span
className={clsx([
darkModeEnabled ? 'translate-x-6' : 'translate-x-1',
'inline-block h-4 w-4 transform rounded-full bg-cement-feet dark:bg-white transition',
])}
/>
</Switch>
<div className="h-full flex flex-col p-4 mt-2 gap-8">
<SettingsGroup
settings={[
{
title: t('auto-connect.title'),
desc: t('auto-connect.desc'),
leadingIcon: 'hdr_auto',
trailing: (
<Switch
checked={autoConnect}
onChange={handleAutoConnectChanged}
className={clsx([
autoConnect
? 'bg-melon'
: 'bg-mercury-pinkish dark:bg-gun-powder',
'relative inline-flex h-6 w-11 items-center rounded-full',
])}
>
<span
className={clsx([
autoConnect ? 'translate-x-6' : 'translate-x-1',
'inline-block h-4 w-4 transform rounded-full bg-cement-feet dark:bg-white transition',
])}
/>
</Switch>
),
},
{
title: t('entry-selector.title'),
desc: t('entry-selector.desc'),
leadingIcon: 'looks_two',
trailing: (
<Switch
checked={entrySelector}
onChange={handleEntrySelectorChange}
className={clsx([
entrySelector
? 'bg-melon'
: 'bg-mercury-pinkish dark:bg-gun-powder',
'relative inline-flex h-6 w-11 items-center rounded-full',
])}
>
<span
className={clsx([
entrySelector ? 'translate-x-6' : 'translate-x-1',
'inline-block h-4 w-4 transform rounded-full bg-cement-feet dark:bg-white transition',
])}
/>
</Switch>
),
},
]}
/>
<SettingsGroup
settings={[
{
title: t('display-theme'),
leadingIcon: 'contrast',
onClick: () => {
navigate(routes.display);
},
trailing: (
<div className="font-icon text-2xl cursor-pointer">
arrow_right
</div>
),
},
]}
/>
<SettingsGroup
settings={[
{
title: t('logs'),
leadingIcon: 'sort',
onClick: () => {
navigate(routes.logs);
},
trailing: (
<div className="font-icon text-2xl cursor-pointer">
arrow_right
</div>
),
},
]}
/>
<SettingsGroup
settings={[
{
title: t('feedback'),
leadingIcon: 'question_answer',
onClick: () => {
navigate(routes.feedback);
},
trailing: (
<div className="font-icon text-2xl cursor-pointer">
arrow_right
</div>
),
},
{
title: t('error-reporting.title'),
desc: t('error-reporting.desc'),
leadingIcon: 'error',
trailing: (
<Switch
checked={monitoring}
onChange={handleMonitoringChanged}
className={clsx([
monitoring
? 'bg-melon'
: 'bg-mercury-pinkish dark:bg-gun-powder',
'relative inline-flex h-6 w-11 items-center rounded-full',
])}
>
<span
className={clsx([
monitoring ? 'translate-x-6' : 'translate-x-1',
'inline-block h-4 w-4 transform rounded-full bg-cement-feet dark:bg-white transition',
])}
/>
</Switch>
),
},
{
title: t('faq'),
leadingIcon: 'help',
trailing: (
<div className="font-icon text-2xl cursor-pointer">launch</div>
),
},
]}
/>
<SettingsGroup
settings={[
{
title: t('legal'),
onClick: () => {
navigate(routes.legal);
},
trailing: (
<div className="font-icon text-2xl cursor-pointer">
arrow_right
</div>
),
},
]}
/>
<SettingsGroup
settings={[
{
title: t('quit'),
onClick: () => {
//TODO shutdown gracefully
},
},
]}
/>
<div className="text-comet text-sm tracking-tight leading-tight">
Version {state.version}
</div>
<UiScaler />
</div>
);
}
@@ -0,0 +1,66 @@
import clsx from 'clsx';
import { ReactNode } from 'react';
import { RadioGroup } from '@headlessui/react';
type Setting = {
title: string;
leadingIcon?: string;
desc?: string;
onClick?: () => void;
trailing?: ReactNode;
};
interface Props {
settings: Setting[];
}
function SettingsGroup({ settings }: Props) {
return (
<RadioGroup>
{settings.map((setting, index) => (
<RadioGroup.Option
key={setting.title}
value={setting.title}
onClick={setting.onClick}
className={clsx([
'bg-white dark:bg-baltic-sea-jaguar relative flex px-5 py-2 shadow-md focus:outline-none',
index === 0 && 'rounded-t-lg',
index === settings.length - 1 &&
settings.length === 2 &&
'border-t border-mercury-mist',
index !== 0 &&
index !== settings.length - 1 &&
'border-y border-mercury-mist',
index === settings.length - 1 && 'rounded-b-lg',
setting.desc === undefined && 'py-4',
])}
>
<div className="flex flex-1 items-center justify-between gap-4">
{setting.leadingIcon && (
<span className="font-icon text-2xl">{setting.leadingIcon}</span>
)}
<div className="flex flex-1 items-center">
<div className="text-sm">
<RadioGroup.Label
as="p"
className="font-semibold text-lg text-baltic-sea dark:text-mercury-pinkish"
>
{setting.title}
</RadioGroup.Label>
<RadioGroup.Description
as="span"
className="text-base text-cement-feet dark:text-mercury-mist"
>
<span>{setting.desc}</span>
</RadioGroup.Description>
</div>
</div>
<div>{setting.trailing}</div>
</div>
</RadioGroup.Option>
))}
</RadioGroup>
);
}
export default SettingsGroup;
@@ -0,0 +1,11 @@
import { Outlet } from 'react-router-dom';
function SettingsLayout() {
return (
<div>
<Outlet />
</div>
);
}
export default SettingsLayout;
@@ -0,0 +1,64 @@
import { useEffect, useState } from 'react';
import clsx from 'clsx';
import { invoke } from '@tauri-apps/api';
import { Switch } from '@headlessui/react';
import { useMainDispatch, useMainState } from '../../../contexts';
import { StateDispatch } from '../../../types';
import UiScaler from './UiScaler';
function Display() {
const state = useMainState();
const dispatch = useMainDispatch() as StateDispatch;
const [darkModeEnabled, setDarkModeEnabled] = useState(
state.uiTheme === 'Dark',
);
useEffect(() => {
setDarkModeEnabled(state.uiTheme === 'Dark');
}, [state]);
const handleThemeChange = async (darkMode: boolean) => {
if (darkMode && state.uiTheme === 'Light') {
dispatch({ type: 'set-ui-theme', theme: 'Dark' });
} else if (!darkMode && state.uiTheme === 'Dark') {
dispatch({ type: 'set-ui-theme', theme: 'Light' });
}
invoke<void>('set_ui_theme', { theme: darkMode ? 'Dark' : 'Light' }).catch(
(e) => {
console.log(e);
},
);
};
return (
<div className="h-full flex flex-col p-4 gap-4">
<div className="flex flex-row justify-between items-center">
<p className="text-lg text-baltic-sea dark:text-mercury-pinkish">
Dark Mode
</p>
<Switch
checked={darkModeEnabled}
onChange={handleThemeChange}
className={clsx([
darkModeEnabled
? 'bg-melon'
: 'bg-mercury-pinkish dark:bg-gun-powder',
'relative inline-flex h-6 w-11 items-center rounded-full',
])}
>
<span className="sr-only">Dark mode</span>
<span
className={clsx([
darkModeEnabled ? 'translate-x-6' : 'translate-x-1',
'inline-block h-4 w-4 transform rounded-full bg-cement-feet dark:bg-white transition',
])}
/>
</Switch>
</div>
<UiScaler />
</div>
);
}
export default Display;
@@ -1,7 +1,7 @@
import { ChangeEvent, useEffect, useState } from 'react';
import { invoke } from '@tauri-apps/api';
import { useMainDispatch, useMainState } from '../../contexts';
import { CmdError, StateDispatch } from '../../types';
import { useMainDispatch, useMainState } from '../../../contexts';
import { CmdError, StateDispatch } from '../../../types';
function UiScaler() {
const [slideValue, setSlideValue] = useState(12);
@@ -0,0 +1 @@
export { default as Display } from './Display';
@@ -0,0 +1,9 @@
function Feedback() {
return (
<div className="h-full flex flex-col p-4 gap-4">
<div className="flex flex-row justify-between items-center"></div>
</div>
);
}
export default Feedback;
@@ -0,0 +1 @@
export { default as Feedback } from './Feedback';
+4
View File
@@ -1 +1,5 @@
export { default as Settings } from './Settings';
export { default as SettingsLayout } from './SettingsLayout';
export * from './display';
export * from './feedback';
export * from './legal';
@@ -0,0 +1,9 @@
function Legal() {
return (
<div className="h-full flex flex-col p-4 gap-4">
<div className="flex flex-row justify-between items-center"></div>
</div>
);
}
export default Legal;
@@ -0,0 +1 @@
export { default as Legal } from './Legal';
+34 -2
View File
@@ -1,5 +1,15 @@
import { createBrowserRouter } from 'react-router-dom';
import { Error, Home, NavLayout, NodeLocation, Settings } from './pages';
import {
Display,
Error,
Feedback,
Home,
Legal,
NavLayout,
NodeLocation,
Settings,
SettingsLayout,
} from './pages';
import { routes } from './constants';
const router = createBrowserRouter([
@@ -14,8 +24,30 @@ const router = createBrowserRouter([
},
{
path: routes.settings,
element: <Settings />,
element: <SettingsLayout />,
errorElement: <Error />,
children: [
{
element: <Settings />,
errorElement: <Error />,
index: true,
},
{
path: routes.display,
element: <Display />,
errorElement: <Error />,
},
{
path: routes.feedback,
element: <Feedback />,
errorElement: <Error />,
},
{
path: routes.legal,
element: <Legal />,
errorElement: <Error />,
},
],
},
{
path: routes.entryNodeLocation,
+22
View File
@@ -19,9 +19,12 @@ export type StateAction =
| { type: 'new-progress-message'; message: ConnectProgressMsg }
| { type: 'connect' }
| { type: 'disconnect' }
| { type: 'set-version'; version: string }
| { type: 'set-connected'; startTime: number }
| { type: 'set-connection-start-time'; startTime?: number | null }
| { type: 'set-disconnected' }
| { type: 'set-auto-connect'; autoConnect: boolean }
| { type: 'set-monitoring'; monitoring: boolean }
| { type: 'reset' }
| { type: 'set-ui-theme'; theme: UiTheme }
| { type: 'set-countries'; countries: Country[] }
@@ -31,12 +34,15 @@ export type StateAction =
export const initialState: AppState = {
state: 'Disconnected',
version: null,
loading: false,
vpnMode: 'TwoHop',
entrySelector: false,
tunnel: { name: 'nym', id: 'nym' },
uiTheme: 'Light',
progressMessages: [],
autoConnect: false,
monitoring: false,
entryNodeLocation: null,
exitNodeLocation: null,
defaultNodeLocation: {
@@ -70,6 +76,16 @@ export function reducer(state: AppState, action: StateAction): AppState {
...state,
entrySelector: action.entrySelector,
};
case 'set-auto-connect':
return {
...state,
autoConnect: action.autoConnect,
};
case 'set-monitoring':
return {
...state,
monitoring: action.monitoring,
};
case 'set-countries':
return {
...state,
@@ -101,6 +117,11 @@ export function reducer(state: AppState, action: StateAction): AppState {
case 'disconnect': {
return { ...state, state: 'Disconnecting', loading: true };
}
case 'set-version':
return {
...state,
version: action.version,
};
case 'set-connected': {
console.log(
`__REDUCER [set-connected] changing connection state to Connected`,
@@ -152,6 +173,7 @@ export function reducer(state: AppState, action: StateAction): AppState {
...state,
rootFontSize: action.size,
};
case 'reset':
return initialState;
}
+14
View File
@@ -1,5 +1,6 @@
import React, { useEffect, useReducer } from 'react';
import { invoke } from '@tauri-apps/api';
import { getVersion } from '@tauri-apps/api/app';
import { MainDispatchContext, MainStateContext } from '../contexts';
import {
AppDataFromBackend,
@@ -41,6 +42,17 @@ export function MainStateProvider({ children }: Props) {
return await invoke<Country>('get_default_node_location');
};
getVersion()
.then((version) =>
dispatch({
type: 'set-version',
version,
}),
)
.catch((e) => {
console.warn(`command [set-version] returned an error: ${e}`);
});
getInitialConnectionState()
.then((state) => dispatch({ type: 'change-connection-state', state }))
.catch((e: CmdError) => {
@@ -99,6 +111,8 @@ export function MainStateProvider({ children }: Props) {
entrySelector: data.entry_location_selector || false,
uiTheme: data.ui_theme || 'Light',
vpnMode: data.vpn_mode || 'TwoHop',
autoConnect: data.autoconnect || false,
monitoring: data.monitoring || false,
rootFontSize: data.ui_root_font_size || DefaultRootFontSize,
};
if (data.entry_node_location) {
+3
View File
@@ -19,6 +19,7 @@ export interface TunnelConfig {
export type AppState = {
state: ConnectionState;
version: string | null;
loading: boolean;
error?: string | null;
progressMessages: ConnectProgressMsg[];
@@ -27,6 +28,8 @@ export type AppState = {
tunnel: TunnelConfig;
uiTheme: 'Light' | 'Dark';
entrySelector: boolean;
autoConnect: boolean;
monitoring: boolean;
entryNodeLocation: Country | null;
exitNodeLocation: Country | null;
defaultNodeLocation: Country;
+28
View File
@@ -45,6 +45,34 @@ export default function TopBar() {
navigate(-1);
},
},
'/settings/display': {
title: t('display-theme'),
leftIcon: 'arrow_back',
handleLeftNav: () => {
navigate(-1);
},
},
'/settings/logs': {
title: t('logs'),
leftIcon: 'arrow_back',
handleLeftNav: () => {
navigate(-1);
},
},
'/settings/feedback': {
title: t('feedback'),
leftIcon: 'arrow_back',
handleLeftNav: () => {
navigate(-1);
},
},
'/settings/legal': {
title: t('legal'),
leftIcon: 'arrow_back',
handleLeftNav: () => {
navigate(-1);
},
},
'/entry-node-location': {
title: t('first-hop-selection'),
leftIcon: 'arrow_back',
+1
View File
@@ -34,6 +34,7 @@ export default {
cornflower: '#7075FF',
// [DL] error status text
teaberry: '#E33B5A',
comet: '#625B71',
// [DL] "Connected" status text
'vert-menthe': '#2BC761',
// [D] main titles text