From b01075dbe4554377f38d527585b3dfae0d49268b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 12:29:49 +0000 Subject: [PATCH 01/10] Let the AI wire protocol and single-file stacks work without a file system or an HTTP client The browser build needs both: it brings its own transport (fetch), so the provider crate's encode/decode become public free functions behind a default 'native' feature that keeps reqwest and the clients exactly as they were; and it has no disk, so a stack can now round-trip through a string with single_file_string and stack_from_single_file. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01R7eaxUAqy2gKQSBNnYZoFr --- crates/ai-providers/Cargo.toml | 9 +- crates/ai-providers/src/anthropic.rs | 111 +++++++++++++------------ crates/ai-providers/src/http.rs | 20 ----- crates/ai-providers/src/lib.rs | 14 +++- crates/ai-providers/src/openai.rs | 102 ++++++++++++++--------- crates/ai-providers/src/text.rs | 29 +++++++ crates/ai-providers/tests/over_http.rs | 2 + crates/persistence/src/lib.rs | 4 +- crates/persistence/src/load.rs | 22 ++++- crates/persistence/src/save.rs | 14 ++++ crates/persistence/tests/round_trip.rs | 42 ++++++++++ 11 files changed, 251 insertions(+), 118 deletions(-) create mode 100644 crates/ai-providers/src/text.rs diff --git a/crates/ai-providers/Cargo.toml b/crates/ai-providers/Cargo.toml index 3f4157e..f3ee976 100644 --- a/crates/ai-providers/Cargo.toml +++ b/crates/ai-providers/Cargo.toml @@ -19,7 +19,14 @@ serde_json.workspace = true reqwest = { version = "0.12", default-features = false, features = [ "json", "rustls-tls", -] } +], optional = true } + +[features] +default = ["native"] +# The clients themselves, on top of reqwest. Without this feature the crate +# is only the wire protocol — how a request is encoded and a reply decoded — +# which is what a host that brings its own transport (the browser) needs. +native = ["dep:reqwest"] [dev-dependencies] # Only the tests need a runtime of their own; the application brings its own. diff --git a/crates/ai-providers/src/anthropic.rs b/crates/ai-providers/src/anthropic.rs index b9c8e37..c053f77 100644 --- a/crates/ai-providers/src/anthropic.rs +++ b/crates/ai-providers/src/anthropic.rs @@ -10,12 +10,16 @@ //! * `max_tokens` is required (see [`DEFAULT_MAX_TOKENS`]). use hyperlab_ai::{ - AiError, AiProvider, AiResult, BoxFuture, Capabilities, ChatMessage, Completion, - CompletionRequest, FinishReason, ProviderConfig, Role, ToolCall, ToolDefinition, Usage, + AiError, AiResult, ChatMessage, Completion, CompletionRequest, FinishReason, Role, ToolCall, + ToolDefinition, Usage, }; +#[cfg(feature = "native")] +use hyperlab_ai::{AiProvider, BoxFuture, Capabilities, ProviderConfig}; use serde_json::{Map, Value, json}; -use crate::http::{Endpoint, text_at}; +#[cfg(feature = "native")] +use crate::http::Endpoint; +use crate::text::text_at; /// Where requests go when the configuration does not say. pub const DEFAULT_BASE_URL: &str = "https://api.anthropic.com/v1"; @@ -34,6 +38,7 @@ pub const API_VERSION: &str = "2023-06-01"; pub const DEFAULT_MAX_TOKENS: u32 = 4096; /// A provider that speaks Anthropic's Messages API. +#[cfg(feature = "native")] pub struct AnthropicProvider { name: String, model: String, @@ -41,6 +46,7 @@ pub struct AnthropicProvider { endpoint: Endpoint, } +#[cfg(feature = "native")] impl AnthropicProvider { /// Builds a provider with a key the caller has already found. /// @@ -72,39 +78,45 @@ impl AnthropicProvider { )?, }) } +} - /// The body of a `/messages` request. - fn completion_body(&self, request: &CompletionRequest) -> Value { - let mut body = Map::new(); - body.insert( - "model".into(), - json!(if request.model.is_empty() { - &self.model - } else { - &request.model - }), - ); +/// The body of a `/messages` request. +/// +/// A free function rather than a method so that a host that brings its own +/// transport — the browser — can speak the protocol without building a +/// client. `default_model` is used when the request names none. +#[must_use] +pub fn completion_body(default_model: &str, request: &CompletionRequest) -> Value { + let mut body = Map::new(); + body.insert( + "model".into(), + json!(if request.model.is_empty() { + default_model + } else { + &request.model + }), + ); + body.insert( + "max_tokens".into(), + json!(request.max_tokens.unwrap_or(DEFAULT_MAX_TOKENS)), + ); + if let Some(system) = hoist_system(&request.messages) { + body.insert("system".into(), json!(system)); + } + body.insert("messages".into(), encode_messages(&request.messages)); + if !request.tools.is_empty() { body.insert( - "max_tokens".into(), - json!(request.max_tokens.unwrap_or(DEFAULT_MAX_TOKENS)), + "tools".into(), + Value::Array(request.tools.iter().map(encode_tool).collect()), ); - if let Some(system) = hoist_system(&request.messages) { - body.insert("system".into(), json!(system)); - } - body.insert("messages".into(), encode_messages(&request.messages)); - if !request.tools.is_empty() { - body.insert( - "tools".into(), - Value::Array(request.tools.iter().map(encode_tool).collect()), - ); - } - if let Some(temperature) = request.temperature { - body.insert("temperature".into(), json!(temperature)); - } - Value::Object(body) } + if let Some(temperature) = request.temperature { + body.insert("temperature".into(), json!(temperature)); + } + Value::Object(body) } +#[cfg(feature = "native")] impl AiProvider for AnthropicProvider { fn name(&self) -> &str { &self.name @@ -122,7 +134,7 @@ impl AiProvider for AnthropicProvider { fn complete<'a>(&'a self, request: CompletionRequest) -> BoxFuture<'a, AiResult> { let endpoint = self.endpoint.clone(); - let body = self.completion_body(&request); + let body = completion_body(&self.model, &request); Box::pin(async move { let reply = endpoint.post_json("messages", body, describe_error).await?; decode_completion(&reply) @@ -214,7 +226,11 @@ fn encode_tool(tool: &ToolDefinition) -> Value { } /// Reads a reply into a [`Completion`]. -fn decode_completion(reply: &Value) -> AiResult { +/// +/// # Errors +/// +/// Returns [`AiError::Protocol`] if the reply is not shaped like an answer. +pub fn decode_completion(reply: &Value) -> AiResult { let blocks = reply .get("content") .and_then(Value::as_array) @@ -295,35 +311,26 @@ fn count(usage: &Value, field: &str) -> u32 { .unwrap_or(0) } -/// The API's own account of what went wrong. -fn describe_error(reply: &Value) -> Option { +/// The API's own account of what went wrong, from an error reply's body. +#[must_use] +pub fn describe_error(reply: &Value) -> Option { text_at(reply, &["error", "message"]) } #[cfg(test)] mod tests { use super::*; - use hyperlab_ai::ProviderKind; - - fn provider() -> AnthropicProvider { - AnthropicProvider::with_api_key( - "anthropic", - &ProviderConfig::new(ProviderKind::Anthropic, "a-model"), - None, - ) - .expect("no key, nothing to go wrong") - } #[test] fn max_tokens_is_always_sent_because_the_api_insists() { let mut request = CompletionRequest::new("a-model", vec![ChatMessage::user("hi")]); assert_eq!( - provider().completion_body(&request)["max_tokens"], + completion_body("a-model", &request)["max_tokens"], DEFAULT_MAX_TOKENS ); request.max_tokens = Some(64); - assert_eq!(provider().completion_body(&request)["max_tokens"], 64); + assert_eq!(completion_body("a-model", &request)["max_tokens"], 64); } #[test] @@ -336,7 +343,7 @@ mod tests { ChatMessage::system("and kind"), ], ); - let body = provider().completion_body(&request); + let body = completion_body("a-model", &request); assert_eq!(body["system"], "be brief\n\nand kind"); assert_eq!( body["messages"], @@ -347,7 +354,7 @@ mod tests { #[test] fn a_conversation_with_no_system_prompt_sends_no_field() { let request = CompletionRequest::new("a-model", vec![ChatMessage::user("hello")]); - assert!(provider().completion_body(&request).get("system").is_none()); + assert!(completion_body("a-model", &request).get("system").is_none()); } #[test] @@ -368,7 +375,7 @@ mod tests { ); assert_eq!( - provider().completion_body(&request)["messages"], + completion_body("a-model", &request)["messages"], json!([ {"role": "user", "content": [{"type": "text", "text": "what is on card 2?"}]}, {"role": "assistant", "content": [ @@ -391,7 +398,7 @@ mod tests { ChatMessage::tool_result("toolu_2", "two"), ], ); - let messages = provider().completion_body(&request)["messages"].clone(); + let messages = completion_body("a-model", &request)["messages"].clone(); assert_eq!(messages.as_array().map(Vec::len), Some(1)); assert_eq!(messages[0]["content"].as_array().map(Vec::len), Some(2)); } @@ -402,7 +409,7 @@ mod tests { "a-model", vec![ChatMessage::user("hi"), ChatMessage::assistant("")], ); - let messages = provider().completion_body(&request)["messages"].clone(); + let messages = completion_body("a-model", &request)["messages"].clone(); assert_eq!(messages.as_array().map(Vec::len), Some(1)); } @@ -413,7 +420,7 @@ mod tests { ToolDefinition::new("read_card", "Reads a card.", json!({"type": "object"})), ]); assert_eq!( - provider().completion_body(&request)["tools"], + completion_body("a-model", &request)["tools"], json!([{ "name": "read_card", "description": "Reads a card.", diff --git a/crates/ai-providers/src/http.rs b/crates/ai-providers/src/http.rs index a4eea2f..f50fe7b 100644 --- a/crates/ai-providers/src/http.rs +++ b/crates/ai-providers/src/http.rs @@ -142,15 +142,6 @@ fn strip_url(message: &str, url: &str) -> String { message.replace(url, "the provider") } -/// Reads a string out of a nested field, for the `describe` functions. -pub(crate) fn text_at(value: &Value, path: &[&str]) -> Option { - let mut here = value; - for key in path { - here = here.get(key)?; - } - here.as_str().map(str::to_string) -} - #[cfg(test)] mod tests { use super::*; @@ -221,15 +212,4 @@ mod tests { ); assert!(matches!(error.err(), Some(AiError::NotConfigured(_)))); } - - #[test] - fn nested_text_is_found_or_missed_quietly() { - let value = serde_json::json!({"error": {"message": "no"}}); - assert_eq!( - text_at(&value, &["error", "message"]).as_deref(), - Some("no") - ); - assert_eq!(text_at(&value, &["error", "detail"]), None); - assert_eq!(text_at(&value, &["nope", "message"]), None); - } } diff --git a/crates/ai-providers/src/lib.rs b/crates/ai-providers/src/lib.rs index 7f70b12..09bc85d 100644 --- a/crates/ai-providers/src/lib.rs +++ b/crates/ai-providers/src/lib.rs @@ -18,6 +18,7 @@ //! a provider means adding a module and one arm. //! //! ``` +//! # #[cfg(feature = "native")] { //! use hyperlab_ai::{NoKeychain, ProviderConfig, ProviderKind}; //! use hyperlab_ai_providers::build; //! @@ -29,6 +30,7 @@ //! Ok(provider) => assert_eq!(provider.name(), "anthropic"), //! Err(why) => println!("{why}"), //! } +//! # } //! ``` //! //! # Keys @@ -50,16 +52,22 @@ #![warn(missing_docs)] pub mod anthropic; +#[cfg(feature = "native")] mod http; pub mod openai; +mod text; +#[cfg(feature = "native")] use std::sync::Arc; +#[cfg(feature = "native")] use hyperlab_ai::{ AiError, AiProvider, AiResult, KeySource, Keychain, MockProvider, ProviderConfig, ProviderKind, }; +#[cfg(feature = "native")] pub use anthropic::AnthropicProvider; +#[cfg(feature = "native")] pub use openai::OpenAiProvider; /// Builds the provider a configuration describes. @@ -72,6 +80,7 @@ pub use openai::OpenAiProvider; /// /// Returns [`AiError::NotConfigured`] if something the client needs is /// missing, and [`AiError::Unsupported`] for a kind no client here speaks. +#[cfg(feature = "native")] pub fn build( name: &str, config: &ProviderConfig, @@ -103,6 +112,7 @@ pub fn build( /// /// A provider that is defined by its address — a local server, a gateway — /// cannot have a sensible default, and guessing a port is worse than asking. +#[cfg(feature = "native")] fn with_required_base_url(config: &ProviderConfig) -> AiResult { if config.base_url.is_none() { return Err(AiError::NotConfigured(format!( @@ -120,6 +130,7 @@ fn with_required_base_url(config: &ProviderConfig) -> AiResult { /// Returns [`AiError::NotConfigured`] naming the empty place. An unset /// variable is nearly always a typo or a shell that did not export it; an /// empty keychain entry means nobody has typed the key in yet. +#[cfg(feature = "native")] fn configured_key( name: &str, config: &ProviderConfig, @@ -135,6 +146,7 @@ fn configured_key( } /// What to say about a place that was named and found empty. +#[cfg(feature = "native")] fn empty(source: &KeySource) -> String { match source { KeySource::Environment(variable) => { @@ -144,7 +156,7 @@ fn empty(source: &KeySource) -> String { } } -#[cfg(test)] +#[cfg(all(test, feature = "native"))] mod tests { use hyperlab_ai::NoKeychain; diff --git a/crates/ai-providers/src/openai.rs b/crates/ai-providers/src/openai.rs index 4e21bea..5c77a38 100644 --- a/crates/ai-providers/src/openai.rs +++ b/crates/ai-providers/src/openai.rs @@ -6,19 +6,25 @@ //! the lot. That is the point: HyperLab gains four kinds of provider without //! learning that any of them exist. +#[cfg(any(feature = "native", test))] +use hyperlab_ai::Embedding; use hyperlab_ai::{ - AiError, AiProvider, AiResult, BoxFuture, Capabilities, ChatMessage, Completion, - CompletionRequest, Embedding, FinishReason, ProviderConfig, Role, ToolCall, ToolDefinition, - Usage, + AiError, AiResult, ChatMessage, Completion, CompletionRequest, FinishReason, Role, ToolCall, + ToolDefinition, Usage, }; +#[cfg(feature = "native")] +use hyperlab_ai::{AiProvider, BoxFuture, Capabilities, ProviderConfig}; use serde_json::{Map, Value, json}; -use crate::http::{Endpoint, text_at}; +#[cfg(feature = "native")] +use crate::http::Endpoint; +use crate::text::text_at; /// Where requests go when the configuration does not say. pub const DEFAULT_BASE_URL: &str = "https://api.openai.com/v1"; /// A provider that speaks the OpenAI chat-completions protocol. +#[cfg(feature = "native")] pub struct OpenAiProvider { name: String, model: String, @@ -27,6 +33,7 @@ pub struct OpenAiProvider { endpoint: Endpoint, } +#[cfg(feature = "native")] impl OpenAiProvider { /// Builds a provider with a key the caller has already found. /// @@ -72,41 +79,44 @@ impl OpenAiProvider { self.embedding_model = Some(model.into()); self } +} - /// The body of a `/chat/completions` request. - fn completion_body(&self, request: &CompletionRequest) -> Value { - let mut body = Map::new(); - body.insert("model".into(), json!(self.model_for(request))); +/// The body of a `/chat/completions` request. +/// +/// A free function rather than a method so that a host that brings its own +/// transport — the browser — can speak the protocol without building a +/// client. `default_model` is used when the request names none. +#[must_use] +pub fn completion_body(default_model: &str, request: &CompletionRequest) -> Value { + let mut body = Map::new(); + body.insert( + "model".into(), + json!(if request.model.is_empty() { + default_model + } else { + &request.model + }), + ); + body.insert( + "messages".into(), + Value::Array(request.messages.iter().map(encode_message).collect()), + ); + if !request.tools.is_empty() { body.insert( - "messages".into(), - Value::Array(request.messages.iter().map(encode_message).collect()), + "tools".into(), + Value::Array(request.tools.iter().map(encode_tool).collect()), ); - if !request.tools.is_empty() { - body.insert( - "tools".into(), - Value::Array(request.tools.iter().map(encode_tool).collect()), - ); - } - if let Some(temperature) = request.temperature { - body.insert("temperature".into(), json!(temperature)); - } - if let Some(max_tokens) = request.max_tokens { - body.insert("max_tokens".into(), json!(max_tokens)); - } - Value::Object(body) } - - /// The model a request should use: its own, or the configured default - /// when it does not name one. - fn model_for<'a>(&'a self, request: &'a CompletionRequest) -> &'a str { - if request.model.is_empty() { - &self.model - } else { - &request.model - } + if let Some(temperature) = request.temperature { + body.insert("temperature".into(), json!(temperature)); } + if let Some(max_tokens) = request.max_tokens { + body.insert("max_tokens".into(), json!(max_tokens)); + } + Value::Object(body) } +#[cfg(feature = "native")] impl AiProvider for OpenAiProvider { fn name(&self) -> &str { &self.name @@ -124,7 +134,7 @@ impl AiProvider for OpenAiProvider { // Everything the request needs is worked out now, so the future owns // its inputs and borrows nothing. let endpoint = self.endpoint.clone(); - let body = self.completion_body(&request); + let body = completion_body(&self.model, &request); Box::pin(async move { let reply = endpoint .post_json("chat/completions", body, describe_error) @@ -217,7 +227,11 @@ fn encode_tool(tool: &ToolDefinition) -> Value { } /// Reads a reply into a [`Completion`]. -fn decode_completion(reply: &Value) -> AiResult { +/// +/// # Errors +/// +/// Returns [`AiError::Protocol`] if the reply is not shaped like an answer. +pub fn decode_completion(reply: &Value) -> AiResult { let choice = reply .get("choices") .and_then(Value::as_array) @@ -310,6 +324,7 @@ fn count(usage: &Value, field: &str) -> u32 { /// Reads an embeddings reply, putting the vectors back in the order the texts /// were given in. +#[cfg(any(feature = "native", test))] fn decode_embeddings(reply: &Value, wanted: usize) -> AiResult> { let data = reply .get("data") @@ -348,16 +363,19 @@ fn decode_embeddings(reply: &Value, wanted: usize) -> AiResult> { .collect()) } -/// The server's own account of what went wrong. -fn describe_error(reply: &Value) -> Option { +/// The server's own account of what went wrong, from an error reply's body. +#[must_use] +pub fn describe_error(reply: &Value) -> Option { text_at(reply, &["error", "message"]).or_else(|| text_at(reply, &["message"])) } #[cfg(test)] mod tests { use super::*; + #[cfg(feature = "native")] use hyperlab_ai::ProviderKind; + #[cfg(feature = "native")] fn provider() -> OpenAiProvider { OpenAiProvider::with_api_key( "openai", @@ -367,6 +385,7 @@ mod tests { .expect("no key, nothing to go wrong") } + #[cfg(feature = "native")] #[test] fn a_local_server_needs_no_key() { let mut config = ProviderConfig::new(ProviderKind::Ollama, "llama"); @@ -375,6 +394,7 @@ mod tests { assert!(provider.capabilities().local); } + #[cfg(feature = "native")] #[test] fn embedding_is_unsupported_until_a_model_is_named() { assert!(!provider().capabilities().embeddings); @@ -389,10 +409,10 @@ mod tests { #[test] fn a_request_uses_the_configured_model_when_it_names_none() { let request = CompletionRequest::new("", vec![ChatMessage::user("hi")]); - assert_eq!(provider().completion_body(&request)["model"], "a-model"); + assert_eq!(completion_body("a-model", &request)["model"], "a-model"); let request = CompletionRequest::new("another", vec![ChatMessage::user("hi")]); - assert_eq!(provider().completion_body(&request)["model"], "another"); + assert_eq!(completion_body("a-model", &request)["model"], "another"); } #[test] @@ -405,7 +425,7 @@ mod tests { ChatMessage::assistant("hi"), ], ); - let body = provider().completion_body(&request); + let body = completion_body("a-model", &request); assert_eq!( body["messages"], json!([ @@ -429,7 +449,7 @@ mod tests { "a-model", vec![asking, ChatMessage::tool_result("call_1", "a card")], ); - let body = provider().completion_body(&request); + let body = completion_body("a-model", &request); let asking = &body["messages"][0]; assert!(asking.get("content").is_none(), "an empty turn sends none"); @@ -447,7 +467,7 @@ mod tests { ToolDefinition::new("read_card", "Reads a card.", json!({"type": "object"})), ]); assert_eq!( - provider().completion_body(&request)["tools"], + completion_body("a-model", &request)["tools"], json!([{ "type": "function", "function": { diff --git a/crates/ai-providers/src/text.rs b/crates/ai-providers/src/text.rs new file mode 100644 index 0000000..7e7695a --- /dev/null +++ b/crates/ai-providers/src/text.rs @@ -0,0 +1,29 @@ +//! One helper both protocols share, kept apart from [`http`](crate::http) +//! so that the wire protocol compiles without a transport. + +use serde_json::Value; + +/// Reads a string out of a nested field, for the `describe_error` functions. +pub(crate) fn text_at(value: &Value, path: &[&str]) -> Option { + let mut here = value; + for key in path { + here = here.get(key)?; + } + here.as_str().map(str::to_string) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn nested_text_is_found_or_missed_quietly() { + let value = serde_json::json!({"error": {"message": "no"}}); + assert_eq!( + text_at(&value, &["error", "message"]).as_deref(), + Some("no") + ); + assert_eq!(text_at(&value, &["error", "detail"]), None); + assert_eq!(text_at(&value, &["nope", "message"]), None); + } +} diff --git a/crates/ai-providers/tests/over_http.rs b/crates/ai-providers/tests/over_http.rs index 4302bba..2b95f33 100644 --- a/crates/ai-providers/tests/over_http.rs +++ b/crates/ai-providers/tests/over_http.rs @@ -6,6 +6,8 @@ //! [`TcpListener`](std::net::TcpListener) on loopback — see [`support`] — so //! nothing here touches a network or a vendor. +#![cfg(feature = "native")] + mod support; use hyperlab_ai::{ diff --git a/crates/persistence/src/lib.rs b/crates/persistence/src/lib.rs index f0746d6..f13b154 100644 --- a/crates/persistence/src/lib.rs +++ b/crates/persistence/src/lib.rs @@ -37,5 +37,5 @@ mod save; pub use error::{PersistenceError, PersistenceResult}; pub use format::{BUNDLE_EXTENSION, FORMAT_VERSION, Metadata}; -pub use load::{load, load_single_file, read_metadata}; -pub use save::{save, save_single_file}; +pub use load::{load, load_single_file, read_metadata, stack_from_single_file}; +pub use save::{save, save_single_file, single_file_string}; diff --git a/crates/persistence/src/load.rs b/crates/persistence/src/load.rs index f05b304..e449d8e 100644 --- a/crates/persistence/src/load.rs +++ b/crates/persistence/src/load.rs @@ -83,7 +83,27 @@ pub fn load(path: impl AsRef) -> PersistenceResult { /// /// Returns a [`PersistenceError`] if the file cannot be read or parsed. pub fn load_single_file(path: impl AsRef) -> PersistenceResult { - let stack: Stack = read_json(path.as_ref())?; + checked(read_json(path.as_ref())?) +} + +/// Reads a stack from the JSON text +/// [`save_single_file`](crate::save_single_file) writes, for a caller with no +/// file system — a browser, a clipboard, a wire. +/// +/// # Errors +/// +/// Returns a [`PersistenceError`] if the text cannot be parsed or holds no +/// cards. +pub fn stack_from_single_file(text: &str) -> PersistenceResult { + checked( + serde_json::from_str(text) + .map_err(|error| PersistenceError::json("a single-file stack", error))?, + ) +} + +/// What every single-file read ends with: a stack with nothing in it was +/// mis-saved or hand-edited, and opening it would strand the window. +fn checked(stack: Stack) -> PersistenceResult { if stack.is_empty() { return Err(PersistenceError::Incomplete( "it contains no cards at all".to_string(), diff --git a/crates/persistence/src/save.rs b/crates/persistence/src/save.rs index e03dd99..8924693 100644 --- a/crates/persistence/src/save.rs +++ b/crates/persistence/src/save.rs @@ -109,6 +109,20 @@ pub fn save_single_file(path: impl AsRef, stack: &Stack) -> PersistenceRes write_json(path.as_ref(), stack) } +/// Renders a stack as the same self-contained JSON document +/// [`save_single_file`] writes, for a caller with no file system — a browser, +/// a clipboard, a wire. +/// +/// # Errors +/// +/// Returns a [`PersistenceError`] if the stack cannot be serialized, which a +/// well-formed [`Stack`] never is. +pub fn single_file_string(stack: &Stack) -> PersistenceResult { + serde_json::to_string_pretty(stack) + .map(|json| json + "\n") + .map_err(|error| PersistenceError::json("a single-file stack", error)) +} + /// Refuses a name that is not a plain file name. /// /// A picture's name reaches this function from a `Stack`, and a `Stack` can diff --git a/crates/persistence/tests/round_trip.rs b/crates/persistence/tests/round_trip.rs index 0b44f37..512a28e 100644 --- a/crates/persistence/tests/round_trip.rs +++ b/crates/persistence/tests/round_trip.rs @@ -4,6 +4,7 @@ use std::{fs, path::PathBuf}; use hyperlab_persistence::{ PersistenceError, load, load_single_file, read_metadata, save, save_single_file, + single_file_string, stack_from_single_file, }; use hyperlab_stack::{Image, Object, PartContainer, PartKind, Rect, Size, Stack, Value}; @@ -187,6 +188,47 @@ fn a_single_file_holds_the_same_stack() { assert_eq!(load_single_file(&path).unwrap(), original); } +#[test] +fn a_single_file_string_holds_the_same_stack_with_no_file_at_all() { + let original = sample(); + let text = single_file_string(&original).unwrap(); + assert_eq!(stack_from_single_file(&text).unwrap(), original); +} + +#[test] +fn the_string_is_byte_for_byte_what_the_file_would_hold() { + let temp = TempDir::new("single-string"); + let path = temp.path("Sample.json"); + let original = sample(); + + save_single_file(&path, &original).unwrap(); + assert_eq!( + fs::read_to_string(&path).unwrap(), + single_file_string(&original).unwrap() + ); +} + +#[test] +fn a_single_file_with_no_cards_is_refused() { + // Hand-built JSON, because no honest save can produce a cardless stack. + let text = single_file_string(&sample()).unwrap(); + let mut broken: serde_json::Value = serde_json::from_str(&text).unwrap(); + broken["cards"] = serde_json::json!([]); + match stack_from_single_file(&broken.to_string()) { + Err(PersistenceError::Incomplete(reason)) => assert!(reason.contains("no cards")), + other => panic!("expected an incompleteness error, got {other:?}"), + } +} + +#[test] +fn text_that_is_not_a_stack_is_refused_readably() { + let error = stack_from_single_file("{ not json").unwrap_err(); + assert!( + error.to_string().contains("not valid HyperLab JSON"), + "unhelpful: {error}" + ); +} + #[test] fn a_bundle_from_the_future_is_refused_clearly() { let temp = TempDir::new("future"); From 7013ab4dcb9a52527989138499ec69033a0cfee3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 12:40:54 +0000 Subject: [PATCH 02/10] Add hyperlab-web: the runtime behind the desktop's command surface, compiled to WebAssembly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One exported function per desktop command, so the React renderer cannot tell which shell it is behind. Dialogs cross a JsHost object the page supplies — the worker blocks where the desktop blocked a thread — a stack travels as single-file JSON instead of a path, keys live in browser storage behind the same one-way rule as the OS keychain, and AI requests go through the browser's own fetch straight to the configured provider. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01R7eaxUAqy2gKQSBNnYZoFr --- Cargo.lock | 20 + crates/web/Cargo.toml | 39 + crates/web/src/api.rs | 1372 +++++++++++++++++++++++++++++++++++ crates/web/src/lib.rs | 33 + crates/web/src/providers.rs | 394 ++++++++++ crates/web/src/view.rs | 292 ++++++++ 6 files changed, 2150 insertions(+) create mode 100644 crates/web/Cargo.toml create mode 100644 crates/web/src/api.rs create mode 100644 crates/web/src/lib.rs create mode 100644 crates/web/src/providers.rs create mode 100644 crates/web/src/view.rs diff --git a/Cargo.lock b/Cargo.lock index 23e4c8c..220137c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -562,6 +562,26 @@ dependencies = [ "serde_json", ] +[[package]] +name = "hyperlab-web" +version = "0.2.0" +dependencies = [ + "hyperlab-ai", + "hyperlab-ai-providers", + "hyperlab-assistant", + "hyperlab-graph", + "hyperlab-hyperscript", + "hyperlab-mcp", + "hyperlab-persistence", + "hyperlab-runtime", + "hyperlab-stack", + "js-sys", + "serde", + "serde_json", + "wasm-bindgen", + "wasm-bindgen-futures", +] + [[package]] name = "icu_collections" version = "2.1.1" diff --git a/crates/web/Cargo.toml b/crates/web/Cargo.toml new file mode 100644 index 0000000..5b113fd --- /dev/null +++ b/crates/web/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "hyperlab-web" +description = "HyperLab in a browser: the runtime compiled to WebAssembly, behind the same command surface the desktop shell exposes." +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true + +[lib] +# `cdylib` is the WebAssembly module; `rlib` lets the pure parts — the view +# model, provider resolution — be tested on the host like any other crate. +crate-type = ["cdylib", "rlib"] + +[dependencies] +hyperlab-ai.workspace = true +# Protocol only: the browser brings its own transport, so reqwest and the +# native clients stay out of the build. +hyperlab-ai-providers = { path = "../ai-providers", version = "0.2.0", default-features = false } +hyperlab-assistant.workspace = true +hyperlab-graph.workspace = true +hyperlab-hyperscript.workspace = true +hyperlab-mcp.workspace = true +hyperlab-persistence.workspace = true +hyperlab-runtime.workspace = true +hyperlab-stack.workspace = true +serde.workspace = true +serde_json.workspace = true + +# The JavaScript boundary exists only on the WebAssembly target, so the host +# build — `cargo test` at the repository root — never compiles it. +[target.'cfg(target_arch = "wasm32")'.dependencies] +js-sys = "0.3" +wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" + +[lints] +workspace = true diff --git a/crates/web/src/api.rs b/crates/web/src/api.rs new file mode 100644 index 0000000..a26406f --- /dev/null +++ b/crates/web/src/api.rs @@ -0,0 +1,1372 @@ +//! The commands the page may ask for. +//! +//! A twin of the desktop shell's `commands.rs` and `ai_commands.rs`, one +//! exported function per command, so the same renderer works behind either. +//! Every function does one of three things: take a snapshot, run a +//! [`Command`], or send a [`Message`]. There is no fourth kind, which is +//! what keeps the promise that the UI cannot touch stack data. +//! +//! # The boundary +//! +//! Arguments arrive as one JSON string and answers leave as one, because a +//! page already speaks JSON and a single shape keeps the JavaScript side to +//! a one-line dispatcher. The exceptions are bytes — a picture crosses as +//! the bytes it is — and [`init`], which is handed the host object itself. +//! +//! # Who blocks where +//! +//! WebAssembly in a browser has one thread, so the desktop's arrangement — +//! block the script's thread while the window answers — is recreated one +//! level up: this module runs in a Web Worker, and the [`JsHost`] the page +//! provides blocks that worker (`Atomics.wait`) while the page, still +//! responsive, shows the dialog. Nothing in this crate knows that; it calls +//! [`Host::ask`] and waits for its answer, exactly as the desktop runtime +//! does. +//! +//! # State +//! +//! The document and the AI conversation live in two separate cells, exactly +//! as the desktop keeps two locks: a script that calls `ai("…")` runs while +//! the document is borrowed, and must still be able to reach the provider +//! configuration. + +use std::cell::RefCell; + +use hyperlab_ai::{AiSettings, CompletionRequest, ContextOptions}; +use hyperlab_assistant::{Briefing, Conversation, SYSTEM_PROMPT, tools}; +use hyperlab_mcp::{Access, Approval, Approver, Policy, ToolRegistry}; +use hyperlab_runtime::{AiRequest, Command, Effect, Host, Message, PartOwner, Runtime, messages}; +use hyperlab_stack::{Id, Object, ObjectId, ObjectKind, PartKind, Point, Rect, Size, Stack, Value}; +use serde::{Deserialize, Serialize}; +use wasm_bindgen::JsCast; +use wasm_bindgen::prelude::*; + +use crate::providers::{self, WebProvider, Wire}; +use crate::view::{StackView, properties_of, snapshot}; + +// ------------------------------------------------------------------- state + +/// One open document, plus whether it has changed since it was opened. +struct Doc { + runtime: Runtime, + dirty: bool, +} + +/// Everything the AI sidebar needs between turns. +struct Ai { + conversation: Conversation, + settings: AiSettings, + providers: Vec, + /// Why a provider named in the settings is not available. + problems: Vec, + policy: Policy, + context: ContextOptions, + /// Whether a turn is already running. A second one would interleave + /// tool calls with the first and corrupt both transcripts. + busy: bool, +} + +thread_local! { + static DOC: RefCell> = const { RefCell::new(None) }; + static AI: RefCell> = const { RefCell::new(None) }; + static HOST: RefCell> = const { RefCell::new(None) }; +} + +/// Where the provider settings live in browser storage. +const SETTINGS_KEY: &str = "hyperlab.ai.settings"; + +/// Where a provider's key lives in browser storage. One entry per provider, +/// written by [`ai_set_key`] and read only when a request is built — there +/// is no command that returns one. +fn key_slot(provider: &str) -> String { + format!("hyperlab.ai.key.{provider}") +} + +// ---------------------------------------------------------------- the host + +#[wasm_bindgen] +extern "C" { + /// The page's side of the runtime's questions: dialogs, sounds, storage, + /// and the one HTTP transport a browser has. See the worker in + /// `apps/web` for the implementation this signature is a contract with. + pub type JsHost; + + /// `answer "…"`: shows a message and waits for it to be dismissed. + #[wasm_bindgen(method, structural)] + fn answer(this: &JsHost, message: &str); + + /// `ask "…"`: shows a question and waits. `null` means cancelled. + #[wasm_bindgen(method, structural)] + fn ask(this: &JsHost, prompt: &str, default: &str) -> Option; + + /// `beep`. + #[wasm_bindgen(method, structural)] + fn beep(this: &JsHost); + + /// Posts JSON and waits for the reply, for a script blocked on + /// `ai("…")`. Returns `{"status": …, "body": …}` as JSON; throws when + /// the request never got an answer. + #[wasm_bindgen(method, structural, catch, js_name = completeSync)] + fn complete_sync( + this: &JsHost, + url: &str, + headers: &str, + body: &str, + ) -> Result; + + /// The same request without the waiting, for the sidebar: the returned + /// promise resolves to the same JSON shape. + #[wasm_bindgen(method, structural, js_name = complete)] + fn complete(this: &JsHost, url: &str, headers: &str, body: &str) -> js_sys::Promise; + + /// Reads one value from browser storage. + #[wasm_bindgen(method, structural, js_name = storageGet)] + fn storage_get(this: &JsHost, key: &str) -> Option; + + /// Writes one value to browser storage; `None` removes it. + #[wasm_bindgen(method, structural, js_name = storageSet)] + fn storage_set(this: &JsHost, key: &str, value: Option); +} + +/// Runs `work` with the host the page handed to [`init`]. +fn with_host(work: impl FnOnce(&JsHost) -> R) -> R { + HOST.with(|cell| { + let held = cell.borrow(); + let value = held.as_ref().expect("init() has not been called"); + work(value.unchecked_ref::()) + }) +} + +/// A [`Host`] that puts the runtime's questions to the page. +/// +/// It holds nothing itself — the JavaScript object lives in a cell of its +/// own — so it is `Send` the way the trait asks, and the single-threaded +/// truth of the matter is the cell's business. +struct WebHost; + +impl Host for WebHost { + fn answer(&mut self, message: &str) { + with_host(|host| host.answer(message)); + } + + fn ask(&mut self, prompt: &str, default: &str) -> Option { + with_host(|host| host.ask(prompt, default)) + } + + fn beep(&mut self) { + with_host(|host| host.beep()); + } + + /// What `ai("…")` and `ask assistant` reach: one question, no tools, no + /// briefing — the script says what to send. The same deliberate limits + /// as the desktop, for the same reasons: the interpreter is mid-handler, + /// and an assistant restructuring the stack under it would pull the + /// ground away. + fn ai(&mut self, request: &AiRequest) -> Result { + let completion_request = |model: &str| { + CompletionRequest::new( + model, + vec![ + hyperlab_ai::ChatMessage::system(SYSTEM_PROMPT), + hyperlab_ai::ChatMessage::user(request.prompt.clone()), + ], + ) + }; + + AI.with(|cell| { + let held = cell.borrow(); + let ai = held.as_ref().ok_or("no assistant is set up")?; + let provider = ai.chosen_provider()?; + let request = completion_request(&provider.model); + + if provider.wire == Wire::Mock { + return Ok(providers::mock_completion(&request).content); + } + + let body = provider.completion_body(&request).to_string(); + let headers = headers_json(provider); + let reply = with_host(|host| { + host.complete_sync(&provider.completion_url(), &headers, &body) + }) + .map_err(|thrown| format!("could not reach the provider: {}", text_of(&thrown)))?; + let (status, reply_body) = split_reply(&reply)?; + provider + .decode_completion(status, &reply_body) + .map(|completion| completion.content) + }) + } +} + +/// The person at the keyboard, who is already driving the sidebar. The +/// panel's switch is what sets the policy; asking again per tool would be a +/// dialog every few seconds saying what the switch already said. +struct SidebarUser; + +impl Approver for SidebarUser { + fn approve(&mut self, _request: &Approval<'_>) -> bool { + true + } +} + +// ------------------------------------------------------------- housekeeping + +/// The result of a command: JSON out, or a sentence the page can show. +type ApiResult = Result; + +/// Turns anything printable into the error arm. +fn fail(message: impl std::fmt::Display) -> JsValue { + JsValue::from_str(&message.to_string()) +} + +/// Serializes a command's answer. +fn reply(value: &T) -> ApiResult { + serde_json::to_string(value).map_err(fail) +} + +/// Runs `work` against the open document. +fn with_doc(work: impl FnOnce(&mut Doc) -> Result) -> Result { + DOC.with(|cell| { + let mut held = cell.borrow_mut(); + let doc = held.as_mut().ok_or_else(|| fail("init() has not run"))?; + work(doc).map_err(fail) + }) +} + +/// Runs `work` against the AI state. +fn with_ai(work: impl FnOnce(&mut Ai) -> Result) -> Result { + AI.with(|cell| { + let mut held = cell.borrow_mut(); + let ai = held.as_mut().ok_or_else(|| fail("init() has not run"))?; + work(ai).map_err(fail) + }) +} + +/// What every command gives back: the new state of the world, plus anything +/// scripts asked the world to do. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct Outcome { + view: StackView, + effects: Vec, +} + +/// Takes a snapshot, and collects whatever scripts left behind. +fn finish(doc: &mut Doc) -> Outcome { + let effects = doc.runtime.take_effects(); + Outcome { + view: snapshot(&doc.runtime, doc.dirty, None), + effects, + } +} + +fn outcome(doc: &mut Doc) -> Result { + serde_json::to_string(&finish(doc)).map_err(|error| error.to_string()) +} + +fn parse_args<'a, T: Deserialize<'a>>(json: &'a str) -> Result { + serde_json::from_str(json).map_err(|error| fail(format!("bad arguments: {error}"))) +} + +// -------------------------------------------------------------------- init + +/// Wakes the module up: keeps the host, reads the settings, and opens an +/// untitled stack so the window always has something in it. HyperCard +/// opened on a stack; so does HyperLab, on either shell. +#[wasm_bindgen] +pub fn init(host: JsValue) { + HOST.with(|cell| *cell.borrow_mut() = Some(host)); + + let (settings, mut problems) = match with_host(|h| h.storage_get(SETTINGS_KEY)) { + None => (AiSettings::default(), Vec::new()), + Some(text) => match serde_json::from_str(&text) { + Ok(settings) => (settings, Vec::new()), + // Reported rather than replaced: silently starting again would + // throw away configuration the user made by hand. + Err(error) => ( + AiSettings::default(), + vec![format!("the saved AI settings could not be read: {error}")], + ), + }, + }; + let (resolved, mut resolution_problems) = resolve_providers(&settings); + problems.append(&mut resolution_problems); + + AI.with(|cell| { + *cell.borrow_mut() = Some(Ai { + conversation: Conversation::new(), + settings, + providers: resolved, + problems, + // The user is sitting in front of the sidebar driving it, so + // there is nobody to ask that they have not already told. + policy: Policy::trusted(), + context: ContextOptions::default(), + busy: false, + }); + }); + + let mut runtime = Runtime::new(Stack::new("Untitled")); + runtime.set_host(Box::new(WebHost)); + let _ = runtime.open_stack(); + DOC.with(|cell| { + *cell.borrow_mut() = Some(Doc { + runtime, + dirty: false, + }); + }); +} + +/// Resolves the providers the settings describe, going to browser storage +/// for keys. +fn resolve_providers(settings: &AiSettings) -> (Vec, Vec) { + providers::resolve_all(settings, &|name| { + with_host(|host| host.storage_get(&key_slot(name))) + }) +} + +// ------------------------------------------------------------------ reading + +/// Returns the current state without changing anything. +#[wasm_bindgen] +pub fn get_view(_args: &str) -> ApiResult { + with_doc(outcome) +} + +#[derive(Deserialize)] +struct ObjectArgs { + kind: String, + id: u64, +} + +/// Returns every property of one object, for the inspector. +#[wasm_bindgen] +pub fn get_properties(args: &str) -> ApiResult { + let args: ObjectArgs = parse_args(args)?; + let object = object_id(&args.kind, args.id).map_err(fail)?; + with_doc(|doc| { + let object = doc + .runtime + .stack() + .object(object.kind, object.id) + .ok_or_else(|| format!("there is no {} with id {}", args.kind, args.id))?; + serde_json::to_string(&properties_of(object)).map_err(|error| error.to_string()) + }) +} + +#[derive(Deserialize)] +struct SourceArgs { + source: String, +} + +/// Checks whether a script parses, for the editor's error line. +#[wasm_bindgen] +pub fn check_script(args: &str) -> ApiResult { + let args: SourceArgs = parse_args(args)?; + Runtime::check_script(&args.source).map_err(fail)?; + reply(&()) +} + +/// Reads the stack as the routes between its cards, for the map. +#[wasm_bindgen] +pub fn stack_graph(_args: &str) -> ApiResult { + with_doc(|doc| { + serde_json::to_string(&hyperlab_graph::Graph::of(doc.runtime.stack())) + .map_err(|error| error.to_string()) + }) +} + +#[derive(Deserialize)] +struct NameArgs { + name: String, +} + +/// One of the stack's pictures, as a `data:` URI the renderer can draw. +#[wasm_bindgen] +pub fn stack_image(args: &str) -> ApiResult { + let args: NameArgs = parse_args(args)?; + with_doc(|doc| { + doc.runtime + .stack() + .image(&args.name) + .map(hyperlab_stack::data_uri) + .ok_or_else(|| format!("this stack has no picture called \"{}\"", args.name)) + .and_then(|uri| serde_json::to_string(&uri).map_err(|error| error.to_string())) + }) +} + +/// The names of every picture the stack carries. +#[wasm_bindgen] +pub fn stack_images(_args: &str) -> ApiResult { + with_doc(|doc| { + let names: Vec = doc.runtime.stack().images().keys().cloned().collect(); + serde_json::to_string(&names).map_err(|error| error.to_string()) + }) +} + +#[derive(Deserialize)] +struct PointArgs { + x: i32, + y: i32, +} + +/// Which part is under a point, for hit testing done here rather than in +/// the renderer. +#[wasm_bindgen] +pub fn part_at(args: &str) -> ApiResult { + use hyperlab_stack::PartContainer; + let args: PointArgs = parse_args(args)?; + with_doc(|doc| { + let stack = doc.runtime.stack(); + let card = doc.runtime.current_card(); + let point = Point::new(args.x, args.y); + let found = stack + .card(card) + .and_then(|card| card.part_at(point)) + .or_else(|| { + stack + .background_of(card) + .and_then(|background| background.part_at(point)) + }) + .map(|part| part.id().get()); + serde_json::to_string(&found).map_err(|error| error.to_string()) + }) +} + +// ----------------------------------------------------------------- browsing + +#[derive(Deserialize)] +struct IdArgs { + id: u64, +} + +/// Sends `mouseUp` to a part, exactly as clicking it does. +#[wasm_bindgen] +pub fn click_part(args: &str) -> ApiResult { + let args: IdArgs = parse_args(args)?; + with_doc(|doc| { + let part = find_part(&doc.runtime, Id::new(args.id))?; + doc.runtime + .send_message(&Message::new(messages::MOUSE_UP), part) + .map_err(|error| error.to_string())?; + outcome(doc) + }) +} + +#[derive(Deserialize)] +struct FieldTextArgs { + id: u64, + text: String, +} + +/// Types into a field, which is a change like any other. +#[wasm_bindgen] +pub fn set_field_text(args: &str) -> ApiResult { + let args: FieldTextArgs = parse_args(args)?; + with_doc(|doc| { + let field = ObjectId::new(ObjectKind::Field, Id::new(args.id)); + doc.runtime + .execute(Command::SetProperty { + object: field, + property: "text".into(), + value: Some(Value::text(args.text)), + }) + .map_err(|error| error.to_string())?; + doc.dirty = true; + let _ = doc + .runtime + .send_message(&Message::new(messages::FIELD_CHANGED), field); + outcome(doc) + }) +} + +#[derive(Deserialize)] +struct PositionArgs { + position: i64, +} + +/// Goes to a card by position, counting from one, wrapping at the ends. +#[wasm_bindgen] +pub fn go_to_card(args: &str) -> ApiResult { + let args: PositionArgs = parse_args(args)?; + with_doc(|doc| { + let index = isize::try_from(args.position - 1).map_err(|_| "that card is out of range")?; + doc.runtime + .go_to_index(index) + .map_err(|error| error.to_string())?; + outcome(doc) + }) +} + +/// Runs whatever is in the message box. +#[wasm_bindgen] +pub fn run_message_box(args: &str) -> ApiResult { + let args: SourceArgs = parse_args(args)?; + with_doc(|doc| { + let card = ObjectId::new(ObjectKind::Card, doc.runtime.current_card()); + doc.runtime + .run_script(&args.source, card) + .map_err(|error| error.to_string())?; + doc.dirty = true; + outcome(doc) + }) +} + +// ------------------------------------------------------------------ editing + +/// Adds a card after the current one. +#[wasm_bindgen] +pub fn new_card(_args: &str) -> ApiResult { + with_doc(|doc| { + let after = doc.runtime.current_card_index(); + let created = doc + .runtime + .execute(Command::CreateCard { + after, + background: None, + }) + .map_err(|error| error.to_string())?; + doc.dirty = true; + if let Some(card) = created { + doc.runtime + .go_to_card(card.id) + .map_err(|error| error.to_string())?; + } + outcome(doc) + }) +} + +/// Deletes the current card. +#[wasm_bindgen] +pub fn delete_card(_args: &str) -> ApiResult { + with_doc(|doc| { + let card = doc.runtime.current_card(); + doc.runtime + .execute(Command::DeleteCard { id: card }) + .map_err(|error| error.to_string())?; + doc.dirty = true; + outcome(doc) + }) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct NewPartArgs { + kind: String, + layer: Layer, + name: Option, +} + +/// Which layer a new part belongs to. +#[derive(Clone, Copy, Deserialize)] +#[serde(rename_all = "camelCase")] +enum Layer { + Card, + Background, +} + +/// Adds a part to the card or its background. +#[wasm_bindgen] +pub fn new_part(args: &str) -> ApiResult { + let args: NewPartArgs = parse_args(args)?; + with_doc(|doc| { + let part_kind = match args.kind.as_str() { + "button" => PartKind::Button, + "field" => PartKind::Field, + "image" => PartKind::Image, + other => return Err(format!("\"{other}\" is not a kind of part")), + }; + + let card = doc.runtime.current_card(); + let owner = owner_for(doc, card, args.layer)?; + + // The cascade counts *every* part on show, not just this kind, so a + // new button and a new field do not land exactly on top of each + // other. The name still counts its own kind, so they are "Button 1" + // and "Field 1". + let placed = count_parts(doc.runtime.stack(), card, None); + let same_kind = count_parts(doc.runtime.stack(), card, Some(part_kind)); + let geometry = doc.runtime.stack().default_part_geometry(part_kind, placed); + let name = args + .name + .unwrap_or_else(|| format!("{} {}", title_case(&args.kind), same_kind + 1)); + + doc.runtime + .execute(Command::CreatePart { + owner, + kind: part_kind, + name, + geometry, + }) + .map_err(|error| error.to_string())?; + doc.dirty = true; + outcome(doc) + }) +} + +#[derive(Deserialize)] +struct ImportImageArgs { + name: String, + layer: Layer, +} + +/// Brings a picture into the stack and puts an image part on the card. +/// +/// The bytes arrive from the page — a file the user picked — and go straight +/// into the model, which checks them before the renderer ever sees them. +#[wasm_bindgen] +pub fn import_image_bytes(args: &str, bytes: Vec) -> ApiResult { + let args: ImportImageArgs = parse_args(args)?; + if bytes.len() > hyperlab_stack::MAX_IMAGE_BYTES { + return Err(fail(format!( + "that file is {} MB, and the most a stack will hold is {} MB", + bytes.len() / (1024 * 1024), + hyperlab_stack::MAX_IMAGE_BYTES / (1024 * 1024) + ))); + } + let image = + hyperlab_stack::Image::new(&args.name, bytes).map_err(|error| fail(error.to_string()))?; + + with_doc(|doc| { + let card = doc.runtime.current_card(); + let owner = owner_for(doc, card, args.layer)?; + let placed = count_parts(doc.runtime.stack(), card, None); + let geometry = doc + .runtime + .stack() + .default_part_geometry(PartKind::Image, placed); + + doc.runtime + .execute(Command::SetImage { + name: args.name.clone(), + image: Some(Box::new(image)), + }) + .map_err(|error| error.to_string())?; + let created = doc + .runtime + .execute(Command::CreatePart { + owner, + kind: PartKind::Image, + name: args.name.clone(), + geometry, + }) + .map_err(|error| error.to_string())?; + + if let Some(part) = created { + doc.runtime + .execute(Command::SetProperty { + object: part, + property: "source".into(), + value: Some(Value::text(args.name.clone())), + }) + .map_err(|error| error.to_string())?; + } + doc.dirty = true; + outcome(doc) + }) +} + +/// Removes a part. +#[wasm_bindgen] +pub fn delete_part(args: &str) -> ApiResult { + let args: IdArgs = parse_args(args)?; + with_doc(|doc| { + doc.runtime + .execute(Command::DeletePart { + id: Id::new(args.id), + }) + .map_err(|error| error.to_string())?; + doc.dirty = true; + outcome(doc) + }) +} + +#[derive(Deserialize)] +struct GeometryArgs { + id: u64, + left: i32, + top: i32, + width: i32, + height: i32, +} + +/// Moves or resizes a part, as dragging it does. +#[wasm_bindgen] +pub fn set_geometry(args: &str) -> ApiResult { + let args: GeometryArgs = parse_args(args)?; + with_doc(|doc| { + doc.runtime + .execute(Command::SetGeometry { + id: Id::new(args.id), + geometry: Rect::new(args.left, args.top, args.width, args.height), + }) + .map_err(|error| error.to_string())?; + doc.dirty = true; + outcome(doc) + }) +} + +#[derive(Deserialize)] +struct SetPropertyArgs { + kind: String, + id: u64, + property: String, + value: serde_json::Value, +} + +/// Sets a property from the inspector. +#[wasm_bindgen] +pub fn set_property(args: &str) -> ApiResult { + let args: SetPropertyArgs = parse_args(args)?; + let object = object_id(&args.kind, args.id).map_err(fail)?; + with_doc(|doc| { + doc.runtime + .execute(Command::SetProperty { + object, + property: args.property.clone(), + value: Some(json_to_value(args.value.clone())), + }) + .map_err(|error| error.to_string())?; + doc.dirty = true; + outcome(doc) + }) +} + +#[derive(Deserialize)] +struct SetScriptArgs { + kind: String, + id: u64, + script: String, +} + +/// Replaces an object's script. +/// +/// A script that does not parse is still stored — half-written code is +/// normal, and losing it would be worse than keeping it. The editor checks +/// separately, with [`check_script`], and says so. +#[wasm_bindgen] +pub fn set_script(args: &str) -> ApiResult { + let args: SetScriptArgs = parse_args(args)?; + let object = object_id(&args.kind, args.id).map_err(fail)?; + with_doc(|doc| { + doc.runtime + .execute(Command::SetScript { + object, + script: args.script.clone(), + }) + .map_err(|error| error.to_string())?; + doc.dirty = true; + outcome(doc) + }) +} + +#[derive(Deserialize)] +struct RenameArgs { + kind: String, + id: u64, + name: String, +} + +/// Renames an object. +#[wasm_bindgen] +pub fn rename(args: &str) -> ApiResult { + let args: RenameArgs = parse_args(args)?; + let object = object_id(&args.kind, args.id).map_err(fail)?; + with_doc(|doc| { + doc.runtime + .execute(Command::Rename { + object, + name: args.name.clone(), + }) + .map_err(|error| error.to_string())?; + doc.dirty = true; + outcome(doc) + }) +} + +#[derive(Deserialize)] +struct SizeArgs { + width: i32, + height: i32, +} + +/// Resizes every card in the stack. +#[wasm_bindgen] +pub fn set_stack_size(args: &str) -> ApiResult { + let args: SizeArgs = parse_args(args)?; + with_doc(|doc| { + doc.runtime + .execute(Command::SetStackSize { + size: Size::new(args.width, args.height), + }) + .map_err(|error| error.to_string())?; + doc.dirty = true; + outcome(doc) + }) +} + +/// Undoes the last change. +#[wasm_bindgen] +pub fn undo(_args: &str) -> ApiResult { + with_doc(|doc| { + doc.runtime.undo().map_err(|error| error.to_string())?; + doc.dirty = true; + outcome(doc) + }) +} + +/// Redoes the last undone change. +#[wasm_bindgen] +pub fn redo(_args: &str) -> ApiResult { + with_doc(|doc| { + doc.runtime.redo().map_err(|error| error.to_string())?; + doc.dirty = true; + outcome(doc) + }) +} + +// ------------------------------------------------------------------- files + +#[derive(Deserialize)] +struct NewStackArgs { + name: Option, +} + +/// Starts a new, empty stack. +#[wasm_bindgen] +pub fn new_stack(args: &str) -> ApiResult { + let args: NewStackArgs = parse_args(args)?; + with_doc(|doc| { + doc.runtime + .open(Stack::new(args.name.unwrap_or_else(|| "Untitled".into()))); + doc.dirty = false; + // `openStack` runs on open, which is how a stack sets itself up. + let _ = doc.runtime.open_stack(); + outcome(doc) + }) +} + +#[derive(Deserialize)] +struct OpenStackArgs { + text: String, +} + +/// Opens a stack from the single-file JSON the desktop's +/// "Save As…" and this module's [`save_stack_json`] both write. +#[wasm_bindgen] +pub fn open_stack_json(args: &str) -> ApiResult { + let args: OpenStackArgs = parse_args(args)?; + let stack = hyperlab_persistence::stack_from_single_file(&args.text) + .map_err(|error| fail(error.to_string()))?; + with_doc(|doc| { + doc.runtime.open(stack); + doc.dirty = false; + let _ = doc.runtime.open_stack(); + outcome(doc) + }) +} + +/// The whole stack as single-file JSON, for the page to hand the user as a +/// download. Marks the document clean: the copy the user holds *is* the +/// save. +#[wasm_bindgen] +pub fn save_stack_json(_args: &str) -> ApiResult { + #[derive(Serialize)] + struct Saved { + name: String, + text: String, + } + with_doc(|doc| { + let text = hyperlab_persistence::single_file_string(doc.runtime.stack()) + .map_err(|error| error.to_string())?; + doc.dirty = false; + serde_json::to_string(&Saved { + name: doc.runtime.stack().name().to_string(), + text, + }) + .map_err(|error| error.to_string()) + }) +} + +/// Writes the stack as a web page driven by _hyperscript, with a line for +/// anything that had no equivalent on a page. +#[wasm_bindgen] +pub fn export_web(_args: &str) -> ApiResult { + #[derive(Serialize)] + struct Exported { + source: String, + notes: Vec, + } + with_doc(|doc| { + let translated = hyperlab_hyperscript::page(doc.runtime.stack()); + serde_json::to_string(&Exported { + source: translated.source, + notes: translated.notes, + }) + .map_err(|error| error.to_string()) + }) +} + +// ------------------------------------------------------------------ helpers + +fn object_id(kind: &str, id: u64) -> Result { + let kind = match kind { + "stack" => ObjectKind::Stack, + "background" => ObjectKind::Background, + "card" => ObjectKind::Card, + "button" => ObjectKind::Button, + "field" => ObjectKind::Field, + "image" => ObjectKind::Image, + other => return Err(format!("\"{other}\" is not a kind of object")), + }; + Ok(ObjectId::new(kind, Id::new(id))) +} + +/// Turns what the inspector sent into a property value, keeping booleans and +/// numbers as themselves rather than as their spelling. +fn json_to_value(value: serde_json::Value) -> Value { + match value { + serde_json::Value::Bool(flag) => Value::Bool(flag), + serde_json::Value::Number(number) => Value::Number(number.as_f64().unwrap_or_default()), + serde_json::Value::Null => Value::Empty, + serde_json::Value::String(text) => Value::text(text), + other => Value::text(other.to_string()), + } +} + +/// Works out whether an id belongs to a button or a field. +fn find_part(runtime: &Runtime, id: Id) -> Result { + runtime + .stack() + .part(id) + .map(|part| ObjectId::new(part.kind(), id)) + .ok_or_else(|| format!("there is no part with id {id}")) +} + +/// Which container a new part on `card` belongs to. +fn owner_for(doc: &Doc, card: Id, layer: Layer) -> Result { + Ok(match layer { + Layer::Card => PartOwner::Card { id: card }, + Layer::Background => PartOwner::Background { + id: doc + .runtime + .stack() + .background_of(card) + .map(Object::id) + .ok_or("this card has no background")?, + }, + }) +} + +/// How many parts the current card shows, including its background's. +fn count_parts(stack: &Stack, card: Id, kind: Option) -> usize { + use hyperlab_stack::PartContainer; + let count = |container: &dyn PartContainer| match kind { + Some(kind) => container.parts_of_kind(kind).len(), + None => container.parts().len(), + }; + let on_card = stack.card(card).map_or(0, |card| count(card)); + let on_background = stack + .background_of(card) + .map_or(0, |background| count(background)); + on_card + on_background +} + +fn title_case(word: &str) -> String { + let mut characters = word.chars(); + characters.next().map_or_else(String::new, |first| { + first.to_uppercase().collect::() + characters.as_str() + }) +} + +/// The provider's headers as the JSON object the host expects. +fn headers_json(provider: &WebProvider) -> String { + let map: std::collections::BTreeMap = provider.headers().into_iter().collect(); + serde_json::to_string(&map).unwrap_or_else(|_| "{}".to_string()) +} + +/// Reads the host's `{"status": …, "body": …}` reply. +fn split_reply(reply: &str) -> Result<(u16, String), String> { + #[derive(Deserialize)] + struct Reply { + status: u16, + body: String, + } + serde_json::from_str::(reply) + .map(|reply| (reply.status, reply.body)) + .map_err(|error| format!("the transport answered something unexpected: {error}")) +} + +/// A thrown JavaScript value as a sentence. +fn text_of(thrown: &JsValue) -> String { + thrown.as_string().unwrap_or_else(|| { + js_sys::Reflect::get(thrown, &JsValue::from_str("message")) + .ok() + .and_then(|message| message.as_string()) + .unwrap_or_else(|| "an unknown error".to_string()) + }) +} + +// ------------------------------------------------------------------- the AI + +/// What the sidebar shows. A twin of the desktop's `AiView`. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct AiView { + entries: Vec, + providers: Vec, + provider: Option, + problems: Vec, + sends_field_text: bool, + may_edit: bool, + busy: bool, +} + +impl Ai { + fn view(&self) -> AiView { + AiView { + entries: self.conversation.entries().to_vec(), + providers: self + .providers + .iter() + .map(|provider| provider.name.clone()) + .collect(), + provider: self.chosen(), + problems: self.problems.clone(), + sends_field_text: self.context.include_field_text, + may_edit: !self + .policy + .would_always_refuse("write_field", Access::Write), + busy: self.busy, + } + } + + fn chosen(&self) -> Option { + let named = |name: &String| self.providers.iter().any(|provider| &provider.name == name); + self.settings + .default_provider + .clone() + .filter(named) + .or_else(|| self.providers.first().map(|provider| provider.name.clone())) + } + + fn chosen_provider(&self) -> Result<&WebProvider, String> { + let name = self + .chosen() + .ok_or("no language model is set up yet — add one in AI ▸ Settings")?; + self.providers + .iter() + .find(|provider| provider.name == name) + .ok_or_else(|| format!("the provider \"{name}\" is no longer available")) + } +} + +/// What the sidebar should draw. +#[wasm_bindgen] +pub fn ai_view(_args: &str) -> ApiResult { + with_ai(|ai| serde_json::to_string(&ai.view()).map_err(|error| error.to_string())) +} + +/// Forgets the conversation. +#[wasm_bindgen] +pub fn ai_clear(_args: &str) -> ApiResult { + with_ai(|ai| { + ai.conversation.clear(); + serde_json::to_string(&ai.view()).map_err(|error| error.to_string()) + }) +} + +#[derive(Deserialize)] +struct SendingArgs { + sending: bool, +} + +/// Chooses whether the contents of fields are sent with a question. +#[wasm_bindgen] +pub fn ai_set_sends_field_text(args: &str) -> ApiResult { + let args: SendingArgs = parse_args(args)?; + with_ai(|ai| { + ai.context = if args.sending { + ContextOptions::everything() + } else { + ContextOptions::default() + }; + serde_json::to_string(&ai.view()).map_err(|error| error.to_string()) + }) +} + +#[derive(Deserialize)] +struct EditingArgs { + editing: bool, +} + +/// Chooses whether the assistant may change the stack. +#[wasm_bindgen] +pub fn ai_set_may_edit(args: &str) -> ApiResult { + let args: EditingArgs = parse_args(args)?; + with_ai(|ai| { + ai.policy = if args.editing { + Policy::trusted() + } else { + Policy::new() + }; + serde_json::to_string(&ai.view()).map_err(|error| error.to_string()) + }) +} + +/// The provider settings, for the settings panel. +#[wasm_bindgen] +pub fn ai_settings(_args: &str) -> ApiResult { + with_ai(|ai| serde_json::to_string(&ai.settings).map_err(|error| error.to_string())) +} + +#[derive(Deserialize)] +struct SaveSettingsArgs { + settings: AiSettings, +} + +/// Writes new provider settings to browser storage and rebuilds the +/// providers. +#[wasm_bindgen] +pub fn ai_save_settings(args: &str) -> ApiResult { + let args: SaveSettingsArgs = parse_args(args)?; + let text = serde_json::to_string(&args.settings).map_err(fail)?; + with_host(|host| host.storage_set(SETTINGS_KEY, Some(text))); + + let (resolved, problems) = resolve_providers(&args.settings); + with_ai(|ai| { + ai.settings = args.settings.clone(); + ai.providers = resolved; + ai.problems = problems; + serde_json::to_string(&ai.view()).map_err(|error| error.to_string()) + }) +} + +/// What the settings panel may say about keys: which providers have one +/// saved in this browser, and never which key. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct KeychainView { + available: bool, + problem: Option, + holding: Vec, +} + +fn keychain_view(ai: &Ai) -> Result { + let holding = ai + .settings + .providers + .keys() + .filter(|name| with_host(|host| host.storage_get(&key_slot(name))).is_some()) + .cloned() + .collect(); + serde_json::to_string(&KeychainView { + available: true, + problem: None, + holding, + }) + .map_err(|error| error.to_string()) +} + +/// Which providers have a key saved in this browser. +#[wasm_bindgen] +pub fn ai_keychain(_args: &str) -> ApiResult { + with_ai(|ai| keychain_view(ai)) +} + +#[derive(Deserialize)] +struct SetKeyArgs { + provider: String, + key: String, +} + +/// Saves a provider's key in this browser's storage. +/// +/// The key arrives, goes into storage under the provider's name, and is not +/// returned, logged, or sent anywhere except the provider itself when a +/// request is built. There is no command that reads one back out. +#[wasm_bindgen] +pub fn ai_set_key(args: &str) -> ApiResult { + let args: SetKeyArgs = parse_args(args)?; + with_host(|host| { + host.storage_set( + &key_slot(args.provider.trim()), + Some(args.key.trim().to_string()), + ); + }); + // A provider that failed to build for want of this key can build now. + let settings = with_ai(|ai| Ok(ai.settings.clone()))?; + let (resolved, problems) = resolve_providers(&settings); + with_ai(|ai| { + ai.providers = resolved; + ai.problems = problems; + keychain_view(ai) + }) +} + +#[derive(Deserialize)] +struct ForgetKeyArgs { + provider: String, +} + +/// Removes a provider's key from this browser's storage. +#[wasm_bindgen] +pub fn ai_forget_key(args: &str) -> ApiResult { + let args: ForgetKeyArgs = parse_args(args)?; + with_host(|host| host.storage_set(&key_slot(args.provider.trim()), None)); + let settings = with_ai(|ai| Ok(ai.settings.clone()))?; + let (resolved, problems) = resolve_providers(&settings); + with_ai(|ai| { + ai.providers = resolved; + ai.problems = problems; + keychain_view(ai) + }) +} + +#[derive(Deserialize)] +struct AskArgs { + question: String, +} + +/// What one round of the turn loop needs from the AI cell while unlocked: +/// everything already turned into strings. +struct PreparedRound { + wire: Wire, + url: String, + headers: String, + body: String, + request: CompletionRequest, +} + +/// Asks the assistant something, and runs whatever tools it asks for. +/// +/// A twin of the desktop's turn loop, with the same shape for the same +/// reason: the borrow of the document is taken for the tools and dropped +/// for the network, so the page stays live while the model thinks. +#[wasm_bindgen] +pub async fn ai_ask(args: String) -> Result { + let args: AskArgs = parse_args(&args)?; + if args.question.trim().is_empty() { + return Err(fail("there is nothing to ask")); + } + + // Whatever happens from here, the sidebar must not be left stuck. + let turn = run_turn(&args.question).await; + with_ai(|ai| { + ai.busy = false; + Ok(()) + })?; + + if let Err(reason) = &turn { + let reason = reason + .as_string() + .unwrap_or_else(|| "the turn failed".to_string()); + with_ai(|ai| { + ai.conversation.record_failure(reason.clone()); + Ok(()) + })?; + return Err(fail(reason)); + } + + // The stack may have changed even when the turn failed part-way, so the + // window is refreshed either way. + with_doc(outcome) +} + +async fn run_turn(question: &str) -> Result<(), JsValue> { + let tools = ToolRegistry::new(); + + let model = with_ai(|ai| { + if ai.busy { + return Err("the assistant is still working on the last question".to_string()); + } + let provider = ai.chosen_provider()?; + let model = provider.model.clone(); + ai.busy = true; + Ok(model) + })?; + + // Borrowed: describing the stack reads it. + let briefing = with_doc(|doc| Ok(Briefing::about(&doc.runtime, ai_context()?)))?; + with_ai(|ai| { + ai.conversation.ask(question, briefing); + Ok(()) + })?; + + loop { + let prepared = with_ai(|ai| { + if !ai.conversation.begin_round() { + return Err("the assistant kept using tools without answering".to_string()); + } + let provider = ai.chosen_provider()?; + let request = ai.conversation.request(&model, tools.definitions()); + Ok(PreparedRound { + wire: provider.wire, + url: provider.completion_url(), + headers: headers_json(provider), + body: provider.completion_body(&request).to_string(), + request, + }) + })?; + + // Unborrowed: this is the slow part, and it needs no stack. + let completion = if prepared.wire == Wire::Mock { + providers::mock_completion(&prepared.request) + } else { + let promise = + with_host(|host| host.complete(&prepared.url, &prepared.headers, &prepared.body)); + let reply = wasm_bindgen_futures::JsFuture::from(promise) + .await + .map_err(|thrown| { + fail(format!( + "could not reach the provider: {}", + text_of(&thrown) + )) + })?; + let reply = reply + .as_string() + .ok_or_else(|| fail("the transport answered something unexpected"))?; + let (status, body) = split_reply(&reply).map_err(fail)?; + with_ai(|ai| ai.chosen_provider()?.decode_completion(status, &body))? + }; + + let done = with_ai(|ai| { + ai.conversation.record_reply(&completion); + Ok(completion.tool_calls.is_empty()) + })?; + if done { + return Ok(()); + } + + // Borrowed again: tools go through the command bus like anyone else. + with_doc(|doc| { + AI.with(|cell| { + let mut held = cell.borrow_mut(); + let ai = held.as_mut().ok_or("init() has not run")?; + let outcomes = tools::run( + &mut doc.runtime, + &tools, + &mut ai.policy, + &mut SidebarUser, + &completion.tool_calls, + ); + if outcomes.iter().any(|outcome| outcome.allowed) { + doc.dirty = true; + } + for outcome in &outcomes { + ai.conversation.record_tool(outcome); + } + Ok(()) + }) + })?; + } +} + +/// The context options as they stand, read without holding the borrow. +fn ai_context() -> Result { + AI.with(|cell| { + cell.borrow() + .as_ref() + .map(|ai| ai.context) + .ok_or_else(|| "init() has not run".to_string()) + }) +} diff --git a/crates/web/src/lib.rs b/crates/web/src/lib.rs new file mode 100644 index 0000000..c08b7f7 --- /dev/null +++ b/crates/web/src/lib.rs @@ -0,0 +1,33 @@ +//! HyperLab in a browser. +//! +//! The desktop application is a Tauri shell around the same crates this one +//! wraps; here the shell is a WebAssembly module and the window is a web +//! page. The command surface is deliberately the same, function for +//! function, so the React renderer cannot tell which shell it is behind. +//! +//! What is different is everything a browser does differently: +//! +//! * **Dialogs.** The desktop blocks a script on a channel while the window +//! shows the dialog. Here the runtime runs in a Web Worker and the +//! [`Host`](hyperlab_runtime::Host) callbacks cross into JavaScript, which +//! blocks the worker on `Atomics.wait` while the page shows the same +//! dialog. Neither side of that trade lives in this crate: the host is a +//! JavaScript object handed to [`api::init`]. +//! * **Files.** There is no file system. A stack travels as the single-file +//! JSON `hyperlab-persistence` already speaks, uploaded and downloaded by +//! the page. +//! * **Keys.** There is no OS keychain. A provider's key goes into browser +//! storage through the host object, is sent only to the provider the user +//! configured, and there is no call that reads one back out — the same +//! one-way rule the desktop keeps. +//! * **AI transport.** The wire protocol comes from `hyperlab-ai-providers` +//! (built without its native clients); the bytes travel through `fetch`, +//! so a request goes straight from the user's browser to their provider +//! and never through the server that served the page. + +#![warn(missing_docs)] + +#[cfg(target_arch = "wasm32")] +pub mod api; +pub mod providers; +pub mod view; diff --git a/crates/web/src/providers.rs b/crates/web/src/providers.rs new file mode 100644 index 0000000..c2215ab --- /dev/null +++ b/crates/web/src/providers.rs @@ -0,0 +1,394 @@ +//! Reaching a language model from a page, with the page's own transport. +//! +//! The native build turns a [`ProviderConfig`] into a client that owns an +//! HTTP stack. A browser already has one — `fetch` — and it is the only one +//! a page may use, so here a configuration resolves to a [`WebProvider`]: +//! the address to post to, the headers to send, and the wire protocol to +//! speak, with the actual sending left to JavaScript. +//! +//! Two promises this module keeps, and where: +//! +//! * **A key goes one way.** It is read through the lookup the caller +//! passes in — browser storage, behind the same one-way interface as the +//! desktop's keychain — lands in a header, and is never printed: +//! [`WebProvider`] has no `Debug`, no `Serialize`, and no method that +//! returns the key. +//! * **A request goes to the provider and nowhere else.** The URL is the +//! configured provider's; the page that served HyperLab never sees it. A +//! static host has no server worth the name, and this keeps it that way. + +use hyperlab_ai::{ + AiSettings, ChatMessage, Completion, CompletionRequest, FinishReason, KeySource, ProviderKind, + Role, +}; +use hyperlab_ai_providers::{anthropic, openai}; +use serde_json::Value; + +/// Which shape of request a provider expects. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Wire { + /// Anthropic's Messages API. + Anthropic, + /// The OpenAI chat-completions protocol, which OpenRouter, Ollama and + /// every "compatible" server also speak. + OpenAiChat, + /// The built-in mock: answered locally, nothing sent anywhere. + Mock, +} + +/// One configured provider, resolved and ready to be asked. +pub struct WebProvider { + /// The name the user gave it. + pub name: String, + /// The protocol it speaks. + pub wire: Wire, + /// Where requests go, without a trailing slash. + pub base_url: String, + /// The model to ask for when a request does not name one. + pub model: String, + /// Whether asking it keeps the stack on this machine. + pub local: bool, + key: Option, +} + +impl std::fmt::Debug for WebProvider { + /// Written by hand so the key cannot end up in a log: it says whether + /// one is held, never what it is. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("WebProvider") + .field("name", &self.name) + .field("wire", &self.wire) + .field("base_url", &self.base_url) + .field("model", &self.model) + .field("local", &self.local) + .field("key", &self.key.as_ref().map(|_| "…")) + .finish() + } +} + +impl WebProvider { + /// The URL a completion is posted to. + #[must_use] + pub fn completion_url(&self) -> String { + match self.wire { + Wire::Anthropic => format!("{}/messages", self.base_url), + Wire::OpenAiChat => format!("{}/chat/completions", self.base_url), + Wire::Mock => String::new(), + } + } + + /// The headers a completion is posted with. The key lands here and + /// nowhere else. + /// + /// `anthropic-dangerous-direct-browser-access` is the header Anthropic + /// requires before answering a browser at all. It is the point of this + /// build rather than a workaround: the request really does come straight + /// from the user's browser, with a key the user typed into it, which is + /// exactly the situation the header exists to make explicit. + #[must_use] + pub fn headers(&self) -> Vec<(String, String)> { + let mut headers = vec![("content-type".to_string(), "application/json".to_string())]; + match self.wire { + Wire::Anthropic => { + headers.push(( + "anthropic-version".to_string(), + anthropic::API_VERSION.to_string(), + )); + headers.push(( + "anthropic-dangerous-direct-browser-access".to_string(), + "true".to_string(), + )); + if let Some(key) = &self.key { + headers.push(("x-api-key".to_string(), key.clone())); + } + } + Wire::OpenAiChat => { + if let Some(key) = &self.key { + headers.push(("authorization".to_string(), format!("Bearer {key}"))); + } + } + Wire::Mock => {} + } + headers + } + + /// The body of a completion request, in the provider's own shape. + #[must_use] + pub fn completion_body(&self, request: &CompletionRequest) -> Value { + match self.wire { + Wire::Anthropic => anthropic::completion_body(&self.model, request), + Wire::OpenAiChat | Wire::Mock => openai::completion_body(&self.model, request), + } + } + + /// Reads what came back, or says what went wrong in a sentence. + /// + /// # Errors + /// + /// Returns a message for the user: the provider's own words when the + /// reply carries any, and the status when it does not. + pub fn decode_completion(&self, status: u16, body: &str) -> Result { + // A failed request may answer with prose — an HTML error page from a + // proxy, say — so the body is only parsed as a courtesy. + let parsed = serde_json::from_str::(body); + if !(200..300).contains(&status) { + let described = parsed.as_ref().ok().and_then(|reply| match self.wire { + Wire::Anthropic => anthropic::describe_error(reply), + Wire::OpenAiChat | Wire::Mock => openai::describe_error(reply), + }); + return Err(match described { + Some(message) => message, + None => format!("the provider answered {status}"), + }); + } + + let reply = parsed.map_err(|error| format!("the reply was not JSON: {error}"))?; + match self.wire { + Wire::Anthropic => anthropic::decode_completion(&reply), + Wire::OpenAiChat | Wire::Mock => openai::decode_completion(&reply), + } + .map_err(|error| error.to_string()) + } +} + +/// What the mock answers, matching `MockProvider` in `hyperlab-ai`: the last +/// thing it was told, echoed, so the plumbing can be watched with no key and +/// no network. +#[must_use] +pub fn mock_completion(request: &CompletionRequest) -> Completion { + let last = request + .messages + .iter() + .rev() + .find(|message| message.role == Role::User) + .map_or_else(String::new, |message: &ChatMessage| message.content.clone()); + Completion { + content: format!("You said: {last}"), + tool_calls: Vec::new(), + finish_reason: FinishReason::Stop, + usage: None, + } +} + +/// Resolves one configuration, going to `stored_key` for a key kept in the +/// browser. +/// +/// The arms mirror `hyperlab_ai_providers::build` deliberately, so a +/// settings file means the same thing on both shells — with one exception a +/// page cannot avoid: an environment variable does not exist here, and the +/// error says where the key can live instead. +/// +/// # Errors +/// +/// Returns a sentence naming what is missing or unsupported. +pub fn resolve( + name: &str, + config: &hyperlab_ai::ProviderConfig, + stored_key: &dyn Fn(&str) -> Option, +) -> Result { + let key = match &config.key { + None => None, + Some(KeySource::Keychain) => Some(stored_key(name).ok_or_else(|| { + "no key is saved for this provider in this browser yet — type one into AI ▸ Settings" + .to_string() + })?), + Some(KeySource::Environment(variable)) => { + return Err(format!( + "a page has no environment variables, so {variable} cannot be read — \ + keep the key in this browser instead" + )); + } + }; + + let (wire, default_base_url) = match &config.kind { + ProviderKind::OpenAi => (Wire::OpenAiChat, Some(openai::DEFAULT_BASE_URL)), + ProviderKind::Anthropic => (Wire::Anthropic, Some(anthropic::DEFAULT_BASE_URL)), + ProviderKind::OpenRouter | ProviderKind::Ollama | ProviderKind::OpenAiCompatible => { + (Wire::OpenAiChat, None) + } + ProviderKind::Mock => (Wire::Mock, Some("")), + ProviderKind::Google | ProviderKind::Local | ProviderKind::Other(_) => { + return Err(format!( + "HyperLab has no client for \"{}\" yet; an OpenAI-compatible endpoint would work", + config.kind.as_str() + )); + } + }; + + let base_url = match (&config.base_url, default_base_url) { + (Some(url), _) => url.trim_end_matches('/').to_string(), + (None, Some(url)) => url.trim_end_matches('/').to_string(), + (None, None) => { + return Err(format!( + "\"{}\" needs a baseUrl, such as http://localhost:11434/v1", + config.kind.as_str() + )); + } + }; + + Ok(WebProvider { + name: name.to_string(), + wire, + base_url, + model: config.model.clone(), + local: config.kind.is_local(), + key, + }) +} + +/// Resolves every provider the settings describe. +/// +/// One that cannot be resolved is left out and the reason collected, rather +/// than failing the lot — the same manners as the desktop's settings loader. +#[must_use] +pub fn resolve_all( + settings: &AiSettings, + stored_key: &dyn Fn(&str) -> Option, +) -> (Vec, Vec) { + let mut providers = Vec::new(); + let mut problems = Vec::new(); + for (name, config) in &settings.providers { + match resolve(name, config, stored_key) { + Ok(provider) => providers.push(provider), + Err(reason) => problems.push(format!("{name}: {reason}")), + } + } + (providers, problems) +} + +#[cfg(test)] +mod tests { + use hyperlab_ai::ProviderConfig; + + use super::*; + + fn no_key(_: &str) -> Option { + None + } + + fn a_key(_: &str) -> Option { + Some("sk-test".into()) + } + + #[test] + fn anthropic_resolves_to_its_own_protocol_and_address() { + let config = ProviderConfig { + key: Some(KeySource::Keychain), + ..ProviderConfig::new(ProviderKind::Anthropic, "a-model") + }; + let provider = resolve("work", &config, &a_key).unwrap(); + assert_eq!(provider.wire, Wire::Anthropic); + assert_eq!( + provider.completion_url(), + "https://api.anthropic.com/v1/messages" + ); + } + + #[test] + fn a_browser_request_to_anthropic_says_out_loud_that_it_is_one() { + let config = ProviderConfig { + key: Some(KeySource::Keychain), + ..ProviderConfig::new(ProviderKind::Anthropic, "a-model") + }; + let headers = resolve("work", &config, &a_key).unwrap().headers(); + assert!(headers.iter().any(|(name, value)| name + == "anthropic-dangerous-direct-browser-access" + && value == "true")); + assert!( + headers + .iter() + .any(|(name, value)| name == "x-api-key" && value == "sk-test") + ); + } + + #[test] + fn the_chat_completions_kinds_need_an_address_and_get_bearer_auth() { + let mut config = ProviderConfig { + key: Some(KeySource::Keychain), + ..ProviderConfig::new(ProviderKind::Ollama, "llama") + }; + assert!(resolve("local", &config, &a_key).is_err(), "no baseUrl yet"); + + config.base_url = Some("http://localhost:11434/v1/".into()); + let provider = resolve("local", &config, &a_key).unwrap(); + assert_eq!(provider.wire, Wire::OpenAiChat); + assert_eq!( + provider.completion_url(), + "http://localhost:11434/v1/chat/completions" + ); + assert!( + provider + .headers() + .iter() + .any(|(name, value)| name == "authorization" && value == "Bearer sk-test") + ); + } + + #[test] + fn an_environment_key_source_is_refused_with_directions() { + let config = + ProviderConfig::new(ProviderKind::OpenAi, "a-model").from_environment("OPENAI_API_KEY"); + let reason = resolve("work", &config, &a_key).unwrap_err(); + assert!(reason.contains("OPENAI_API_KEY"), "{reason}"); + assert!(reason.contains("browser"), "{reason}"); + } + + #[test] + fn a_missing_stored_key_is_a_sentence_not_a_request_without_credentials() { + let config = ProviderConfig { + key: Some(KeySource::Keychain), + ..ProviderConfig::new(ProviderKind::Anthropic, "a-model") + }; + let reason = resolve("work", &config, &no_key).unwrap_err(); + assert!(reason.contains("Settings"), "{reason}"); + } + + #[test] + fn one_broken_provider_does_not_take_the_others_with_it() { + let mut settings = AiSettings::default(); + settings.providers.insert( + "offline".into(), + ProviderConfig::new(ProviderKind::Mock, "any"), + ); + settings.providers.insert( + "broken".into(), + ProviderConfig::new(ProviderKind::Ollama, "llama"), + ); + + let (providers, problems) = resolve_all(&settings, &no_key); + assert_eq!(providers.len(), 1); + assert_eq!(providers[0].name, "offline"); + assert_eq!(problems.len(), 1); + assert!(problems[0].contains("broken"), "{problems:?}"); + } + + #[test] + fn the_mock_answers_locally_and_echoes() { + let request = CompletionRequest::new( + "any", + vec![ + hyperlab_ai::ChatMessage::system("be brief"), + hyperlab_ai::ChatMessage::user("hello"), + ], + ); + let completion = mock_completion(&request); + assert_eq!(completion.content, "You said: hello"); + assert_eq!(completion.finish_reason, FinishReason::Stop); + } + + #[test] + fn an_error_reply_is_reported_in_the_providers_own_words() { + let config = ProviderConfig::new(ProviderKind::OpenAi, "a-model"); + let provider = resolve("work", &config, &no_key).unwrap(); + + let said = provider + .decode_completion(401, r#"{"error": {"message": "bad key"}}"#) + .unwrap_err(); + assert_eq!(said, "bad key"); + + let unsaid = provider + .decode_completion(502, "bad gateway") + .unwrap_err(); + assert!(unsaid.contains("502"), "{unsaid}"); + } +} diff --git a/crates/web/src/view.rs b/crates/web/src/view.rs new file mode 100644 index 0000000..d1f38f9 --- /dev/null +++ b/crates/web/src/view.rs @@ -0,0 +1,292 @@ +//! What the renderer is allowed to see. +//! +//! A twin of `apps/desktop/src-tauri/src/view.rs`, kept in step by hand: the +//! two shells must send the same snapshot, because they stand behind the +//! same renderer. The frontend never receives a `Stack`. It receives a +//! [`StackView`]: a flat, serializable snapshot of the current card, taken +//! after every change. +//! +//! This is the "view model" step in the architecture: +//! +//! ```text +//! Runtime → View Model → Renderer +//! ``` +//! +//! It exists so that the renderer cannot hold a reference to anything the +//! runtime owns, cannot mutate anything by accident, and does not break when +//! the object model changes shape. + +use hyperlab_runtime::Runtime; +use hyperlab_stack::{Object, PartContainer, PartKind, Rect, Stack, Value}; +use serde::Serialize; + +/// Everything the window needs to draw itself. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StackView { + /// The stack's name, shown in the title bar. + pub stack_name: String, + /// The stack's id, so the inspector can address it. + pub stack_id: u64, + /// The stack's script. + pub stack_script: String, + /// The size every card is drawn at. + pub card_size: SizeView, + /// How many cards there are. + pub card_count: usize, + /// Which one is showing, counting from one. + pub card_number: usize, + /// The current card. + pub card: CardView, + /// The background beneath it. + pub background: Option, + /// The message box. + pub message_box: String, + /// Whether there is anything to undo, and what. + pub undo: Option, + /// Whether there is anything to redo, and what. + pub redo: Option, + /// Whether there are unsaved changes. + pub dirty: bool, + /// Where the stack is saved, if it has been saved. A browser has nowhere + /// to save one, so this stays `None` and the page offers a download + /// instead. + pub path: Option, +} + +/// A width and a height. +#[derive(Debug, Clone, Copy, Serialize)] +pub struct SizeView { + /// Width in card-space pixels. + pub width: i32, + /// Height in card-space pixels. + pub height: i32, +} + +/// A card or a background, with everything on it. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CardView { + /// Its id. + pub id: u64, + /// `"card"` or `"background"`. + pub kind: String, + /// Its name. + pub name: String, + /// Its script. + pub script: String, + /// Its parts, furthest back first. + pub parts: Vec, +} + +/// One part, ready to draw. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PartView { + /// Its id. + pub id: u64, + /// `"button"`, `"field"` or `"image"`. + pub kind: String, + /// Whether it belongs to the card or to the background, which decides + /// whether editing it changes every card. + pub layer: String, + /// Its name, which is also a button's label. + pub name: String, + /// A field's contents. + pub text: String, + /// Where it sits: left, top, width, height. + pub rect: [i32; 4], + /// Whether it is drawn at all. + pub visible: bool, + /// Whether it responds to the mouse. + pub enabled: bool, + /// How it is drawn: `roundRect`, `rectangle`, `transparent`, … + pub style: String, + /// Whether a field refuses typing. + pub locked: bool, + /// Whether a button draws its name. A transparent button with this off + /// is a clickable region over artwork, which is how a drawn board works. + pub show_name: bool, + /// Which picture an image part draws, by its name in the stack's + /// library. Empty for everything else, and for an image with none + /// chosen yet. + pub source: String, + /// Its script, so the inspector need not ask again. + pub script: String, + /// Every property, for the property editor. Values are JSON so that + /// booleans stay booleans. + pub properties: Vec, +} + +/// One row of the property editor. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PropertyView { + /// The property's name. + pub name: String, + /// Its value. + pub value: serde_json::Value, + /// Whether the editor should refuse to change it. + pub read_only: bool, +} + +/// Takes a snapshot of the runtime. +#[must_use] +pub fn snapshot(runtime: &Runtime, dirty: bool, path: Option) -> StackView { + let stack = runtime.stack(); + let card_id = runtime.current_card(); + let card = stack.card(card_id); + + StackView { + stack_name: stack.name().to_string(), + stack_id: stack.id().get(), + stack_script: stack.script().to_string(), + card_size: SizeView { + width: stack.size().width, + height: stack.size().height, + }, + card_count: stack.card_count(), + card_number: runtime.current_card_index() + 1, + card: card.map_or_else( + || CardView { + id: 0, + kind: "card".into(), + name: String::new(), + script: String::new(), + parts: Vec::new(), + }, + |card| container_view(stack, card, "card"), + ), + background: stack + .background_of(card_id) + .map(|background| container_view(stack, background, "background")), + message_box: runtime.message_box().to_string(), + undo: runtime.history().undo_label().map(str::to_string), + redo: runtime.history().redo_label().map(str::to_string), + dirty, + path, + } +} + +fn container_view(_stack: &Stack, container: &T, kind: &str) -> CardView +where + T: Object + PartContainer, +{ + CardView { + id: container.id().get(), + kind: kind.to_string(), + name: container.name().to_string(), + script: container.script().to_string(), + parts: container + .parts() + .iter() + .map(|part| part_view(part, kind)) + .collect(), + } +} + +fn part_view(part: &hyperlab_stack::Part, layer: &str) -> PartView { + let rect: Rect = part.geometry(); + let flag = |name: &str, fallback: bool| { + part.property(name) + .and_then(|value| value.as_bool()) + .unwrap_or(fallback) + }; + PartView { + id: part.id().get(), + kind: part.kind().as_str().to_string(), + layer: layer.to_string(), + name: part.name().to_string(), + text: if part.part_kind() == PartKind::Field { + part.text() + } else { + String::new() + }, + rect: [rect.left, rect.top, rect.width, rect.height], + visible: flag("visible", true), + enabled: flag("enabled", true), + style: part.property("style").unwrap_or(Value::Empty).as_text(), + locked: flag("locked", false), + show_name: flag("showName", true), + source: if part.part_kind() == PartKind::Image { + part.property("source").unwrap_or(Value::Empty).as_text() + } else { + String::new() + }, + script: part.script().to_string(), + properties: properties_of(part), + } +} + +/// Lists an object's properties for the inspector. +/// +/// The inspector shows whatever is there, including properties this version +/// of HyperLab has never heard of, which is what makes the object model +/// extensible in practice rather than only in principle. +#[must_use] +pub fn properties_of(object: &dyn Object) -> Vec { + object + .property_names() + .into_iter() + .filter(|name| name != "script") + .filter_map(|name| { + let value = object.property(&name)?; + Some(PropertyView { + read_only: name == "id", + value: match value { + Value::Bool(flag) => serde_json::Value::Bool(flag), + Value::Number(number) => serde_json::json!(number), + other => serde_json::Value::String(other.as_text()), + }, + name, + }) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use hyperlab_runtime::{Command, Runtime}; + use hyperlab_stack::{PartKind, Rect, Stack}; + + use super::*; + + #[test] + fn a_snapshot_reads_like_the_stack_it_was_taken_of() { + let mut runtime = Runtime::new(Stack::new("Notes")); + let card = runtime.current_card(); + runtime + .execute(Command::CreatePart { + owner: hyperlab_runtime::PartOwner::Card { id: card }, + kind: PartKind::Button, + name: "Go".into(), + geometry: Rect::new(10, 20, 100, 30), + }) + .unwrap(); + + let view = snapshot(&runtime, true, None); + assert_eq!(view.stack_name, "Notes"); + assert_eq!(view.card_count, 1); + assert_eq!(view.card_number, 1); + assert!(view.dirty); + assert_eq!(view.path, None); + + let button = &view.card.parts[0]; + assert_eq!(button.kind, "button"); + assert_eq!(button.name, "Go"); + assert_eq!(button.rect, [10, 20, 100, 30]); + } + + #[test] + fn the_snapshot_serializes_in_the_shape_the_renderer_expects() { + let runtime = Runtime::new(Stack::new("Notes")); + let json = serde_json::to_value(snapshot(&runtime, false, None)).unwrap(); + // Spot-checked against apps/desktop/src/types.ts, which both shells + // must satisfy. + assert_eq!(json["stackName"], "Notes"); + assert!(json["cardSize"]["width"].is_number()); + assert!(json["card"]["parts"].is_array()); + assert_eq!(json["messageBox"], ""); + assert_eq!(json["path"], serde_json::Value::Null); + } +} From 7735ab0d7c37bf69b5e0a61faf410490b0b30c02 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 13:04:20 +0000 Subject: [PATCH 03/10] Add the website: a retro landing page, and the whole application in the browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apps/web serves two pages from GitHub Pages. The landing page tells HyperCard's story — 1987, the teachers, the museums, Myst — dressed in the Neo Classic theme's own values. The playground is the desktop's React renderer, unchanged, over the WebAssembly runtime: Vite swaps one module (api.ts) and everything else — cards, inspector, assistant, map, dialogs — is the same code the desktop draws. The runtime lives in a Web Worker so a script can block on a real, HyperCard-shaped dialog: the worker parks on Atomics.wait while the page shows it, which is the desktop's blocking-thread arrangement rebuilt from browser parts. GitHub Pages cannot send the isolation headers that allows, so a vendored coi-serviceworker (MIT) adds them; without isolation the runtime falls back to the main thread and the browser's own prompt. Files became uploads and downloads of the single-file stack JSON, pictures arrive through a file picker, and the six examples are packed by a new persistence example and fetched from the site. API keys live in browser storage behind the same one-way rule as the OS keychain and travel only to the configured provider — the site is static files, so there is no server for them to visit. The stack crate's clock became pluggable along the way: SystemTime traps on wasm32, so the web shell names the browser's clock, and the interpreter's time and randomness follow it. The Todo example also gained the trailing newline its Add button always assumed. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01R7eaxUAqy2gKQSBNnYZoFr --- .gitignore | 5 + Cargo.lock | 11 + apps/web/.prettierrc | 5 + apps/web/index.html | 240 ++++ apps/web/package-lock.json | 1033 +++++++++++++++++ apps/web/package.json | 34 + apps/web/play.html | 22 + apps/web/public/coi-serviceworker.js | 146 +++ apps/web/public/favicon.svg | 6 + apps/web/src/App.tsx | 546 +++++++++ apps/web/src/api.ts | 252 ++++ apps/web/src/backend/index.ts | 261 +++++ apps/web/src/backend/transport.ts | 49 + apps/web/src/backend/worker.ts | 158 +++ apps/web/src/components/AiSettings.tsx | 251 ++++ apps/web/src/landing.css | 295 +++++ apps/web/src/main.tsx | 19 + apps/web/src/vite-env.d.ts | 7 + apps/web/tsconfig.json | 45 + apps/web/tsconfig.node.json | 13 + apps/web/vite.config.ts | 84 ++ crates/persistence/examples/build_examples.rs | 5 +- .../persistence/examples/pack_single_files.rs | 64 + crates/runtime/src/interpreter/builtins.rs | 20 +- crates/stack/src/lib.rs | 2 +- crates/stack/src/time.rs | 40 +- crates/stack/tests/clock.rs | 21 + crates/web/Cargo.toml | 1 + crates/web/src/api.rs | 5 + crates/web/tests/examples.rs | 41 + examples/Address Book.hl/metadata.json | 2 +- examples/Cluedo.hl/metadata.json | 2 +- examples/LLMs for n00bs.hl/metadata.json | 2 +- examples/Myst.hl/metadata.json | 2 +- examples/Recipe Box.hl/metadata.json | 2 +- examples/Todo.hl/cards/3.json | 2 +- examples/Todo.hl/metadata.json | 2 +- 37 files changed, 3674 insertions(+), 21 deletions(-) create mode 100644 apps/web/.prettierrc create mode 100644 apps/web/index.html create mode 100644 apps/web/package-lock.json create mode 100644 apps/web/package.json create mode 100644 apps/web/play.html create mode 100644 apps/web/public/coi-serviceworker.js create mode 100644 apps/web/public/favicon.svg create mode 100644 apps/web/src/App.tsx create mode 100644 apps/web/src/api.ts create mode 100644 apps/web/src/backend/index.ts create mode 100644 apps/web/src/backend/transport.ts create mode 100644 apps/web/src/backend/worker.ts create mode 100644 apps/web/src/components/AiSettings.tsx create mode 100644 apps/web/src/landing.css create mode 100644 apps/web/src/main.tsx create mode 100644 apps/web/src/vite-env.d.ts create mode 100644 apps/web/tsconfig.json create mode 100644 apps/web/tsconfig.node.json create mode 100644 apps/web/vite.config.ts create mode 100644 crates/persistence/examples/pack_single_files.rs create mode 100644 crates/stack/tests/clock.rs create mode 100644 crates/web/tests/examples.rs diff --git a/.gitignore b/.gitignore index 0e8be1a..d941b4e 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,8 @@ dist/ # Tauri's build output, which lives inside the shell crate apps/desktop/src-tauri/target/ + +# The website's generated assets: the WebAssembly package wasm-pack writes, +# and the example stacks packed as single files. Both are built in CI. +apps/web/wasm-pkg/ +apps/web/public/examples/ diff --git a/Cargo.lock b/Cargo.lock index 220137c..af9b9cf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -127,6 +127,16 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + [[package]] name = "core_maths" version = "0.1.1" @@ -566,6 +576,7 @@ dependencies = [ name = "hyperlab-web" version = "0.2.0" dependencies = [ + "console_error_panic_hook", "hyperlab-ai", "hyperlab-ai-providers", "hyperlab-assistant", diff --git a/apps/web/.prettierrc b/apps/web/.prettierrc new file mode 100644 index 0000000..4ba8ea1 --- /dev/null +++ b/apps/web/.prettierrc @@ -0,0 +1,5 @@ +{ + "singleQuote": true, + "printWidth": 90, + "trailingComma": "all" +} diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..2fb5fe0 --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,240 @@ + + + + + + HyperLab — an open-source, AI-native successor to HyperCard + + + + + + + + + +
+ +
+
+ +

