diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
index 7ca06f10..d23c5f7e 100644
--- a/.github/workflows/pages.yml
+++ b/.github/workflows/pages.yml
@@ -82,6 +82,70 @@ jobs:
> dist/supporters.json || echo '[]' > dist/supporters.json
echo "supporters: $(jq 'length' dist/supporters.json)"
+ # Browsers cannot reliably fetch YouTube playlist/oEmbed responses because
+ # of CORS. Build a same-origin listing and thumbnail directory for the
+ # Start page. Keep the checked-in snapshot if YouTube is temporarily
+ # unavailable during deployment.
+ - name: Generate videos.json
+ run: |
+ python3 - <<'PY'
+ import json
+ import re
+ import shutil
+ import urllib.request
+ from pathlib import Path
+
+ playlist = "https://youtube.com/playlist?list=PLZq_TEkIFh9bAnoOX1HiCAunm3anZDBOl"
+ fallback = Path("web/videos.json")
+ output = Path("dist/videos.json")
+ thumbs = Path("dist/video_thumbs")
+ thumbs.mkdir(parents=True, exist_ok=True)
+ request_headers = {"User-Agent": "Mozilla/5.0"}
+
+ def fetch(url):
+ request = urllib.request.Request(url, headers=request_headers)
+ with urllib.request.urlopen(request, timeout=20) as response:
+ return response.read()
+
+ try:
+ page = fetch(playlist).decode("utf-8", errors="replace")
+ ids = []
+ for video_id in re.findall(r'"videoId":"([^"]+)"', page):
+ if len(video_id) == 11 and video_id not in ids:
+ ids.append(video_id)
+ if len(ids) >= 50:
+ break
+
+ entries = []
+ for video_id in ids:
+ try:
+ metadata = json.loads(fetch(
+ "https://www.youtube.com/oembed"
+ f"?url=https://youtu.be/{video_id}&format=json"
+ ))
+ title = str(metadata.get("title", "")).strip()
+ if not title:
+ continue
+ entries.append({"id": video_id, "title": title})
+ (thumbs / f"{video_id}.jpg").write_bytes(fetch(
+ f"https://i.ytimg.com/vi/{video_id}/mqdefault.jpg"
+ ))
+ except Exception as error:
+ print(f"video {video_id}: {error}")
+
+ if not entries:
+ raise RuntimeError("playlist returned no usable videos")
+ output.write_text(
+ json.dumps(list(reversed(entries)), ensure_ascii=False),
+ encoding="utf-8",
+ )
+ except Exception as error:
+ print(f"video snapshot fallback: {error}")
+ shutil.copyfile(fallback, output)
+
+ print(f"videos: {len(json.loads(output.read_text(encoding='utf-8')))}")
+ PY
+
# GitHub's Discussions API is authenticated GraphQL. Generate a public,
# token-free snapshot next to the web app; discussion activity (including
# pin/unpin) triggers this workflow so pinned entries stay at the top.
diff --git a/Cargo.toml b/Cargo.toml
index 165613ab..073de1e6 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -126,11 +126,20 @@ bincode = "1.3"
# (Response) for the lazy per-script web font loader (#141).
web-sys = { version = "0.3", features = [
"Window",
+ "Storage",
+ "StorageManager",
"Navigator",
"Document",
"Element",
"HtmlAnchorElement",
"Blob",
+ "File",
+ "FileSystemDirectoryHandle",
+ "FileSystemFileHandle",
+ "FileSystemGetDirectoryOptions",
+ "FileSystemGetFileOptions",
+ "FileSystemWritableFileStream",
+ "WritableStream",
"Url",
"Response",
"Worker",
diff --git a/index.html b/index.html
index 1bcf5c3b..4e48eb49 100644
--- a/index.html
+++ b/index.html
@@ -47,6 +47,7 @@
+
diff --git a/src/app/alias.rs b/src/app/alias.rs
index 740ef9b5..6888af69 100644
--- a/src/app/alias.rs
+++ b/src/app/alias.rs
@@ -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, `
/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 {
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 {
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 {
- 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) -> std::io::Result<()> {
- let Some(path) = alias_file_path() else {
- return Ok(());
- };
- if let Some(dir) = path.parent() {
- std::fs::create_dir_all(dir)?;
+ #[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(());
}
- std::fs::write(path, to_pgp(map))
}
impl OpenCADStudio {
diff --git a/src/app/config.rs b/src/app/config.rs
index 237071e8..06a8183e 100644
--- a/src/app/config.rs
+++ b/src/app/config.rs
@@ -1,8 +1,9 @@
-//! Consolidated user configuration — one grouped JSON file
-//! (`/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
+//! (`/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 {
Some(crate::config::config_dir()?.join("settings.json"))
}
diff --git a/src/app/mod.rs b/src/app/mod.rs
index dcd84150..96b0a68f 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -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),
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]))
}
}
diff --git a/src/app/recent.rs b/src/app/recent.rs
index 2ccb767f..3563cb3c 100644
--- a/src/app/recent.rs
+++ b/src/app/recent.rs
@@ -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 {
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- ) {
+ #[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;
+ });
+ }
+}
diff --git a/src/app/update/file.rs b/src/app/update/file.rs
index 81f04e7b..87f67168 100644
--- a/src/app/update/file.rs
+++ b/src/app/update/file.rs
@@ -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 {
/// 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 {
- 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 {
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 {
{
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])
}
}
diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs
index 20eac761..a49f7f1c 100644
--- a/src/app/update/mod.rs
+++ b/src/app/update/mod.rs
@@ -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)
}
diff --git a/src/config.rs b/src/config.rs
index 46bb238c..e8f90744 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -2,11 +2,13 @@
//! files, status-bar layout, ribbon collapse mode, …). Everything lives under
//! `/OpenCADStudio` so the app keeps a single tidy folder.
+#[cfg(not(target_arch = "wasm32"))]
use std::path::PathBuf;
/// The OpenCADStudio config directory (not created). `None` when the platform
/// config base can't be resolved (e.g. no `HOME`). Callers `join` their own
/// file name onto it and `create_dir_all` its parent before writing.
+#[cfg(not(target_arch = "wasm32"))]
pub fn config_dir() -> Option {
let base: PathBuf = if cfg!(target_os = "windows") {
std::env::var_os("APPDATA").map(PathBuf::from)?
diff --git a/src/io/mod.rs b/src/io/mod.rs
index c47841e6..1bf7865d 100644
--- a/src/io/mod.rs
+++ b/src/io/mod.rs
@@ -22,6 +22,8 @@ pub mod paper_sizes;
pub mod thumbnail;
#[cfg(target_arch = "wasm32")]
mod web_worker;
+#[cfg(target_arch = "wasm32")]
+pub(crate) mod web_recent;
use crate::scene::DerivedCaches;
use acadrust::entities::EntityType;
@@ -224,8 +226,45 @@ pub async fn pick_and_load_web(
let name = handle.file_name();
progress.set(crate::app::OPEN_PHASE_READING, 500, 1, 2);
let bytes = handle.read().await;
+ let parsed = load_web_bytes(&name, &bytes, progress.clone());
+ let cached = web_recent::store(&name, &bytes);
+ let (result, cache_result) = iced::futures::future::join(parsed, cached).await;
+ if result.is_err() && cache_result.is_ok() {
+ let _ = web_recent::remove(&name).await;
+ }
+ result
+}
+
+/// Reopen a browser-private recent copy without showing the file picker.
+#[cfg(target_arch = "wasm32")]
+pub async fn open_recent_web(
+ path: PathBuf,
+ progress: Arc,
+) -> Result<(String, PathBuf, CadDocument, DerivedCaches), String> {
+ let name = path
+ .file_name()
+ .map(|name| name.to_string_lossy().into_owned())
+ .ok_or_else(|| "Recent drawing has no file name".to_string())?;
+ let bytes = web_recent::read(&name)
+ .await
+ .map_err(|error| format!("Recent copy unavailable for \"{name}\": {error}"))?;
+ progress.set(
+ crate::app::OPEN_PHASE_READING,
+ 1000,
+ bytes.len(),
+ bytes.len(),
+ );
+ load_web_bytes(&name, &bytes, progress).await
+}
+
+#[cfg(target_arch = "wasm32")]
+async fn load_web_bytes(
+ name: &str,
+ bytes: &[u8],
+ progress: Arc,
+) -> Result<(String, PathBuf, CadDocument, DerivedCaches), String> {
progress.set(crate::app::OPEN_PHASE_PARSING, 1000, 0, 1);
- let mut doc = match web_worker::parse_document(&name, bytes).await {
+ let mut doc = match web_worker::parse_document(name, bytes).await {
Ok(document) => document,
Err(error) => return Err(format!("Web parser worker: {error}")),
};
@@ -240,8 +279,8 @@ pub async fn pick_and_load_web(
let mut caches = crate::scene::build_derived_caches(&doc);
caches.corrupt_dropped = dropped;
progress.set(crate::app::OPEN_PHASE_FINALIZING, 9900, 1, 1);
- let path = PathBuf::from(&name);
- Ok((name, path, doc, caches))
+ let path = PathBuf::from(name);
+ Ok((name.to_string(), path, doc, caches))
}
/// Parse a CAD document from in-memory bytes, choosing the format from
diff --git a/src/io/web_recent.rs b/src/io/web_recent.rs
new file mode 100644
index 00000000..12708cdb
--- /dev/null
+++ b/src/io/web_recent.rs
@@ -0,0 +1,89 @@
+//! Browser-backed copies of recently opened drawings.
+//!
+//! A web file picker exposes bytes and a display name, not a reusable native
+//! path. Keep the last-opened copy in the origin-private file system (OPFS) so
+//! the Start page can reopen it without asking the user to pick it again.
+
+use wasm_bindgen::JsCast;
+use wasm_bindgen_futures::JsFuture;
+
+const RECENT_DIRECTORY: &str = "opencadstudio-recent";
+
+pub async fn store(name: &str, bytes: &[u8]) -> Result<(), String> {
+ let directory = recent_directory(true).await?;
+ let options = web_sys::FileSystemGetFileOptions::new();
+ options.set_create(true);
+ let handle = JsFuture::from(directory.get_file_handle_with_options(&cache_key(name), &options))
+ .await
+ .map_err(js_error)?
+ .dyn_into::()
+ .map_err(js_error)?;
+ let writable = JsFuture::from(handle.create_writable())
+ .await
+ .map_err(js_error)?
+ .dyn_into::()
+ .map_err(js_error)?;
+ let write = writable.write_with_u8_array(bytes).map_err(js_error)?;
+ JsFuture::from(write).await.map_err(js_error)?;
+ JsFuture::from(writable.close()).await.map_err(js_error)?;
+ Ok(())
+}
+
+pub async fn read(name: &str) -> Result, String> {
+ let directory = recent_directory(false).await?;
+ let handle = JsFuture::from(directory.get_file_handle(&cache_key(name)))
+ .await
+ .map_err(js_error)?
+ .dyn_into::()
+ .map_err(js_error)?;
+ let file = JsFuture::from(handle.get_file())
+ .await
+ .map_err(js_error)?
+ .dyn_into::()
+ .map_err(js_error)?;
+ let buffer = JsFuture::from(file.array_buffer())
+ .await
+ .map_err(js_error)?;
+ Ok(js_sys::Uint8Array::new(&buffer).to_vec())
+}
+
+pub async fn remove(name: &str) -> Result<(), String> {
+ let directory = recent_directory(false).await?;
+ JsFuture::from(directory.remove_entry(&cache_key(name)))
+ .await
+ .map_err(js_error)?;
+ Ok(())
+}
+
+async fn recent_directory(create: bool) -> Result {
+ let window = web_sys::window().ok_or_else(|| "browser window unavailable".to_string())?;
+ let root = JsFuture::from(window.navigator().storage().get_directory())
+ .await
+ .map_err(js_error)?
+ .dyn_into::()
+ .map_err(js_error)?;
+ let options = web_sys::FileSystemGetDirectoryOptions::new();
+ options.set_create(create);
+ JsFuture::from(root.get_directory_handle_with_options(RECENT_DIRECTORY, &options))
+ .await
+ .map_err(js_error)?
+ .dyn_into::()
+ .map_err(js_error)
+}
+
+/// Stable, short OPFS entry name. The original display name remains in
+/// `AppConfig::recent`; only the browser-private cache uses this key.
+fn cache_key(name: &str) -> String {
+ let mut hash = 0xcbf29ce484222325_u64;
+ for byte in name.as_bytes() {
+ hash ^= u64::from(*byte);
+ hash = hash.wrapping_mul(0x100000001b3);
+ }
+ format!("{hash:016x}.cad")
+}
+
+fn js_error(value: wasm_bindgen::JsValue) -> String {
+ value
+ .as_string()
+ .unwrap_or_else(|| format!("browser storage error: {value:?}"))
+}
diff --git a/src/io/web_worker.rs b/src/io/web_worker.rs
index 40b1703d..3bdb97b3 100644
--- a/src/io/web_worker.rs
+++ b/src/io/web_worker.rs
@@ -7,7 +7,7 @@ use wasm_bindgen::closure::Closure;
use wasm_bindgen::{JsCast, JsValue};
use web_sys::{ErrorEvent, MessageEvent, Worker, WorkerOptions, WorkerType};
-pub(super) async fn parse_document(name: &str, bytes: Vec) -> Result {
+pub(super) async fn parse_document(name: &str, bytes: &[u8]) -> Result {
let options = WorkerOptions::new();
options.set_type(WorkerType::Module);
let worker = Worker::new_with_options("ocs-parse-worker.js", &options).map_err(js_error)?;
@@ -55,7 +55,7 @@ pub(super) async fn parse_document(name: &str, bytes: Vec) -> Result.jpg`), so later launches show the list instantly and
-//! offline launches still show everything fetched before.
+//! Native builds scan the playlist at boot and cache the result. Web builds
+//! read a same-origin `videos.json` and thumbnails generated by the Pages
+//! workflow, avoiding YouTube's browser CORS restrictions.
/// The official tutorials playlist.
pub const PLAYLIST_URL: &str =
@@ -14,11 +11,12 @@ pub const PLAYLIST_URL: &str =
const PLAYLIST_ID: &str = "PLZq_TEkIFh9bAnoOX1HiCAunm3anZDBOl";
/// One playlist entry, ready for the Start page.
-#[derive(Clone, Debug)]
+#[derive(Clone, Debug, serde::Deserialize)]
pub struct VideoEntry {
pub id: String,
pub title: String,
/// JPEG bytes of the thumbnail (from cache or the network), if available.
+ #[serde(default)]
pub thumb: Option>,
}
@@ -173,3 +171,82 @@ pub fn fetch_playlist() -> Result, String> {
}
Ok(out)
}
+
+#[cfg(target_arch = "wasm32")]
+pub async fn fetch_playlist_web() -> Result, String> {
+ use wasm_bindgen::JsCast;
+ use wasm_bindgen_futures::JsFuture;
+
+ let window = web_sys::window().ok_or("no window")?;
+ let response = JsFuture::from(window.fetch_with_str("videos.json"))
+ .await
+ .map_err(|_| "videos.json fetch failed")?
+ .dyn_into::()
+ .map_err(|_| "videos.json response is invalid")?;
+ if !response.ok() {
+ return Err(format!("videos.json HTTP {}", response.status()));
+ }
+ let text = JsFuture::from(
+ response
+ .text()
+ .map_err(|_| "videos.json text() unavailable")?,
+ )
+ .await
+ .map_err(|_| "videos.json body read failed")?;
+ let body = text.as_string().ok_or("videos.json body is not a string")?;
+ let mut entries: Vec =
+ serde_json::from_str(&body).map_err(|error| error.to_string())?;
+ entries.retain(|entry| entry.id.len() == 11 && !entry.title.trim().is_empty());
+ if entries.is_empty() {
+ return Err("videos.json contains no videos".into());
+ }
+
+ let thumbnails = iced::futures::future::join_all(
+ entries
+ .iter()
+ .map(|entry| fetch_thumbnail_web(entry.id.clone())),
+ )
+ .await;
+ for (entry, thumbnail) in entries.iter_mut().zip(thumbnails) {
+ entry.thumb = thumbnail.ok();
+ }
+ Ok(entries)
+}
+
+#[cfg(target_arch = "wasm32")]
+async fn fetch_thumbnail_web(id: String) -> Result, String> {
+ let local = format!("video_thumbs/{id}.jpg");
+ match fetch_thumbnail_url(&local).await {
+ Ok(bytes) => Ok(bytes),
+ Err(_) => {
+ // Local development uses the checked-in JSON snapshot without
+ // binary thumbnails. YouTube's image CDN explicitly permits CORS,
+ // so it is a safe fallback; deployed Pages builds use local files.
+ fetch_thumbnail_url(&format!("https://i.ytimg.com/vi/{id}/mqdefault.jpg")).await
+ }
+ }
+}
+
+#[cfg(target_arch = "wasm32")]
+async fn fetch_thumbnail_url(url: &str) -> Result, String> {
+ use wasm_bindgen::JsCast;
+ use wasm_bindgen_futures::JsFuture;
+
+ let window = web_sys::window().ok_or("no window")?;
+ let response = JsFuture::from(window.fetch_with_str(url))
+ .await
+ .map_err(|_| "thumbnail fetch failed")?
+ .dyn_into::()
+ .map_err(|_| "thumbnail response is invalid")?;
+ if !response.ok() {
+ return Err(format!("thumbnail HTTP {}", response.status()));
+ }
+ let buffer = JsFuture::from(
+ response
+ .array_buffer()
+ .map_err(|_| "thumbnail arrayBuffer() unavailable")?,
+ )
+ .await
+ .map_err(|_| "thumbnail body read failed")?;
+ Ok(js_sys::Uint8Array::new(&buffer).to_vec())
+}
diff --git a/web/videos.json b/web/videos.json
new file mode 100644
index 00000000..68ef0261
--- /dev/null
+++ b/web/videos.json
@@ -0,0 +1,22 @@
+[
+ {
+ "id": "QFDMSRNrZOU",
+ "title": "Create 2D Chair DWG Drawing in Open CAD Studio"
+ },
+ {
+ "id": "bHwKKOdBtCQ",
+ "title": "Open CAD Studio: Object Snap Explained"
+ },
+ {
+ "id": "JRlmIInZRiQ",
+ "title": "How to Install Open CAD Studio – Free DWG Authoring Tool"
+ },
+ {
+ "id": "4_glyJFo0qI",
+ "title": "Open CAD Studio as a Free DWG File Viewer with No Login Needed"
+ },
+ {
+ "id": "uN9zxM7p_fc",
+ "title": "Open CAD Studio Tutorial: Create a 2D DWG Drawing & Export to PDF"
+ }
+]