File based settings (#1679)

* Rename GlobalStateContext to SettingsAuthContext

* Naive initial impl of settings persistence to file system

* Update app identifier in tauri config

* Add "show in folder" tauri command

* Load from and save to file system in Tauri app

* Add documents drive to tauri permission scope

* Add recursive prop to default dir selection dialog

* Add success toast to web restore defaults action

* Add a way to validate read-in settings

* Update imports to use separate settings lib file

* Validate localStorage-loaded settings, combine error message

* Add a e2e test for validation

* Clean up state state bugs

* Reverse validation looping so new users don't error

* update settingsMachine typegen to remove conflicts

* Fmt

* Fix TS errors

* Fix import paths, etc post-merge

* Make default length units `mm` and 'metric'

* Rename to SettingsAuth*

* cargo fmt

* Revert Tauri config identifier change

* Update clientSideInfra's baseUnits from settings

* Break apart CommandBar and CommandBarProvider

* Bugfix: don't validate defaultValue when it's not configured

* Allow some TauriFS functions to no-op from browser

* Sidestep circular deps by loading context and kclManager only from React-land

* Update broken import paths

* Separate loaders from Router, load settings on every route

* Break apart settings types, utils, and constants

* Fix Jest tests by decoupling reliance on useLoaderData from SettingsAuthProvider

* Fix up Router loader data with "layout routes"
https://reactrouter.com/en/main/route/route#layout-routes

* Move settings validation and toast to custom hook so the toast renders

* fmt

* Use forks for Vitest
https://vitest.dev/guide/common-errors.html#failed-to-terminate-worker

* $APPCONFIG !== $APPDATA only on Linux
+ change the identifier back since it really doesn't seem to affect app signing

* Debugging on Linux

* Better directory validation, fix reset settings button

* A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu)

* defaultDirectory can be empty in browser

* fmt

* A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu)

* re-trigger CI

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Frank Noirot
2024-03-14 15:56:45 -04:00
committed by GitHub
parent 13cd3e179b
commit f40cdabfdf
30 changed files with 842 additions and 389 deletions

View File

@ -0,0 +1,88 @@
import { type CameraSystem, cameraSystems } from '../cameraControls'
import { Themes } from '../theme'
import { isTauri } from '../isTauri'
import { getInitialDefaultDir, readSettingsFile } from '../tauriFS'
import { initialSettings } from 'lib/settings/initialSettings'
import {
type BaseUnit,
baseUnitsUnion,
type Toggle,
type SettingsMachineContext,
toggleAsArray,
UnitSystem,
} from './settingsTypes'
import { SETTINGS_PERSIST_KEY } from '../constants'
export const fallbackLoadedSettings = {
settings: initialSettings,
errors: [] as (keyof SettingsMachineContext)[],
}
function isEnumMember<T extends Record<string, unknown>>(v: unknown, e: T) {
return Object.values(e).includes(v)
}
export async function loadAndValidateSettings(): Promise<
ReturnType<typeof validateSettings>
> {
const fsSettings = isTauri() ? await readSettingsFile() : {}
const localStorageSettings = JSON.parse(
localStorage?.getItem(SETTINGS_PERSIST_KEY) || '{}'
)
const mergedSettings = Object.assign({}, localStorageSettings, fsSettings)
return await validateSettings(mergedSettings)
}
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' && (v.length > 0 || !isTauri()),
defaultProjectName: (v) => typeof v === 'string' && v.length > 0,
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<string, unknown>) {
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 async function validateSettings(s: Record<string, unknown>) {
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)
}
}
// Here's our chance to insert the fallback defaultDir
const defaultDirectory = isTauri() ? await getInitialDefaultDir() : ''
const settings = Object.assign(
initialSettings,
{ defaultDirectory },
settingsNoInvalidKeys
) as SettingsMachineContext
return {
settings,
errors,
}
}