Welcome to HyperLab

+
+
+

+ An open-source, AI-native successor to HyperCard. Cards, + stacks, fields, buttons and HyperTalk are all still here — and a stack stays + inspectable and editable while it runs. +

+
+on mouseUp
+  put ai("Summarize this card") into field "Summary"
+end mouseUp
+ +

+ The playground is the full application — the same runtime, compiled to + WebAssembly, running entirely in your browser. Nothing you make here leaves + your machine unless you ask a model about it. +

+
+
+ + +
+
+ +

A short history

+
+
+

+ 🍎 In 1987, Apple shipped HyperCard, and software stopped being something only + programmers made. +

+

+ You drew a button, typed two lines of plain English, and that was the app. No + project file. No compiler. Sounds familiar? +

+ +

+ It came free on every Mac for over a decade. Then it was gone, and nothing + quite took its place. +

+

+ HyperLab is what that idea looks like thirty years on, with language models + built into the way a stack thinks rather than bolted onto the side of one. + Draw a button, write two lines of HyperTalk — or ask the assistant to do it + for you, and undo it like any other change. +

+

+ 🕯️ Dedicated to Bill Atkinson (1951–2025), who wrote + QuickDraw, MacPaint and HyperCard, and insisted the last of them ship free + with every Mac. +

+
+
+ + +
+
+ +

