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" + } +]