diff --git a/hel/src/commands.rs b/hel/src/commands.rs index 4b4e76a..370b732 100644 --- a/hel/src/commands.rs +++ b/hel/src/commands.rs @@ -1,10 +1,7 @@ use regex::Regex; use sha1::{Digest, Sha1}; use std::cmp::min; -use std::collections::{HashMap, HashSet}; -use std::fs; -use std::io::{BufRead, BufReader}; -use std::io::{BufWriter, Write}; +use std::collections::HashSet; use crate::parser::command_parser; use crate::password::fix_password_recursion; @@ -12,6 +9,9 @@ use crate::password::{Name, Password, PasswordRef}; use crate::repl::LKEval; use crate::structs::{config_get, config_set, LKOut, Radix, CORRECT_FILE, DUMP_FILE}; use crate::utils::editor::password; +// call_cmd_with_input / get_cmd_args_from_command / get_copy_command_from_env are +// only used by the native (non-wasm) subprocess branches. +#[cfg_attr(target_arch = "wasm32", allow(unused_imports))] use crate::utils::{call_cmd_with_input, get_cmd_args_from_command, get_copy_command_from_env, rnd}; impl<'a> LKEval<'a> { @@ -214,18 +214,27 @@ impl<'a> LKEval<'a> { let data = print.out.data(); print.out.copy_err(&out); if data.len() > 0 { - let (copy_command, copy_cmd_args) = get_copy_command_from_env(); - match call_cmd_with_input(©_command, ©_cmd_args, &data) { - Ok(s) if s.len() > 0 => { - out.o(format!( - "Copied output with the command {}, and got following output:", - copy_command - )); - out.o(s.trim().to_string()); - } - Ok(_) => out.o(format!("Copied output with command {}", copy_command)), - Err(e) => out.e(format!("error: failed to copy: {}", e.to_string())), - }; + // Clipboard copy shells out to pbcopy/xclip/tmux: native only. + // In the browser the page provides a Copy button instead. + #[cfg(target_arch = "wasm32")] + { + out.e("error: pb (clipboard copy) is not available in the browser; use the Copy button".to_string()); + } + #[cfg(not(target_arch = "wasm32"))] + { + let (copy_command, copy_cmd_args) = get_copy_command_from_env(); + match call_cmd_with_input(©_command, ©_cmd_args, &data) { + Ok(s) if s.len() > 0 => { + out.o(format!( + "Copied output with the command {}, and got following output:", + copy_command + )); + out.o(s.trim().to_string()); + } + Ok(_) => out.o(format!("Copied output with command {}", copy_command)), + Err(e) => out.e(format!("error: failed to copy: {}", e.to_string())), + }; + } } } Err(e) => out.e(format!("error: failed to parse command {}: {}", command, e.to_string())), @@ -234,31 +243,42 @@ impl<'a> LKEval<'a> { pub fn cmd_source(&self, out: &LKOut, source: &String) -> bool { out.o(format!("source {}", source)); - let script = if source.trim().ends_with("|") { - let (cmd, args) = match get_cmd_args_from_command(source.trim().trim_end_matches('|')) { - Ok(c) => c, + let script: String; + if source.trim().ends_with("|") { + // Loading from a command's output needs a subprocess: native only. + #[cfg(target_arch = "wasm32")] + { + out.e("error: pipe source is not available in the browser".to_string()); + return false; + } + #[cfg(not(target_arch = "wasm32"))] + { + let (cmd, args) = match get_cmd_args_from_command(source.trim().trim_end_matches('|')) { + Ok(c) => c, + Err(e) => { + out.e(format!("error: failed to parse command {:?}: {}", source, e.to_string())); + return false; + } + }; + script = match call_cmd_with_input(&cmd, &args, "") { + Ok(o) => o, + Err(e) => { + out.e(format!("error: failed to execute command {}: {}", cmd, e.to_string())); + return false; + } + }; + } + } else { + // File path on native; localStorage key in the browser. + let key = shellexpand::full(source).unwrap().into_owned(); + script = match crate::storage::read(&key) { + Ok(script) => script, Err(e) => { - out.e(format!("error: failed to parse command {:?}: {}", source, e.to_string())); + out.e(format!("error: failed to read {}: {}", source, e.to_string())); return false; } }; - match call_cmd_with_input(&cmd, &args, "") { - Ok(o) => o, - Err(e) => { - out.e(format!("error: failed to execute command {}: {}", cmd, e.to_string())); - return false; - } - } - } else { - let script = shellexpand::full(source).unwrap().into_owned(); - match std::fs::read_to_string(script) { - Ok(script) => script, - Err(e) => { - out.e(format!("error: failed to read file {}: {}", source, e.to_string())); - return false; - } - } - }; + } match command_parser::script(&script) { Ok(cmd_list) => { for cmd in cmd_list { @@ -321,39 +341,37 @@ impl<'a> LKEval<'a> { None => config_get("hel_dump").unwrap_or_else(|| DUMP_FILE.to_str().unwrap().to_string()), }; let script = shellexpand::full(&script).unwrap().into_owned(); - fn save_dump(data: &HashMap, script: &String) -> std::io::Result<()> { - let file = fs::File::create(script)?; - let mut writer = BufWriter::new(file); - let mut vals = data.values().map(|v| v.clone()).collect::>(); - vals.sort_by(|a, b| a.lock().borrow().name.cmp(&b.lock().borrow().name)); - for pwd in vals { - writeln!(writer, "add {}", pwd.lock().borrow().to_string())? - } - Ok(()) - } if script.trim().starts_with("|") { - let (cmd, args) = match get_cmd_args_from_command(script.trim().trim_start_matches('|')) { - Ok(c) => c, - Err(e) => { - out.e(format!("error: failed to parse command {:?}: {}", script, e.to_string())); - return; + // Piping the dump to a command needs a subprocess: native only. + #[cfg(target_arch = "wasm32")] + { + out.e("error: pipe dump is not available in the browser".to_string()); + } + #[cfg(not(target_arch = "wasm32"))] + { + let (cmd, args) = match get_cmd_args_from_command(script.trim().trim_start_matches('|')) { + Ok(c) => c, + Err(e) => { + out.e(format!("error: failed to parse command {:?}: {}", script, e.to_string())); + return; + } + }; + let data = self.serialize_db(); + self.show_dump_diff(out, &data); + let output = match call_cmd_with_input(&cmd, &args, data.as_str()) { + Ok(o) => o, + Err(e) => { + out.e(format!("error: failed to execute command {}: {}", cmd, e.to_string())); + return; + } + }; + self.state.lock().borrow_mut().last_dump = Some(data); + if output.len() > 0 { + out.e(format!("Passwords saved to command {} and got following output:", cmd)); + out.o(output); + } else { + out.o(format!("Passwords saved to command {}", cmd)); } - }; - let data = self.serialize_db(); - self.show_dump_diff(out, &data); - let output = match call_cmd_with_input(&cmd, &args, data.as_str()) { - Ok(o) => o, - Err(e) => { - out.e(format!("error: failed to execute command {}: {}", cmd, e.to_string())); - return; - } - }; - self.state.lock().borrow_mut().last_dump = Some(data); - if output.len() > 0 { - out.e(format!("Passwords saved to command {} and got following output:", cmd)); - out.o(output); - } else { - out.o(format!("Passwords saved to command {}", cmd)); } } else if script.trim() == "-" { let mut vals = (&self.state.lock().borrow().db).values().map(|v| v.clone()).collect::>(); @@ -362,14 +380,15 @@ impl<'a> LKEval<'a> { out.o(format!("add {}", pwd.lock().borrow().to_string())) } } else { + // File path on native; localStorage key in the browser. let data = self.serialize_db(); self.show_dump_diff(out, &data); - // Bind first so the immutable borrow drops before the borrow_mut below. - let res = save_dump(&self.state.lock().borrow().db, &script); - match res { + // Trailing newline to match the historical file format (writeln per line). + let body = if data.is_empty() { String::new() } else { format!("{}\n", data) }; + match crate::storage::write(&script, &body) { Ok(()) => { self.state.lock().borrow_mut().last_dump = Some(data); - out.o(format!("Passwords saved to file {}", script)); + out.o(format!("Passwords saved to {}", script)); } Err(e) => out.e(format!("error: failed to dump passwords to {}: {}", script, e.to_string())), }; @@ -434,11 +453,13 @@ impl<'a> LKEval<'a> { None => return, }; fn load_lines() -> std::io::Result> { - let file = fs::File::open(CORRECT_FILE.to_str().unwrap())?; - let reader = BufReader::new(file); + let content = crate::storage::read(CORRECT_FILE.to_str().unwrap())?; let mut lines = HashSet::new(); - for line in reader.lines() { - lines.insert(line?.trim().to_owned()); + for line in content.lines() { + let line = line.trim(); + if !line.is_empty() { + lines.insert(line.to_owned()); + } } Ok(lines) } @@ -469,12 +490,12 @@ impl<'a> LKEval<'a> { data.remove(&encpwd); } fn save_lines(data: &HashSet) -> std::io::Result<()> { - let file = fs::File::create(CORRECT_FILE.to_str().unwrap())?; - let mut writer = BufWriter::new(file); + let mut content = String::new(); for entry in data { - writeln!(writer, "{}", entry)?; + content.push_str(entry); + content.push('\n'); } - Ok(()) + crate::storage::write(CORRECT_FILE.to_str().unwrap(), &content) } match save_lines(&data) { Ok(()) => out.o(format!( diff --git a/hel/src/lib.rs b/hel/src/lib.rs index a48a092..6aba282 100644 --- a/hel/src/lib.rs +++ b/hel/src/lib.rs @@ -11,5 +11,6 @@ pub mod parser; pub mod password; pub mod repl; pub mod skey; +pub mod storage; pub mod structs; pub mod utils; diff --git a/hel/src/storage.rs b/hel/src/storage.rs new file mode 100644 index 0000000..87133a9 --- /dev/null +++ b/hel/src/storage.rs @@ -0,0 +1,48 @@ +//! Key -> value persistence with a per-target backend. +//! +//! - Native: the key is a filesystem path; backed by `std::fs`. +//! - WASM: the key is a localStorage key; backed by JS imports +//! `hel_storage_get` / `hel_storage_set` (provided by the host page). +//! +//! This lets the same command code (`init`, `source`, `dump`/`save`, `correct`) +//! persist to files on the CLI and to the browser's localStorage on the web, +//! with no per-call-site branching. + +use std::io; + +#[cfg(not(target_arch = "wasm32"))] +pub fn read(key: &str) -> io::Result { + std::fs::read_to_string(key) +} + +#[cfg(not(target_arch = "wasm32"))] +pub fn write(key: &str, data: &str) -> io::Result<()> { + std::fs::write(key, data) +} + +#[cfg(target_arch = "wasm32")] +mod imp { + use wasm_bindgen::prelude::*; + + #[wasm_bindgen] + extern "C" { + #[wasm_bindgen(js_name = hel_storage_get)] + pub fn get(key: &str) -> Option; + #[wasm_bindgen(js_name = hel_storage_set)] + pub fn set(key: &str, val: &str); + } +} + +#[cfg(target_arch = "wasm32")] +pub fn read(key: &str) -> io::Result { + match imp::get(key) { + Some(v) => Ok(v), + None => Err(io::Error::new(io::ErrorKind::NotFound, "key not found in localStorage")), + } +} + +#[cfg(target_arch = "wasm32")] +pub fn write(key: &str, data: &str) -> io::Result<()> { + imp::set(key, data); + Ok(()) +} diff --git a/hel/src/structs.rs b/hel/src/structs.rs index 31761c7..cd779a9 100644 --- a/hel/src/structs.rs +++ b/hel/src/structs.rs @@ -392,7 +392,7 @@ pub fn init() -> Option { let lk = Arc::new(ReentrantMutex::new(RefCell::new(LK::new()))); let editor = Editor::new(); - match std::fs::read_to_string(INIT_FILE.to_str().unwrap()) { + match crate::storage::read(INIT_FILE.to_str().unwrap()) { Ok(script) => match command_parser::script(&script) { Ok(cmd_list) => { for cmd in cmd_list { diff --git a/hel/src/utils.rs b/hel/src/utils.rs index f63e5f8..c77c661 100644 --- a/hel/src/utils.rs +++ b/hel/src/utils.rs @@ -150,25 +150,32 @@ pub mod editor { #[cfg(target_arch = "wasm32")] pub mod editor { use crate::structs::LKErr; + use parking_lot::Mutex; + use std::sync::Arc; use wasm_bindgen::prelude::*; + // Mirror the unix editor's contract so repl.rs (which holds an `EditorRef` + // and calls `.lock()`) compiles unchanged under wasm. + pub type EditorRef = Arc>; + #[wasm_bindgen] extern "C" { - #[wasm_bindgen(js_name = hel_read_password)] - fn extern_read_password(prompt: &str); - - #[wasm_bindgen(js_name = hel_current_password)] - fn extern_current_password() -> Option; + // Synchronous: the host page returns the current master-password value. + // (The old read/poll pair used thread::sleep, which deadlocks the single + // browser thread — never use blocking polling under wasm.) + #[wasm_bindgen(js_name = hel_get_password)] + fn extern_get_password(prompt: &str) -> String; } #[derive(Debug)] pub struct Editor { + #[allow(dead_code)] history: Vec, } impl Editor { - pub fn new() -> Self { - Self { history: vec![] } + pub fn new() -> EditorRef { + Arc::new(Mutex::new(Self { history: vec![] })) } pub fn clear_history(&mut self) { @@ -193,13 +200,7 @@ pub mod editor { } pub fn password(prompt: String) -> std::io::Result { - extern_read_password(&prompt); - loop { - match extern_current_password() { - Some(p) => return Ok(p), - None => std::thread::sleep(std::time::Duration::from_millis(100)), - } - } + Ok(extern_get_password(&prompt)) } } diff --git a/helwasm/Cargo.toml b/helwasm/Cargo.toml index e9ef650..7e9f7da 100644 --- a/helwasm/Cargo.toml +++ b/helwasm/Cargo.toml @@ -16,7 +16,4 @@ hel = { version = "0.1.0", path = "../hel" } lazy_static = "1.4.0" wasm-bindgen = "0.2.83" parking_lot = "0.12.1" - -[dependencies.web-sys] -version = "0.3.4" -features = [ 'Document' ] +console_error_panic_hook = "0.1" diff --git a/helwasm/build.sh b/helwasm/build.sh new file mode 100755 index 0000000..3227845 --- /dev/null +++ b/helwasm/build.sh @@ -0,0 +1,12 @@ +#!/bin/sh +# Build the helwasm web bundle into helwasm/pkg/ (committed so GitHub Pages can +# serve the static dir directly, no CI). Requires the wasm32 target and a +# wasm-bindgen CLI matching the wasm-bindgen crate version: +# rustup target add wasm32-unknown-unknown +# cargo install wasm-bindgen-cli --version +set -e +cd "$(dirname "$0")/.." +cargo build --target wasm32-unknown-unknown -p helwasm --release +wasm-bindgen --target web --out-dir helwasm/pkg \ + target/wasm32-unknown-unknown/release/helwasm.wasm +echo "built helwasm/pkg/ (helwasm.js + helwasm_bg.wasm)" diff --git a/helwasm/index.html b/helwasm/index.html index 57e0885..0eae979 100644 --- a/helwasm/index.html +++ b/helwasm/index.html @@ -1,172 +1,257 @@ - + - - - - + + + + LesS/KEY — password generator + + + + -
- - diff --git a/helwasm/pkg/helwasm.d.ts b/helwasm/pkg/helwasm.d.ts new file mode 100644 index 0000000..c04ebfd --- /dev/null +++ b/helwasm/pkg/helwasm.d.ts @@ -0,0 +1,56 @@ +/* tslint:disable */ +/* eslint-disable */ + +/** + * Run a single hel command line and return its combined output. + */ +export function hel_command(cmd: string): string; + +/** + * Call once at page load: routes Rust panics to the browser console with a + * readable message + stack instead of an opaque "unreachable" trap. + */ +export function hel_init(): void; + +/** + * Run a whole multi-line script (every `add …` line, `set …`, etc.) against the + * shared state in one call. Used to bulk-import a pasted catalog (e.g. the text + * of the Notion page) and to load the persisted catalog from localStorage. + */ +export function hel_load_script(script: string): string; + +export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module; + +export interface InitOutput { + readonly memory: WebAssembly.Memory; + readonly hel_command: (a: number, b: number) => [number, number]; + readonly hel_init: () => void; + readonly hel_load_script: (a: number, b: number) => [number, number]; + readonly __wbindgen_free: (a: number, b: number, c: number) => void; + readonly __wbindgen_malloc: (a: number, b: number) => number; + readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number; + readonly __wbindgen_externrefs: WebAssembly.Table; + readonly __wbindgen_start: () => void; +} + +export type SyncInitInput = BufferSource | WebAssembly.Module; + +/** + * Instantiates the given `module`, which can either be bytes or + * a precompiled `WebAssembly.Module`. + * + * @param {{ module: SyncInitInput }} module - Passing `SyncInitInput` directly is deprecated. + * + * @returns {InitOutput} + */ +export function initSync(module: { module: SyncInitInput } | SyncInitInput): InitOutput; + +/** + * If `module_or_path` is {RequestInfo} or {URL}, makes a request and + * for everything else, calls `WebAssembly.instantiate` directly. + * + * @param {{ module_or_path: InitInput | Promise }} module_or_path - Passing `InitInput` directly is deprecated. + * + * @returns {Promise} + */ +export default function __wbg_init (module_or_path?: { module_or_path: InitInput | Promise } | InitInput | Promise): Promise; diff --git a/helwasm/pkg/helwasm.js b/helwasm/pkg/helwasm.js new file mode 100644 index 0000000..23798af --- /dev/null +++ b/helwasm/pkg/helwasm.js @@ -0,0 +1,320 @@ +/* @ts-self-types="./helwasm.d.ts" */ + +/** + * Run a single hel command line and return its combined output. + * @param {string} cmd + * @returns {string} + */ +export function hel_command(cmd) { + let deferred2_0; + let deferred2_1; + try { + const ptr0 = passStringToWasm0(cmd, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len0 = WASM_VECTOR_LEN; + const ret = wasm.hel_command(ptr0, len0); + deferred2_0 = ret[0]; + deferred2_1 = ret[1]; + return getStringFromWasm0(ret[0], ret[1]); + } finally { + wasm.__wbindgen_free(deferred2_0, deferred2_1, 1); + } +} + +/** + * Call once at page load: routes Rust panics to the browser console with a + * readable message + stack instead of an opaque "unreachable" trap. + */ +export function hel_init() { + wasm.hel_init(); +} + +/** + * Run a whole multi-line script (every `add …` line, `set …`, etc.) against the + * shared state in one call. Used to bulk-import a pasted catalog (e.g. the text + * of the Notion page) and to load the persisted catalog from localStorage. + * @param {string} script + * @returns {string} + */ +export function hel_load_script(script) { + let deferred2_0; + let deferred2_1; + try { + const ptr0 = passStringToWasm0(script, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len0 = WASM_VECTOR_LEN; + const ret = wasm.hel_load_script(ptr0, len0); + deferred2_0 = ret[0]; + deferred2_1 = ret[1]; + return getStringFromWasm0(ret[0], ret[1]); + } finally { + wasm.__wbindgen_free(deferred2_0, deferred2_1, 1); + } +} +function __wbg_get_imports() { + const import0 = { + __proto__: null, + __wbg___wbindgen_throw_1506f2235d1bdba0: function(arg0, arg1) { + throw new Error(getStringFromWasm0(arg0, arg1)); + }, + __wbg_error_a6fa202b58aa1cd3: function(arg0, arg1) { + let deferred0_0; + let deferred0_1; + try { + deferred0_0 = arg0; + deferred0_1 = arg1; + console.error(getStringFromWasm0(arg0, arg1)); + } finally { + wasm.__wbindgen_free(deferred0_0, deferred0_1, 1); + } + }, + __wbg_getTime_00b3f7db575e4ef5: function(arg0) { + const ret = arg0.getTime(); + return ret; + }, + __wbg_getTimezoneOffset_08e2892156231088: function(arg0) { + const ret = arg0.getTimezoneOffset(); + return ret; + }, + __wbg_hel_get_password_271a2beac04c29db: function(arg0, arg1, arg2) { + const ret = hel_get_password(getStringFromWasm0(arg1, arg2)); + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, + __wbg_hel_rnd_range_d01857a65a482c6d: function(arg0, arg1) { + const ret = hel_rnd_range(arg0 >>> 0, arg1 >>> 0); + return ret; + }, + __wbg_hel_storage_get_9915f302e24bdacb: function(arg0, arg1, arg2) { + const ret = hel_storage_get(getStringFromWasm0(arg1, arg2)); + var ptr1 = isLikeNone(ret) ? 0 : passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + var len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, + __wbg_hel_storage_set_78052521669832fa: function(arg0, arg1, arg2, arg3) { + hel_storage_set(getStringFromWasm0(arg0, arg1), getStringFromWasm0(arg2, arg3)); + }, + __wbg_new_0_445c13a750296eb6: function() { + const ret = new Date(); + return ret; + }, + __wbg_new_227d7c05414eb861: function() { + const ret = new Error(); + return ret; + }, + __wbg_new_6d75fd236f920a62: function(arg0) { + const ret = new Date(arg0); + return ret; + }, + __wbg_stack_3b0d974bbf31e44f: function(arg0, arg1) { + const ret = arg1.stack; + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, + __wbindgen_cast_0000000000000001: function(arg0) { + // Cast intrinsic for `F64 -> Externref`. + const ret = arg0; + return ret; + }, + __wbindgen_init_externref_table: function() { + const table = wasm.__wbindgen_externrefs; + const offset = table.grow(4); + table.set(0, undefined); + table.set(offset + 0, undefined); + table.set(offset + 1, null); + table.set(offset + 2, true); + table.set(offset + 3, false); + }, + }; + return { + __proto__: null, + "./helwasm_bg.js": import0, + }; +} + +let cachedDataViewMemory0 = null; +function getDataViewMemory0() { + if (cachedDataViewMemory0 === null || cachedDataViewMemory0.buffer.detached === true || (cachedDataViewMemory0.buffer.detached === undefined && cachedDataViewMemory0.buffer !== wasm.memory.buffer)) { + cachedDataViewMemory0 = new DataView(wasm.memory.buffer); + } + return cachedDataViewMemory0; +} + +function getStringFromWasm0(ptr, len) { + return decodeText(ptr >>> 0, len); +} + +let cachedUint8ArrayMemory0 = null; +function getUint8ArrayMemory0() { + if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) { + cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer); + } + return cachedUint8ArrayMemory0; +} + +function isLikeNone(x) { + return x === undefined || x === null; +} + +function passStringToWasm0(arg, malloc, realloc) { + if (realloc === undefined) { + const buf = cachedTextEncoder.encode(arg); + const ptr = malloc(buf.length, 1) >>> 0; + getUint8ArrayMemory0().subarray(ptr, ptr + buf.length).set(buf); + WASM_VECTOR_LEN = buf.length; + return ptr; + } + + let len = arg.length; + let ptr = malloc(len, 1) >>> 0; + + const mem = getUint8ArrayMemory0(); + + let offset = 0; + + for (; offset < len; offset++) { + const code = arg.charCodeAt(offset); + if (code > 0x7F) break; + mem[ptr + offset] = code; + } + if (offset !== len) { + if (offset !== 0) { + arg = arg.slice(offset); + } + ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0; + const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len); + const ret = cachedTextEncoder.encodeInto(arg, view); + + offset += ret.written; + ptr = realloc(ptr, len, offset, 1) >>> 0; + } + + WASM_VECTOR_LEN = offset; + return ptr; +} + +let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }); +cachedTextDecoder.decode(); +const MAX_SAFARI_DECODE_BYTES = 2146435072; +let numBytesDecoded = 0; +function decodeText(ptr, len) { + numBytesDecoded += len; + if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) { + cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }); + cachedTextDecoder.decode(); + numBytesDecoded = len; + } + return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len)); +} + +const cachedTextEncoder = new TextEncoder(); + +if (!('encodeInto' in cachedTextEncoder)) { + cachedTextEncoder.encodeInto = function (arg, view) { + const buf = cachedTextEncoder.encode(arg); + view.set(buf); + return { + read: arg.length, + written: buf.length + }; + }; +} + +let WASM_VECTOR_LEN = 0; + +let wasmModule, wasmInstance, wasm; +function __wbg_finalize_init(instance, module) { + wasmInstance = instance; + wasm = instance.exports; + wasmModule = module; + cachedDataViewMemory0 = null; + cachedUint8ArrayMemory0 = null; + wasm.__wbindgen_start(); + return wasm; +} + +async function __wbg_load(module, imports) { + if (typeof Response === 'function' && module instanceof Response) { + if (typeof WebAssembly.instantiateStreaming === 'function') { + try { + return await WebAssembly.instantiateStreaming(module, imports); + } catch (e) { + const validResponse = module.ok && expectedResponseType(module.type); + + if (validResponse && module.headers.get('Content-Type') !== 'application/wasm') { + console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e); + + } else { throw e; } + } + } + + const bytes = await module.arrayBuffer(); + return await WebAssembly.instantiate(bytes, imports); + } else { + const instance = await WebAssembly.instantiate(module, imports); + + if (instance instanceof WebAssembly.Instance) { + return { instance, module }; + } else { + return instance; + } + } + + function expectedResponseType(type) { + switch (type) { + case 'basic': case 'cors': case 'default': return true; + } + return false; + } +} + +function initSync(module) { + if (wasm !== undefined) return wasm; + + + if (module !== undefined) { + if (Object.getPrototypeOf(module) === Object.prototype) { + ({module} = module) + } else { + console.warn('using deprecated parameters for `initSync()`; pass a single object instead') + } + } + + const imports = __wbg_get_imports(); + if (!(module instanceof WebAssembly.Module)) { + module = new WebAssembly.Module(module); + } + const instance = new WebAssembly.Instance(module, imports); + return __wbg_finalize_init(instance, module); +} + +async function __wbg_init(module_or_path) { + if (wasm !== undefined) return wasm; + + + if (module_or_path !== undefined) { + if (Object.getPrototypeOf(module_or_path) === Object.prototype) { + ({module_or_path} = module_or_path) + } else { + console.warn('using deprecated parameters for the initialization function; pass a single object instead') + } + } + + if (module_or_path === undefined) { + module_or_path = new URL('helwasm_bg.wasm', import.meta.url); + } + const imports = __wbg_get_imports(); + + if (typeof module_or_path === 'string' || (typeof Request === 'function' && module_or_path instanceof Request) || (typeof URL === 'function' && module_or_path instanceof URL)) { + module_or_path = fetch(module_or_path); + } + + const { instance, module } = await __wbg_load(await module_or_path, imports); + + return __wbg_finalize_init(instance, module); +} + +export { initSync, __wbg_init as default }; diff --git a/helwasm/pkg/helwasm_bg.wasm b/helwasm/pkg/helwasm_bg.wasm new file mode 100644 index 0000000..1a4e12f Binary files /dev/null and b/helwasm/pkg/helwasm_bg.wasm differ diff --git a/helwasm/pkg/helwasm_bg.wasm.d.ts b/helwasm/pkg/helwasm_bg.wasm.d.ts new file mode 100644 index 0000000..d68fdd4 --- /dev/null +++ b/helwasm/pkg/helwasm_bg.wasm.d.ts @@ -0,0 +1,11 @@ +/* tslint:disable */ +/* eslint-disable */ +export const memory: WebAssembly.Memory; +export const hel_command: (a: number, b: number) => [number, number]; +export const hel_init: () => void; +export const hel_load_script: (a: number, b: number) => [number, number]; +export const __wbindgen_free: (a: number, b: number, c: number) => void; +export const __wbindgen_malloc: (a: number, b: number) => number; +export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number; +export const __wbindgen_externrefs: WebAssembly.Table; +export const __wbindgen_start: () => void; diff --git a/helwasm/src/hel_state.rs b/helwasm/src/hel_state.rs index f8c5f1b..e600657 100644 --- a/helwasm/src/hel_state.rs +++ b/helwasm/src/hel_state.rs @@ -1,22 +1,42 @@ use hel::lk::{LK, LKRef}; -use hel::repl::LKRead; -use hel::utils::editor::Editor; -use std::sync::Arc; +use hel::parser::command_parser; +use hel::repl::{LKEval, LKRead}; +use hel::structs::LKOut; +use hel::utils::editor::{password, Editor}; use parking_lot::ReentrantMutex; use std::cell::RefCell; +use std::sync::Arc; use wasm_bindgen::prelude::*; lazy_static! { static ref STATE: LKRef = Arc::new(ReentrantMutex::new(RefCell::new(LK::new()))); } -#[allow(dead_code)] +/// Run a single hel command line and return its combined output. #[wasm_bindgen] pub fn hel_command(cmd: String) -> String { let editor = Editor::new(); let mut lkread = LKRead::new(editor, "> ".to_string(), STATE.clone()); - lkread.input = Some(cmd.to_string()); + lkread.input = Some(cmd); let lkeval = lkread.read(); let lkprint = lkeval.eval(); lkprint.out.output().join("\n") } + +/// Run a whole multi-line script (every `add …` line, `set …`, etc.) against the +/// shared state in one call. Used to bulk-import a pasted catalog (e.g. the text +/// of the Notion page) and to load the persisted catalog from localStorage. +#[wasm_bindgen] +pub fn hel_load_script(script: String) -> String { + let out = LKOut::new(); + match command_parser::script(&script) { + Ok(cmds) => { + for cmd in cmds { + let print = LKEval::new(Editor::new(), cmd, STATE.clone(), password).eval(); + print.out.copy(&out); + } + } + Err(e) => out.e(format!("error: {}", e)), + } + out.output().join("\n") +} diff --git a/helwasm/src/lib.rs b/helwasm/src/lib.rs index 92e3d7d..3f632ba 100644 --- a/helwasm/src/lib.rs +++ b/helwasm/src/lib.rs @@ -4,13 +4,11 @@ extern crate hel; use wasm_bindgen::prelude::*; -#[wasm_bindgen] -pub fn ok_add(a: i32, b: i32) -> i32 { - a + b + 1 -} - mod hel_state; +/// Call once at page load: routes Rust panics to the browser console with a +/// readable message + stack instead of an opaque "unreachable" trap. #[wasm_bindgen] pub fn hel_init() { + console_error_panic_hook::set_once(); } diff --git a/helwasm/style.css b/helwasm/style.css new file mode 100644 index 0000000..651fa2a --- /dev/null +++ b/helwasm/style.css @@ -0,0 +1,177 @@ +/* LesS/KEY web app — visual language mirrored from kaizenkodo.org + (~/Repos/Identity/website/main.css): washi-paper cream, ocean blue, hanko red, + Poppins / Inter / JetBrains Mono. Light, responsive, restrained. */ + +:root { + --cream: #f4f0e8; + --paper: #fbf8f2; + --ink: #15181b; + --muted: #6a675e; + --line: #ddd6c6; + --ocean: #1b4161; + --ocean-2: #2f6286; + --deep: #0a2540; + --seal: #e23b2e; + --disp: "Poppins", system-ui, sans-serif; + --body: "Inter", system-ui, sans-serif; + --mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace; + --maxw: 880px; + --radius: 14px; +} + +/* Masked-secret font: dots glyphs so the real chars stay selectable/copyable. + WebKit/Blink use -webkit-text-security; Firefox falls back to this font. */ +@font-face { + font-family: "text-security-disc"; + src: url("https://cdn.jsdelivr.net/npm/text-security/dist/text-security-disc.woff2") format("woff2"), + url("https://cdn.jsdelivr.net/npm/text-security/dist/text-security-disc.woff") format("woff"); + font-display: swap; +} + +* { margin: 0; padding: 0; box-sizing: border-box; } + +html { height: 100%; } + +body { + min-height: 100%; + font-family: var(--body); + font-size: 16px; + line-height: 1.6; + color: var(--ink); + background-color: var(--cream); + background-image: + linear-gradient(rgba(27, 65, 97, 0.05) 1px, transparent 1px), + linear-gradient(90deg, rgba(27, 65, 97, 0.05) 1px, transparent 1px); + background-size: 48px 48px; + -webkit-font-smoothing: antialiased; +} + +.wrap { max-width: var(--maxw); margin: 0 auto; padding: 0 20px; } + +/* Header */ +.site { + position: sticky; top: 0; z-index: 50; + background: rgba(244, 240, 232, 0.82); + backdrop-filter: blur(12px); + border-bottom: 1px solid var(--line); +} +.nav { display: flex; align-items: center; gap: 14px; height: 64px; } +.brand { display: inline-flex; align-items: center; gap: 11px; text-decoration: none; } +.enso { + width: 34px; height: 34px; border-radius: 50%; + border: 3px solid var(--ocean); border-right-color: transparent; + transform: rotate(-20deg); +} +.wordmark { font-family: var(--disp); font-weight: 700; font-size: 21px; color: var(--ocean); letter-spacing: -0.01em; } +.nav-spacer { flex: 1; } +.nav .ghost { font-family: var(--mono); font-size: 12px; } + +/* Eyebrow label */ +.eyebrow { + font-family: var(--mono); font-size: 12px; letter-spacing: 0.16em; + text-transform: uppercase; color: var(--ocean); + display: inline-flex; align-items: center; gap: 9px; margin-bottom: 14px; +} +.eyebrow::before { + content: ""; width: 7px; height: 7px; border-radius: 50%; + background: var(--seal); box-shadow: 0 0 0 3px rgba(226, 59, 46, 0.16); +} + +main { padding: 30px 0 64px; display: grid; gap: 22px; } + +/* Cards */ +.card { + background: var(--paper); border: 1px solid var(--line); + border-radius: var(--radius); padding: 22px; +} +.card h2 { font-family: var(--disp); font-weight: 600; font-size: clamp(20px, 3vw, 26px); color: var(--ink); margin-bottom: 4px; } +.card .hint { color: var(--muted); font-size: 14px; margin-bottom: 16px; } + +/* Form fields */ +.field { display: flex; flex-direction: column; gap: 6px; } +.field label { font-family: var(--mono); font-size: 11px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--muted); } +input[type="text"], input[type="password"] { + font-family: var(--mono); font-size: 15px; color: var(--ink); + background: #fff; border: 1px solid var(--line); border-radius: 10px; + padding: 11px 13px; width: 100%; outline: none; + transition: border-color .15s, box-shadow .15s; +} +input:focus { border-color: var(--ocean); box-shadow: 0 0 0 3px rgba(27, 65, 97, 0.12); } + +.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; } +.row { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin-top: 16px; } + +/* Buttons */ +.btn { + display: inline-flex; align-items: center; gap: 8px; cursor: pointer; + font-family: var(--disp); font-weight: 600; font-size: 14px; + background: var(--ocean); color: var(--cream); + border: 1px solid var(--ocean); border-radius: 999px; padding: 10px 20px; + transition: transform .15s, background .15s; +} +.btn:hover { transform: translateY(-1px); background: var(--deep); border-color: var(--deep); } +.btn.ghost { background: transparent; color: var(--ocean); } +.btn.ghost:hover { background: rgba(27, 65, 97, 0.06); color: var(--ocean); } +.btn:active { transform: none; } + +/* Secret rendering: real text in the DOM, visually masked. Click toggles. */ +.secret-line { margin-top: 16px; display: flex; align-items: baseline; gap: 12px; min-height: 28px; } +.secret-line .tag { font-family: var(--mono); font-size: 11px; color: var(--muted); white-space: nowrap; } +.secret { + font-family: "text-security-disc", var(--mono); + -webkit-text-security: disc; + font-size: 19px; letter-spacing: 0.06em; color: var(--ink); + cursor: pointer; word-break: break-all; user-select: text; + border-bottom: 1px dashed var(--line); +} +.secret.revealed { font-family: var(--mono); -webkit-text-security: none; color: var(--ocean); } +.secret:empty::before { content: "—"; color: var(--muted); -webkit-text-security: none; } + +/* Console */ +.console-out { + font-family: var(--mono); font-size: 13.5px; line-height: 1.55; + background: #fff; border: 1px solid var(--line); border-radius: 10px; + padding: 14px; height: 320px; overflow-y: auto; white-space: pre-wrap; word-break: break-word; +} +.console-out .cmd { color: var(--ocean); font-weight: 500; } +.console-out .err { color: var(--seal); } +.console-out .muted { color: var(--muted); } +.console-out .secret { font-size: 13.5px; } +.console-in { display: flex; align-items: center; gap: 8px; margin-top: 10px; + background: #fff; border: 1px solid var(--line); border-radius: 10px; padding: 4px 12px; } +.console-in .prompt { font-family: var(--mono); color: var(--ocean); font-weight: 600; } +.console-in input { border: none; box-shadow: none; padding: 8px 0; background: transparent; } +.console-in input:focus { box-shadow: none; } + +.toast { + position: fixed; left: 50%; bottom: 26px; transform: translateX(-50%); + background: var(--deep); color: var(--cream); font-family: var(--mono); font-size: 13px; + padding: 10px 18px; border-radius: 999px; box-shadow: 0 8px 24px rgba(10, 16, 24, 0.35); + opacity: 0; pointer-events: none; transition: opacity .2s; z-index: 80; +} +.toast.show { opacity: 1; } + +/* Modal (import/export) */ +.modal-bg { + position: fixed; inset: 0; background: rgba(10, 16, 24, 0.45); + display: none; align-items: center; justify-content: center; padding: 20px; z-index: 90; +} +.modal-bg.show { display: flex; } +.modal { background: var(--paper); border-radius: 16px; border: 1px solid var(--line); + width: 100%; max-width: 620px; padding: 22px; } +.modal h3 { font-family: var(--disp); font-weight: 600; margin-bottom: 10px; } +.modal textarea { + width: 100%; height: 240px; resize: vertical; font-family: var(--mono); font-size: 13px; + border: 1px solid var(--line); border-radius: 10px; padding: 12px; outline: none; +} +.modal textarea:focus { border-color: var(--ocean); } + +.foot { color: var(--muted); font-family: var(--mono); font-size: 12px; text-align: center; padding: 8px 0 40px; } +.foot a { color: var(--ocean-2); } + +@media (max-width: 620px) { + .grid2 { grid-template-columns: 1fr; } + .nav { gap: 8px; } + .console-out { height: 260px; } + body { font-size: 15px; } +}