diff --git a/package.json b/package.json index 81ae7e82..1c65af7d 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "@lezer/highlight": "^1.2.0", "@mantine/core": "^7.6.2", "@mantine/hooks": "^7.6.2", + "@mantine/modals": "^7.12.0", "@mantine/notifications": "^7.6.2", "@mdi/js": "^7.2.96", "@replit/codemirror-indentation-markers": "^6.5.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 546b606e..e60349ed 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -76,6 +76,9 @@ importers: '@mantine/hooks': specifier: ^7.6.2 version: 7.10.2(react@18.3.1) + '@mantine/modals': + specifier: ^7.12.0 + version: 7.12.0(@mantine/core@7.10.2(@mantine/hooks@7.10.2(react@18.3.1))(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/hooks@7.10.2(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@mantine/notifications': specifier: ^7.6.2 version: 7.10.2(@mantine/core@7.10.2(@mantine/hooks@7.10.2(react@18.3.1))(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/hooks@7.10.2(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -310,7 +313,7 @@ importers: version: 5.3.1(@types/node@20.14.7)(sass@1.77.6)(terser@5.31.1) vite-plugin-compression2: specifier: ^1.1.2 - version: 1.1.2(rollup@4.18.0) + version: 1.1.2(rollup@2.79.1) vite-plugin-image-optimizer: specifier: ^1.1.8 version: 1.1.8(vite@5.3.1(@types/node@20.14.7)(sass@1.77.6)(terser@5.31.1)) @@ -1405,6 +1408,14 @@ packages: peerDependencies: react: ^18.2.0 + '@mantine/modals@7.12.0': + resolution: {integrity: sha512-CXt2nUK0VuWc+cwC1flCeH5FnQYjA8iQfGgZ37wSFv2qxzJFQ61QlRJjdgIG7T+DccUHjqXKkjYohLxXE36EQQ==} + peerDependencies: + '@mantine/core': 7.12.0 + '@mantine/hooks': 7.12.0 + react: ^18.2.0 + react-dom: ^18.2.0 + '@mantine/notifications@7.10.2': resolution: {integrity: sha512-wX6qNBvpV7iqlH98AkGuS9plq02yYhTG7bkzP3Y7jd7o2ognLPoN83YeIaxzuZ/qVnWrwZrOHOx87Ox2e9Qyxw==} peerDependencies: @@ -5340,6 +5351,13 @@ snapshots: dependencies: react: 18.3.1 + '@mantine/modals@7.12.0(@mantine/core@7.10.2(@mantine/hooks@7.10.2(react@18.3.1))(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/hooks@7.10.2(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@mantine/core': 7.10.2(@mantine/hooks@7.10.2(react@18.3.1))(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@mantine/hooks': 7.10.2(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + '@mantine/notifications@7.10.2(@mantine/core@7.10.2(@mantine/hooks@7.10.2(react@18.3.1))(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/hooks@7.10.2(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@mantine/core': 7.10.2(@mantine/hooks@7.10.2(react@18.3.1))(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -5495,13 +5513,13 @@ snapshots: picomatch: 2.3.1 rollup: 2.79.1 - '@rollup/pluginutils@5.1.0(rollup@4.18.0)': + '@rollup/pluginutils@5.1.0(rollup@2.79.1)': dependencies: '@types/estree': 1.0.5 estree-walker: 2.0.2 picomatch: 2.3.1 optionalDependencies: - rollup: 4.18.0 + rollup: 2.79.1 '@rollup/rollup-android-arm-eabi@4.18.0': optional: true @@ -8182,9 +8200,9 @@ snapshots: visit-values@2.0.0: {} - vite-plugin-compression2@1.1.2(rollup@4.18.0): + vite-plugin-compression2@1.1.2(rollup@2.79.1): dependencies: - '@rollup/pluginutils': 5.1.0(rollup@4.18.0) + '@rollup/pluginutils': 5.1.0(rollup@2.79.1) transitivePeerDependencies: - rollup diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 67890ad3..a390813e 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -1,19 +1,18 @@ use std::{ - fs::{self, File}, - io::{Read, Write}, + fs::{self, copy, File}, + io::{Read, Write}, path::PathBuf, }; -use crate::paths::{get_config_path, get_legacy_config_backup_path, get_legacy_config_path}; +use crate::paths::{get_config_backup_path, get_config_path, get_legacy_config_backup_path, get_legacy_config_path}; const DEFAULT_CONFIG: &str = "{}"; -fn write_config(config: &str) { - let config_path = get_config_path(); - let parent = config_path.parent().unwrap(); +fn write_config(config: &str, path: PathBuf) { + let parent = path.parent().unwrap(); fs::create_dir_all(parent).expect("config directory should be writable"); - let mut write_op = File::create(config_path).unwrap(); + let mut write_op = File::create(path).unwrap(); let config_json_value: serde_json::Value = serde_json::from_str(config).unwrap(); let mut pretty_config = serde_json::to_string_pretty(&config_json_value).unwrap(); @@ -38,7 +37,7 @@ pub fn load_config() -> String { .expect("config should be readable"); } Err(_) => { - write_config(DEFAULT_CONFIG); + write_config(DEFAULT_CONFIG, get_config_path()); buffer = DEFAULT_CONFIG.to_string(); } } @@ -46,6 +45,37 @@ pub fn load_config() -> String { buffer } +#[tauri::command] +pub fn save_config(config: &str) { + write_config(config, get_config_path()) +} + +#[tauri::command] +pub fn backup_config(config: &str, version: u32) { + write_config(config, get_config_backup_path(version)); +} + +#[tauri::command] +pub fn has_config_backup(version: u32) -> bool { + get_config_backup_path(version).exists() +} + +#[tauri::command] +pub fn restore_config_backup(version: u32) -> Result<(), String> { + let backup_path = get_config_backup_path(version); + let config_path = get_config_path(); + + if !backup_path.exists() { + return Err("Backup does not exist".into()); + } + + match copy(backup_path, config_path) { + Ok(_) => Ok(()), + Err(_) => Err("Failed to restore config backup".into()), + } +} + +#[deprecated] #[tauri::command] pub fn load_legacy_config() -> String { let config_path = get_legacy_config_path(); @@ -60,7 +90,7 @@ pub fn load_legacy_config() -> String { .expect("legacy config should be readable"); } Err(_) => { - write_config(DEFAULT_CONFIG); + write_config(DEFAULT_CONFIG, get_config_path()); buffer = DEFAULT_CONFIG.to_string(); } } @@ -68,16 +98,13 @@ pub fn load_legacy_config() -> String { buffer } -#[tauri::command] -pub fn save_config(config: &str) { - write_config(config) -} - +#[deprecated] #[tauri::command] pub fn has_legacy_config() -> bool { get_legacy_config_path().exists() } +#[deprecated] #[tauri::command] pub fn complete_legacy_migrate() { let legacy = get_legacy_config_path(); diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index f62cb014..ec91282f 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -90,9 +90,12 @@ fn main() { .manage(DatabaseState(Default::default())) .invoke_handler(tauri::generate_handler![ config::load_config, - config::load_legacy_config, config::save_config, + config::backup_config, + config::has_config_backup, + config::restore_config_backup, config::has_legacy_config, + config::load_legacy_config, config::complete_legacy_migrate, database::start_database, database::stop_database, diff --git a/src-tauri/src/open.rs b/src-tauri/src/open.rs index 8ae24402..0a0ce906 100644 --- a/src-tauri/src/open.rs +++ b/src-tauri/src/open.rs @@ -65,10 +65,15 @@ pub fn get_opened_resources(state: State) -> Vec Some(OpenedResource::Link(LinkResource { - host: u.host_str().unwrap_or_default().to_owned(), - params: u.query().unwrap_or_default().to_owned(), - })), + "surrealist" => { + let host = u.host_str().unwrap_or_default().to_owned(); + let params = u.query().unwrap_or_default().to_owned(); + + Some(OpenedResource::Link(LinkResource { + host, + params + })) + }, _ => Some(OpenedResource::Unknown), }) .collect() diff --git a/src-tauri/src/paths.rs b/src-tauri/src/paths.rs index e4ac8443..e3baa58c 100644 --- a/src-tauri/src/paths.rs +++ b/src-tauri/src/paths.rs @@ -19,6 +19,14 @@ pub fn get_config_path() -> PathBuf { config_path } +/// The path to a backup configuration file +pub fn get_config_backup_path(version: u32) -> PathBuf { + let mut config_path = get_data_directory(); + config_path.push("backups"); + config_path.push(format!("config-version-{}.json", version)); + config_path +} + /// The path to the legacy configuration file (Surrealist 1.x) pub fn get_legacy_config_path() -> PathBuf { let mut config_path = config_dir().expect("config directory should be resolvable"); diff --git a/src/components/App/index.tsx b/src/components/App/index.tsx index 82b06ab4..d558dbd8 100644 --- a/src/components/App/index.tsx +++ b/src/components/App/index.tsx @@ -30,6 +30,7 @@ import { KeymapModal } from "./modals/hotkeys"; import { UpdaterDialog } from "./modals/updater"; import { isDesktop } from "~/adapter"; import { HighlightToolModal } from "./modals/highlight-tool"; +import { ModalsProvider } from "@mantine/modals"; const queryClient = new QueryClient(); @@ -58,43 +59,45 @@ export function App() { > - - - - - {screen === "start" - ? - : - } + + + + + + {screen === "start" + ? + : + } - + - - - - - - - - - - - + + + + + + + + + + + - {isDesktop && ( - - )} - - - - + {isDesktop && ( + + )} + + + + + diff --git a/src/components/App/modals/updater.tsx b/src/components/App/modals/updater.tsx index 03eacc05..841ca41e 100644 --- a/src/components/App/modals/updater.tsx +++ b/src/components/App/modals/updater.tsx @@ -6,9 +6,16 @@ import { Icon } from "~/components/Icon"; import { useStable } from "~/hooks/stable"; import { useInterfaceStore } from "~/stores/interface"; import { iconClose, iconDownload } from "~/util/icons"; +import { useConfirmation } from "~/providers/Confirmation"; +import { invoke } from "@tauri-apps/api/core"; +import { useConfigStore } from "~/stores/config"; type Phase = 'idle' | 'downloading' | 'error'; +function extractMajor(version: string) { + return Number.parseInt(version.split(".")[0] ?? 0); +} + export function UpdaterDialog() { const { hideAvailableUpdate } = useInterfaceStore.getState(); @@ -18,6 +25,10 @@ export function UpdaterDialog() { const [packageTotal, setPackageTotal] = useState(0); const [packageProgress, setPackageProgress] = useState(0); + const currentMajor = extractMajor(import.meta.env.VERSION); + const latestMajor = extractMajor(update?.version || "0.0.0"); + const isDangerous = latestMajor > currentMajor; + const hideUpdate = useStable((e: MouseEvent) => { e.stopPropagation(); hideAvailableUpdate(); @@ -26,6 +37,15 @@ export function UpdaterDialog() { const installUpdate = useStable(async () => { if (!update || phase !== 'idle') return; + if (isDangerous) { + const config = useConfigStore.getState(); + + await invoke("backup_config", { + config: JSON.stringify(config), + version: config.configVersion + }); + } + setPhase('downloading'); setPackageProgress(0); @@ -45,6 +65,23 @@ export function UpdaterDialog() { } }); + const promptUpdate = useConfirmation({ + title: "New major release", + message: "The update you are about to install is a new major version of Surrealist. Are you sure you want to proceed?", + confirmText: "Install update", + confirmProps: { variant: "gradient" }, + dismissText: "Don't update now", + onConfirm: () => installUpdate() + }); + + const handleClick = useStable(() => { + if (isDangerous) { + promptUpdate(); + } else { + installUpdate(); + } + }); + const progress = packageTotal > 0 ? (packageProgress / packageTotal * 100).toFixed(0) : 0; @@ -67,7 +104,7 @@ export function UpdaterDialog() { classNames={{ root: classes.updateDialog }} - onClick={installUpdate} + onClick={handleClick} > { useInterfaceStore.getState().setColorPreference(matches ? "light" : "dark"); @@ -56,20 +58,25 @@ export function watchColorScheme() { * Watch for changes to the store and save the config to the adapter */ export async function watchConfigStore() { - const config = await adapter.loadConfig(); - const merged = assign(useConfigStore.getState(), config); - - // TODO Temporary fix - if (compare(import.meta.env.VERSION, merged.previousVersion) > 0) { - merged.activeScreen = 'database'; + const loadedConfig = await adapter.loadConfig(); + const config = assign(useConfigStore.getState(), loadedConfig); + const compatible = config.configVersion <= CONFIG_VERSION; + + // Handle incompatible config versions + if (!compatible) { + setTimeout(showDowngradeWarningModal, 250); + return; } - useConfigStore.setState(merged); + // Update the internal config state + useConfigStore.setState(config); - // TODO include a ~300ms debounce - useConfigStore.subscribe((state) => { + // Sync the config with the adapter + useConfigStore.subscribe(debounce({ + delay: 250 + }, (state) => { adapter.saveConfig(state); - }); + })); } /** diff --git a/src/util/downgrade.tsx b/src/util/downgrade.tsx new file mode 100644 index 00000000..7ad0f1ae --- /dev/null +++ b/src/util/downgrade.tsx @@ -0,0 +1,68 @@ +import { Alert, Box, Button, Text } from "@mantine/core"; +import { openModal } from "@mantine/modals"; +import { invoke } from "@tauri-apps/api/core"; +import { adapter } from "~/adapter"; +import { DesktopAdapter } from "~/adapter/desktop"; +import { ModalTitle } from "~/components/ModalTitle"; +import { CONFIG_VERSION } from "./defaults"; +import { relaunch } from "@tauri-apps/plugin-process"; + +async function hasConfigBackup() { + if (!(adapter instanceof DesktopAdapter)) { + return false; + } + + return invoke("has_config_backup", { + version: CONFIG_VERSION + }); +} + +async function restoreBackup() { + await invoke("restore_config_backup", { + version: CONFIG_VERSION + }); + + await relaunch(); +} + +export async function showDowngradeWarningModal() { + const hasBackup = await hasConfigBackup(); + + openModal({ + closeOnClickOutside: false, + closeOnEscape: false, + title: ( + Incompatible configuration + ), + children: ( + + + Your config file was updated by a newer version of Surrealist and is incompatible with this version. + + {hasBackup ? ( + + + A backup of your previous configuration file was found. You can restore it by clicking the button below. + Note that this will discard any changes you made since the last update. + + + + ) : ( + + Please reset your configuration file or update your version of Surrealist to continue. + + )} + + ) + }); +} \ No newline at end of file