Six stacks to open

+
+ +
+ + +
+
+ +

Bring your own model, or none

+
+
+

+ HyperLab works with no AI provider configured, and always will. When you want + one, open AI ▸ Settings in the playground and add Anthropic, + OpenAI, OpenRouter, an Ollama on your own machine — or the built-in mock, + which answers locally and needs no key at all. +

+

+ Your API key stays in your browser. This site is static files + on GitHub Pages — there is no HyperLab server, so there is nowhere for a key + to be sent. A key you type in is kept in this browser's storage on your device + and travels exactly one way: with the requests your stack makes, straight from + your browser to the provider you chose. There is no call that reads a key back + out, and the sidebar shows the exact text of everything that was sent. +

+

+ The assistant can only do what you can do — it works the runtime's own + commands, so everything it makes is visible, inspectable, and undoable. +

+
+
+ + +
+
+ +

Take it home

+
+
+

+ The desktop application — a .dmg, an .msi, and a + .deb, .rpm or .AppImage — is on the + releases page. Nothing phones home. A stack you download from the playground opens in the + app, and the other way round: it is the same file. +

+

+ A stack can also leave for somewhere else entirely: the cards as a PDF, the + map as a PNG, the whole thing as a self-contained web page, or as a + Decker deck. +

+
+
+ +
+

