forked from 1b-agent/opencadstudio-upstream
feat(web): persist browser state
Keep settings, aliases, and recent drawings across browser sessions. Load Start-page videos from a Pages-generated snapshot.
This commit is contained in:
1 parent
25166b9508
commit
d4e792b2bc
15 files changed
+546
-88
No files matched your search
+57
-27
@@ -15,11 +15,12 @@
|
||||
//!
|
||||
//! An alias is the token left of the comma; the command is the token right of
|
||||
//! it, with an optional leading `*`. Both are matched case-insensitively and
|
||||
//! stored uppercased. Stored next to the other per-user config under
|
||||
//! `crate::config::config_dir()`, so no serialization crate is pulled in.
|
||||
//! stored uppercased. Native builds use `ocad.pgp`; web builds store the same
|
||||
//! PGP text in `localStorage`.
|
||||
|
||||
use super::OpenCADStudio;
|
||||
use rustc_hash::FxHashMap;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// The shipped default alias file, embedded at compile time. Its aliases live in
|
||||
@@ -31,11 +32,15 @@ use std::path::PathBuf;
|
||||
const DEFAULT_ALIASES_PGP: &str = include_str!("../../assets/ocad.pgp");
|
||||
|
||||
/// Path to the user's alias file, `<config>/ocad.pgp`. `None` when the platform
|
||||
/// config base can't be resolved (headless, no `HOME`, wasm).
|
||||
/// config base can't be resolved (headless, no `HOME`).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn alias_file_path() -> Option<PathBuf> {
|
||||
Some(crate::config::config_dir()?.join("ocad.pgp"))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
const WEB_ALIAS_KEY: &str = "opencadstudio.aliases";
|
||||
|
||||
/// Parse `.pgp` text into an `alias → command` map, both uppercased. Skips blank
|
||||
/// lines and `;` comments; tolerates a leading `*` on the command and arbitrary
|
||||
/// whitespace padding.
|
||||
@@ -81,38 +86,63 @@ fn default_map() -> FxHashMap<String, String> {
|
||||
parse_pgp(DEFAULT_ALIASES_PGP)
|
||||
}
|
||||
|
||||
/// Load the alias table at boot. Reads the user's `aliases.pgp`; if it is
|
||||
/// missing, writes the shipped default file verbatim (preserving its comments
|
||||
/// and layout) so the user has a well-formatted starting point to edit, then
|
||||
/// parses it. Falls back to the embedded defaults when the file can't be read
|
||||
/// or written (read-only home, wasm — where it silently no-ops).
|
||||
/// Load the alias table at boot. Native reads `ocad.pgp`; web reads the same
|
||||
/// text from `localStorage`. Missing or unavailable storage falls back to the
|
||||
/// embedded defaults.
|
||||
pub(super) fn load_aliases() -> FxHashMap<String, String> {
|
||||
match alias_file_path() {
|
||||
Some(path) => match std::fs::read_to_string(&path) {
|
||||
Ok(body) => parse_pgp(&body),
|
||||
Err(_) => {
|
||||
// No file yet — copy the shipped default file, best-effort.
|
||||
if let Some(dir) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
match alias_file_path() {
|
||||
Some(path) => match std::fs::read_to_string(&path) {
|
||||
Ok(body) => parse_pgp(&body),
|
||||
Err(_) => {
|
||||
// No file yet — copy the shipped default file, best-effort.
|
||||
if let Some(dir) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
}
|
||||
let _ = std::fs::write(&path, DEFAULT_ALIASES_PGP);
|
||||
default_map()
|
||||
}
|
||||
let _ = std::fs::write(&path, DEFAULT_ALIASES_PGP);
|
||||
default_map()
|
||||
}
|
||||
},
|
||||
None => default_map(),
|
||||
},
|
||||
None => default_map(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
web_sys::window()
|
||||
.and_then(|window| window.local_storage().ok().flatten())
|
||||
.and_then(|storage| storage.get_item(WEB_ALIAS_KEY).ok().flatten())
|
||||
.map(|body| parse_pgp(&body))
|
||||
.unwrap_or_else(default_map)
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist the alias table to `aliases.pgp`. Best-effort; returns `Ok` (no-op)
|
||||
/// when there is no config dir (wasm/headless).
|
||||
/// Persist the alias table to native `ocad.pgp` or web `localStorage`.
|
||||
pub(super) fn save_map(map: &FxHashMap<String, String>) -> std::io::Result<()> {
|
||||
let Some(path) = alias_file_path() else {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let Some(path) = alias_file_path() else {
|
||||
return Ok(());
|
||||
};
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
return std::fs::write(path, to_pgp(map));
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let Some(storage) =
|
||||
web_sys::window().and_then(|window| window.local_storage().ok().flatten())
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
storage
|
||||
.set_item(WEB_ALIAS_KEY, &to_pgp(map))
|
||||
.map_err(|_| std::io::Error::other("browser alias storage unavailable"))?;
|
||||
return Ok(());
|
||||
};
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
std::fs::write(path, to_pgp(map))
|
||||
}
|
||||
|
||||
impl OpenCADStudio {
|
||||
|
||||
+39
-17
@@ -1,8 +1,9 @@
|
||||
//! Consolidated user configuration — one grouped JSON file
|
||||
//! (`<config>/OpenCADStudio/settings.json`) holding every app preference except
|
||||
//! the command aliases (which stay in the hand-editable `ocad.pgp`). Serialized
|
||||
//! via serde so the file is structured and grouped, replacing the former
|
||||
//! scattered flat stores (`settings.txt` / `recent.txt` / `recent_limit.txt` /
|
||||
//! Consolidated user configuration. Native builds use one grouped JSON file
|
||||
//! (`<config>/OpenCADStudio/settings.json`); web builds keep the same JSON in
|
||||
//! `localStorage`. It holds every app preference except the command aliases,
|
||||
//! which use native `ocad.pgp` or a separate web storage key. Serialized via
|
||||
//! serde so the data is structured and grouped, replacing the former scattered
|
||||
//! flat stores (`settings.txt` / `recent.txt` / `recent_limit.txt` /
|
||||
//! `statusbar.txt` / `ribbon.txt` / `plot.txt`).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -209,28 +210,49 @@ pub struct RibbonConfig {
|
||||
|
||||
impl AppConfig {
|
||||
/// Read the saved config, or all-defaults when the file is missing or
|
||||
/// unreadable (fresh install / wasm). Unknown or missing fields fall back to
|
||||
/// their section defaults via `#[serde(default)]`.
|
||||
/// unreadable. Unknown or missing fields fall back to their section defaults
|
||||
/// via `#[serde(default)]`.
|
||||
pub fn load() -> Self {
|
||||
config_path()
|
||||
.and_then(|p| std::fs::read_to_string(p).ok())
|
||||
.and_then(|body| serde_json::from_str(&body).ok())
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let body = config_path().and_then(|p| std::fs::read_to_string(p).ok());
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let body = web_sys::window()
|
||||
.and_then(|window| window.local_storage().ok().flatten())
|
||||
.and_then(|storage| storage.get_item(WEB_CONFIG_KEY).ok().flatten());
|
||||
|
||||
body.and_then(|body| serde_json::from_str(&body).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Persist the config as pretty JSON. Best-effort; silent on failure
|
||||
/// (read-only home, full disk, wasm — where `config_dir` is `None`).
|
||||
/// Persist the config as JSON. Best-effort; silent on unavailable or
|
||||
/// read-only storage.
|
||||
pub fn save(&self) {
|
||||
let Some(path) = config_path() else { return };
|
||||
if let Some(dir) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let Some(path) = config_path() else { return };
|
||||
if let Some(dir) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
}
|
||||
if let Ok(json) = serde_json::to_string_pretty(self) {
|
||||
let _ = std::fs::write(path, json);
|
||||
}
|
||||
}
|
||||
if let Ok(json) = serde_json::to_string_pretty(self) {
|
||||
let _ = std::fs::write(path, json);
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
if let (Some(storage), Ok(json)) = (
|
||||
web_sys::window().and_then(|window| window.local_storage().ok().flatten()),
|
||||
serde_json::to_string(self),
|
||||
) {
|
||||
let _ = storage.set_item(WEB_CONFIG_KEY, &json);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
const WEB_CONFIG_KEY: &str = "opencadstudio.settings";
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn config_path() -> Option<std::path::PathBuf> {
|
||||
Some(crate::config::config_dir()?.join("settings.json"))
|
||||
}
|
||||
+9
-1
@@ -1471,6 +1471,9 @@ pub enum Message {
|
||||
/// running but its result is discarded.
|
||||
OpenCancel,
|
||||
FileOpened(Result<(String, PathBuf, CadDocument, crate::scene::DerivedCaches), String>),
|
||||
/// Web: an asynchronous OPFS copy written after Save is ready for recents.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
WebRecentStored(Result<PathBuf, String>),
|
||||
SaveFile,
|
||||
SaveAs,
|
||||
// ── Custom Save-As dialog ─────────────────────────────────────────────
|
||||
@@ -3016,12 +3019,17 @@ impl OpenCADStudio {
|
||||
crate::patreon::fetch_patrons_web(),
|
||||
Message::PatronsFetched,
|
||||
);
|
||||
s.videos_loading = true;
|
||||
let videos = Task::perform(
|
||||
crate::videos::fetch_playlist_web(),
|
||||
Message::VideosFetched,
|
||||
);
|
||||
s.discussions_loading = true;
|
||||
let discussions = Task::perform(
|
||||
crate::discussions::fetch_discussions_web(),
|
||||
Message::DiscussionsFetched,
|
||||
);
|
||||
(s, Task::batch([focus, patrons, discussions]))
|
||||
(s, Task::batch([focus, patrons, videos, discussions]))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+30
-5
@@ -1,7 +1,7 @@
|
||||
//! Recent-files list backing the Start page's Recent Documents panel. The list
|
||||
//! itself lives in the consolidated app config (`settings.json`, the "recent"
|
||||
//! section); this module just mutates the in-memory list and persists via
|
||||
//! `save_config`.
|
||||
//! itself lives in the consolidated app config (native `settings.json` or web
|
||||
//! `localStorage`, in the "recent" section); this module mutates the in-memory
|
||||
//! list, persists it via `save_config`, and evicts matching web OPFS copies.
|
||||
|
||||
use super::OpenCADStudio;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -17,7 +17,10 @@ impl OpenCADStudio {
|
||||
pub(super) fn push_recent(&mut self, path: PathBuf) -> iced::Task<crate::app::Message> {
|
||||
self.recent_files.retain(|r| r != &path);
|
||||
self.recent_files.insert(0, path);
|
||||
self.recent_files.truncate(self.recent_limit);
|
||||
let evicted = self
|
||||
.recent_files
|
||||
.split_off(self.recent_limit.min(self.recent_files.len()));
|
||||
remove_cached_copies(evicted);
|
||||
self.save_config();
|
||||
self.refresh_recent_thumbs()
|
||||
}
|
||||
@@ -68,6 +71,7 @@ impl OpenCADStudio {
|
||||
/// Drop a path from the recents list (manual removal from the Start page).
|
||||
pub(super) fn remove_recent(&mut self, path: &Path) {
|
||||
self.recent_files.retain(|r| r.as_path() != path);
|
||||
remove_cached_copies([path.to_path_buf()]);
|
||||
self.save_config();
|
||||
}
|
||||
|
||||
@@ -75,7 +79,28 @@ impl OpenCADStudio {
|
||||
/// persist both.
|
||||
pub(super) fn set_recent_limit(&mut self, limit: usize) {
|
||||
self.recent_limit = limit.clamp(RECENT_MIN, RECENT_MAX);
|
||||
self.recent_files.truncate(self.recent_limit);
|
||||
let evicted = self
|
||||
.recent_files
|
||||
.split_off(self.recent_limit.min(self.recent_files.len()));
|
||||
remove_cached_copies(evicted);
|
||||
self.save_config();
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_cached_copies(paths: impl IntoIterator<Item = PathBuf>) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let _ = paths;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
for path in paths {
|
||||
let Some(name) = path
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy().into_owned())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let _ = crate::io::web_recent::remove(&name).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
+46
-14
@@ -366,8 +366,8 @@ impl OpenCADStudio {
|
||||
self.plot_dialog = cfg.plot;
|
||||
}
|
||||
|
||||
/// Write the config to disk only when it changed since the last write, so a
|
||||
/// toggle persists immediately without thrashing the file.
|
||||
/// Write the config only when it changed since the last write, so a toggle
|
||||
/// persists immediately without thrashing native or browser storage.
|
||||
pub(in crate::app) fn save_config(&mut self) {
|
||||
let cur = self.current_config();
|
||||
if self.last_saved_config.as_ref() != Some(&cur) {
|
||||
@@ -423,13 +423,24 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
|
||||
/// resolved (deleted since) matches nothing and falls through to the normal
|
||||
/// open, which reports the miss.
|
||||
pub(in crate::app) fn tab_showing(&self, path: &std::path::Path) -> Option<usize> {
|
||||
let want = std::fs::canonicalize(path).ok()?;
|
||||
self.tabs.iter().position(|t| {
|
||||
t.current_path
|
||||
.as_deref()
|
||||
.and_then(|p| std::fs::canonicalize(p).ok())
|
||||
.is_some_and(|p| p == want)
|
||||
})
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
return self
|
||||
.tabs
|
||||
.iter()
|
||||
.position(|tab| tab.current_path.as_deref() == Some(path));
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let want = std::fs::canonicalize(path).ok()?;
|
||||
self.tabs.iter().position(|t| {
|
||||
t.current_path
|
||||
.as_deref()
|
||||
.and_then(|p| std::fs::canonicalize(p).ok())
|
||||
.is_some_and(|p| p == want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the next drawing a second launch handed us, if any.
|
||||
@@ -1632,12 +1643,33 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
|
||||
self.stamp_header_sysvars(i);
|
||||
self.sync_truck_solids_to_acis(i);
|
||||
self.stamp_thumbnail(i, version);
|
||||
let saved =
|
||||
match crate::io::save_to_bytes(&self.tabs[i].scene.document, ext, version) {
|
||||
let mut recent_task = Task::none();
|
||||
let saved = match crate::io::save_to_bytes(
|
||||
&self.tabs[i].scene.document,
|
||||
ext,
|
||||
version,
|
||||
) {
|
||||
Ok(bytes) => {
|
||||
crate::sys::download_bytes(&filename, &bytes);
|
||||
let cache_name = std::path::Path::new(&filename)
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| filename.clone());
|
||||
let path = std::path::PathBuf::from(cache_name);
|
||||
self.tabs[i].current_path = Some(path.clone());
|
||||
self.tabs[i].scene.document.version = version;
|
||||
self.tabs[i].dirty = false;
|
||||
recent_task = Task::perform(
|
||||
async move {
|
||||
crate::io::web_recent::store(
|
||||
&path.to_string_lossy(),
|
||||
&bytes,
|
||||
)
|
||||
.await
|
||||
.map(|_| path)
|
||||
},
|
||||
Message::WebRecentStored,
|
||||
);
|
||||
self.command_line.push_output(&format!("Saved: {filename}"));
|
||||
true
|
||||
}
|
||||
@@ -1654,14 +1686,14 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
|
||||
{
|
||||
let cont = self.update(Message::TabClose(idx));
|
||||
let rest = self.continue_tab_close_queue();
|
||||
return Task::batch([close, cont, rest]);
|
||||
return Task::batch([close, recent_task, cont, rest]);
|
||||
}
|
||||
} else if self.pending_close.is_some() {
|
||||
let retry = self.open_unsaved_dialog_window();
|
||||
return Task::batch([close, retry]);
|
||||
return Task::batch([close, recent_task, retry]);
|
||||
}
|
||||
}
|
||||
close
|
||||
Task::batch([close, recent_task])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+49
-11
@@ -290,18 +290,45 @@ impl OpenCADStudio {
|
||||
),
|
||||
|
||||
Message::OpenRecent(path) => {
|
||||
// Recents are read from disk every save → the path may be
|
||||
// stale. Skip silently if the file no longer exists; the
|
||||
// entry stays in the list so the user can clean it up.
|
||||
match std::fs::metadata(&path) {
|
||||
Ok(m) => self.update(Message::OpenPathPicked(Some((path, m.len())))),
|
||||
Err(_) => {
|
||||
self.command_line.push_error(&format!(
|
||||
"Recent file no longer exists: {}",
|
||||
path.display()
|
||||
));
|
||||
Task::none()
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
// Recents are read from disk every save → the path may be
|
||||
// stale. Skip silently if the file no longer exists; the
|
||||
// entry stays in the list so the user can clean it up.
|
||||
return match std::fs::metadata(&path) {
|
||||
Ok(m) => self.update(Message::OpenPathPicked(Some((path, m.len())))),
|
||||
Err(_) => {
|
||||
self.command_line.push_error(&format!(
|
||||
"Recent file no longer exists: {}",
|
||||
path.display()
|
||||
));
|
||||
Task::none()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
if let Some(idx) = self.tab_showing(&path) {
|
||||
return self.update(Message::TabSwitch(idx));
|
||||
}
|
||||
let name = path
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| path.to_string_lossy().into_owned());
|
||||
let state = std::sync::Arc::new(crate::io::OpenProgressState::new(
|
||||
crate::app::OPEN_PHASE_READING,
|
||||
));
|
||||
self.opening = Some(crate::app::OpenProgress {
|
||||
name,
|
||||
size_bytes: 0,
|
||||
state: state.clone(),
|
||||
started: Instant::now(),
|
||||
});
|
||||
Task::perform(
|
||||
crate::io::open_recent_web(path, state),
|
||||
Message::FileOpened,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -548,6 +575,17 @@ impl OpenCADStudio {
|
||||
self.drain_pending_open()
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
Message::WebRecentStored(result) => match result {
|
||||
Ok(path) => self.push_recent(path),
|
||||
Err(error) => {
|
||||
self.command_line.push_error(&format!(
|
||||
"Saved download, but recent copy could not be stored: {error}"
|
||||
));
|
||||
Task::none()
|
||||
}
|
||||
},
|
||||
|
||||
Message::ImagePick => {
|
||||
Task::perform(crate::io::pick_image_file(), Message::ImagePickResult)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user