diff --git a/e2e/playwright/flow-tests.spec.ts b/e2e/playwright/flow-tests.spec.ts index be6da17e7..c2c465bc9 100644 --- a/e2e/playwright/flow-tests.spec.ts +++ b/e2e/playwright/flow-tests.spec.ts @@ -3,6 +3,7 @@ import { secrets } from './secrets' import { getUtils } from './test-utils' import waitOn from 'wait-on' import { Themes } from '../../src/lib/theme' +import { initialSettings } from '../../src/lib/settings' /* debug helper: unfortunately we do rely on exact coord mouse clicks in a few places @@ -386,6 +387,53 @@ test('Auto complete works', async ({ page }) => { |> xLine(5, %)`) }) +// Stored settings validation test +test('Stored settings are validated and fall back to defaults', async ({ + page, + context, +}) => { + // Override beforeEach test setup + // with corrupted settings + await context.addInitScript(async () => { + const storedSettings = JSON.parse( + localStorage.getItem('SETTINGS_PERSIST_KEY') || '{}' + ) + + // Corrupt the settings + storedSettings.baseUnit = 'invalid' + storedSettings.cameraControls = `() => alert('hack the planet')` + storedSettings.defaultDirectory = 123 + storedSettings.defaultProjectName = false + + localStorage.setItem('SETTINGS_PERSIST_KEY', JSON.stringify(storedSettings)) + }) + + await page.setViewportSize({ width: 1200, height: 500 }) + await page.goto('/', { waitUntil: 'domcontentloaded' }) + + // Check the toast appeared + await expect( + page.getByText(`Error validating persisted settings:`, { exact: false }) + ).toBeVisible() + + // Check the settings were reset + const storedSettings = JSON.parse( + await page.evaluate( + () => localStorage.getItem('SETTINGS_PERSIST_KEY') || '{}' + ) + ) + await expect(storedSettings.baseUnit).toBe(initialSettings.baseUnit) + await expect(storedSettings.cameraControls).toBe( + initialSettings.cameraControls + ) + await expect(storedSettings.defaultDirectory).toBe( + initialSettings.defaultDirectory + ) + await expect(storedSettings.defaultProjectName).toBe( + initialSettings.defaultProjectName + ) +}) + // Onboarding tests test('Onboarding redirects and code updating', async ({ page, context }) => { const u = getUtils(page) diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index b80b0038e..c44c2213d 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -8,6 +8,7 @@ use std::io::Read; use anyhow::Result; use oauth2::TokenResponse; use tauri::{InvokeError, Manager}; +use std::process::Command; const DEFAULT_HOST: &str = "https://api.kittycad.io"; /// This command returns the a json string parse from a toml file at the path. @@ -142,6 +143,28 @@ async fn get_user( Ok(user_info) } +/// Open the selected path in the system file manager. +/// From this GitHub comment: https://github.com/tauri-apps/tauri/issues/4062#issuecomment-1338048169 +/// But with the Linux support removed since we don't need it for now. +#[tauri::command] +fn show_in_folder(path: String) { + #[cfg(target_os = "windows")] + { + Command::new("explorer") + .args(["/select,", &path]) // The comma after select is not a typo + .spawn() + .unwrap(); + } + + #[cfg(target_os = "macos")] + { + Command::new("open") + .args(["-R", &path]) + .spawn() + .unwrap(); + } +} + fn main() { tauri::Builder::default() .setup(|_app| { @@ -158,7 +181,8 @@ fn main() { get_user, login, read_toml, - read_txt_file + read_txt_file, + show_in_folder, ]) .plugin(tauri_plugin_fs_extra::init()) .run(tauri::generate_context!()) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index c2f521662..50a39d2f5 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -23,7 +23,8 @@ "fs": { "scope": [ "$HOME/**/*", - "$APPDATA/**/*" + "$APPDATA/**/*", + "$DOCUMENT/**/*" ], "all": true }, @@ -60,7 +61,7 @@ "icons/icon.icns", "icons/icon.ico" ], - "identifier": "io.kittycad.modeling-app", + "identifier": "zoo-modeling-app", "longDescription": "", "macOS": { "entitlements": null, diff --git a/src/App.tsx b/src/App.tsx index 344f1edc4..b9e5f5be6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -22,7 +22,7 @@ import { getNormalisedCoordinates } from './lib/utils' import { useLoaderData, useNavigate } from 'react-router-dom' import { type IndexLoaderData } from 'lib/types' import { paths } from 'lib/paths' -import { useGlobalStateContext } from 'hooks/useGlobalStateContext' +import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' import { onboardingPaths } from 'routes/Onboarding/paths' import { CodeMenu } from 'components/CodeMenu' import { TextEditor } from 'components/TextEditor' @@ -53,7 +53,7 @@ export function App() { streamDimensions: s.streamDimensions, })) - const { settings } = useGlobalStateContext() + const { settings } = useSettingsAuthContext() const { showDebugPanel, onboardingStatus, theme } = settings?.context || {} const { state, send } = useModelingContext() diff --git a/src/Auth.tsx b/src/Auth.tsx index 8b1b188a9..19260e605 100644 --- a/src/Auth.tsx +++ b/src/Auth.tsx @@ -1,9 +1,9 @@ import Loading from './components/Loading' -import { useGlobalStateContext } from 'hooks/useGlobalStateContext' +import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' // Wrapper around protected routes, used in src/Router.tsx export const Auth = ({ children }: React.PropsWithChildren) => { - const { auth } = useGlobalStateContext() + const { auth } = useSettingsAuthContext() const isLoggingIn = auth?.state.matches('checkIfLoggedIn') return isLoggingIn ? ( diff --git a/src/Router.tsx b/src/Router.tsx index 4b593401d..ce2e7faa8 100644 --- a/src/Router.tsx +++ b/src/Router.tsx @@ -29,11 +29,9 @@ import { import { metadata } from 'tauri-plugin-fs-extra-api' import DownloadAppBanner from './components/DownloadAppBanner' import { WasmErrBanner } from './components/WasmErrBanner' -import { GlobalStateProvider } from './components/GlobalStateProvider' -import { - SETTINGS_PERSIST_KEY, - settingsMachine, -} from './machines/settingsMachine' +import { SettingsAuthStateProvider } from './components/SettingsAuthStateProvider' +import { settingsMachine } from './machines/settingsMachine' +import { SETTINGS_PERSIST_KEY } from 'lib/settings' import { ContextFrom } from 'xstate' import CommandBarProvider from 'components/CommandBar/CommandBar' import { TEST, VITE_KC_SENTRY_DSN } from './env' @@ -91,7 +89,9 @@ const addGlobalContextToElements = ( ...route, element: ( - {route.element} + + {route.element} + ), } @@ -229,32 +229,42 @@ const router = createBrowserRouter( const projectDir = await initializeProjectDirectory( persistedSettings.defaultDirectory || '' ) - let newDefaultDirectory: string | undefined = undefined - if (projectDir !== persistedSettings.defaultDirectory) { - localStorage.setItem( - SETTINGS_PERSIST_KEY, - JSON.stringify({ - ...persistedSettings, - defaultDirectory: projectDir, - }) - ) - newDefaultDirectory = projectDir - } - const projectsNoMeta = (await readDir(projectDir)).filter( - isProjectDirectory - ) - const projects = await Promise.all( - projectsNoMeta.map(async (p: FileEntry) => ({ - entrypointMetadata: await metadata( - p.path + sep + PROJECT_ENTRYPOINT - ), - ...p, - })) - ) - return { - projects, - newDefaultDirectory, + let newDefaultDirectory: string | undefined = undefined + if (projectDir.path) { + if (projectDir.path !== persistedSettings.defaultDirectory) { + localStorage.setItem( + SETTINGS_PERSIST_KEY, + JSON.stringify({ + ...persistedSettings, + defaultDirectory: projectDir, + }) + ) + newDefaultDirectory = projectDir.path + } + const projectsNoMeta = (await readDir(projectDir.path)).filter( + isProjectDirectory + ) + const projects = await Promise.all( + projectsNoMeta.map(async (p: FileEntry) => ({ + entrypointMetadata: await metadata( + p.path + sep + PROJECT_ENTRYPOINT + ), + ...p, + })) + ) + + return { + projects, + newDefaultDirectory, + error: projectDir.error, + } + } else { + return { + projects: [], + newDefaultDirectory, + error: projectDir.error, + } } }, children: [ diff --git a/src/clientSideScene/ClientSideSceneComp.tsx b/src/clientSideScene/ClientSideSceneComp.tsx index 7325336aa..c09feaeed 100644 --- a/src/clientSideScene/ClientSideSceneComp.tsx +++ b/src/clientSideScene/ClientSideSceneComp.tsx @@ -2,7 +2,7 @@ import { useRef, useEffect, useState } from 'react' import { useModelingContext } from 'hooks/useModelingContext' import { cameraMouseDragGuards } from 'lib/cameraControls' -import { useGlobalStateContext } from 'hooks/useGlobalStateContext' +import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' import { useStore } from 'useStore' import { DEBUG_SHOW_BOTH_SCENES, @@ -38,7 +38,7 @@ export const ClientSideScene = ({ cameraControls, }: { cameraControls: ReturnType< - typeof useGlobalStateContext + typeof useSettingsAuthContext >['settings']['context']['cameraControls'] }) => { const canvasRef = useRef(null) diff --git a/src/components/AppHeader.tsx b/src/components/AppHeader.tsx index 5f1b663b6..275c4aea3 100644 --- a/src/components/AppHeader.tsx +++ b/src/components/AppHeader.tsx @@ -2,7 +2,7 @@ import { Toolbar } from '../Toolbar' import UserSidebarMenu from './UserSidebarMenu' import { type IndexLoaderData } from 'lib/types' import ProjectSidebarMenu from './ProjectSidebarMenu' -import { useGlobalStateContext } from 'hooks/useGlobalStateContext' +import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' import styles from './AppHeader.module.css' import { NetworkHealthIndicator } from './NetworkHealthIndicator' import { useCommandsContext } from 'hooks/useCommandsContext' @@ -25,7 +25,7 @@ export const AppHeader = ({ }: AppHeaderProps) => { const platform = usePlatform() const { commandBarSend } = useCommandsContext() - const { auth } = useGlobalStateContext() + const { auth } = useSettingsAuthContext() const user = auth?.context?.user return ( diff --git a/src/components/ExportButton.tsx b/src/components/ExportButton.tsx index 581ccbb99..d577f5929 100644 --- a/src/components/ExportButton.tsx +++ b/src/components/ExportButton.tsx @@ -6,7 +6,7 @@ import React from 'react' import { useFormik } from 'formik' import { Models } from '@kittycad/lib' import { engineCommandManager } from '../lang/std/engineConnection' -import { useGlobalStateContext } from 'hooks/useGlobalStateContext' +import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' type OutputFormat = Models['OutputFormat_type'] type OutputTypeKey = OutputFormat['type'] @@ -29,7 +29,7 @@ export const ExportButton = ({ children, className }: ExportButtonProps) => { context: { baseUnit }, }, }, - } = useGlobalStateContext() + } = useSettingsAuthContext() const defaultType = 'gltf' const [type, setType] = React.useState(defaultType) diff --git a/src/components/ModelingMachineProvider.tsx b/src/components/ModelingMachineProvider.tsx index 70ff6b94e..87ea7313a 100644 --- a/src/components/ModelingMachineProvider.tsx +++ b/src/components/ModelingMachineProvider.tsx @@ -10,7 +10,7 @@ import { } from 'xstate' import { SetSelections, modelingMachine } from 'machines/modelingMachine' import { useSetupEngineManager } from 'hooks/useSetupEngineManager' -import { useGlobalStateContext } from 'hooks/useGlobalStateContext' +import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' import { isCursorInSketchCommandRange } from 'lang/util' import { engineCommandManager } from 'lang/std/engineConnection' import { kclManager, useKclContext } from 'lang/KclSingleton' @@ -53,7 +53,7 @@ export const ModelingMachineProvider = ({ }: { children: React.ReactNode }) => { - const { auth } = useGlobalStateContext() + const { auth } = useSettingsAuthContext() const { code } = useKclContext() const token = auth?.context?.token const streamRef = useRef(null) diff --git a/src/components/NetworkHealthIndicator.test.tsx b/src/components/NetworkHealthIndicator.test.tsx index 40937ecb8..c08c2d1d9 100644 --- a/src/components/NetworkHealthIndicator.test.tsx +++ b/src/components/NetworkHealthIndicator.test.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, screen } from '@testing-library/react' import { BrowserRouter } from 'react-router-dom' -import { GlobalStateProvider } from './GlobalStateProvider' +import { SettingsAuthStateProvider } from './SettingsAuthStateProvider' import CommandBarProvider from './CommandBar/CommandBar' import { NETWORK_HEALTH_TEXT, @@ -13,7 +13,7 @@ function TestWrap({ children }: { children: React.ReactNode }) { return ( - {children} + {children} ) diff --git a/src/components/ProjectSidebarMenu.test.tsx b/src/components/ProjectSidebarMenu.test.tsx index 6aef589c0..e95ed079f 100644 --- a/src/components/ProjectSidebarMenu.test.tsx +++ b/src/components/ProjectSidebarMenu.test.tsx @@ -2,7 +2,7 @@ import { fireEvent, render, screen } from '@testing-library/react' import { BrowserRouter } from 'react-router-dom' import ProjectSidebarMenu from './ProjectSidebarMenu' import { type ProjectWithEntryPointMetadata } from 'lib/types' -import { GlobalStateProvider } from './GlobalStateProvider' +import { SettingsAuthStateProvider } from './SettingsAuthStateProvider' import CommandBarProvider from './CommandBar/CommandBar' import { APP_NAME } from 'lib/constants' @@ -42,9 +42,9 @@ describe('ProjectSidebarMenu tests', () => { render( - + - + ) @@ -63,9 +63,9 @@ describe('ProjectSidebarMenu tests', () => { render( - + - + ) @@ -79,12 +79,12 @@ describe('ProjectSidebarMenu tests', () => { render( - + - + ) diff --git a/src/components/GlobalStateProvider.tsx b/src/components/SettingsAuthStateProvider.tsx similarity index 56% rename from src/components/GlobalStateProvider.tsx rename to src/components/SettingsAuthStateProvider.tsx index 10724241e..b38a8a306 100644 --- a/src/components/GlobalStateProvider.tsx +++ b/src/components/SettingsAuthStateProvider.tsx @@ -5,7 +5,12 @@ import { authMachine, TOKEN_PERSIST_KEY } from '../machines/authMachine' import withBaseUrl from '../lib/withBaseURL' import React, { createContext, useEffect, useRef } from 'react' import useStateMachineCommands from '../hooks/useStateMachineCommands' -import { SETTINGS_PERSIST_KEY, settingsMachine } from 'machines/settingsMachine' +import { settingsMachine } from 'machines/settingsMachine' +import { + initialSettings, + SETTINGS_PERSIST_KEY, + validateSettings, +} from 'lib/settings' import { toast } from 'react-hot-toast' import { setThemeClass, Themes } from 'lib/theme' import { @@ -18,6 +23,7 @@ import { import { isTauri } from 'lib/isTauri' import { settingsCommandBarConfig } from 'lib/commandBarConfigs/settingsCommandConfig' import { authCommandBarConfig } from 'lib/commandBarConfigs/authCommandConfig' +import { initializeProjectDirectory, readSettingsFile } from 'lib/tauriFS' type MachineContext = { state: StateFrom @@ -25,14 +31,14 @@ type MachineContext = { send: Prop, 'send'> } -type GlobalContext = { +type SettingsAuthContext = { auth: MachineContext settings: MachineContext } -export const GlobalStateContext = createContext({} as GlobalContext) +export const SettingsAuthStateContext = createContext({} as SettingsAuthContext) -export const GlobalStateProvider = ({ +export const SettingsAuthStateProvider = ({ children, }: { children: React.ReactNode @@ -40,14 +46,17 @@ export const GlobalStateProvider = ({ const navigate = useNavigate() // Settings machine setup + // Load settings from local storage + // and validate them const retrievedSettings = useRef( - localStorage?.getItem(SETTINGS_PERSIST_KEY) || '{}' + validateSettings( + JSON.parse(localStorage?.getItem(SETTINGS_PERSIST_KEY) || '{}') + ) ) const persistedSettings = Object.assign( - settingsMachine.initialState.context, - JSON.parse(retrievedSettings.current) as Partial< - (typeof settingsMachine)['context'] - > + {}, + initialSettings, + retrievedSettings.current.settings ) const [settingsState, settingsSend] = useMachine(settingsMachine, { @@ -72,6 +81,75 @@ export const GlobalStateProvider = ({ }, }) + // If the app is running in the Tauri context, + // try to read the settings from a file + // after doing some validation on them + useEffect(() => { + async function getFileBasedSettings() { + if (isTauri()) { + const newSettings = await readSettingsFile() + + if (newSettings) { + if (newSettings.defaultDirectory) { + const newDefaultDirectory = await initializeProjectDirectory( + newSettings.defaultDirectory || '' + ) + if (newDefaultDirectory.error !== null) { + toast.error(newDefaultDirectory.error.message) + } + + if (newDefaultDirectory.path !== null) { + newSettings.defaultDirectory = newDefaultDirectory.path + } + } + const { settings: validatedSettings, errors: validationErrors } = + validateSettings(newSettings) + + retrievedSettings.current = Object.assign( + {}, + initialSettings, + retrievedSettings.current, + validatedSettings + ) + + settingsSend({ + type: 'Set All Settings', + data: validatedSettings, + }) + + return validationErrors + } + } else { + // If the app is not running in the Tauri context, + // just use the settings from local storage + // after they've been validated to ensure they are correct. + settingsSend({ + type: 'Set All Settings', + data: retrievedSettings.current.settings, + }) + } + return [] + } + + // If there were validation errors either from local storage or from the file, + // log them to the console and show a toast message to the user. + void getFileBasedSettings().then((validationErrors: string[]) => { + const combinedErrors = new Set([ + ...retrievedSettings.current.errors, + ...validationErrors, + ]) + + if (combinedErrors.size > 0) { + const errorMessage = + 'Error validating persisted settings: ' + + Array.from(combinedErrors).join(', ') + + '. Using defaults.' + console.error(errorMessage) + toast.error(errorMessage) + } + }) + }, [settingsSend]) + useStateMachineCommands({ machineId: 'settings', state: settingsState, @@ -119,7 +197,7 @@ export const GlobalStateProvider = ({ }) return ( - {children} - + ) } -export default GlobalStateProvider +export default SettingsAuthStateProvider export function logout() { localStorage.removeItem(TOKEN_PERSIST_KEY) diff --git a/src/components/Stream.tsx b/src/components/Stream.tsx index b934b598c..e4c00edbe 100644 --- a/src/components/Stream.tsx +++ b/src/components/Stream.tsx @@ -10,7 +10,7 @@ import { useStore } from '../useStore' import { getNormalisedCoordinates, throttle } from '../lib/utils' import Loading from './Loading' import { cameraMouseDragGuards } from 'lib/cameraControls' -import { useGlobalStateContext } from 'hooks/useGlobalStateContext' +import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' import { Models } from '@kittycad/lib' import { engineCommandManager } from '../lang/std/engineConnection' import { useModelingContext } from 'hooks/useModelingContext' @@ -34,7 +34,7 @@ export const Stream = ({ className = '' }: { className?: string }) => { setDidDragInStream: s.setDidDragInStream, streamDimensions: s.streamDimensions, })) - const { settings } = useGlobalStateContext() + const { settings } = useSettingsAuthContext() const cameraControls = settings?.context?.cameraControls const { state } = useModelingContext() const { isExecuting } = useKclContext() diff --git a/src/components/TextEditor.tsx b/src/components/TextEditor.tsx index f630246df..bae9bfcbc 100644 --- a/src/components/TextEditor.tsx +++ b/src/components/TextEditor.tsx @@ -8,7 +8,7 @@ import Server from '../editor/lsp/server' import Client from '../editor/lsp/client' import { TEST } from 'env' import { useCommandsContext } from 'hooks/useCommandsContext' -import { useGlobalStateContext } from 'hooks/useGlobalStateContext' +import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' import { useConvertToVariable } from 'hooks/useToolbarGuards' import { Themes } from 'lib/theme' import { useMemo, useRef } from 'react' @@ -67,7 +67,7 @@ export const TextEditor = ({ } = useModelingContext() const { settings: { context: { textWrapping } = {} } = {} } = - useGlobalStateContext() + useSettingsAuthContext() const { commandBarSend } = useCommandsContext() const { enable: convertEnabled, handleClick: convertCallback } = useConvertToVariable() diff --git a/src/components/UserSidebarMenu.test.tsx b/src/components/UserSidebarMenu.test.tsx index 5c14046fb..d50e8893d 100644 --- a/src/components/UserSidebarMenu.test.tsx +++ b/src/components/UserSidebarMenu.test.tsx @@ -7,7 +7,7 @@ import { createRoutesFromElements, } from 'react-router-dom' import { Models } from '@kittycad/lib' -import { GlobalStateProvider } from './GlobalStateProvider' +import { SettingsAuthStateProvider } from './SettingsAuthStateProvider' import CommandBarProvider from './CommandBar/CommandBar' type User = Models['User_type'] @@ -107,7 +107,7 @@ function TestWrap({ children }: { children: React.ReactNode }) { path="/file/:id" element={ - {children} + {children} } /> diff --git a/src/components/UserSidebarMenu.tsx b/src/components/UserSidebarMenu.tsx index 5b170020f..266428ff6 100644 --- a/src/components/UserSidebarMenu.tsx +++ b/src/components/UserSidebarMenu.tsx @@ -6,7 +6,7 @@ import { useLocation, useNavigate } from 'react-router-dom' import { Fragment, useState } from 'react' import { paths } from 'lib/paths' import { Models } from '@kittycad/lib' -import { useGlobalStateContext } from 'hooks/useGlobalStateContext' +import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' import { useAbsoluteFilePath } from 'hooks/useAbsoluteFilePath' type User = Models['User_type'] @@ -17,7 +17,7 @@ const UserSidebarMenu = ({ user }: { user?: User }) => { const displayedName = getDisplayName(user) const [imageLoadFailed, setImageLoadFailed] = useState(false) const navigate = useNavigate() - const send = useGlobalStateContext()?.auth?.send + const send = useSettingsAuthContext()?.auth?.send // Fallback logic for displaying user's "name": // 1. user.name diff --git a/src/hooks/useGlobalStateContext.ts b/src/hooks/useGlobalStateContext.ts deleted file mode 100644 index b15a101a4..000000000 --- a/src/hooks/useGlobalStateContext.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { GlobalStateContext } from 'components/GlobalStateProvider' -import { useContext } from 'react' - -export const useGlobalStateContext = () => { - return useContext(GlobalStateContext) -} diff --git a/src/hooks/useSettingsAuthContext.ts b/src/hooks/useSettingsAuthContext.ts new file mode 100644 index 000000000..141737cf4 --- /dev/null +++ b/src/hooks/useSettingsAuthContext.ts @@ -0,0 +1,6 @@ +import { SettingsAuthStateContext } from 'components/SettingsAuthStateProvider' +import { useContext } from 'react' + +export const useSettingsAuthContext = () => { + return useContext(SettingsAuthStateContext) +} diff --git a/src/lib/commandBarConfigs/settingsCommandConfig.ts b/src/lib/commandBarConfigs/settingsCommandConfig.ts index 15b092f03..3a892ef84 100644 --- a/src/lib/commandBarConfigs/settingsCommandConfig.ts +++ b/src/lib/commandBarConfigs/settingsCommandConfig.ts @@ -1,11 +1,6 @@ import { CommandSetConfig } from '../commandTypes' -import { - BaseUnit, - Toggle, - UnitSystem, - baseUnitsUnion, - settingsMachine, -} from 'machines/settingsMachine' +import { BaseUnit, Toggle, UnitSystem, baseUnitsUnion } from 'lib/settings' +import { settingsMachine } from 'machines/settingsMachine' import { CameraSystem, cameraSystems } from '../cameraControls' import { Themes } from '../theme' diff --git a/src/lib/settings.ts b/src/lib/settings.ts new file mode 100644 index 000000000..91e912259 --- /dev/null +++ b/src/lib/settings.ts @@ -0,0 +1,95 @@ +import { type Models } from '@kittycad/lib' +import { CameraSystem, cameraSystems } from './cameraControls' +import { Themes } from './theme' + +export const DEFAULT_PROJECT_NAME = 'project-$nnn' +export const SETTINGS_PERSIST_KEY = 'SETTINGS_PERSIST_KEY' +export const SETTINGS_FILE_NAME = 'settings.json' + +export enum UnitSystem { + Imperial = 'imperial', + Metric = 'metric', +} + +export const baseUnits = { + imperial: ['in', 'ft', 'yd'], + metric: ['mm', 'cm', 'm'], +} as const + +export type BaseUnit = Models['UnitLength_type'] + +export const baseUnitsUnion = Object.values(baseUnits).flatMap((v) => v) + +export type Toggle = 'On' | 'Off' +const toggleAsArray = ['On', 'Off'] as const + +export type SettingsMachineContext = { + baseUnit: BaseUnit + cameraControls: CameraSystem + defaultDirectory: string + defaultProjectName: string + onboardingStatus: string + showDebugPanel: boolean + textWrapping: Toggle + theme: Themes + unitSystem: UnitSystem +} + +export const initialSettings: SettingsMachineContext = { + baseUnit: 'in' as BaseUnit, + cameraControls: 'KittyCAD' as CameraSystem, + defaultDirectory: '', + defaultProjectName: DEFAULT_PROJECT_NAME, + onboardingStatus: '', + showDebugPanel: false, + textWrapping: 'On' as Toggle, + theme: Themes.System, + unitSystem: UnitSystem.Imperial, +} + +function isEnumMember>(v: unknown, e: T) { + return Object.values(e).includes(v) +} + +const settingsValidators: Record< + keyof SettingsMachineContext, + (v: unknown) => boolean +> = { + baseUnit: (v) => baseUnitsUnion.includes(v as BaseUnit), + cameraControls: (v) => cameraSystems.includes(v as CameraSystem), + defaultDirectory: (v) => typeof v === 'string', + defaultProjectName: (v) => typeof v === 'string', + onboardingStatus: (v) => typeof v === 'string', + showDebugPanel: (v) => typeof v === 'boolean', + textWrapping: (v) => toggleAsArray.includes(v as Toggle), + theme: (v) => isEnumMember(v, Themes), + unitSystem: (v) => isEnumMember(v, UnitSystem), +} + +function removeInvalidSettingsKeys(s: Record) { + const validKeys = Object.keys(initialSettings) + for (const key in s) { + if (!validKeys.includes(key)) { + console.warn(`Invalid key found in settings: ${key}`) + delete s[key] + } + } + return s +} + +export function validateSettings(s: Record) { + let settingsNoInvalidKeys = removeInvalidSettingsKeys({ ...s }) + let errors: (keyof SettingsMachineContext)[] = [] + for (const key in settingsNoInvalidKeys) { + const k = key as keyof SettingsMachineContext + if (!settingsValidators[k](settingsNoInvalidKeys[k])) { + delete settingsNoInvalidKeys[k] + errors.push(k) + } + } + + return { + settings: settingsNoInvalidKeys as Partial, + errors, + } +} diff --git a/src/lib/tauriFS.ts b/src/lib/tauriFS.ts index 398e9b141..cd137dd69 100644 --- a/src/lib/tauriFS.ts +++ b/src/lib/tauriFS.ts @@ -3,12 +3,16 @@ import { createDir, exists, readDir, + readTextFile, writeTextFile, } from '@tauri-apps/api/fs' -import { documentDir, homeDir, sep } from '@tauri-apps/api/path' +import { appConfigDir, documentDir, homeDir, sep } from '@tauri-apps/api/path' import { isTauri } from './isTauri' import { type ProjectWithEntryPointMetadata } from 'lib/types' import { metadata } from 'tauri-plugin-fs-extra-api' +import { settingsMachine } from 'machines/settingsMachine' +import { ContextFrom } from 'xstate' +import { SETTINGS_FILE_NAME } from 'lib/settings' const PROJECT_FOLDER = 'zoo-modeling-app-projects' export const FILE_EXT = '.kcl' @@ -17,39 +21,101 @@ const INDEX_IDENTIFIER = '$n' // $nn.. will pad the number with 0s export const MAX_PADDING = 7 const RELEVANT_FILE_TYPES = ['kcl'] +type PathWithPossibleError = { + path: string | null + error: Error | null +} + +export async function getInitialDefaultDir() { + let dir + try { + dir = await documentDir() + } catch (e) { + dir = `${await homeDir()}Documents/` // for headless Linux (eg. Github Actions) + } + + return dir + PROJECT_FOLDER +} + // Initializes the project directory and returns the path -export async function initializeProjectDirectory(directory: string) { +// with any Errors that occurred +export async function initializeProjectDirectory( + directory: string +): Promise { if (!isTauri()) { throw new Error( 'initializeProjectDirectory() can only be called from a Tauri app' ) } + let returnValue: PathWithPossibleError = { + path: null, + error: null, + } + if (directory) { - const dirExists = await exists(directory) - if (!dirExists) { - await createDir(directory, { recursive: true }) + returnValue = await testAndCreateDir(directory, returnValue) + } + + // If the directory from settings does not exist or could not be created, + // use the default directory + if (returnValue.path === null) { + const INITIAL_DEFAULT_DIR = await getInitialDefaultDir() + const defaultReturnValue = await testAndCreateDir( + INITIAL_DEFAULT_DIR, + returnValue, + { + exists: 'Error checking default directory.', + create: 'Error creating default directory.', + } + ) + returnValue.path = defaultReturnValue.path + returnValue.error = + returnValue.error === null ? defaultReturnValue.error : returnValue.error + } + + return returnValue +} + +async function testAndCreateDir( + directory: string, + returnValue = { + path: null, + error: null, + } as PathWithPossibleError, + errorMessages = { + exists: + 'Error checking directory at path from saved settings. Using default.', + create: + 'Error creating directory at path from saved settings. Using default.', + } +): Promise { + const dirExists = await exists(directory).catch((e) => { + console.error(`Error checking directory ${directory}. Original error:`, e) + return new Error(errorMessages.exists) + }) + + if (dirExists instanceof Error) { + returnValue.error = dirExists + } else if (dirExists && dirExists === true) { + const newDirCreated = await createDir(directory, { recursive: true }).catch( + (e) => { + console.error( + `Error creating directory ${directory}. Original error:`, + e + ) + return new Error(errorMessages.create) + } + ) + + if (newDirCreated instanceof Error) { + returnValue.error = newDirCreated + } else { + returnValue.path = directory } - return directory } - let docDirectory: string - try { - docDirectory = await documentDir() - } catch (e) { - console.log('error', e) - docDirectory = `${await homeDir()}Documents/` // for headless Linux (eg. Github Actions) - } - - const INITIAL_DEFAULT_DIR = docDirectory + PROJECT_FOLDER - - const defaultDirExists = await exists(INITIAL_DEFAULT_DIR) - - if (!defaultDirExists) { - await createDir(INITIAL_DEFAULT_DIR, { recursive: true }) - } - - return INITIAL_DEFAULT_DIR + return returnValue } export function isProjectDirectory(fileOrDir: Partial) { @@ -300,3 +366,44 @@ function getPaddedIdentifierRegExp() { const escapedIdentifier = escapeRegExpChars(INDEX_IDENTIFIER) return new RegExp(`${escapedIdentifier}(${escapedIdentifier.slice(-1)}*)`) } + +export async function getSettingsFilePath() { + const dir = await appConfigDir() + return dir + SETTINGS_FILE_NAME +} + +export async function writeToSettingsFile( + settings: ContextFrom +) { + return writeTextFile( + await getSettingsFilePath(), + JSON.stringify(settings, null, 2) + ) +} + +export async function readSettingsFile(): Promise | null> { + const dir = await appConfigDir() + const path = dir + SETTINGS_FILE_NAME + const dirExists = await exists(dir) + if (!dirExists) { + await createDir(dir, { recursive: true }) + } + + const settingsExist = dirExists ? await exists(path) : false + + if (!settingsExist) { + console.log(`Settings file does not exist at ${path}`) + await writeToSettingsFile(settingsMachine.initialState.context) + return null + } + + try { + const settings = await readTextFile(path) + return JSON.parse(settings) + } catch (e) { + console.error('Error reading settings file:', e) + return null + } +} diff --git a/src/lib/types.ts b/src/lib/types.ts index ce323c06c..d351b4c7d 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -13,4 +13,5 @@ export type ProjectWithEntryPointMetadata = FileEntry & { export type HomeLoaderData = { projects: ProjectWithEntryPointMetadata[] newDefaultDirectory?: string + error: Error | null } diff --git a/src/machines/settingsMachine.ts b/src/machines/settingsMachine.ts index 08649aadc..dbb132c05 100644 --- a/src/machines/settingsMachine.ts +++ b/src/machines/settingsMachine.ts @@ -1,49 +1,43 @@ import { assign, createMachine } from 'xstate' import { Themes, getSystemTheme, setThemeClass } from '../lib/theme' import { CameraSystem } from 'lib/cameraControls' -import { Models } from '@kittycad/lib' - -export const DEFAULT_PROJECT_NAME = 'project-$nnn' - -export enum UnitSystem { - Imperial = 'imperial', - Metric = 'metric', -} - -export const baseUnits = { - imperial: ['in', 'ft', 'yd'], - metric: ['mm', 'cm', 'm'], -} as const - -export type BaseUnit = Models['UnitLength_type'] - -export const baseUnitsUnion = Object.values(baseUnits).flatMap((v) => v) - -export type Toggle = 'On' | 'Off' - -export const SETTINGS_PERSIST_KEY = 'SETTINGS_PERSIST_KEY' +import { isTauri } from 'lib/isTauri' +import { writeToSettingsFile } from 'lib/tauriFS' +import { + BaseUnit, + DEFAULT_PROJECT_NAME, + SETTINGS_PERSIST_KEY, + SettingsMachineContext, + Toggle, + UnitSystem, + initialSettings, +} from 'lib/settings' export const settingsMachine = createMachine( { /** @xstate-layout N4IgpgJg5mDOIC5QGUwBc0EsB2VYDpMIAbMAYlTQAIAVACzAFswBtABgF1FQAHAe1iYsfbNxAAPRAA42+AEwB2KQFYAzGznKAnADZli1QBoQAT2kBGKfm37lOned3nzqgL6vjlLLgJFSFdCoAETAAMwBDAFdiagAFACc+ACswAGNqADlw5nYuJBB+QWFRfMkEABY5fDYa2rra83LjMwQdLWV8BXLyuxlVLU1Ld090bzxCEnJKYLComODMeLS0PniTXLFCoUwRMTK7fC1zNql7NgUjtnKjU0RlBSqpLVUVPVUda60tYZAvHHG-FNAgBVbBCKjIEywNBMDb5LbFPaILqdfRSORsS4qcxXZqIHqyK6qY4XOxsGTKco-P4+Cb+aYAIXCsDAVFBQjhvAE212pWkskUKnUml0+gUNxaqkU+EccnKF1UCnucnMcjcHl+o3+vkmZBofCgUFIMwARpEoFRYuFsGBiJyCtzEXzWrJlGxlKdVFKvfY1XiEBjyvhVOVzBdzu13pYFNStbTAQFqAB5bAmvjheIQf4QtDhNCRWD2hE7EqgfayHTEh7lHQNSxSf1Scz4cpHHFyFVujTKczuDXYPgQOBiGl4TaOktIhAAWg6X3nC4Xp39050sYw2rpYHHRUnztVhPJqmUlIGbEriv9WhrLZ6uibHcqUr7riAA */ id: 'Settings', predictableActionArguments: true, - context: { - baseUnit: 'in' as BaseUnit, - cameraControls: 'KittyCAD' as CameraSystem, - defaultDirectory: '', - defaultProjectName: DEFAULT_PROJECT_NAME, - onboardingStatus: '', - showDebugPanel: false, - textWrapping: 'On' as Toggle, - theme: Themes.System, - unitSystem: UnitSystem.Imperial, - }, + context: { ...initialSettings }, initial: 'idle', states: { idle: { entry: ['setThemeClass'], on: { + 'Set All Settings': { + actions: [ + assign((context, event) => { + return { + ...context, + ...event.data, + } + }), + 'persistSettings', + 'setThemeClass', + ], + target: 'idle', + internal: true, + }, 'Set Base Unit': { actions: [ assign({ @@ -157,6 +151,7 @@ export const settingsMachine = createMachine( tsTypes: {} as import('./settingsMachine.typegen').Typegen0, schema: { events: {} as + | { type: 'Set All Settings'; data: Partial } | { type: 'Set Base Unit'; data: { baseUnit: BaseUnit } } | { type: 'Set Camera Controls' @@ -180,6 +175,11 @@ export const settingsMachine = createMachine( { actions: { persistSettings: (context) => { + if (isTauri()) { + writeToSettingsFile(context).catch((err) => { + console.error('Error writing settings:', err) + }) + } try { localStorage.setItem(SETTINGS_PERSIST_KEY, JSON.stringify(context)) } catch (e) { diff --git a/src/routes/Home.tsx b/src/routes/Home.tsx index 5190f6088..227275665 100644 --- a/src/routes/Home.tsx +++ b/src/routes/Home.tsx @@ -29,9 +29,9 @@ import { getSortIcon, } from '../lib/sorting' import useStateMachineCommands from '../hooks/useStateMachineCommands' -import { useGlobalStateContext } from 'hooks/useGlobalStateContext' +import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' import { useCommandsContext } from 'hooks/useCommandsContext' -import { DEFAULT_PROJECT_NAME } from 'machines/settingsMachine' +import { DEFAULT_PROJECT_NAME } from 'lib/settings' import { sep } from '@tauri-apps/api/path' import { homeCommandBarConfig } from 'lib/commandBarConfigs/homeCommandConfig' import { useHotkeys } from 'react-hotkeys-hook' @@ -42,14 +42,17 @@ import { isTauri } from 'lib/isTauri' const Home = () => { const { commandBarSend } = useCommandsContext() const navigate = useNavigate() - const { projects: loadedProjects, newDefaultDirectory } = - useLoaderData() as HomeLoaderData + const { + projects: loadedProjects, + newDefaultDirectory, + error, + } = useLoaderData() as HomeLoaderData const { settings: { context: { defaultDirectory, defaultProjectName }, send: sendToSettings, }, - } = useGlobalStateContext() + } = useSettingsAuthContext() // Set the default directory if it's been updated // during the loading of the home page. This is wrapped @@ -57,11 +60,17 @@ const Home = () => { useEffect(() => { if (newDefaultDirectory) { sendToSettings({ - type: 'Set Default Directory', + type: 'Set All Settings', data: { defaultDirectory: newDefaultDirectory }, }) } + + // Toast any errors that occurred during the loading process + if (error) { + toast.error(error.message) + } }, []) + useHotkeys( isTauri() ? 'mod+,' : 'shift+mod+,', () => navigate(paths.HOME + paths.SETTINGS), diff --git a/src/routes/Onboarding/Camera.tsx b/src/routes/Onboarding/Camera.tsx index 0d8c91fd7..60a73db7c 100644 --- a/src/routes/Onboarding/Camera.tsx +++ b/src/routes/Onboarding/Camera.tsx @@ -2,7 +2,7 @@ import { OnboardingButtons, useDismiss, useNextClick } from '.' import { onboardingPaths } from 'routes/Onboarding/paths' import { useStore } from '../../useStore' import { SettingsSection } from 'routes/Settings' -import { useGlobalStateContext } from 'hooks/useGlobalStateContext' +import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' import { CameraSystem, cameraMouseDragGuards, @@ -22,7 +22,7 @@ export default function Units() { context: { cameraControls }, }, }, - } = useGlobalStateContext() + } = useSettingsAuthContext() return (
diff --git a/src/routes/Onboarding/Introduction.tsx b/src/routes/Onboarding/Introduction.tsx index d96b8abcb..01a3bb224 100644 --- a/src/routes/Onboarding/Introduction.tsx +++ b/src/routes/Onboarding/Introduction.tsx @@ -5,7 +5,7 @@ import { useNextClick, } from '.' import { onboardingPaths } from 'routes/Onboarding/paths' -import { useGlobalStateContext } from 'hooks/useGlobalStateContext' +import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' import { Themes, getSystemTheme } from 'lib/theme' import { bracket } from 'lib/exampleKcl' import { @@ -31,7 +31,7 @@ function OnboardingWithNewFile() { settings: { context: { defaultDirectory }, }, - } = useGlobalStateContext() + } = useSettingsAuthContext() async function createAndOpenNewProject() { const projects = await getProjectsInDir(defaultDirectory) @@ -111,7 +111,7 @@ export default function Introduction() { context: { theme }, }, }, - } = useGlobalStateContext() + } = useSettingsAuthContext() const getLogoTheme = () => theme === Themes.Light || (theme === Themes.System && getSystemTheme() === Themes.Light) diff --git a/src/routes/Onboarding/ParametricModeling.tsx b/src/routes/Onboarding/ParametricModeling.tsx index cb21294ed..caefb639d 100644 --- a/src/routes/Onboarding/ParametricModeling.tsx +++ b/src/routes/Onboarding/ParametricModeling.tsx @@ -3,7 +3,7 @@ import { onboardingPaths } from 'routes/Onboarding/paths' import { useStore } from '../../useStore' import { useBackdropHighlight } from 'hooks/useBackdropHighlight' import { Themes, getSystemTheme } from 'lib/theme' -import { useGlobalStateContext } from 'hooks/useGlobalStateContext' +import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' export default function ParametricModeling() { const { buttonDownInStream } = useStore((s) => ({ @@ -13,7 +13,7 @@ export default function ParametricModeling() { settings: { context: { theme }, }, - } = useGlobalStateContext() + } = useSettingsAuthContext() const getImageTheme = () => theme === Themes.Light || (theme === Themes.System && getSystemTheme() === Themes.Light) diff --git a/src/routes/Onboarding/Units.tsx b/src/routes/Onboarding/Units.tsx index e3cf620db..5cc92b684 100644 --- a/src/routes/Onboarding/Units.tsx +++ b/src/routes/Onboarding/Units.tsx @@ -1,12 +1,11 @@ import { faArrowRight, faXmark } from '@fortawesome/free-solid-svg-icons' -import { BaseUnit, baseUnits } from '../../machines/settingsMachine' +import { BaseUnit, baseUnits, UnitSystem } from 'lib/settings' import { ActionButton } from '../../components/ActionButton' import { SettingsSection } from '../Settings' import { Toggle } from '../../components/Toggle/Toggle' import { useDismiss, useNextClick } from '.' import { onboardingPaths } from 'routes/Onboarding/paths' -import { useGlobalStateContext } from 'hooks/useGlobalStateContext' -import { UnitSystem } from 'machines/settingsMachine' +import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' export default function Units() { const dismiss = useDismiss() @@ -16,7 +15,7 @@ export default function Units() { send, context: { unitSystem, baseUnit }, }, - } = useGlobalStateContext() + } = useSettingsAuthContext() return (
diff --git a/src/routes/Onboarding/index.tsx b/src/routes/Onboarding/index.tsx index 82da6dd25..b27987bbe 100644 --- a/src/routes/Onboarding/index.tsx +++ b/src/routes/Onboarding/index.tsx @@ -5,7 +5,7 @@ import Camera from './Camera' import Sketching from './Sketching' import { useCallback } from 'react' import makeUrlPathRelative from '../../lib/makeUrlPathRelative' -import { useGlobalStateContext } from 'hooks/useGlobalStateContext' +import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' import Streaming from './Streaming' import CodeEditor from './CodeEditor' import ParametricModeling from './ParametricModeling' @@ -78,7 +78,7 @@ export function useNextClick(newStatus: string) { const filePath = useAbsoluteFilePath() const { settings: { send }, - } = useGlobalStateContext() + } = useSettingsAuthContext() const navigate = useNavigate() return useCallback(() => { @@ -94,7 +94,7 @@ export function useDismiss() { const filePath = useAbsoluteFilePath() const { settings: { send }, - } = useGlobalStateContext() + } = useSettingsAuthContext() const navigate = useNavigate() return useCallback(() => { diff --git a/src/routes/Settings.tsx b/src/routes/Settings.tsx index 91f5ae601..70bd5af4b 100644 --- a/src/routes/Settings.tsx +++ b/src/routes/Settings.tsx @@ -5,31 +5,38 @@ import { open } from '@tauri-apps/api/dialog' import { BaseUnit, DEFAULT_PROJECT_NAME, + SETTINGS_PERSIST_KEY, baseUnits, -} from '../machines/settingsMachine' + initialSettings, + UnitSystem, +} from 'lib/settings' import { Toggle } from '../components/Toggle/Toggle' import { useLocation, useNavigate, useRouteLoaderData } from 'react-router-dom' import { useHotkeys } from 'react-hotkeys-hook' import { type IndexLoaderData } from 'lib/types' import { paths } from 'lib/paths' import { Themes } from '../lib/theme' -import { useGlobalStateContext } from 'hooks/useGlobalStateContext' +import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' import { CameraSystem, cameraSystems, cameraMouseDragGuards, } from 'lib/cameraControls' -import { UnitSystem } from 'machines/settingsMachine' import { useDotDotSlash } from 'hooks/useDotDotSlash' import { createNewProject, getNextProjectIndex, getProjectsInDir, + getSettingsFilePath, + initializeProjectDirectory, interpolateProjectNameWithIndex, } from 'lib/tauriFS' import { ONBOARDING_PROJECT_NAME } from './Onboarding' import { sep } from '@tauri-apps/api/path' import { bracket } from 'lib/exampleKcl' +import { isTauri } from 'lib/isTauri' +import { invoke } from '@tauri-apps/api' +import toast from 'react-hot-toast' export const Settings = () => { const APP_VERSION = import.meta.env.PACKAGE_VERSION || 'unknown' @@ -55,11 +62,14 @@ export const Settings = () => { }, }, }, - } = useGlobalStateContext() + } = useSettingsAuthContext() async function handleDirectorySelection() { + // the `recursive` property added following + // this advice for permissions: https://github.com/tauri-apps/tauri/issues/4851#issuecomment-1210711455 const newDirectory = await open({ directory: true, + recursive: true, defaultPath: defaultDirectory || paths.INDEX, title: 'Choose a new default directory', }) @@ -306,6 +316,59 @@ export const Settings = () => { Replay Onboarding +

+ Your settings are saved in{' '} + {isTauri() + ? 'a file in the app data folder for your OS.' + : "your browser's local storage."}{' '} + {isTauri() ? ( + + + + + ) : ( + + )} +

{/* This uses a Vite plugin, set in vite.config.ts to inject the version from package.json */} diff --git a/src/routes/SignIn.tsx b/src/routes/SignIn.tsx index e7ab03485..ef6fb2eb5 100644 --- a/src/routes/SignIn.tsx +++ b/src/routes/SignIn.tsx @@ -4,7 +4,7 @@ import { invoke } from '@tauri-apps/api/tauri' import { VITE_KC_SITE_BASE_URL, VITE_KC_API_BASE_URL } from '../env' import { Themes, getSystemTheme } from '../lib/theme' import { paths } from 'lib/paths' -import { useGlobalStateContext } from 'hooks/useGlobalStateContext' +import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' import { APP_NAME } from 'lib/constants' const SignIn = () => { @@ -20,7 +20,7 @@ const SignIn = () => { context: { theme }, }, }, - } = useGlobalStateContext() + } = useSettingsAuthContext() const signInTauri = async () => { // We want to invoke our command to login via device auth.