+ HyperLab is MIT-licensed and + contains no Apple code and no Apple artwork; the resemblance is to the era, not + to any particular thing. Built with Rust, WebAssembly, and affection for 1987. +

+
+
+ + diff --git a/apps/web/package-lock.json b/apps/web/package-lock.json new file mode 100644 index 0000000..743a528 --- /dev/null +++ b/apps/web/package-lock.json @@ -0,0 +1,1033 @@ +{ + "name": "hyperlab-web", + "version": "0.2.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "hyperlab-web", + "version": "0.2.0", + "license": "MIT", + "dependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@tauri-apps/api": "^2.1.1", + "@tauri-apps/plugin-dialog": "^2.2.0", + "@types/node": "^24.0.0", + "@types/react": "^19.0.2", + "@types/react-dom": "^19.0.2", + "@vitejs/plugin-react": "^6.0.5", + "prettier": "^3.4.2", + "typescript": "^6.0.3", + "vite": "^8.2.0" + } + }, + "node_modules/@emnapi/core": { + "version": "2.0.0-alpha.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-2.0.0-alpha.3.tgz", + "integrity": "sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "2.0.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "2.0.0-alpha.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-2.0.0-alpha.3.tgz", + "integrity": "sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-2.0.1.tgz", + "integrity": "sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz", + "integrity": "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.142.0.tgz", + "integrity": "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.1.tgz", + "integrity": "sha512-02hOeOSryYxVrOIphmLAsqnCJWxwlzFk+pEt/N/i6OgT3lShHO7xGCU5cpgchRDHboAEbSjzgGh+O/u1GswQmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.1.tgz", + "integrity": "sha512-fMsTOnN0OjFm3CyppWPitKnc8UlliVARUULW6cfU6AIqjdtgmSFWSk9vecHzZduv/yMWIHDlRhM1e8Iff9uAfA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.1.tgz", + "integrity": "sha512-1wjKdz/XLGKHaTNHjQveQ/B23TKx4ItAqm1JbyVuvNPc4Ze0Fb48s49TAd/2zcplPl8okE/UbTgmlVfwT7eFeQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.1.tgz", + "integrity": "sha512-Fa0jHR07E7YBN4vOEsbVf2briYNsuOowfLJaXULZM0ldMlaCaj2LJgLMbMe4iacRyZmvR8efFhgR9wKuGclQUg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.1.tgz", + "integrity": "sha512-pzkgu1SSHGgRRyRZ4fbmSgmajbVt+epaLP99NDjFft69v/ypfTi6swBMiVdh2EkQ0OSnHE1lZDM7DRGkyAzUpA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.1.tgz", + "integrity": "sha512-QI5SEDY8cbiYWHx0VO4vIc3UlS6a32vXHjU8Qy/17adEmZIPuByJg13UEvo9c/UCiUkdcVWY83C+b+JrwnNyUg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.1.tgz", + "integrity": "sha512-Sm41FyCeXqmYcERoYOCbGIL5hNfd8w9LQ7Y61Bev48HkcjaJqV/iiVOaiDxjVTRMS+QKrZmD8cfPt4uMVnvM+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.1.tgz", + "integrity": "sha512-2x+WhXTGl9yJYPbltW/BSEPTVz9OIWQyER4N+gJEDWkkn904eRcBzELqh/Hf7K0w/ubGbKNMv0ZC+94QK/IFEg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.1.tgz", + "integrity": "sha512-eEjmQpuRQayHPWWnywaWHkFT3ToPbP3RYy42VVd/B9aBGDA+Ol25EIWHxKQST3IiWJjikCWUF7KtbfqwZrzVwQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.1.tgz", + "integrity": "sha512-/Orga1fZYkLc/56jBICcHrKchl8Z2UKdDSr3LG9ToWO1lQ6a4Livk9Xz+9WN91zsz5QR3XQz2NNoSDEvP6qadw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.1.tgz", + "integrity": "sha512-xxBJRL+0q0Kce7orznGWLuylHDY65vuARXZRpX+hPdv+DqK2c3NlCsVA98tlWzWNEE7yPqA/1NQ5nnCrj49Y5A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.1.tgz", + "integrity": "sha512-M6AdXIXw3s+/8XpKMzdGDEXGS1S7kwUsy+rcTIUIOx5Ge4nXKCtAFHFV9YKkXvGcC5WMoTjAteLzlsQROVI0Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.2.1.tgz", + "integrity": "sha512-/TX0SoRGojHzSAHpfVBbavRVSazg5U3h3Y3VXfcc0cdugq6kxdqw8LPGFiPr+/7gE/60zRcsOY2Vi9b9eT0jww==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "2.0.0-alpha.3", + "@emnapi/runtime": "2.0.0-alpha.3", + "@napi-rs/wasm-runtime": "^1.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.1.tgz", + "integrity": "sha512-EvRrivJieyHG+AO9lleZWgq+g0+S7oV2C51yuqlcyU/R9net+sI4Pj0F+lUoP2bEr6TWX3SqFaaS0SzfLxSzkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.1.tgz", + "integrity": "sha512-Z4eCmn5QJ/5+azF9knpLWKfVd9aidn0mAe9TpJgvBLId9Ax3t0+JVxBmT25Bv7NBbVW1TZyKjQjQReouMeH5UQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tauri-apps/api": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.1.tgz", + "integrity": "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==", + "dev": true, + "license": "Apache-2.0 OR MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + } + }, + "node_modules/@tauri-apps/plugin-dialog": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.2.tgz", + "integrity": "sha512-pX0IGm1I3I6wc+zeKYcq1GSqogK6okCNX5fOdaNU5ab1AjGS6l1E5wFNjEb7meg7ZFSp0JUs+0jQGQNyOvLrsg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.11.0" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz", + "integrity": "sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/rolldown": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.1.tgz", + "integrity": "sha512-4FKJhg8d3OiyQOA6Q1Q0hoFFpW9/OoX+VsHzpECsdsIZoOArrAK90gl59YK/Z+gnDel45bgJZK03ozH/9bCqEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.142.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.2.1", + "@rolldown/binding-darwin-arm64": "1.2.1", + "@rolldown/binding-darwin-x64": "1.2.1", + "@rolldown/binding-freebsd-x64": "1.2.1", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.1", + "@rolldown/binding-linux-arm64-gnu": "1.2.1", + "@rolldown/binding-linux-arm64-musl": "1.2.1", + "@rolldown/binding-linux-ppc64-gnu": "1.2.1", + "@rolldown/binding-linux-s390x-gnu": "1.2.1", + "@rolldown/binding-linux-x64-gnu": "1.2.1", + "@rolldown/binding-linux-x64-musl": "1.2.1", + "@rolldown/binding-openharmony-arm64": "1.2.1", + "@rolldown/binding-wasm32-wasi": "1.2.1", + "@rolldown/binding-win32-arm64-msvc": "1.2.1", + "@rolldown/binding-win32-x64-msvc": "1.2.1" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz", + "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.23", + "rolldown": "~1.2.0", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..c3d24b9 --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,34 @@ +{ + "name": "hyperlab-web", + "private": true, + "version": "0.2.0", + "type": "module", + "description": "The HyperLab website: a retro landing page and the full application in the browser.", + "license": "MIT", + "scripts": { + "wasm": "wasm-pack build ../../crates/web --target web --out-dir ../../apps/web/wasm-pkg --out-name hyperlab_web --no-pack", + "examples": "cargo run -p hyperlab-persistence --example pack_single_files -- public/examples", + "prepare:assets": "npm run wasm && npm run examples", + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit", + "format": "prettier --write \"src/**/*.{ts,tsx,css}\"", + "format:check": "prettier --check \"src/**/*.{ts,tsx,css}\"" + }, + "dependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@tauri-apps/api": "^2.1.1", + "@tauri-apps/plugin-dialog": "^2.2.0", + "@types/node": "^24.0.0", + "@types/react": "^19.0.2", + "@types/react-dom": "^19.0.2", + "@vitejs/plugin-react": "^6.0.5", + "prettier": "^3.4.2", + "typescript": "^6.0.3", + "vite": "^8.2.0" + } +} diff --git a/apps/web/play.html b/apps/web/play.html new file mode 100644 index 0000000..0d6c66d --- /dev/null +++ b/apps/web/play.html @@ -0,0 +1,22 @@ + + + + + + HyperLab + + + + + + +
+ + + diff --git a/apps/web/public/coi-serviceworker.js b/apps/web/public/coi-serviceworker.js new file mode 100644 index 0000000..9901474 --- /dev/null +++ b/apps/web/public/coi-serviceworker.js @@ -0,0 +1,146 @@ +/*! coi-serviceworker v0.1.7 - Guido Zuidhof and contributors, licensed under MIT */ +let coepCredentialless = false; +if (typeof window === 'undefined') { + self.addEventListener("install", () => self.skipWaiting()); + self.addEventListener("activate", (event) => event.waitUntil(self.clients.claim())); + + self.addEventListener("message", (ev) => { + if (!ev.data) { + return; + } else if (ev.data.type === "deregister") { + self.registration + .unregister() + .then(() => { + return self.clients.matchAll(); + }) + .then(clients => { + clients.forEach((client) => client.navigate(client.url)); + }); + } else if (ev.data.type === "coepCredentialless") { + coepCredentialless = ev.data.value; + } + }); + + self.addEventListener("fetch", function (event) { + const r = event.request; + if (r.cache === "only-if-cached" && r.mode !== "same-origin") { + return; + } + + const request = (coepCredentialless && r.mode === "no-cors") + ? new Request(r, { + credentials: "omit", + }) + : r; + event.respondWith( + fetch(request) + .then((response) => { + if (response.status === 0) { + return response; + } + + const newHeaders = new Headers(response.headers); + newHeaders.set("Cross-Origin-Embedder-Policy", + coepCredentialless ? "credentialless" : "require-corp" + ); + if (!coepCredentialless) { + newHeaders.set("Cross-Origin-Resource-Policy", "cross-origin"); + } + newHeaders.set("Cross-Origin-Opener-Policy", "same-origin"); + + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: newHeaders, + }); + }) + .catch((e) => console.error(e)) + ); + }); + +} else { + (() => { + const reloadedBySelf = window.sessionStorage.getItem("coiReloadedBySelf"); + window.sessionStorage.removeItem("coiReloadedBySelf"); + const coepDegrading = (reloadedBySelf == "coepdegrade"); + + // You can customize the behavior of this script through a global `coi` variable. + const coi = { + shouldRegister: () => !reloadedBySelf, + shouldDeregister: () => false, + coepCredentialless: () => true, + coepDegrade: () => true, + doReload: () => window.location.reload(), + quiet: false, + ...window.coi + }; + + const n = navigator; + const controlling = n.serviceWorker && n.serviceWorker.controller; + + // Record the failure if the page is served by serviceWorker. + if (controlling && !window.crossOriginIsolated) { + window.sessionStorage.setItem("coiCoepHasFailed", "true"); + } + const coepHasFailed = window.sessionStorage.getItem("coiCoepHasFailed"); + + if (controlling) { + // Reload only on the first failure. + const reloadToDegrade = coi.coepDegrade() && !( + coepDegrading || window.crossOriginIsolated + ); + n.serviceWorker.controller.postMessage({ + type: "coepCredentialless", + value: (reloadToDegrade || coepHasFailed && coi.coepDegrade()) + ? false + : coi.coepCredentialless(), + }); + if (reloadToDegrade) { + !coi.quiet && console.log("Reloading page to degrade COEP."); + window.sessionStorage.setItem("coiReloadedBySelf", "coepdegrade"); + coi.doReload("coepdegrade"); + } + + if (coi.shouldDeregister()) { + n.serviceWorker.controller.postMessage({ type: "deregister" }); + } + } + + // If we're already coi: do nothing. Perhaps it's due to this script doing its job, or COOP/COEP are + // already set from the origin server. Also if the browser has no notion of crossOriginIsolated, just give up here. + if (window.crossOriginIsolated !== false || !coi.shouldRegister()) return; + + if (!window.isSecureContext) { + !coi.quiet && console.log("COOP/COEP Service Worker not registered, a secure context is required."); + return; + } + + // In some environments (e.g. Firefox private mode) this won't be available + if (!n.serviceWorker) { + !coi.quiet && console.error("COOP/COEP Service Worker not registered, perhaps due to private mode."); + return; + } + + n.serviceWorker.register(window.document.currentScript.src).then( + (registration) => { + !coi.quiet && console.log("COOP/COEP Service Worker registered", registration.scope); + + registration.addEventListener("updatefound", () => { + !coi.quiet && console.log("Reloading page to make use of updated COOP/COEP Service Worker."); + window.sessionStorage.setItem("coiReloadedBySelf", "updatefound"); + coi.doReload(); + }); + + // If the registration is active, but it's not controlling the page + if (registration.active && !n.serviceWorker.controller) { + !coi.quiet && console.log("Reloading page to make use of COOP/COEP Service Worker."); + window.sessionStorage.setItem("coiReloadedBySelf", "notcontrolling"); + coi.doReload(); + } + }, + (err) => { + !coi.quiet && console.error("COOP/COEP Service Worker failed to register:", err); + } + ); + })(); +} diff --git a/apps/web/public/favicon.svg b/apps/web/public/favicon.svg new file mode 100644 index 0000000..4a888dd --- /dev/null +++ b/apps/web/public/favicon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx new file mode 100644 index 0000000..138137e --- /dev/null +++ b/apps/web/src/App.tsx @@ -0,0 +1,546 @@ +/** + * The application, as a page. + * + * A sibling of the desktop's `App.tsx` that renders the same components — + * the card, the inspector, the assistant, the map — over the WebAssembly + * runtime. What is different is exactly what a browser does differently: + * files are uploads and downloads, pictures arrive from a file picker, and + * the example stacks are fetched from the site itself. + */ + +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { Assistant } from '../../desktop/src/components/Assistant'; +import { Card } from '../../desktop/src/components/Card'; +import { Dialog } from '../../desktop/src/components/Dialog'; +import { Inspector } from '../../desktop/src/components/Inspector'; +import { MenuBar, type MenuEntry } from '../../desktop/src/components/MenuBar'; +import { StackMap } from '../../desktop/src/components/StackMap'; +import { StatusBar } from '../../desktop/src/components/StatusBar'; +import { toPng } from '../../desktop/src/map/picture'; +import type { + AiView, + DialogRequest, + Graph, + Outcome, + PartView, + Selection, + StackView, + Tool, +} from '../../desktop/src/types'; + +import * as api from './api'; +import { AiSettings } from './components/AiSettings'; + +/** + * The stacks bundled with the site, packed from `examples/` by + * `cargo run -p hyperlab-persistence --example pack_single_files`. + */ +const EXAMPLES = [ + 'Address Book', + 'Cluedo', + 'LLMs for n00bs', + 'Myst', + 'Recipe Box', + 'Todo', +] as const; + +/** Hands the user a file, which is what "save" means on a page. */ +function download(name: string, text: string, type: string) { + const url = URL.createObjectURL(new Blob([text], { type })); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = name; + anchor.click(); + URL.revokeObjectURL(url); +} + +function downloadBytes(name: string, bytes: Uint8Array) { + const url = URL.createObjectURL( + new Blob([bytes as unknown as BlobPart], { type: 'image/png' }), + ); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = name; + anchor.click(); + URL.revokeObjectURL(url); +} + +export function App() { + const [view, setView] = useState(api.emptyView); + const [selection, setSelection] = useState(null); + const [tool, setTool] = useState('browse'); + const [error, setError] = useState(null); + // Kept apart from `error` so that "downloaded" is not dressed as a failure. + const [notice, setNotice] = useState(null); + const [dialog, setDialog] = useState(null); + const [ready, setReady] = useState(false); + const [ai, setAi] = useState(null); + const [aiOpen, setAiOpen] = useState(false); + const [aiSettings, setAiSettings] = useState(false); + const [map, setMap] = useState(null); + // Pictures are fetched by name and kept, because a snapshot arrives after + // every command and re-encoding a card of artwork each time would be + // absurd. Cleared when a different stack is opened. + const [pictures, setPictures] = useState(new Map()); + + // The two file pickers, hidden until a menu item clicks them. + const stackPicker = useRef(null); + const picturePicker = useRef(null); + const pictureLayer = useRef<'card' | 'background'>('card'); + + const apply = useCallback((outcome: Outcome) => { + setView(outcome.view); + setError(null); + }, []); + + /** Runs a command, showing anything that went wrong rather than throwing. */ + const run = useCallback( + (work: () => Promise) => { + work().then(apply, (reason: unknown) => setError(String(reason))); + }, + [apply], + ); + + const openExample = useCallback( + (name: string) => { + setPictures(new Map()); + run(() => + fetch(`examples/${encodeURIComponent(name)}.hl.json`) + .then((response) => { + if (!response.ok) throw new Error(`could not fetch "${name}"`); + return response.text(); + }) + .then((text) => api.openStackText(text)), + ); + }, + [run], + ); + + // Watching for dialogs comes first: a stack whose openStack handler asks a + // question would otherwise block with nothing listening. + useEffect(() => { + let stop: (() => void) | undefined; + let cancelled = false; + api.onDialog(setDialog).then( + (unlisten) => { + if (cancelled) unlisten(); + else stop = unlisten; + }, + () => {}, + ); + return () => { + cancelled = true; + stop?.(); + }; + }, []); + + useEffect(() => { + let cancelled = false; + api + .ready() + .then(() => Promise.all([api.aiView().catch(() => null), api.getView()])) + .then( + ([aiLoaded, outcome]) => { + if (cancelled) return; + setAi(aiLoaded); + apply(outcome); + setReady(true); + // A link from the landing page may name a stack to start in. + const wanted = new URLSearchParams(window.location.search).get('stack'); + if (wanted !== null && (EXAMPLES as readonly string[]).includes(wanted)) { + openExample(wanted); + } + void api.backendMode().then((mode) => { + if (mode === 'fallback' && !cancelled) { + setNotice( + 'Running without cross-origin isolation, so script dialogs use the ' + + 'browser’s plain prompt instead of the real thing.', + ); + } + }); + }, + (reason: unknown) => { + if (cancelled) return; + setError(String(reason)); + setReady(true); + }, + ); + return () => { + cancelled = true; + }; + }, [apply, openExample]); + + // Whatever the current card draws, fetched once. + useEffect(() => { + const wanted = [...(view.background?.parts ?? []), ...view.card.parts] + .map((part) => part.source) + .filter((source) => source !== '' && !pictures.has(source)); + if (wanted.length === 0) return; + + let cancelled = false; + Promise.all( + [...new Set(wanted)].map((source) => + api.stackImage(source).then( + (uri) => [source, uri] as const, + () => null, + ), + ), + ).then((fetched) => { + const found = fetched.filter((one) => one !== null); + if (cancelled || found.length === 0) return; + setPictures((known) => new Map([...known, ...found])); + }); + return () => { + cancelled = true; + }; + }, [view.card, view.background, pictures]); + + // Keyboard shortcuts, kept in one place so the menus and the keys cannot + // drift apart: both call the same functions. + useEffect(() => { + const onKey = (event: KeyboardEvent) => { + if (!(event.metaKey || event.ctrlKey)) return; + const key = event.key.toLowerCase(); + const shortcuts: Record void> = { + s: () => downloadStack(), + o: () => stackPicker.current?.click(), + n: () => run(() => api.newCard()), + z: () => run(() => (event.shiftKey ? api.redo() : api.undo())), + }; + const action = shortcuts[key]; + if (action) { + event.preventDefault(); + action(); + } + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [run]); + + function downloadStack() { + api + .saveStackText() + .then(({ name, text }) => { + download(`${name}.hl.json`, text, 'application/json'); + setNotice(`Downloaded ${name}.hl.json — open it here or in the desktop app`); + return api.getView(); + }) + .then(apply, (reason: unknown) => setError(String(reason))); + } + + function openPickedStack(files: FileList | null) { + const file = files?.[0]; + if (file === undefined) return; + setPictures(new Map()); + run(() => file.text().then((text) => api.openStackText(text))); + } + + function importPickedPicture(files: FileList | null) { + const file = files?.[0]; + if (file === undefined) return; + run(() => + file + .arrayBuffer() + .then((buffer) => + api.importImageBytes(file.name, pictureLayer.current, new Uint8Array(buffer)), + ), + ); + } + + /** The stack as one HTML file, which a browser can hand straight over. */ + function exportWebPage() { + api.exportWebPage().then( + ({ source, notes }) => { + download(`${view.stackName}.html`, source, 'text/html'); + const missing = + notes.length === 0 + ? '' + : ` — ${notes.length} thing${notes.length === 1 ? '' : 's'} had no equivalent: ${[...new Set(notes)].join('; ')}`; + setNotice(`Downloaded ${view.stackName}.html${missing}`); + }, + (reason: unknown) => setError(String(reason)), + ); + } + + /** The map as a PNG. Drawn in the window, because only it knows the shape. */ + function saveMap(svg: SVGSVGElement) { + toPng(svg).then( + (bytes) => { + downloadBytes(`${view.stackName} map.png`, bytes); + setNotice(`Downloaded ${view.stackName} map.png`); + }, + (reason: unknown) => setError(String(reason)), + ); + } + + if (!ready) { + return ( +
+

HyperLab

+

Waking the runtime up…

+
+ ); + } + + const menus: { title: string; entries: MenuEntry[] }[] = [ + { + title: 'File', + entries: [ + { label: 'New Stack', run: () => run(() => api.newStack()) }, + { + label: 'Open Stack…', + shortcut: '⌘O', + run: () => stackPicker.current?.click(), + }, + null, + { label: 'Download Stack', shortcut: '⌘S', run: downloadStack }, + null, + { label: 'Export as a Web Page…', run: exportWebPage }, + ], + }, + { + title: 'Edit', + entries: [ + { + label: view.undo === null ? 'Undo' : `Undo ${view.undo}`, + shortcut: '⌘Z', + disabled: view.undo === null, + run: () => run(() => api.undo()), + }, + { + label: view.redo === null ? 'Redo' : `Redo ${view.redo}`, + shortcut: '⇧⌘Z', + disabled: view.redo === null, + run: () => run(() => api.redo()), + }, + ], + }, + { + title: 'Objects', + entries: [ + { + label: 'New Card', + shortcut: '⌘N', + run: () => run(() => api.newCard()), + }, + { + label: 'Delete Card', + disabled: view.cardCount <= 1, + run: () => run(() => api.deleteCard()), + }, + null, + { + label: 'New Button', + run: () => run(() => api.newPart('button', 'card')), + }, + { + label: 'New Field', + run: () => run(() => api.newPart('field', 'card')), + }, + { + label: 'New Background Button', + run: () => run(() => api.newPart('button', 'background')), + }, + { + label: 'New Background Field', + run: () => run(() => api.newPart('field', 'background')), + }, + null, + { + label: 'Import Picture…', + run: () => { + pictureLayer.current = 'card'; + picturePicker.current?.click(); + }, + }, + { + label: 'Import Background Picture…', + run: () => { + pictureLayer.current = 'background'; + picturePicker.current?.click(); + }, + }, + null, + { + label: 'Delete Selected Part', + disabled: selection === null || !isPart(selection.kind), + run: () => { + if (selection && isPart(selection.kind)) { + const id = selection.id; + setSelection(null); + run(() => api.deletePart(id)); + } + }, + }, + ], + }, + { + title: 'Go', + entries: [ + { label: 'First Card', run: () => run(() => api.goToCard(1)) }, + { + label: 'Previous Card', + run: () => run(() => api.goToCard(view.cardNumber - 1)), + }, + { + label: 'Next Card', + run: () => run(() => api.goToCard(view.cardNumber + 1)), + }, + { + label: 'Last Card', + run: () => run(() => api.goToCard(view.cardCount)), + }, + null, + { + label: 'Map…', + run: () => + api.stackGraph().then(setMap, (reason: unknown) => setError(String(reason))), + }, + ], + }, + { + title: 'Examples', + entries: EXAMPLES.map((name) => ({ + label: name, + run: () => openExample(name), + })), + }, + { + title: 'AI', + entries: [ + { + label: aiOpen ? 'Hide Assistant' : 'Show Assistant', + run: () => { + if (aiOpen) { + setAiOpen(false); + return; + } + setAiOpen(true); + api.aiView().then(setAi, (reason: unknown) => setError(String(reason))); + }, + }, + null, + { label: 'Settings…', run: () => setAiSettings(true) }, + ], + }, + ]; + + return ( +
+ + +
+
+ run(() => api.clickPart(part.id))} + onSelectPart={(part: PartView) => + setSelection({ kind: part.kind, id: part.id }) + } + onMovePart={(part, left, top) => + run(() => api.setGeometry(part.id, left, top, part.rect[2], part.rect[3])) + } + onEditField={(part, text) => run(() => api.setFieldText(part.id, text))} + onSelectCard={() => setSelection({ kind: 'card', id: view.card.id })} + /> +
+ + {aiOpen && ai !== null && ( + setAiSettings(true)} + /> + )} + + + run(() => api.setProperty(kind, id, property, value)) + } + onSetScript={(kind, id, script) => run(() => api.setScript(kind, id, script))} + /> +
+ + run(() => api.goToCard(position))} + onSetTool={setTool} + onRunMessage={(source) => run(() => api.runMessageBox(source))} + onDismiss={() => { + setError(null); + setNotice(null); + }} + /> + + {aiSettings && ( + { + setAi(next); + setAiOpen(true); + setAiSettings(false); + }} + onCancel={() => setAiSettings(false)} + onError={setError} + /> + )} + + {map && ( + run(() => api.goToCard(position))} + onClose={() => setMap(null)} + onSave={saveMap} + /> + )} + + {dialog && ( + { + setDialog(null); + api.dialogReply(text).catch((reason: unknown) => setError(String(reason))); + }} + /> + )} + + { + openPickedStack(event.target.files); + event.target.value = ''; + }} + /> + { + importPickedPicture(event.target.files); + event.target.value = ''; + }} + /> +
+ ); +} + +function isPart(kind: Selection['kind']): boolean { + return kind === 'button' || kind === 'field' || kind === 'image'; +} diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts new file mode 100644 index 0000000..7d6d6b6 --- /dev/null +++ b/apps/web/src/api.ts @@ -0,0 +1,252 @@ +/** + * The only way the interface talks to the runtime — the browser edition. + * + * Vite swaps this module in for the desktop's `api.ts`, so the functions + * here keep that file's names and shapes exactly: a component written + * against the desktop cannot tell the difference. What differs is the far + * side — a WebAssembly module in a worker instead of a Tauri shell — and + * the file-shaped commands, which trade paths for text because a page has + * no file system. + */ + +import type { + AiSettings, + AiView, + DialogRequest, + Graph, + KeychainView, + Layer, + ObjectKind, + Outcome, + PropertyView, + StackView, +} from '../../desktop/src/types'; + +import { startBackend, type Backend, type BackendMode } from './backend'; + +/** Started once, awaited by every call. */ +let backend: Promise | null = null; + +function runtime(): Promise { + backend ??= startBackend(); + return backend; +} + +/** Resolves when the runtime is up, for the window's first render. */ +export async function ready(): Promise { + await runtime(); +} + +/** Which arrangement the runtime settled on: `worker` blocks scripts behind + * real dialogs; `fallback` (no cross-origin isolation) uses the browser's + * own prompt and alert. */ +export async function backendMode(): Promise { + return (await runtime()).mode; +} + +async function call(command: string, args?: Record): Promise { + return (await runtime()).call(command, args); +} + +/** + * Watches for dialogs a running script wants shown. + * + * Resolves to a function that stops watching. The script stays blocked until + * {@link dialogReply} is called, so a handler that never replies leaves it + * waiting. + */ +export async function onDialog( + handler: (request: DialogRequest) => void, +): Promise<() => void> { + return (await runtime()).onDialog(handler); +} + +/** Answers the dialog a script is waiting on. `null` means cancelled. */ +export const dialogReply = async (text: string | null): Promise => + (await runtime()).dialogReply(text); + +/** Takes a fresh snapshot without changing anything. */ +export const getView = (): Promise => call('get_view'); + +/** Every property of one object, for the inspector. */ +export const getProperties = (kind: ObjectKind, id: number): Promise => + call('get_properties', { kind, id }); + +/** Checks whether a script parses. Resolves if it does. */ +export const checkScript = async (source: string): Promise => { + await call('check_script', { source }); +}; + +/** Reads the stack as the routes between its cards. */ +export const stackGraph = (): Promise => call('stack_graph'); + +/** + * One of the stack's pictures, as a `data:` URI. + * + * Asked for by name rather than sent with every snapshot: a snapshot is + * taken after every command, and a card of artwork would be re-encoded on + * every keystroke. + */ +export const stackImage = (name: string): Promise => + call('stack_image', { name }); + +/** The names of every picture the stack carries. */ +export const stackImages = (): Promise => call('stack_images'); + +/** Brings a picture the user picked into the stack and onto the card. */ +export const importImageBytes = async ( + name: string, + layer: Layer, + bytes: Uint8Array, +): Promise => + (await runtime()).call('import_image_bytes', { name, layer }, bytes); + +/** Sends `mouseUp` to a part, exactly as clicking it does. */ +export const clickPart = (id: number): Promise => call('click_part', { id }); + +/** Types into a field. */ +export const setFieldText = (id: number, text: string): Promise => + call('set_field_text', { id, text }); + +/** Goes to a card by position, counting from one. */ +export const goToCard = (position: number): Promise => + call('go_to_card', { position }); + +/** Runs whatever is in the message box. */ +export const runMessageBox = (source: string): Promise => + call('run_message_box', { source }); + +/** Adds a card after the current one. */ +export const newCard = (): Promise => call('new_card'); + +/** Deletes the current card. */ +export const deleteCard = (): Promise => call('delete_card'); + +/** Adds a part. */ +export const newPart = ( + kind: 'button' | 'field' | 'image', + layer: Layer, + name?: string, +): Promise => call('new_part', { kind, layer, name: name ?? null }); + +/** Removes a part. */ +export const deletePart = (id: number): Promise => call('delete_part', { id }); + +/** Moves or resizes a part. */ +export const setGeometry = ( + id: number, + left: number, + top: number, + width: number, + height: number, +): Promise => call('set_geometry', { id, left, top, width, height }); + +/** Sets a property from the inspector. */ +export const setProperty = ( + kind: ObjectKind, + id: number, + property: string, + value: string | number | boolean | null, +): Promise => call('set_property', { kind, id, property, value }); + +/** Replaces an object's script. */ +export const setScript = ( + kind: ObjectKind, + id: number, + script: string, +): Promise => call('set_script', { kind, id, script }); + +/** Renames an object. */ +export const rename = (kind: ObjectKind, id: number, name: string): Promise => + call('rename', { kind, id, name }); + +/** Resizes every card in the stack. */ +export const setStackSize = (width: number, height: number): Promise => + call('set_stack_size', { width, height }); + +/** Undoes the last change. */ +export const undo = (): Promise => call('undo'); + +/** Redoes the last undone change. */ +export const redo = (): Promise => call('redo'); + +/** Starts a new, empty stack. */ +export const newStack = (name?: string): Promise => + call('new_stack', { name: name ?? null }); + +/** Opens a stack from single-file JSON — an upload, or a bundled example. */ +export const openStackText = (text: string): Promise => + call('open_stack_json', { text }); + +/** + * The stack as single-file JSON, for the page to offer as a download. The + * copy the user takes *is* the save, so the document is clean afterwards. + */ +export const saveStackText = (): Promise<{ name: string; text: string }> => + call('save_stack_json'); + +/** The stack as one HTML page driven by _hyperscript, plus a line for + * everything that had no equivalent there. */ +export const exportWebPage = (): Promise<{ source: string; notes: string[] }> => + call('export_web'); + +/** An empty snapshot, for the moment before the first one arrives. */ +export const emptyView: StackView = { + stackName: '', + stackId: 0, + stackScript: '', + cardSize: { width: 512, height: 342 }, + cardCount: 0, + cardNumber: 0, + card: { id: 0, kind: 'card', name: '', script: '', parts: [] }, + background: null, + messageBox: '', + undo: null, + redo: null, + dirty: false, + path: null, +}; + +// ------------------------------------------------------------------- the AI + +/** What the assistant sidebar should draw. */ +export const aiView = (): Promise => call('ai_view'); + +/** Asks the assistant something. Slow: it goes to a model and back. */ +export const aiAsk = (question: string): Promise => call('ai_ask', { question }); + +/** Forgets the conversation. */ +export const aiClear = (): Promise => call('ai_clear'); + +/** Chooses whether the contents of fields are sent with a question. */ +export const aiSetSendsFieldText = (sending: boolean): Promise => + call('ai_set_sends_field_text', { sending }); + +/** Chooses whether the assistant may change the stack. */ +export const aiSetMayEdit = (editing: boolean): Promise => + call('ai_set_may_edit', { editing }); + +/** The provider settings. */ +export const aiSettings = (): Promise => call('ai_settings'); + +/** Saves provider settings to this browser and rebuilds the providers. */ +export const aiSaveSettings = (settings: AiSettings): Promise => + call('ai_save_settings', { settings }); + +/** Which providers have a key saved in this browser. */ +export const aiKeychain = (): Promise => call('ai_keychain'); + +/** + * Saves a provider's key in this browser's storage. + * + * The key goes one way. What comes back says which providers have one, and + * there is no call anywhere that reads a key out again. It is sent only to + * the provider it belongs to, straight from this browser — the server this + * page came from is static files and never sees it. + */ +export const aiSetKey = (provider: string, key: string): Promise => + call('ai_set_key', { provider, key }); + +/** Removes a provider's key from this browser. */ +export const aiForgetKey = (provider: string): Promise => + call('ai_forget_key', { provider }); diff --git a/apps/web/src/backend/index.ts b/apps/web/src/backend/index.ts new file mode 100644 index 0000000..25abb64 --- /dev/null +++ b/apps/web/src/backend/index.ts @@ -0,0 +1,261 @@ +/** + * The page's side of the runtime. + * + * Two arrangements, one interface. Where the page is cross-origin isolated — + * the dev server sends the headers; on GitHub Pages `coi-serviceworker.js` + * arranges them — the runtime lives in a worker, and a script's dialog + * blocks that worker while the page shows the real, HyperCard-shaped one. + * Where isolation is not available, the runtime runs on this thread and + * dialogs fall back to the browser's own `prompt` and `alert`: everything + * works, it just isn't dressed for the part. + */ + +import type { DialogRequest } from '../../../desktop/src/types'; + +import wasmUrl from '../../wasm-pkg/hyperlab_web_bg.wasm?url'; + +import { complete, completeSync } from './transport'; + +/** Which arrangement [`startBackend`] settled on. */ +export type BackendMode = 'worker' | 'fallback'; + +/** What the api module needs from either arrangement. */ +export interface Backend { + mode: BackendMode; + /** Runs one command. `bytes` rides along for picture imports only. */ + call(command: string, args?: unknown, bytes?: Uint8Array): Promise; + /** Watches for dialogs a running script wants shown. */ + onDialog(handler: (request: DialogRequest) => void): () => void; + /** Answers the dialog a script is waiting on. `null` means cancelled. + * Returns whether anything was waiting. */ + dialogReply(text: string | null): boolean; +} + +/** Only this app's keys travel to the worker's storage mirror. */ +const STORAGE_PREFIX = 'hyperlab.'; + +/** The dialog reply buffer: two `Int32` flags, then up to 64 KiB of UTF-8. */ +const REPLY_CAPACITY = 8 + 64 * 1024; + +// -------------------------------------------------------------------- beep + +let audio: AudioContext | null = null; + +/** A short square-wave blip, in the spirit of the original. */ +function beep(): void { + try { + audio ??= new AudioContext(); + if (audio.state === 'suspended') void audio.resume(); + const oscillator = audio.createOscillator(); + oscillator.type = 'square'; + oscillator.frequency.value = 830; + const gain = audio.createGain(); + gain.gain.value = 0.06; + oscillator.connect(gain); + gain.connect(audio.destination); + oscillator.start(); + oscillator.stop(audio.currentTime + 0.12); + } catch { + // A page that cannot make a sound still works; `beep` is best-effort on + // every platform HyperLab runs on. + } +} + +// ------------------------------------------------------------------ worker + +function snapshotStorage(): Record { + const found: Record = {}; + for (let index = 0; index < localStorage.length; index += 1) { + const key = localStorage.key(index); + if (key !== null && key.startsWith(STORAGE_PREFIX)) { + const value = localStorage.getItem(key); + if (value !== null) found[key] = value; + } + } + return found; +} + +function persist(key: string, value: string | null): void { + if (!key.startsWith(STORAGE_PREFIX)) return; + if (value === null) localStorage.removeItem(key); + else localStorage.setItem(key, value); +} + +interface WorkerReply { + type: 'ready' | 'ready-error' | 'result' | 'dialog' | 'beep' | 'storage'; + id?: number; + ok?: boolean; + value?: string; + error?: string; + request?: DialogRequest; + key?: string; +} + +function startWorkerBackend(): Promise { + const worker = new Worker(new URL('./worker.ts', import.meta.url), { + type: 'module', + }); + const shared = new SharedArrayBuffer(REPLY_CAPACITY); + const flag = new Int32Array(shared, 0, 2); + const replyBytes = new Uint8Array(shared, 8); + const encoder = new TextEncoder(); + + const pending = new Map< + number, + { resolve: (value: unknown) => void; reject: (reason: Error) => void } + >(); + let nextId = 1; + let handler: ((request: DialogRequest) => void) | null = null; + let waiting = false; + + const dialogReply = (text: string | null): boolean => { + if (!waiting) return false; + waiting = false; + if (text === null) { + Atomics.store(flag, 0, 2); + } else { + const encoded = encoder.encode(text); + const length = Math.min(encoded.length, replyBytes.length); + replyBytes.set(encoded.subarray(0, length)); + Atomics.store(flag, 1, length); + Atomics.store(flag, 0, 1); + } + Atomics.notify(flag, 0); + return true; + }; + + return new Promise((resolveBackend, rejectBackend) => { + worker.onmessage = (event: MessageEvent) => { + const message = event.data; + switch (message.type) { + case 'ready': + resolveBackend({ + mode: 'worker', + call(command: string, args?: unknown, bytes?: Uint8Array): Promise { + return new Promise((resolve, reject) => { + const id = nextId; + nextId += 1; + pending.set(id, { + resolve: resolve as (value: unknown) => void, + reject, + }); + const call = { + type: 'call', + id, + command, + args: JSON.stringify(args ?? {}), + ...(bytes === undefined ? {} : { bytes }), + }; + if (bytes === undefined) worker.postMessage(call); + else worker.postMessage(call, [bytes.buffer]); + }); + }, + onDialog(wanted) { + handler = wanted; + return () => { + if (handler === wanted) handler = null; + }; + }, + dialogReply, + }); + break; + case 'ready-error': + rejectBackend(new Error(message.error ?? 'the runtime failed to start')); + break; + case 'result': { + const settle = message.id === undefined ? undefined : pending.get(message.id); + if (settle === undefined || message.id === undefined) break; + pending.delete(message.id); + if (message.ok === true) { + settle.resolve( + message.value === undefined ? undefined : JSON.parse(message.value), + ); + } else { + settle.reject(new Error(message.error ?? 'the command failed')); + } + break; + } + case 'dialog': + waiting = true; + if (handler !== null && message.request !== undefined) { + handler(message.request); + } else { + // Nothing is listening — an `openStack` handler asked before the + // window mounted. Cancelling is what a host that cannot ask + // reports; leaving the script parked would be worse. + dialogReply(null); + } + break; + case 'beep': + beep(); + break; + case 'storage': + if (message.key !== undefined) persist(message.key, message.value ?? null); + break; + default: + break; + } + }; + worker.postMessage({ + type: 'init', + sab: shared, + storage: snapshotStorage(), + }); + }); +} + +// ---------------------------------------------------------------- fallback + +async function startFallbackBackend(): Promise { + const module = await import('../../wasm-pkg/hyperlab_web'); + await module.default({ module_or_path: wasmUrl }); + module.init({ + answer: (message: string) => window.alert(message), + ask: (prompt: string, defaultText: string) => window.prompt(prompt, defaultText), + beep, + completeSync, + complete, + storageGet: (key: string) => localStorage.getItem(key), + storageSet: persist, + }); + + const commands = module as unknown as Record string>; + return { + mode: 'fallback', + async call(command: string, args?: unknown, bytes?: Uint8Array): Promise { + const argsJson = JSON.stringify(args ?? {}); + let value: string | undefined; + if (command === 'import_image_bytes') { + value = module.import_image_bytes(argsJson, bytes ?? new Uint8Array()); + } else if (command === 'ai_ask') { + value = await module.ai_ask(argsJson); + } else { + const run = commands[command]; + if (typeof run !== 'function') { + throw new Error(`the runtime has no command called "${command}"`); + } + value = run(argsJson); + } + return (value === undefined ? undefined : JSON.parse(value)) as T; + }, + onDialog() { + // Dialogs are the browser's own in this arrangement, shown and + // answered inside the host's `ask`/`answer` before the call returns. + return () => {}; + }, + dialogReply() { + return false; + }, + }; +} + +// ------------------------------------------------------------------- entry + +/** Starts the runtime, in the best arrangement this browser offers. */ +export function startBackend(): Promise { + const isolated = + typeof SharedArrayBuffer !== 'undefined' && + typeof window !== 'undefined' && + window.crossOriginIsolated === true; + return isolated ? startWorkerBackend() : startFallbackBackend(); +} diff --git a/apps/web/src/backend/transport.ts b/apps/web/src/backend/transport.ts new file mode 100644 index 0000000..35e6d31 --- /dev/null +++ b/apps/web/src/backend/transport.ts @@ -0,0 +1,49 @@ +/** + * The one HTTP transport a page has, in the two tempos the runtime needs. + * + * Both functions answer with the same JSON envelope the WebAssembly module + * expects — `{"status": …, "body": …}` — and throw only when the request + * never got an answer at all. A refusal (401, 429, a vendor error) is a + * *reply*, and reading it is the wire protocol's job in Rust, not ours. + * + * The request goes from this browser straight to the provider the user + * configured. There is no server of ours in between — the site is static + * files — which is the whole arrangement that makes a bring-your-own-key + * playground honest. + */ + +/** Asks and waits, for a script blocked mid-line on `ai("…")`. */ +export function completeSync(url: string, headersJson: string, body: string): string { + const request = new XMLHttpRequest(); + // Synchronous on purpose: `ai("…")` is an expression, and its value has + // to reach the rest of the line. In the worker this blocks nothing the + // user can see. + request.open('POST', url, false); + for (const [name, value] of Object.entries( + JSON.parse(headersJson) as Record, + )) { + request.setRequestHeader(name, value); + } + request.send(body); + if (request.status === 0) { + throw new Error('the request never got an answer'); + } + return JSON.stringify({ status: request.status, body: request.responseText }); +} + +/** The same question without the waiting, for the assistant sidebar. */ +export async function complete( + url: string, + headersJson: string, + body: string, +): Promise { + const response = await fetch(url, { + method: 'POST', + headers: JSON.parse(headersJson) as Record, + body, + }); + return JSON.stringify({ + status: response.status, + body: await response.text(), + }); +} diff --git a/apps/web/src/backend/worker.ts b/apps/web/src/backend/worker.ts new file mode 100644 index 0000000..25560fc --- /dev/null +++ b/apps/web/src/backend/worker.ts @@ -0,0 +1,158 @@ +/** + * The worker the runtime lives in. + * + * The desktop runs a script on a blocking thread so the window can stay + * responsive while the script waits on a dialog. A page gets the same + * arrangement from a Web Worker: the WebAssembly runtime runs here, and when + * a script calls `ask`, this thread parks on `Atomics.wait` while the page — + * still live — shows the dialog and writes the answer into shared memory. + * + * Everything else is a plain message pump: the page posts + * `{type: 'call', command, args}` and gets `{type: 'result'}` back, one + * message per command, in order. A command that shows a dialog holds the + * pump — which is exactly the desktop's "the session is locked while a + * script waits" behaviour, arrived at by different plumbing. + */ + +import init, * as wasm from '../../wasm-pkg/hyperlab_web'; +import wasmUrl from '../../wasm-pkg/hyperlab_web_bg.wasm?url'; + +import { complete, completeSync } from './transport'; + +/** How long a script waits for an answer before giving up, matching the + * desktop's patience: generous, because a person may reasonably take a + * while, and finite, so an unanswerable dialog cannot park the runtime for + * ever. */ +const PATIENCE_MILLISECONDS = 10 * 60 * 1000; + +/** Shared with the page: [0] the state flag, [1] the reply's byte length. */ +let flag: Int32Array | null = null; +/** The reply's bytes, UTF-8, after the two flags. */ +let replyBytes: Uint8Array | null = null; + +/** + * The worker's copy of the page's storage, kept because a worker cannot + * touch `localStorage`. Reads are answered from here; writes go through to + * the page, which persists them. Only this app writes these keys, so the + * copy cannot go stale. + */ +const storage = new Map(); + +const decoder = new TextDecoder(); + +/** Parks this thread until the page answers the dialog, or gives up. */ +function blockOnDialog(request: unknown): string | null { + if (flag === null || replyBytes === null) return null; + Atomics.store(flag, 0, 0); + postMessage({ type: 'dialog', request }); + const outcome = Atomics.wait(flag, 0, 0, PATIENCE_MILLISECONDS); + if (outcome === 'timed-out') return null; + if (Atomics.load(flag, 0) !== 1) return null; + const length = Math.min(Atomics.load(flag, 1), replyBytes.length); + // Copied out before decoding: TextDecoder refuses views over shared + // memory. + const copy = new Uint8Array(length); + copy.set(replyBytes.subarray(0, length)); + return decoder.decode(copy); +} + +/** The host object the WebAssembly module calls back into. Its shape is a + * contract with `crates/web/src/api.rs`. */ +const host = { + answer(message: string): void { + blockOnDialog({ kind: 'answer', message }); + }, + ask(prompt: string, defaultText: string): string | null { + return blockOnDialog({ kind: 'ask', prompt, default: defaultText }); + }, + beep(): void { + postMessage({ type: 'beep' }); + }, + completeSync, + complete, + storageGet(key: string): string | null { + return storage.get(key) ?? null; + }, + storageSet(key: string, value: string | null): void { + if (value === null) storage.delete(key); + else storage.set(key, value); + postMessage({ type: 'storage', key, value }); + }, +}; + +/** Resolves once the WebAssembly module is up; calls queue behind it. */ +let started: Promise | null = null; + +interface InitMessage { + type: 'init'; + sab: SharedArrayBuffer | null; + storage: Record; +} + +interface CallMessage { + type: 'call'; + id: number; + command: string; + args: string; + bytes?: Uint8Array; +} + +function start(message: InitMessage): Promise { + if (message.sab !== null) { + flag = new Int32Array(message.sab, 0, 2); + replyBytes = new Uint8Array(message.sab, 8); + } + for (const [key, value] of Object.entries(message.storage)) { + storage.set(key, value); + } + return init({ module_or_path: wasmUrl }).then(() => { + wasm.init(host); + }); +} + +/** Every command the module exports, called by name. The page's api module + * is the one place names originate, and the module itself is the contract: + * an unknown name is answered with an error, not silence. */ +type Command = (args: string) => string; + +async function run(message: CallMessage): Promise { + if (message.command === 'import_image_bytes') { + return wasm.import_image_bytes(message.args, message.bytes ?? new Uint8Array()); + } + if (message.command === 'ai_ask') { + return wasm.ai_ask(message.args); + } + const commands = wasm as unknown as Record; + const command = commands[message.command]; + if (typeof command !== 'function') { + throw new Error(`the runtime has no command called "${message.command}"`); + } + return command(message.args); +} + +self.onmessage = (event: MessageEvent) => { + const message = event.data; + if (message.type === 'init') { + started = start(message); + started.then( + () => postMessage({ type: 'ready' }), + (reason: unknown) => postMessage({ type: 'ready-error', error: String(reason) }), + ); + return; + } + + void (async () => { + try { + await started; + const value = await run(message); + postMessage({ type: 'result', id: message.id, ok: true, value }); + } catch (reason) { + postMessage({ + type: 'result', + id: message.id, + ok: false, + error: String(reason), + }); + } + })(); +}; diff --git a/apps/web/src/components/AiSettings.tsx b/apps/web/src/components/AiSettings.tsx new file mode 100644 index 0000000..8d76918 --- /dev/null +++ b/apps/web/src/components/AiSettings.tsx @@ -0,0 +1,251 @@ +/** + * Choosing a model, and saying where its key is — the browser edition. + * + * A sibling of the desktop's panel with the key arrangements a page really + * has. There is no OS keychain and no environment here: a key typed in goes + * into this browser's storage, under this site's origin, and the settings + * record the word `keychain` and nothing else — the same shape the desktop + * writes, so a settings file means the same thing on both. + * + * A key travels one way. Once saved, this panel can tell you that there is + * one and cannot tell you what it is: there is no call that reads one back. + * When a question is asked, the key goes from this browser straight to the + * provider named below — this site is static files, with no server of its + * own for a key to visit. + */ + +import { useEffect, useState } from 'react'; + +import type { + AiSettings as Settings, + AiView, + KeychainView, + ProviderConfig, +} from '../../../desktop/src/types'; + +import * as api from '../api'; + +interface Props { + onDone: (view: AiView) => void; + onCancel: () => void; + onError: (reason: string) => void; +} + +/** The kinds a provider can be, and what each one needs. */ +const KINDS = [ + { kind: 'anthropic', label: 'Anthropic', url: false }, + { kind: 'openAi', label: 'OpenAI', url: false }, + { kind: 'openRouter', label: 'OpenRouter', url: true }, + { kind: 'ollama', label: 'Ollama (on this machine)', url: true }, + { kind: 'openAiCompatible', label: 'Anything OpenAI-compatible', url: true }, + { kind: 'mock', label: 'Mock (no network, for trying it out)', url: false }, +] as const; + +const BLANK: ProviderConfig = { kind: 'anthropic', model: '' }; + +/** Which of the two key arrangements a configuration is using. */ +type Where = 'keychain' | 'none'; + +function whereIsTheKey(config: ProviderConfig): Where { + return config.key === undefined ? 'none' : 'keychain'; +} + +export function AiSettings({ onDone, onCancel, onError }: Props) { + const [settings, setSettings] = useState(null); + const [keychain, setKeychain] = useState(null); + const [name, setName] = useState(''); + const [config, setConfig] = useState(BLANK); + // Held only while it is being typed, and cleared the moment it is saved. + const [typed, setTyped] = useState(''); + + useEffect(() => { + Promise.all([api.aiSettings(), api.aiKeychain()]).then( + ([loaded, store]) => { + setSettings(loaded); + setKeychain(store); + const first = loaded.defaultProvider ?? Object.keys(loaded.providers)[0]; + const chosen = first === undefined ? undefined : loaded.providers[first]; + if (first !== undefined && chosen !== undefined) { + setName(first); + setConfig(chosen); + } + }, + (reason: unknown) => onError(String(reason)), + ); + }, [onError]); + + if (settings === null || keychain === null) return null; + + const kind = KINDS.find((entry) => entry.kind === config.kind) ?? KINDS[0]; + const where = whereIsTheKey(config); + const saved = keychain.holding.includes(name.trim()); + + /** Moves the key into this browser, or to nowhere. */ + const putTheKey = (next: Where) => { + setTyped(''); + if (next === 'keychain') setConfig({ ...config, key: { in: 'keychain' } }); + else { + // Removed rather than set to undefined: the field is absent from the + // stored settings, which is what "needs no key" looks like. + const without = { ...config }; + delete without.key; + setConfig(without); + } + }; + + const forget = () => { + api + .aiForgetKey(name.trim()) + .then(setKeychain, (reason: unknown) => onError(String(reason))); + }; + + const save = () => { + const label = name.trim(); + if (label === '' || config.model.trim() === '') { + onError('a provider needs a name and a model'); + return; + } + // Saying the key is saved and putting none there would build a provider + // that fails on its first question, with the reason two menus away from + // where it was caused. + if (where === 'keychain' && typed.trim() === '' && !saved) { + onError(`there is no key saved for “${label}” yet — type one in`); + return; + } + + const next: Settings = { + defaultProvider: label, + providers: { ...settings.providers, [label]: config }, + }; + // The key first: a provider rebuilt before its key is in place reports + // itself broken, and the panel would close over the top of that. + const stored = + where === 'keychain' && typed.trim() !== '' + ? api.aiSetKey(label, typed.trim()) + : Promise.resolve(keychain); + + stored + .then((store) => { + setKeychain(store); + setTyped(''); + return api.aiSaveSettings(next); + }) + .then(onDone, (reason: unknown) => onError(String(reason))); + }; + + return ( +
+
+

