diff --git a/packages/desktop-client/src/components/manager/ConfigServer.tsx b/packages/desktop-client/src/components/manager/ConfigServer.tsx index 4af6883c08..fdda092f3b 100644 --- a/packages/desktop-client/src/components/manager/ConfigServer.tsx +++ b/packages/desktop-client/src/components/manager/ConfigServer.tsx @@ -4,10 +4,12 @@ import { Trans, useTranslation } from 'react-i18next'; import { Button, ButtonWithLoading } from '@actual-app/components/button'; import { BigInput } from '@actual-app/components/input'; +import { Label } from '@actual-app/components/label'; import { Text } from '@actual-app/components/text'; import { theme } from '@actual-app/components/theme'; import { View } from '@actual-app/components/view'; +import { saveGlobalPrefs } from 'loot-core/client/prefs/prefsSlice'; import { loggedIn, signOut } from 'loot-core/client/users/usersSlice'; import { isNonProductionEnvironment, @@ -24,6 +26,241 @@ import { Title } from './subscribe/common'; import { useGlobalPref } from '@desktop-client/hooks/useGlobalPref'; import { useNavigate } from '@desktop-client/hooks/useNavigate'; +export function ElectronServerConfig({ + onDoNotUseServer, + onSetServerConfigView, +}: { + onDoNotUseServer: () => void; + onSetServerConfigView: (view: 'internal' | 'external') => void; +}) { + const { t } = useTranslation(); + const navigate = useNavigate(); + const setServerUrl = useSetServerURL(); + const currentUrl = useServerURL(); + const dispatch = useDispatch(); + + const [syncServerConfig, setSyncServerConfig] = + useGlobalPref('syncServerConfig'); + + const [electronServerPort, setElectronServerPort] = useState( + syncServerConfig?.port || 5007, + ); + const [configError, setConfigError] = useState(null); + + const canShowExternalServerConfig = !syncServerConfig?.port && !currentUrl; + const hasInternalServerConfig = syncServerConfig?.port; + + const [startingSyncServer, setStartingSyncServer] = useState(false); + + const onConfigureSyncServer = async () => { + if ( + isNaN(electronServerPort) || + electronServerPort <= 0 || + electronServerPort > 65535 + ) { + setConfigError('Ports must be within range 1 - 65535'); + return; + } + + try { + setConfigError(null); + setStartingSyncServer(true); + // Ensure config is saved before starting the server + await dispatch( + saveGlobalPrefs({ + prefs: { + syncServerConfig: { + ...syncServerConfig, + port: electronServerPort, + autoStart: true, + }, + }, + }), + ).unwrap(); + + await window.globalThis.Actual.stopSyncServer(); + await window.globalThis.Actual.startSyncServer(); + setStartingSyncServer(false); + initElectronSyncServerRunningStatus(); + await setServerUrl(`http://localhost:${electronServerPort}`); + navigate('/'); + } catch (error) { + setStartingSyncServer(false); + setConfigError('Failed to configure sync server'); + console.error('Failed to configure sync server:', error); + } + }; + + const [electronSyncServerRunning, setElectronSyncServerRunning] = + useState(false); + + const initElectronSyncServerRunningStatus = async () => { + setElectronSyncServerRunning( + await window.globalThis.Actual.isSyncServerRunning(), + ); + }; + + useEffect(() => { + initElectronSyncServerRunningStatus(); + }, []); + + async function dontUseSyncServer() { + setSyncServerConfig(null); + + if (electronSyncServerRunning) { + await window.globalThis.Actual.stopSyncServer(); + } + + onDoNotUseServer(); + } + + return ( + <> + + <View + style={{ + display: 'flex', + flexDirection: 'column', + gap: 20, + }} + > + <Text + style={{ + fontSize: 16, + color: theme.pageText, + lineHeight: 1.5, + }} + > + <Trans> + Configure your local server below to allow seamless data + synchronization across your devices, bank sync and more... + </Trans> + </Text> + + {configError && ( + <Text style={{ color: theme.errorText, marginTop: 10 }}> + {configError} + </Text> + )} + + <View + style={{ + display: 'flex', + flexDirection: 'row', + gap: 10, + }} + > + <View style={{ flexDirection: 'column', gap: 5, flex: 1 }}> + <Label title={t('Domain')} style={{ textAlign: 'left' }} /> + <BigInput + value="localhost" + disabled + type="text" + style={{ + '&::-webkit-outer-spin-button, &::-webkit-inner-spin-button': { + WebkitAppearance: 'none', + margin: 0, + }, + }} + /> + </View> + + <View style={{ flexDirection: 'column', gap: 5 }}> + <Label + title={t('Port')} + style={{ textAlign: 'left', width: '7ch' }} + /> + <BigInput + name="port" + value={String(electronServerPort)} + aria-label={t('Port')} + type="number" + style={{ + '&::-webkit-outer-spin-button, &::-webkit-inner-spin-button': { + WebkitAppearance: 'none', + margin: 0, + }, + width: '7ch', + textAlign: 'center', + }} + autoFocus={true} + maxLength={5} + onChange={event => + setElectronServerPort(Number(event.target.value)) + } + /> + </View> + + <View + style={{ + flexDirection: 'column', + gap: 5, + justifyContent: 'end', + }} + > + <Label title={t('')} style={{ textAlign: 'left', width: '7ch' }} /> + {!electronSyncServerRunning ? ( + <Button + variant="primary" + style={{ padding: 10, width: '8ch' }} + onPress={onConfigureSyncServer} + isPending={startingSyncServer} + > + <Trans>Start</Trans> + </Button> + ) : ( + <Button + variant="primary" + style={{ padding: 10, width: '8ch' }} + onPress={onConfigureSyncServer} + isPending={startingSyncServer} + > + <Trans>Save</Trans> + </Button> + )} + </View> + </View> + </View> + + <View + style={{ + flexDirection: 'row', + marginTop: 20, + gap: 15, + flexFlow: 'row wrap', + justifyContent: 'center', + }} + > + {hasInternalServerConfig && ( + <Button + variant="bare" + style={{ color: theme.pageTextLight, margin: 5 }} + onPress={() => navigate(-1)} + > + <Trans>Cancel</Trans> + </Button> + )} + <Button + variant="bare" + style={{ color: theme.pageTextLight, margin: 5 }} + onPress={dontUseSyncServer} + > + <Trans>Don’t use a server</Trans> + </Button> + {canShowExternalServerConfig && ( + <Button + variant="bare" + style={{ color: theme.pageTextLight, margin: 5 }} + onPress={() => onSetServerConfigView('external')} + > + <Trans>Use an external server</Trans> + </Button> + )} + </View> + </> + ); +} + export function ConfigServer() { const { t } = useTranslation(); const dispatch = useDispatch(); @@ -61,7 +298,7 @@ export function ConfigServer() { } async function onSubmit() { - if (url === '' || loading) { + if (url === null || url === '' || loading) { return; } @@ -118,153 +355,179 @@ export function ConfigServer() { navigate('/'); } + const [syncServerConfig] = useGlobalPref('syncServerConfig'); + + const hasExternalServerConfig = !syncServerConfig?.port && !!currentUrl; + + const [serverConfigView, onSetServerConfigView] = useState< + 'internal' | 'external' + >(() => { + if (isElectron() && !hasExternalServerConfig) { + return 'internal'; + } + + return 'external'; + }); + return ( <View style={{ maxWidth: 500, marginTop: -30 }}> - <Title text={t('Where’s the server?')} /> - - <Text - style={{ - fontSize: 16, - color: theme.tableRowHeaderText, - lineHeight: 1.5, - }} - > - {currentUrl ? ( - <Trans> - Existing sessions will be logged out and you will log in to this - server. We will validate that Actual is running at this URL. - </Trans> - ) : ( - <Trans> - There is no server configured. After running the server, specify the - URL here to use the app. You can always change this later. We will - validate that Actual is running at this URL. - </Trans> - )} - </Text> - - {error && ( + {serverConfigView === 'internal' && ( + <ElectronServerConfig + onDoNotUseServer={onSkip} + onSetServerConfigView={onSetServerConfigView} + /> + )} + {serverConfigView === 'external' && ( <> + <Title text={t('Where’s the server?')} /> <Text style={{ - marginTop: 20, - color: theme.errorText, - borderRadius: 4, - fontSize: 15, + fontSize: 16, + color: theme.tableRowHeaderText, + lineHeight: 1.5, }} > - {getErrorMessage(error)} + {currentUrl ? ( + <Trans> + Existing sessions will be logged out and you will log in to this + server. We will validate that Actual is running at this URL. + </Trans> + ) : ( + <Trans> + There is no server configured. After running the server, specify + the URL here to use the app. You can always change this later. + We will validate that Actual is running at this URL. + </Trans> + )} </Text> - {isElectron() && ( - <View - style={{ display: 'flex', flexDirection: 'row', marginTop: 20 }} - > + {error && ( + <> <Text style={{ + marginTop: 20, color: theme.errorText, borderRadius: 4, fontSize: 15, }} > - <Trans> - If the server is using a self-signed certificate{' '} - <Link - variant="text" - style={{ fontSize: 15 }} - onClick={onSelectSelfSignedCertificate} - > - select it here - </Link> - . - </Trans> + {getErrorMessage(error)} </Text> - </View> + {isElectron() && ( + <View + style={{ + display: 'flex', + flexDirection: 'row', + marginTop: 20, + }} + > + <Text + style={{ + color: theme.errorText, + borderRadius: 4, + fontSize: 15, + }} + > + <Trans> + If the server is using a self-signed certificate{' '} + <Link + variant="text" + style={{ fontSize: 15 }} + onClick={onSelectSelfSignedCertificate} + > + select it here + </Link> + . + </Trans> + </Text> + </View> + )} + </> )} - </> - )} - - <View style={{ display: 'flex', flexDirection: 'row', marginTop: 30 }}> - <BigInput - autoFocus={true} - placeholder={t('https://example.com')} - value={url || ''} - onChangeValue={setUrl} - style={{ flex: 1, marginRight: 10 }} - onEnter={onSubmit} - /> - <ButtonWithLoading - variant="primary" - isLoading={loading} - style={{ fontSize: 15 }} - onPress={onSubmit} - > - {t('OK')} - </ButtonWithLoading> - {currentUrl && ( - <Button - variant="bare" - style={{ fontSize: 15, marginLeft: 10 }} - onPress={() => navigate(-1)} + <View + style={{ display: 'flex', flexDirection: 'row', marginTop: 30 }} > - {t('Cancel')} - </Button> - )} - </View> - - <View - style={{ - flexDirection: 'row', - flexFlow: 'row wrap', - justifyContent: 'center', - marginTop: 15, - }} - > - {currentUrl ? ( - <Button - variant="bare" - style={{ color: theme.pageTextLight }} - onPress={onSkip} - > - {t('Stop using a server')} - </Button> - ) : ( - <> - {!isElectron() && ( + <BigInput + autoFocus={true} + placeholder={t('https://example.com')} + value={url || ''} + onChangeValue={setUrl} + style={{ flex: 1, marginRight: 10 }} + onEnter={onSubmit} + /> + <ButtonWithLoading + variant="primary" + isLoading={loading} + style={{ fontSize: 15 }} + onPress={onSubmit} + > + {t('OK')} + </ButtonWithLoading> + {currentUrl && ( <Button variant="bare" - style={{ - color: theme.pageTextLight, - margin: 5, - marginRight: 15, - }} - onPress={onSameDomain} + style={{ fontSize: 15, marginLeft: 10 }} + onPress={() => navigate(-1)} > - {t('Use current domain')} + {t('Cancel')} </Button> )} - <Button - variant="bare" - style={{ color: theme.pageTextLight, margin: 5 }} - onPress={onSkip} - > - {t('Don’t use a server')} - </Button> - - {isNonProductionEnvironment() && ( + </View> + <View + style={{ + flexDirection: 'row', + flexFlow: 'row wrap', + justifyContent: 'center', + marginTop: 15, + }} + > + {currentUrl ? ( <Button - variant="primary" - style={{ marginLeft: 15 }} - onPress={async () => { - await onCreateTestFile(); - navigate('/'); - }} + variant="bare" + style={{ color: theme.pageTextLight }} + onPress={onSkip} > - {t('Create test file')} + {t('Stop using a server')} </Button> + ) : ( + <> + {!isElectron() && ( + <Button + variant="bare" + style={{ + color: theme.pageTextLight, + margin: 5, + marginRight: 15, + }} + onPress={onSameDomain} + > + {t('Use current domain')} + </Button> + )} + <Button + variant="bare" + style={{ color: theme.pageTextLight, margin: 5 }} + onPress={onSkip} + > + {t('Don’t use a server')} + </Button> + + {isNonProductionEnvironment() && ( + <Button + variant="primary" + style={{ marginLeft: 15 }} + onPress={async () => { + await onCreateTestFile(); + navigate('/'); + }} + > + {t('Create test file')} + </Button> + )} + </> )} - </> - )} - </View> + </View> + </> + )} </View> ); } diff --git a/packages/desktop-electron/e2e/__screenshots__/onboarding.test.ts/Onboarding-checks-the-page-visuals-1-linux.png b/packages/desktop-electron/e2e/__screenshots__/onboarding.test.ts/Onboarding-checks-the-page-visuals-1-linux.png index 3463c27dd0..75e1e49734 100644 Binary files a/packages/desktop-electron/e2e/__screenshots__/onboarding.test.ts/Onboarding-checks-the-page-visuals-1-linux.png and b/packages/desktop-electron/e2e/__screenshots__/onboarding.test.ts/Onboarding-checks-the-page-visuals-1-linux.png differ diff --git a/packages/desktop-electron/index.ts b/packages/desktop-electron/index.ts index bc637fc01f..4987b90dad 100644 --- a/packages/desktop-electron/index.ts +++ b/packages/desktop-electron/index.ts @@ -207,6 +207,14 @@ async function createBackgroundProcess() { async function startSyncServer() { try { + if (syncServerProcess) { + logMessage( + 'info', + 'Sync-Server: Already started! Ignoring request to start.', + ); + return; + } + const globalPrefs = await loadGlobalPrefs(); const syncServerConfig = { diff --git a/upcoming-release-notes/4870.md b/upcoming-release-notes/4870.md new file mode 100644 index 0000000000..7898532979 --- /dev/null +++ b/upcoming-release-notes/4870.md @@ -0,0 +1,6 @@ +--- +category: Features +authors: [MikesGlitch] +--- + +Desktop app Sync Server server configuration screen