Which model should the assistant use?

+ + + + + + + + {kind.url && ( + + )} + + + + {where === 'keychain' && ( + <> +
+ + {saved ? 'Replace with' : 'Type it in'} + + setTyped(event.target.value)} + /> + {saved && ( + + )} +
+

+ The key stays in this browser’s storage, on this device, and is sent + only to the provider above when you ask something. This site is served as + static files — there is no HyperLab server for it to visit. Anyone who can + open this browser profile could read it, so on a shared computer prefer + Forget when you are done. +

+ + )} + +
+ + +
+
+
+ ); +} diff --git a/apps/web/src/landing.css b/apps/web/src/landing.css new file mode 100644 index 0000000..0cfc19f --- /dev/null +++ b/apps/web/src/landing.css @@ -0,0 +1,295 @@ +/* + * The landing page, dressed the way the application is. + * + * The values are the Neo Classic theme's — same faces, same measurements, + * same hard shadows — restated here because this page is plain HTML with no + * bundle to share custom properties through. An original design in the + * spirit of late-1980s Macintosh software; nothing is copied from Apple. + */ + +:root { + --font-ui: + 'ChicagoFLF', 'Sysfont', 'Geneva', 'Verdana', 'DejaVu Sans', ui-sans-serif, sans-serif; + --font-code: 'Monaco', 'Menlo', 'DejaVu Sans Mono', ui-monospace, monospace; + + --ink: #000000; + --paper: #ffffff; + --shade: #808080; + --shade-light: #c0c0c0; + --desk: #9a9a9a; + + --border: 1px solid var(--ink); + --radius-button: 9px; + --shadow-card: 3px 3px 0 rgba(0, 0, 0, 0.55); + + --space: 8px; + --space-wide: 16px; + + --text: 13px; + --text-small: 11px; + + color-scheme: light; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; +} + +body { + font-family: var(--font-ui); + font-size: var(--text); + line-height: 1.5; + color: var(--ink); + background: var(--desk); + /* The classic 50% dither, drawn rather than pictured. */ + background-image: + linear-gradient(45deg, rgba(0, 0, 0, 0.06) 25%, transparent 25%), + linear-gradient(-45deg, rgba(0, 0, 0, 0.06) 25%, transparent 25%); + background-size: 4px 4px; + -webkit-font-smoothing: none; +} + +/* --------------------------------------------------------------- menu bar */ + +.menubar { + position: sticky; + top: 0; + z-index: 2; + display: flex; + align-items: center; + gap: var(--space); + padding: 4px 12px; + background: var(--paper); + border-bottom: var(--border); +} + +.menubar__mark { + font-size: 15px; +} + +.menubar__title { + font-weight: bold; +} + +.menubar__menu { + color: var(--ink); +} + +.menubar__spring { + flex: 1; +} + +.menubar__link { + color: var(--ink); +} + +/* ---------------------------------------------------------------- windows */ + +.desk { + max-width: 44rem; + margin: 0 auto; + padding: 32px var(--space-wide) 48px; + display: flex; + flex-direction: column; + gap: 28px; +} + +.window { + background: var(--paper); + border: var(--border); + box-shadow: var(--shadow-card); +} + +.window__bar { + display: flex; + align-items: center; + gap: var(--space); + padding: 3px 6px; + border-bottom: var(--border); + /* The classic title bar's pinstripes. */ + background: repeating-linear-gradient( + to bottom, + var(--paper) 0 2px, + var(--shade-light) 2px 3px + ); +} + +.window__box { + width: 11px; + height: 11px; + background: var(--paper); + border: var(--border); + flex: none; +} + +.window__title { + flex: 1; + margin: 0; + font-size: var(--text); + font-weight: bold; + text-align: center; + background: var(--paper); + padding: 0 var(--space); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.window__body { + padding: var(--space-wide) 20px; +} + +.window__body p { + margin: 0 0 12px; +} + +.window__body p:last-child { + margin-bottom: 0; +} + +.window__body a { + color: var(--ink); +} + +.window__body code { + font-family: var(--font-code); + font-size: var(--text-small); +} + +/* ------------------------------------------------------------------- hero */ + +.hero__line { + font-size: 16px; +} + +.hero__script { + margin: 12px 0; + padding: 10px 12px; + border: var(--border); + background: var(--paper); + box-shadow: inset 2px 2px 0 var(--shade-light); + font-family: var(--font-code); + font-size: 12px; + line-height: 1.5; + overflow-x: auto; +} + +.hero__buttons { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin: 14px 0 10px; +} + +.button { + display: inline-block; + padding: 5px 18px; + border: var(--border); + border-radius: var(--radius-button); + background: var(--paper); + color: var(--ink); + text-decoration: none; + box-shadow: 2px 2px 0 var(--ink); +} + +.button:active { + translate: 2px 2px; + box-shadow: none; +} + +/* HyperCard's default button: the one Return would press. */ +.button--default { + outline: 3px solid var(--ink); + outline-offset: 2px; + font-weight: bold; +} + +.hero__note { + color: var(--ink); + font-size: var(--text-small); +} + +/* ---------------------------------------------------------------- history */ + +.history__list { + margin: 0 0 12px; + padding: 0 0 0 8px; + list-style: none; +} + +.history__list li { + margin: 4px 0; +} + +.history__dedication { + border-top: 1px solid var(--shade-light); + padding-top: 10px; + font-size: var(--text-small); +} + +/* --------------------------------------------------------------- examples */ + +.stacks { + margin: 14px 0 0; + padding: 0; + list-style: none; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(17rem, 1fr)); + gap: 10px; +} + +.stack { + display: block; + height: 100%; + padding: 10px 12px; + border: var(--border); + background: var(--paper); + box-shadow: 2px 2px 0 var(--ink); + color: var(--ink); + text-decoration: none; +} + +.stack:active { + translate: 2px 2px; + box-shadow: none; +} + +.stack__name { + display: block; + font-weight: bold; + margin-bottom: 2px; +} + +.stack__name::before { + content: '🃏 '; +} + +.stack__blurb { + display: block; + font-size: var(--text-small); +} + +/* --------------------------------------------------------------------- AI */ + +.ai__promise { + padding: 10px 12px; + border: var(--border); + box-shadow: inset 2px 2px 0 var(--shade-light); +} + +/* --------------------------------------------------------------- colophon */ + +.colophon { + text-align: center; + font-size: var(--text-small); + padding: 0 var(--space-wide); +} + +.colophon a { + color: var(--ink); +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx new file mode 100644 index 0000000..02db55c --- /dev/null +++ b/apps/web/src/main.tsx @@ -0,0 +1,19 @@ +/** Where the playground starts. */ + +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; + +import '../../desktop/src/theme/neo-classic.css'; + +import { App } from './App'; + +const root = document.getElementById('root'); +if (!root) { + throw new Error('the page is missing its root element'); +} + +createRoot(root).render( + + + , +); diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts new file mode 100644 index 0000000..eb6f692 --- /dev/null +++ b/apps/web/src/vite-env.d.ts @@ -0,0 +1,7 @@ +/// + +/** The WebAssembly module's bytes, as the URL Vite serves them from. */ +declare module '*.wasm?url' { + const url: string; + export default url; +} diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json new file mode 100644 index 0000000..a0bc440 --- /dev/null +++ b/apps/web/tsconfig.json @@ -0,0 +1,45 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable", "WebWorker"], + "module": "ESNext", + "skipLibCheck": true, + + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + + // The desktop's components are compiled into this app too — that reuse + // is the point of the site — and they resolve their imports against + // *their* directory, where nothing is installed. These map them to this + // app's packages. The @tauri-apps entries exist only to give the + // desktop's api module its types; nothing from it reaches the bundle, + // because Vite swaps that module for src/api.ts. + "paths": { + "react": ["./node_modules/@types/react"], + "react/jsx-runtime": ["./node_modules/@types/react/jsx-runtime.d.ts"], + "react-dom/client": ["./node_modules/@types/react-dom/client.d.ts"], + "@tauri-apps/api/core": ["./node_modules/@tauri-apps/api/core.d.ts"], + "@tauri-apps/api/event": ["./node_modules/@tauri-apps/api/event.d.ts"], + "@tauri-apps/plugin-dialog": [ + "./node_modules/@tauri-apps/plugin-dialog/dist-js/index.d.ts" + ] + } + }, + "include": ["src", "../desktop/src"], + // The desktop's own entry points are not part of this site: its App is + // replaced by src/App.tsx, and its main.tsx by src/main.tsx. + "exclude": ["../desktop/src/main.tsx", "../desktop/src/App.tsx", "../desktop/src/vite-env.d.ts"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/apps/web/tsconfig.node.json b/apps/web/tsconfig.node.json new file mode 100644 index 0000000..3f9705c --- /dev/null +++ b/apps/web/tsconfig.node.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true, + "types": ["node"], + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo" + }, + "include": ["vite.config.ts"] +} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts new file mode 100644 index 0000000..93ac06a --- /dev/null +++ b/apps/web/vite.config.ts @@ -0,0 +1,84 @@ +import path from 'node:path'; + +const here = import.meta.dirname; + +import react from '@vitejs/plugin-react'; +import { defineConfig, type Plugin } from 'vite'; + +/** + * The desktop's components are compiled into this app unchanged — that is + * the point of the site — and three of them import `../api`, the module + * that talks to the Tauri shell. This plugin swaps that one module for + * `src/api.ts`, which exposes the same functions backed by the WebAssembly + * runtime. Nothing else from the desktop tree is touched. + */ +function webApi(): Plugin { + const desktopApi = path.resolve(here, '../desktop/src/api.ts'); + const browserApi = path.resolve(here, 'src/api.ts'); + return { + name: 'hyperlab:web-api', + enforce: 'pre', + async resolveId(source, importer, options) { + const resolved = await this.resolve(source, importer, { + ...options, + skipSelf: true, + }); + if (resolved !== null && path.normalize(resolved.id) === desktopApi) { + return browserApi; + } + return null; + }, + }; +} + +/** + * The headers that make `SharedArrayBuffer` available, which is what lets a + * script block on a dialog the way HyperCard's did. GitHub Pages cannot send + * them, so production uses `coi-serviceworker.js` instead; the dev server + * sends them directly so development looks like production. + */ +const isolation = { + 'Cross-Origin-Opener-Policy': 'same-origin', + 'Cross-Origin-Embedder-Policy': 'require-corp', +}; + +export default defineConfig({ + plugins: [webApi(), react()], + resolve: { + // The desktop's components resolve their imports against their own + // directory, where nothing is installed; point them at this app's copy. + alias: { + react: path.resolve(here, 'node_modules/react'), + 'react-dom': path.resolve(here, 'node_modules/react-dom'), + }, + dedupe: ['react', 'react-dom'], + }, + // Served from https://.github.io//, so every URL is relative. + base: './', + clearScreen: false, + server: { + headers: isolation, + fs: { + // The desktop's sources and the built wasm package sit outside this + // app's root. + allow: [path.resolve(here, '../..')], + }, + }, + preview: { + headers: isolation, + }, + worker: { + format: 'es', + }, + build: { + target: 'esnext', + outDir: 'dist', + emptyOutDir: true, + rollupOptions: { + input: { + index: path.resolve(here, 'index.html'), + play: path.resolve(here, 'play.html'), + }, + }, + }, +}); diff --git a/crates/persistence/examples/build_examples.rs b/crates/persistence/examples/build_examples.rs index 5366eb4..a155e2b 100644 --- a/crates/persistence/examples/build_examples.rs +++ b/crates/persistence/examples/build_examples.rs @@ -316,7 +316,10 @@ fn todo() -> Stack { card, "Items", Rect::new(20, 44, 340, 196), - "x read the HyperTalk reference\nwrite a stack of my own\nshow it to someone", + // The trailing newline matters: the Add button does `put it & return + // after field "Items"`, and without it the first thing anyone adds + // glues itself to "show it to someone". + "x read the HyperTalk reference\nwrite a stack of my own\nshow it to someone\n", ); add_button( diff --git a/crates/persistence/examples/pack_single_files.rs b/crates/persistence/examples/pack_single_files.rs new file mode 100644 index 0000000..574c90e --- /dev/null +++ b/crates/persistence/examples/pack_single_files.rs @@ -0,0 +1,64 @@ +//! Packs the example stacks as single files, for the website. +//! +//! The bundles in `examples/` are the format to edit; the playground on the +//! website has no file system, so it is served each example as the one-file +//! JSON `save_single_file` writes. Run +//! +//! ```text +//! cargo run -p hyperlab-persistence --example pack_single_files -- +//! ``` +//! +//! and every `examples/*.hl` bundle is loaded and rewritten there as +//! `.hl.json`. With no argument they go to `target/web-examples`. + +use std::path::{Path, PathBuf}; + +use hyperlab_persistence::{BUNDLE_EXTENSION, load, single_file_string}; + +fn main() { + let destination = std::env::args().nth(1).map_or_else( + || repository_root().join("target/web-examples"), + PathBuf::from, + ); + std::fs::create_dir_all(&destination).expect("the destination should be writable"); + + let examples = repository_root().join("examples"); + let mut bundles: Vec = std::fs::read_dir(&examples) + .expect("examples/ should exist") + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| { + path.extension() + .is_some_and(|kind| kind == BUNDLE_EXTENSION) + }) + .collect(); + bundles.sort(); + + assert!( + !bundles.is_empty(), + "no .hl bundles in {}", + examples.display() + ); + + for bundle in bundles { + let stack = load(&bundle) + .unwrap_or_else(|error| panic!("{} should load: {error}", bundle.display())); + let name = bundle + .file_stem() + .and_then(|name| name.to_str()) + .expect("a bundle has a name"); + let text = single_file_string(&stack).expect("a loaded stack serializes"); + let target = destination.join(format!("{name}.hl.json")); + std::fs::write(&target, text) + .unwrap_or_else(|error| panic!("could not write {}: {error}", target.display())); + println!("packed {}", target.display()); + } +} + +fn repository_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(Path::parent) + .expect("crates/persistence sits two levels below the root") + .to_path_buf() +} diff --git a/crates/runtime/src/interpreter/builtins.rs b/crates/runtime/src/interpreter/builtins.rs index 20bb92f..0a93905 100644 --- a/crates/runtime/src/interpreter/builtins.rs +++ b/crates/runtime/src/interpreter/builtins.rs @@ -336,11 +336,10 @@ fn object_argument<'a>( // ------------------------------------------------------------------- clocks -/// Seconds since the Unix epoch. +/// Seconds since the Unix epoch, from the same clock that stamps objects — +/// which is the one a host may have replaced on a platform with none. fn unix_seconds() -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map_or(0, |duration| duration.as_secs()) + hyperlab_stack::now_millis() / 1_000 } /// A random number, from a generator seeded by the clock. @@ -355,10 +354,15 @@ fn next_random() -> u64 { STATE.with(|state| { let mut x = state.get(); if x == 0 { - x = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map_or(0x2545_F491_4F6C_DD1D, |d| d.subsec_nanos().into()) - | 1; + // Seeded from the shared clock rather than the platform's, which + // a browser does not have. The constant covers a clock stuck at + // zero; the `| 1` keeps xorshift off its fixed point. + let seed = hyperlab_stack::now_millis(); + x = if seed == 0 { + 0x2545_F491_4F6C_DD1D + } else { + seed + } | 1; } // xorshift64: tiny, fast, and good enough to shuffle cards. x ^= x << 13; diff --git a/crates/stack/src/lib.rs b/crates/stack/src/lib.rs index e774bcb..1094ab6 100644 --- a/crates/stack/src/lib.rs +++ b/crates/stack/src/lib.rs @@ -66,5 +66,5 @@ pub use object::{Object, ObjectCore, ObjectId, ObjectKind}; pub use part::{Part, PartKind}; pub use property::PropertyBag; pub use stack::{PartLocation, Stack, centre_of}; -pub use time::now_millis; +pub use time::{now_millis, set_clock}; pub use value::Value; diff --git a/crates/stack/src/time.rs b/crates/stack/src/time.rs index c5fd63e..00e4db2 100644 --- a/crates/stack/src/time.rs +++ b/crates/stack/src/time.rs @@ -1,14 +1,46 @@ //! Wall-clock timestamps. +//! +//! Objects record when they were created and last changed. The clock is +//! isolated here so a host can replace it — which stopped being theoretical +//! the day HyperLab compiled to WebAssembly: `SystemTime::now` traps in a +//! browser, so the web shell names the browser's own clock instead. -use std::time::{SystemTime, UNIX_EPOCH}; +use std::sync::OnceLock; -/// Milliseconds since the Unix epoch. +/// The clock a host installed, when one did. +static CLOCK: OnceLock u64> = OnceLock::new(); + +/// Names the clock, for hosts on platforms where the standard one traps — +/// WebAssembly in a browser. `clock` answers in milliseconds since the Unix +/// epoch. /// -/// Objects record when they were created and last changed. The clock is -/// isolated here so tests and future deterministic replay can replace it. +/// The first call wins and later calls change nothing, so no host can swap +/// the clock out from under another mid-run. +pub fn set_clock(clock: fn() -> u64) { + let _ = CLOCK.set(clock); +} + +/// Milliseconds since the Unix epoch. #[must_use] pub fn now_millis() -> u64 { + match CLOCK.get() { + Some(clock) => clock(), + None => platform_now(), + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn platform_now() -> u64 { + use std::time::{SystemTime, UNIX_EPOCH}; SystemTime::now() .duration_since(UNIX_EPOCH) .map_or(0, |d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX)) } + +/// The epoch, on a platform with no clock of its own. A host that cares — +/// the web shell does — installs one with [`set_clock`] before anything is +/// stamped. +#[cfg(target_arch = "wasm32")] +fn platform_now() -> u64 { + 0 +} diff --git a/crates/stack/tests/clock.rs b/crates/stack/tests/clock.rs new file mode 100644 index 0000000..87cd45c --- /dev/null +++ b/crates/stack/tests/clock.rs @@ -0,0 +1,21 @@ +//! The pluggable clock, tested in a process of its own. +//! +//! `set_clock` is once-per-process on purpose, so this test cannot share a +//! binary with anything that wants real timestamps — integration tests each +//! get their own process, which is exactly the isolation it needs. + +use hyperlab_stack::{Object, Stack, now_millis, set_clock}; + +#[test] +fn a_host_installed_clock_stamps_everything_and_cannot_be_replaced() { + set_clock(|| 1_234_567); + assert_eq!(now_millis(), 1_234_567); + + let stack = Stack::new("Notes"); + assert_eq!(stack.core().created_at, 1_234_567); + + // The second installer loses quietly: no host can swap the clock out + // from under another mid-run. + set_clock(|| 42); + assert_eq!(now_millis(), 1_234_567); +} diff --git a/crates/web/Cargo.toml b/crates/web/Cargo.toml index 5b113fd..72c3ec1 100644 --- a/crates/web/Cargo.toml +++ b/crates/web/Cargo.toml @@ -31,6 +31,7 @@ serde_json.workspace = true # The JavaScript boundary exists only on the WebAssembly target, so the host # build — `cargo test` at the repository root — never compiles it. [target.'cfg(target_arch = "wasm32")'.dependencies] +console_error_panic_hook = "0.1" js-sys = "0.3" wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" diff --git a/crates/web/src/api.rs b/crates/web/src/api.rs index a26406f..be1ef3f 100644 --- a/crates/web/src/api.rs +++ b/crates/web/src/api.rs @@ -274,6 +274,11 @@ fn parse_args<'a, T: Deserialize<'a>>(json: &'a str) -> Result { /// opened on a stack; so does HyperLab, on either shell. #[wasm_bindgen] pub fn init(host: JsValue) { + // A panic in here would otherwise reach the page as "unreachable", which + // helps nobody; this turns it into the message and a stack trace. + console_error_panic_hook::set_once(); + // The platform has no clock, so objects are stamped by the browser's. + hyperlab_stack::set_clock(|| js_sys::Date::now() as u64); HOST.with(|cell| *cell.borrow_mut() = Some(host)); let (settings, mut problems) = match with_host(|h| h.storage_get(SETTINGS_KEY)) { diff --git a/crates/web/tests/examples.rs b/crates/web/tests/examples.rs new file mode 100644 index 0000000..80b6d62 --- /dev/null +++ b/crates/web/tests/examples.rs @@ -0,0 +1,41 @@ +//! The exact journey a stack takes to the playground, walked on the host. +//! +//! The website packs each example bundle as single-file JSON, ships it as a +//! static file, and the WebAssembly module reads it back and opens it. A +//! panic anywhere on that road is a blank card in a browser with no message +//! worth reading, so the same road is walked here, where a failure can +//! actually speak. + +use hyperlab_persistence::{load, single_file_string, stack_from_single_file}; +use hyperlab_runtime::Runtime; + +#[test] +fn every_example_survives_the_trip_to_the_playground_and_opens() { + let examples = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(std::path::Path::parent) + .expect("crates/web sits two levels below the root") + .join("examples"); + + let mut walked = 0; + for entry in std::fs::read_dir(&examples).expect("examples/ should exist") { + let path = entry.expect("readable directory").path(); + if path.extension().is_none_or(|kind| kind != "hl") { + continue; + } + + let stack = + load(&path).unwrap_or_else(|error| panic!("{} should load: {error}", path.display())); + let text = single_file_string(&stack).expect("a loaded stack serializes"); + let reread = stack_from_single_file(&text) + .unwrap_or_else(|error| panic!("{} should reread: {error}", path.display())); + assert_eq!(reread, stack, "{} changed in transit", path.display()); + + let mut runtime = Runtime::new(reread); + runtime + .open_stack() + .unwrap_or_else(|error| panic!("{} should open: {error}", path.display())); + walked += 1; + } + assert!(walked >= 6, "only {walked} examples were walked"); +} diff --git a/examples/Address Book.hl/metadata.json b/examples/Address Book.hl/metadata.json index 83a0327..706f3ea 100644 --- a/examples/Address Book.hl/metadata.json +++ b/examples/Address Book.hl/metadata.json @@ -1,6 +1,6 @@ { "formatVersion": 1, "name": "Address Book", - "savedAt": 1785597484870, + "savedAt": 1785675722648, "cardCount": 3 } diff --git a/examples/Cluedo.hl/metadata.json b/examples/Cluedo.hl/metadata.json index 1371759..3dfba2c 100644 --- a/examples/Cluedo.hl/metadata.json +++ b/examples/Cluedo.hl/metadata.json @@ -1,6 +1,6 @@ { "formatVersion": 1, "name": "Cluedo", - "savedAt": 1785597484875, + "savedAt": 1785675722651, "cardCount": 3 } diff --git a/examples/LLMs for n00bs.hl/metadata.json b/examples/LLMs for n00bs.hl/metadata.json index 7abcb72..5859e17 100644 --- a/examples/LLMs for n00bs.hl/metadata.json +++ b/examples/LLMs for n00bs.hl/metadata.json @@ -1,6 +1,6 @@ { "formatVersion": 1, "name": "LLMs for n00bs", - "savedAt": 1785601893522, + "savedAt": 1785675722654, "cardCount": 9 } diff --git a/examples/Myst.hl/metadata.json b/examples/Myst.hl/metadata.json index 2d16c4c..b77a216 100644 --- a/examples/Myst.hl/metadata.json +++ b/examples/Myst.hl/metadata.json @@ -1,6 +1,6 @@ { "formatVersion": 1, "name": "Myst", - "savedAt": 1785597484879, + "savedAt": 1785675722653, "cardCount": 11 } diff --git a/examples/Recipe Box.hl/metadata.json b/examples/Recipe Box.hl/metadata.json index 0fca52c..4f6cf49 100644 --- a/examples/Recipe Box.hl/metadata.json +++ b/examples/Recipe Box.hl/metadata.json @@ -1,6 +1,6 @@ { "formatVersion": 1, "name": "Recipe Box", - "savedAt": 1785597484871, + "savedAt": 1785675722649, "cardCount": 2 } diff --git a/examples/Todo.hl/cards/3.json b/examples/Todo.hl/cards/3.json index b82e572..7c95023 100644 --- a/examples/Todo.hl/cards/3.json +++ b/examples/Todo.hl/cards/3.json @@ -15,7 +15,7 @@ "enabled": true, "locked": false, "style": "rectangle", - "text": "x read the HyperTalk reference\nwrite a stack of my own\nshow it to someone", + "text": "x read the HyperTalk reference\nwrite a stack of my own\nshow it to someone\n", "visible": true, "wrap": true }, diff --git a/examples/Todo.hl/metadata.json b/examples/Todo.hl/metadata.json index b75d55e..2a38ccf 100644 --- a/examples/Todo.hl/metadata.json +++ b/examples/Todo.hl/metadata.json @@ -1,6 +1,6 @@ { "formatVersion": 1, "name": "Todo", - "savedAt": 1785597484872, + "savedAt": 1785675722649, "cardCount": 1 } From 974fd35f2219eedcaa933901dbca7be454c536d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 13:07:07 +0000 Subject: [PATCH 04/10] Deploy the website from CI, and write down how it works MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pages.yml builds the wasm runtime, packs the examples, and deploys apps/web to GitHub Pages on every push to main; a matching CI job runs the same build on pull requests so it cannot rot. docs/website.md explains the arrangement — one swapped module, a worker parked on Atomics.wait behind real dialogs, keys that never leave the browser — and the README and changelog point at the site. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01R7eaxUAqy2gKQSBNnYZoFr --- .github/workflows/ci.yml | 35 +++++++++++ .github/workflows/pages.yml | 71 ++++++++++++++++++++++ CHANGELOG.md | 28 +++++++++ README.md | 16 +++++ docs/architecture.md | 4 ++ docs/website.md | 114 ++++++++++++++++++++++++++++++++++++ 6 files changed, 268 insertions(+) create mode 100644 .github/workflows/pages.yml create mode 100644 docs/website.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 807e80c..ccbe7f6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -100,6 +100,41 @@ jobs: - run: npm run typecheck - run: npx vite build + website: + # The same build the Pages workflow deploys, run here so a pull request + # cannot rot it: the wasm target, the packed examples, the typecheck and + # the bundle. + name: Website + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + - uses: Swatinem/rust-cache@v2 + - uses: taiki-e/install-action@v2 + with: + tool: wasm-pack + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: apps/web/package-lock.json + + - name: The runtime holds to its promises on the wasm target too + run: cargo clippy -p hyperlab-web --target wasm32-unknown-unknown --all-targets + + - name: Pack the example stacks as single files + run: cargo run -p hyperlab-persistence --example pack_single_files -- apps/web/public/examples + + - name: Build the site + working-directory: apps/web + run: | + npm ci + npm run wasm + npm run format:check + npm run build + desktop: name: Desktop shell (${{ matrix.os }}) runs-on: ${{ matrix.os }} diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..66b9016 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,71 @@ +name: Website + +# Builds the site in apps/web — the retro landing page, and the whole +# application compiled to WebAssembly — and deploys it to GitHub Pages. +# +# One-time setup: the repository's Settings ▸ Pages ▸ Source must say +# "GitHub Actions", or the deploy step has nowhere to put the artifact. + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +# One deployment at a time, and a new push makes the queued one irrelevant — +# but a deployment already running is left to finish. +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + name: Build the site + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + - uses: Swatinem/rust-cache@v2 + # A prebuilt binary, for the same reason cargo-audit is one in ci.yml. + - uses: taiki-e/install-action@v2 + with: + tool: wasm-pack + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: apps/web/package-lock.json + + - name: Pack the example stacks as single files + run: cargo run -p hyperlab-persistence --example pack_single_files -- apps/web/public/examples + + - name: Build the WebAssembly runtime + working-directory: apps/web + run: | + npm ci + npm run wasm + + - name: Build the site + working-directory: apps/web + run: npm run build + + - uses: actions/upload-pages-artifact@v3 + with: + path: apps/web/dist + + deploy: + name: Deploy to GitHub Pages + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index bf79d18..3d2a3ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,34 @@ does, the entry says so and says what to do about it. Each entry is written for the person deciding whether to upgrade, so it describes what is now possible rather than which files moved. +## Unreleased + +HyperLab now runs in a browser, whole: +**[jgalego.github.io/HyperLab](https://jgalego.github.io/HyperLab/)**. + +- **The website.** A retro landing page with a short history of HyperCard, + and a playground that is the full application — the same renderer the + desktop draws, over the same runtime compiled to WebAssembly. The six + examples are one click away, scripts block on real dialogs, and the map, + the message box, the inspector and undo all work as they do at home. +- **Files travel.** The playground opens and downloads stacks as the + single-file JSON the desktop already reads and writes, so a stack moves + between the two by moving one file. +- **Bring your own model, or none.** AI ▸ Settings in the browser takes + Anthropic, OpenAI, OpenRouter, anything OpenAI-compatible, or the built-in + mock. A key is kept in the browser's own storage, is never readable back, + and travels only to the provider it belongs to — the site is static files, + with no server of its own. +- The Todo example's list now ends with a return, so the first item anyone + adds lands on its own line instead of gluing itself to the last one. + +For integrators: `hyperlab-persistence` gained in-memory single-file +functions (`single_file_string`, `stack_from_single_file`); +`hyperlab-ai-providers` gained a default-on `native` feature — without it +the crate is only the wire protocol, for hosts that bring their own +transport; and `hyperlab_stack::set_clock` lets a host name the clock on +platforms where the standard one traps, which WebAssembly in a browser is. + ## 0.2.0 — 2026-08-01 A stack that cannot leave HyperLab is a stack you cannot show anyone. This diff --git a/README.md b/README.md index 4cd4851..0f4414a 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,12 @@ Cards, stacks, fields, buttons, scripts and message passing are all still here, and a stack stays inspectable and editable while it runs — the two qualities that made the original worth learning in the first place. +🌐 **Try it right now, in your browser:** +**[jgalego.github.io/HyperLab](https://jgalego.github.io/HyperLab/)** — the +full application compiled to WebAssembly, with the six examples one click +away. Nothing to install, and nothing you make there leaves your machine +unless you ask a model about it. + 🕯️ **Bill Atkinson (1951–2025)** wrote QuickDraw, MacPaint and HyperCard, and insisted the last of them ship free with every Mac. Millions of people built something with it, and a great many of them never thought of themselves as @@ -67,6 +73,12 @@ card back](docs/demo.gif)

🚀 Getting Started

+🌐 **The website** — [jgalego.github.io/HyperLab](https://jgalego.github.io/HyperLab/) +is the whole application running in your browser, plus a short history of the +idea it comes from. A stack you download there opens in the desktop +application, and the other way round: it is the same file. How the site works +is written down in [`docs/website.md`](docs/website.md). + 🖥️ **The application** — a `.dmg`, an `.msi`, and a `.deb`, `.rpm` or `.AppImage`, on the [releases page](https://github.com/JGalego/HyperLab/releases). Nothing phones @@ -194,8 +206,12 @@ crates/ _hyperscript — where it goes next decker/ a stack as a Decker deck — where else it goes + web/ the same commands as + the shell, but as + WebAssembly — how it runs in a browser apps/desktop/ the Tauri shell and the React renderer +apps/web/ the website: the same renderer over crates/web docs/ architecture, roadmap, HyperTalk reference examples/ six stacks, which are also tests ``` diff --git a/docs/architecture.md b/docs/architecture.md index 69f6b4f..974dd0c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1071,6 +1071,10 @@ crates/ apps/desktop/ src-tauri/ the shell: state, commands, view model src/ React: renderer, inspector, editors, theme + +crates/web/ the same commands as the shell, as WebAssembly +apps/web/ the website: the desktop's renderer over crates/web, + and the landing page — see docs/website.md ``` The dependency graph runs one way, and is enforced by Cargo: diff --git a/docs/website.md b/docs/website.md new file mode 100644 index 0000000..b53e3a2 --- /dev/null +++ b/docs/website.md @@ -0,0 +1,114 @@ +# The website + +> **What:** [jgalego.github.io/HyperLab](https://jgalego.github.io/HyperLab/) — +> a retro landing page telling HyperCard's story, and the full application +> running in the browser. +> +> **Where:** `apps/web`, over `crates/web`, deployed by +> `.github/workflows/pages.yml`. + +The site is two pages of static files on GitHub Pages. There is no server, +which is not a limitation but the design: everything below follows from it. + +## The playground is the application + +The playground page renders the desktop's own React components — the card, +the inspector, the assistant sidebar, the map, the dialogs. It can, because +the desktop keeps its whole shell behind one module (`apps/desktop/src/api.ts`, +a typed wrapper per command), and the renderer never learns which shell +answers. Vite swaps that one module for `apps/web/src/api.ts`; nothing else +in the desktop tree is touched, so the two interfaces cannot drift apart — +they are the same files. + +Behind the web `api.ts` sits `crates/web` (`hyperlab-web`): the same crates +the desktop wraps — runtime, parser, persistence, graph, the assistant — +compiled to WebAssembly, exporting one function per desktop command. Its +`view.rs` is a hand-kept twin of the desktop's, and a test serializes a +snapshot and checks the field names against what the renderer expects. + +``` +apps/desktop: React renderer → api.ts → Tauri commands → Runtime +apps/web: React renderer → api.ts′ → Web Worker → wasm → Runtime + (the same files) +``` + +## How a script blocks on a dialog + +HyperTalk's `ask` stops the script until the person answers, and the answer +lands in `it` on the very next line. The desktop does this by running scripts +on a blocking thread while the window stays live. A page gets the same +arrangement from a Web Worker: + +``` +script → Host::ask → worker JS → postMessage ───────────► page + (parked on Atomics.wait) │ shows the + ◄─────────── shared memory + Atomics.notify ◄──────┘ real dialog +``` + +The WebAssembly runtime lives in the worker. When a script asks, the worker +posts the request to the page and parks on `Atomics.wait`; the page — still +responsive — shows the same dialog component the desktop shows, and writes +the reply into a `SharedArrayBuffer`. The runtime never learns any of this: +it called `Host::ask` and got an answer, exactly as it does on the desktop. + +`SharedArrayBuffer` needs cross-origin isolation, and GitHub Pages cannot +send the two headers that grant it. A vendored service worker +(`coi-serviceworker.js`, MIT) adds them, at the cost of one automatic reload +on first visit — the accepted arrangement for static hosts. Where isolation +is unavailable anyway, the runtime falls back to the page's own thread and +`window.prompt`: everything works, it just isn't dressed for the part. + +## Files, without a file system + +A stack travels as the single-file JSON `hyperlab-persistence` already +speaks: **File ▸ Open Stack…** is an upload, **File ▸ Download Stack** is a +download, and the same file opens in the desktop application. The six +examples are packed from `examples/` at build time by + +```sh +cargo run -p hyperlab-persistence --example pack_single_files -- apps/web/public/examples +``` + +and fetched from the site itself; the landing page's gallery links open the +playground with `?stack=`. + +## Keys never leave the browser + +The desktop keeps API keys in the operating system's keychain, one-way: the +settings panel can learn that a key is saved, never what it is. The web +keeps the same shape with the parts a page has: + +- A key typed into **AI ▸ Settings** goes into this browser's storage under + the provider's name. No command reads one back out. +- A request goes straight from the browser to the configured provider — + `fetch` from the worker for the sidebar, a synchronous request for a + script blocked mid-line on `ai("…")`. The wire protocol is the same Rust + code the desktop uses (`hyperlab-ai-providers` built without its native + clients); only the transport is the browser's. +- The site being static files is the guarantee's other half: there is no + HyperLab server for a key, a stack, or a question to visit. + +Anthropic requires the `anthropic-dangerous-direct-browser-access` header +before answering a browser at all. Sending it is honest here: the request +really does come from the user's own browser, with a key they typed into it, +which is the situation the header exists to make explicit. The mock provider +answers locally, so the whole AI surface can be tried with no key and no +network. + +## Building it locally + +```sh +cd apps/web +npm install +npm run prepare:assets # wasm-pack build + pack the examples +npm run dev # the dev server sends the isolation headers itself +``` + +Needs Rust with the `wasm32-unknown-unknown` target, +[wasm-pack](https://rustwasm.github.io/wasm-pack/), and Node 20. + +## Deploying + +`.github/workflows/pages.yml` builds and deploys on every push to `main`. +One-time repository setup: **Settings ▸ Pages ▸ Source** must say +**GitHub Actions**. From 691a2ccb4f76c6013a0cdd6cdd852522c0f10c1b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 13:11:34 +0000 Subject: [PATCH 05/10] Trim the website's copy Plain section titles, shorter paragraphs, and fewer slogans: Examples instead of a counted headline, AI and The desktop application instead of coinages, one sentence on keys instead of four. Your history text is untouched. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01R7eaxUAqy2gKQSBNnYZoFr --- apps/web/index.html | 67 +++++++++++--------------- apps/web/play.html | 5 +- apps/web/src/App.tsx | 5 +- apps/web/src/components/AiSettings.tsx | 8 ++- 4 files changed, 33 insertions(+), 52 deletions(-) diff --git a/apps/web/index.html b/apps/web/index.html index 2fb5fe0..fec93fe 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -6,7 +6,7 @@ HyperLab — an open-source, AI-native successor to HyperCard @@ -37,8 +37,7 @@

Welcome to HyperLab

An open-source, AI-native successor to HyperCard. Cards, - stacks, fields, buttons and HyperTalk are all still here — and a stack stays - inspectable and editable while it runs. + buttons, fields and HyperTalk, and a stack stays editable while it runs.

 on mouseUp
@@ -49,14 +48,11 @@ 

Welcome to HyperLab

Get the desktop app - Read the source + Source

- The playground is the full application — the same runtime, compiled to - WebAssembly, running entirely in your browser. Nothing you make here leaves - your machine unless you ask a model about it. + The playground is the full application, compiled to WebAssembly. Nothing you + make leaves your machine unless you ask a model about it.

@@ -99,10 +95,8 @@

A short history

quite took its place.

- HyperLab is what that idea looks like thirty years on, with language models - built into the way a stack thinks rather than bolted onto the side of one. - Draw a button, write two lines of HyperTalk — or ask the assistant to do it - for you, and undo it like any other change. + HyperLab picks the idea back up, with language models part of HyperTalk + itself.

🕯️ Dedicated to Bill Atkinson (1951–2025), who wrote @@ -116,13 +110,12 @@

A short history

-

Six stacks to open

+

Examples

- Every example opens in the playground, running. Click things. Then switch to - the edit tool and read the scripts that made them work — the two qualities - that made the original worth learning. + Each opens in the playground, running. Switch to the edit tool to read the + scripts that make it work.

  • @@ -182,26 +175,22 @@

    Six stacks to open

    -

    Bring your own model, or none

    +

    AI

    - HyperLab works with no AI provider configured, and always will. When you want - one, open AI ▸ Settings in the playground and add Anthropic, - OpenAI, OpenRouter, an Ollama on your own machine — or the built-in mock, - which answers locally and needs no key at all. + HyperLab works with no provider configured. + AI ▸ Settings takes Anthropic, OpenAI, OpenRouter, a local + Ollama, or a mock that answers offline.

    - Your API key stays in your browser. This site is static files - on GitHub Pages — there is no HyperLab server, so there is nowhere for a key - to be sent. A key you type in is kept in this browser's storage on your device - and travels exactly one way: with the requests your stack makes, straight from - your browser to the provider you chose. There is no call that reads a key back - out, and the sidebar shows the exact text of everything that was sent. + Your API key stays in your browser. The site is static files; + requests go from your browser directly to your provider, and no command reads + a saved key back out. The sidebar shows the exact text of everything sent.

    - The assistant can only do what you can do — it works the runtime's own - commands, so everything it makes is visible, inspectable, and undoable. + The assistant works through the same commands you do, so its changes are + undoable like any other.

    @@ -210,20 +199,19 @@

    Bring your own model, or none

    -

    Take it home

    +

    The desktop application

    - The desktop application — a .dmg, an .msi, and a - .deb, .rpm or .AppImage — is on the + A .dmg, an .msi, and a .deb, + .rpm or .AppImage on the releases page. Nothing phones home. A stack you download from the playground opens in the - app, and the other way round: it is the same file. + >. A stack downloaded from the playground opens in the application; it is the + same file.

    - A stack can also leave for somewhere else entirely: the cards as a PDF, the - map as a PNG, the whole thing as a self-contained web page, or as a - Decker deck. + Stacks also export as a PDF, as a PNG of the map, as a self-contained web + page, and as a Decker deck.

    @@ -231,8 +219,7 @@

    Take it home

    HyperLab is MIT-licensed and - contains no Apple code and no Apple artwork; the resemblance is to the era, not - to any particular thing. Built with Rust, WebAssembly, and affection for 1987. + contains no Apple code and no Apple artwork; the resemblance is to the era.

    diff --git a/apps/web/play.html b/apps/web/play.html index 0d6c66d..4b1e292 100644 --- a/apps/web/play.html +++ b/apps/web/play.html @@ -4,10 +4,7 @@ HyperLab - +