feat(wasm): port Ghostty's wasm32 build

Ports main_wasm.zig as crates/ghostty-wasm (cdylib exporting the Zig
names: atlas_*, face_*, deferred_face_*, shaper_*, config_*), the
web_canvas font face and shaper (backend selected on wasm32), os/wasm
(logging, target, JS interop) and config/Wasm.zig.

C-library dependencies (freetype, harfbuzz, fontconfig, onig, gettext,
D-Bus, rustix) are target-gated. Like Zig's wasm build, link regex
search (StringMap::search_iterator) is unavailable without Oniguruma;
Collection/SharedGrid/harfbuzz are compiled out on wasm32. ghostty-os
switches from forbid to deny unsafe_code for the JS import blocks.

The JS import ABI is currently a documented handle-based interface with
a reference host (js/ghostty_host.js); aligning it with zig-js's exact
import names follows separately.

Test Plan:
- cargo build --target wasm32-unknown-unknown -p ghostty-font \
  -p ghostty-config -p ghostty-terminal -p ghostty-vt -p ghostty-wasm
- cargo test -p ghostty-wasm (node runs the module against a fake canvas)
This commit is contained in:
ddidderr committed 2026-10-12 05:21:14 +02:00
1 parent 0915f7b935
commit d89691b0ce
33 files changed
+2866 -30

No files matched your search

+39 -4
View File
@@ -6,16 +6,51 @@ license.workspace = true
[dependencies]
ghostty-vt.workspace = true
gettext-rs = { version = "0.8.0", features = ["gettext-system"] }
log = "0.4.34"
thiserror = "2.0.21"
# Zig's wasm build only compiles `os/wasm.zig`; the POSIX / D-Bus / gettext
# dependencies are unavailable on wasm32.
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
gettext-rs = { version = "0.8.0", features = ["gettext-system"] }
nix = { version = "0.31.3", features = ["user"] }
rustix = { version = "1.1.5", features = ["fs", "process", "system", "pipe", "net", "time", "rand", "pty"] }
thiserror = "2.0.21"
zbus = "5.19.0"
[features]
# Mirrors Zig's `wasm_shared` build option (SharedArrayBuffer-backed memory).
wasm-shared = []
[lints]
workspace = true
# Same as `[workspace.lints]` except `unsafe_code` is denied instead of
# forbidden: the wasm JS import blocks (`src/wasm`) need `unsafe`.
[lints.rust]
unsafe_code = "deny"
[lints.clippy]
pedantic = { level = "warn", priority = -1 }
todo = "warn"
unwrap_used = "warn"
cast_possible_truncation = "allow"
cast_possible_wrap = "allow"
cast_sign_loss = "allow"
cast_precision_loss = "allow"
cast_lossless = "allow"
module_name_repetitions = "allow"
too_many_lines = "allow"
missing_errors_doc = "allow"
missing_panics_doc = "allow"
similar_names = "allow"
struct_excessive_bools = "allow"
fn_params_excessive_bools = "allow"
match_same_arms = "allow"
unreadable_literal = "allow"
many_single_char_names = "allow"
doc_markdown = "allow"
must_use_candidate = "allow"
return_self_not_must_use = "allow"
used_underscore_binding = "allow"
large_stack_arrays = "allow"
inline_always = "allow"
[dev-dependencies]
temp-env = "0.3.6"
+42
View File
@@ -15,38 +15,69 @@
)]
pub mod args;
#[cfg(not(target_arch = "wasm32"))]
pub mod cgroup;
#[cfg(not(target_arch = "wasm32"))]
pub mod dbus;
#[cfg(not(target_arch = "wasm32"))]
pub mod desktop;
#[cfg(not(target_arch = "wasm32"))]
pub mod env;
#[cfg(not(target_arch = "wasm32"))]
pub mod file;
pub mod file_type;
#[cfg(not(target_arch = "wasm32"))]
pub mod flatpak;
#[cfg(not(target_arch = "wasm32"))]
pub mod homedir;
#[cfg(not(target_arch = "wasm32"))]
pub mod hostname;
#[cfg(not(target_arch = "wasm32"))]
pub mod i18n;
pub mod i18n_locales;
#[cfg(not(target_arch = "wasm32"))]
pub mod kernel_info;
#[cfg(not(target_arch = "wasm32"))]
pub mod locale;
pub mod mouse;
#[cfg(not(target_arch = "wasm32"))]
pub mod open;
#[cfg(not(target_arch = "wasm32"))]
pub mod passwd;
#[cfg(not(target_arch = "wasm32"))]
pub mod path;
#[cfg(not(target_arch = "wasm32"))]
pub mod pipe;
#[cfg(not(target_arch = "wasm32"))]
pub mod resourcesdir;
pub mod shell;
pub mod string_encoding;
#[cfg(not(target_arch = "wasm32"))]
pub mod systemd;
#[cfg(not(target_arch = "wasm32"))]
pub mod temp_dir;
pub mod uri;
#[cfg(target_arch = "wasm32")]
pub mod wasm;
#[cfg(target_arch = "wasm32")]
mod unsupported;
#[cfg(target_arch = "wasm32")]
pub use unsupported::homedir::expand_home;
#[cfg(target_arch = "wasm32")]
pub use unsupported::{ResourcesDir, homedir, is_flatpak, passwd, resources_dir, xdg};
#[cfg(not(target_arch = "wasm32"))]
pub mod xdg;
#[cfg(test)]
pub(crate) mod testutil;
// Functions and types (mirrors the flat exports of `os/main.zig`).
#[cfg(not(target_arch = "wasm32"))]
pub use dbus::launched_by_dbus_activation;
#[cfg(not(target_arch = "wasm32"))]
pub use desktop::{DesktopEnvironment, desktop_environment, launched_from_desktop};
#[cfg(not(target_arch = "wasm32"))]
pub use env::{
EnvMap,
append_env,
@@ -59,21 +90,32 @@ pub use env::{
prepend_env,
prepend_env_os,
};
#[cfg(not(target_arch = "wasm32"))]
pub use file::{Rlimit, alloc_tmp_dir, fix_max_files, restore_max_files};
#[cfg(not(target_arch = "wasm32"))]
pub use flatpak::{FlatpakHostCommand, is_flatpak};
#[cfg(not(target_arch = "wasm32"))]
pub use homedir::{expand_home, home};
#[cfg(not(target_arch = "wasm32"))]
pub use kernel_info::get_kernel_info;
#[cfg(not(target_arch = "wasm32"))]
pub use locale::ensure_locale;
pub use mouse::click_interval;
#[cfg(not(target_arch = "wasm32"))]
pub use open::open;
#[cfg(not(target_arch = "wasm32"))]
pub use pipe::pipe;
#[cfg(not(target_arch = "wasm32"))]
pub use resourcesdir::{ResourcesDir, resources_dir};
pub use shell::ShellEscapeWriter;
#[cfg(not(target_arch = "wasm32"))]
pub use systemd::launched_by_systemd;
#[cfg(not(target_arch = "wasm32"))]
pub use temp_dir::TempDir;
/// Join path components like `std.fs.path.join` (POSIX): empty components
/// are skipped and exactly one separator is placed between components.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn path_join(parts: &[&str]) -> String {
let mut out = String::new();
for p in parts {
+104
View File
@@ -0,0 +1,104 @@
//! API-compatible stand-ins for OS facilities that do not exist on wasm32,
//! so crates that are also built for wasm (`ghostty-config`) compile
//! unchanged. In Zig these are simply never analyzed on wasm
//! (`os/passwd.zig` even `@compileError`s); here each reports "unavailable"
//! the way the Zig callers' error paths expect.
use std::io;
/// `homedir.zig` is unavailable: nothing can be expanded.
pub mod homedir {
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum ExpandError {
#[error("home detection failed")]
HomeDetectionFailed,
#[error("buffer too small")]
BufferTooSmall,
}
/// Paths without a leading `~/` pass through; others fail.
pub fn expand_home(path: &str) -> Result<String, ExpandError> {
if path.starts_with("~/") {
Err(ExpandError::HomeDetectionFailed)
} else {
Ok(path.to_owned())
}
}
}
pub mod passwd {
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Entry {
pub shell: Option<String>,
pub home: Option<String>,
pub name: Option<String>,
}
/// Zig: `@compileError("passwd is not available for wasm")`.
pub fn get() -> std::io::Result<Entry> {
Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"passwd is not available for wasm",
))
}
}
pub mod xdg {
use std::ffi::CStr;
#[derive(Debug, Clone, Copy, Default)]
pub struct Options<'a> {
pub subdir: Option<&'a str>,
pub home: Option<&'a str>,
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum Error {
#[error("xdg directories are not available on wasm")]
Unavailable,
}
pub fn config(_: &Options<'_>) -> Result<String, Error> {
Err(Error::Unavailable)
}
pub fn cache(_: &Options<'_>) -> Result<String, Error> {
Err(Error::Unavailable)
}
pub fn state(_: &Options<'_>) -> Result<String, Error> {
Err(Error::Unavailable)
}
/// No `xdg-terminal-exec` mode on wasm.
pub fn parse_terminal_exec<'a>(_: &'a [&'a CStr]) -> Option<&'a [&'a CStr]> {
None
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ResourcesDir {
pub app_path: Option<String>,
pub host_path: Option<String>,
}
impl ResourcesDir {
pub fn app(&self) -> Option<&str> {
self.app_path.as_deref()
}
pub fn host(&self) -> Option<&str> {
self.host_path.as_deref()
}
}
pub fn resources_dir() -> io::Result<ResourcesDir> {
Err(io::Error::new(
io::ErrorKind::Unsupported,
"resources dir is not available on wasm",
))
}
pub fn is_flatpak() -> bool {
false
}
+307
View File
@@ -0,0 +1,307 @@
//! JS interop for wasm. Stands in for the `zig-js` package
//! (`mitchellh/zig-js`) that Ghostty's Zig wasm code (`font/face/web_canvas`,
//! `font/Atlas`) imports as `js`.
//!
//! The `zig-js` source is not part of the Ghostty tree, so its exact import
//! names cannot be mirrored; this module defines an equivalent handle based
//! protocol in the import module `ghostty_js` and keeps the *Rust-side API*
//! shaped like `zig-js` (`Object`, `get`, `set`, `call`, `new`, string and
//! number arguments, global object lookup). The host (JS) keeps a table of
//! values; wasm only holds `u32` handles. Handle `0` is "invalid" and is
//! what every import returns when the JS operation threw.
//!
//! Imports (module `ghostty_js`):
//!
//! | import | signature |
//! |---|---|
//! | `global` | `() -> handle` of `globalThis` |
//! | `get` | `(obj, name_ptr, name_len) -> handle` |
//! | `set` | `(obj, name_ptr, name_len, value) -> i32` (1 = ok) |
//! | `call` | `(obj, name_ptr, name_len, args_ptr, args_len) -> handle` |
//! | `new` | `(ctor, args_ptr, args_len) -> handle` |
//! | `string` | `(ptr, len) -> handle` (UTF-8 -> JS string) |
//! | `number` | `(f64) -> handle` |
//! | `boolean` | `(i32) -> handle` |
//! | `to_f64` | `(handle) -> f64` |
//! | `string_len` | `(handle) -> usize` (UTF-8 byte length) |
//! | `string_copy` | `(handle, ptr, cap) -> usize` (writes UTF-8) |
//! | `is_nullish` | `(handle) -> i32` |
//! | `bytes` | `(ptr, len) -> handle` (a `Uint8ClampedArray` copy of wasm memory) |
//! | `copy_bytes` | `(array_handle, dst_ptr, len) -> i32` (copies a `Uint8Array`-like into wasm memory) |
//! | `release` | `(handle)` |
#![allow(unsafe_code)]
use std::fmt;
mod ffi {
#[link(wasm_import_module = "ghostty_js")]
unsafe extern "C" {
pub fn global() -> u32;
pub fn get(obj: u32, name: *const u8, len: usize) -> u32;
pub fn set(obj: u32, name: *const u8, len: usize, value: u32) -> i32;
pub fn call(obj: u32, name: *const u8, len: usize, args: *const u32, argc: usize) -> u32;
pub fn new(ctor: u32, args: *const u32, argc: usize) -> u32;
pub fn string(ptr: *const u8, len: usize) -> u32;
pub fn number(v: f64) -> u32;
pub fn boolean(v: i32) -> u32;
pub fn to_f64(h: u32) -> f64;
pub fn string_len(h: u32) -> usize;
pub fn string_copy(h: u32, ptr: *mut u8, cap: usize) -> usize;
pub fn is_nullish(h: u32) -> i32;
pub fn bytes(ptr: *const u8, len: usize) -> u32;
pub fn copy_bytes(h: u32, dst: *mut u8, len: usize) -> i32;
pub fn release(h: u32);
}
}
/// Errors from JS interop (`zig-js` `error.JSError` and friends).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Error {
/// The JS operation threw (or the handle was invalid).
Js,
/// The value is `null`/`undefined` where an object was required.
Nullish,
/// A returned string was not valid UTF-8.
Utf8,
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Error::Js => f.write_str("javascript error"),
Error::Nullish => f.write_str("value is null or undefined"),
Error::Utf8 => f.write_str("invalid utf-8 from javascript"),
}
}
}
impl std::error::Error for Error {}
pub type Result<T> = std::result::Result<T, Error>;
/// A reference to a JS value. Released on drop (zig-js `Object.deinit`).
#[derive(Debug, PartialEq, Eq)]
pub struct Object(u32);
/// An argument for [`Object::call`] / [`Object::new_instance`].
#[derive(Debug, Clone, Copy)]
pub enum Arg<'a> {
Object(&'a Object),
Str(&'a str),
Num(f64),
Bool(bool),
}
impl<'a> From<&'a Object> for Arg<'a> {
fn from(v: &'a Object) -> Self {
Arg::Object(v)
}
}
impl<'a> From<&'a str> for Arg<'a> {
fn from(v: &'a str) -> Self {
Arg::Str(v)
}
}
impl From<f64> for Arg<'_> {
fn from(v: f64) -> Self {
Arg::Num(v)
}
}
impl From<f32> for Arg<'_> {
fn from(v: f32) -> Self {
Arg::Num(f64::from(v))
}
}
impl From<u32> for Arg<'_> {
fn from(v: u32) -> Self {
Arg::Num(f64::from(v))
}
}
impl From<bool> for Arg<'_> {
fn from(v: bool) -> Self {
Arg::Bool(v)
}
}
fn check(h: u32) -> Result<Object> {
if h == 0 {
Err(Error::Js)
} else {
Ok(Object(h))
}
}
/// Converts arguments to handles. Temporaries are released by [`Args::drop`].
struct Args {
handles: Vec<u32>,
owned: Vec<u32>,
}
impl Args {
fn new(args: &[Arg<'_>]) -> Result<Args> {
let mut out = Args {
handles: Vec::with_capacity(args.len()),
owned: Vec::new(),
};
for a in args {
let h = match a {
Arg::Object(o) => o.0,
// SAFETY: pointer/length of a live &str; host copies it.
Arg::Str(s) => unsafe { ffi::string(s.as_ptr(), s.len()) },
// SAFETY: plain value call.
Arg::Num(n) => unsafe { ffi::number(*n) },
// SAFETY: plain value call.
Arg::Bool(b) => unsafe { ffi::boolean(i32::from(*b)) },
};
if h == 0 {
return Err(Error::Js);
}
if !matches!(a, Arg::Object(_)) {
out.owned.push(h);
}
out.handles.push(h);
}
Ok(out)
}
}
impl Drop for Args {
fn drop(&mut self) {
for h in &self.owned {
// SAFETY: handle was created by us and not yet released.
unsafe { ffi::release(*h) };
}
}
}
/// `js.global`: the `globalThis` object.
pub fn global() -> Result<Object> {
// SAFETY: plain call into the host.
check(unsafe { ffi::global() })
}
impl Object {
/// Creates a JS string value.
pub fn string(s: &str) -> Result<Object> {
// SAFETY: pointer/length of a live &str; host copies it.
check(unsafe { ffi::string(s.as_ptr(), s.len()) })
}
/// Creates a `Uint8ClampedArray` holding a copy of `data` (what zig-js
/// callers built by viewing wasm memory with `new Uint8ClampedArray(
/// memory.buffer, ptr, len)`; copying also works for shared memory).
pub fn clamped_array(data: &[u8]) -> Result<Object> {
// SAFETY: pointer/length of a live slice; host copies it.
check(unsafe { ffi::bytes(data.as_ptr(), data.len()) })
}
/// Gives up ownership of the handle without releasing it (the host is
/// expected to release it, like zig-js `deleteValue`).
pub fn into_raw(self) -> u32 {
let h = self.0;
std::mem::forget(self);
h
}
/// Gets a property as an object reference (also used for primitives,
/// which are boxed on the host side).
pub fn get(&self, name: &str) -> Result<Object> {
// SAFETY: pointer/length of a live &str; host copies it.
check(unsafe { ffi::get(self.0, name.as_ptr(), name.len()) })
}
/// Gets a property that must be an object (not null/undefined).
pub fn get_object(&self, name: &str) -> Result<Object> {
let v = self.get(name)?;
if v.is_nullish() {
return Err(Error::Nullish);
}
Ok(v)
}
/// Gets a numeric property.
pub fn get_f64(&self, name: &str) -> Result<f64> {
Ok(self.get(name)?.as_f64())
}
/// Gets a numeric property as `f32` (zig `get(f32, ...)`).
pub fn get_f32(&self, name: &str) -> Result<f32> {
Ok(self.get_f64(name)? as f32)
}
/// Gets a string property.
pub fn get_string(&self, name: &str) -> Result<String> {
self.get(name)?.to_string_value()
}
/// Sets a property.
pub fn set(&self, name: &str, value: Arg<'_>) -> Result<()> {
let args = Args::new(&[value])?;
// SAFETY: pointer/length of a live &str; handle is valid.
let ok = unsafe { ffi::set(self.0, name.as_ptr(), name.len(), args.handles[0]) };
if ok == 1 { Ok(()) } else { Err(Error::Js) }
}
/// Calls a method and returns the result (which may be nullish).
pub fn call(&self, name: &str, args: &[Arg<'_>]) -> Result<Object> {
let args = Args::new(args)?;
// SAFETY: name and args pointers are live for the call.
check(unsafe {
ffi::call(
self.0,
name.as_ptr(),
name.len(),
args.handles.as_ptr(),
args.handles.len(),
)
})
}
/// `new this(...args)` (zig-js `Object.new`).
pub fn new_instance(&self, args: &[Arg<'_>]) -> Result<Object> {
let args = Args::new(args)?;
// SAFETY: args pointer is live for the call.
check(unsafe { ffi::new(self.0, args.handles.as_ptr(), args.handles.len()) })
}
pub fn is_nullish(&self) -> bool {
// SAFETY: plain handle call.
unsafe { ffi::is_nullish(self.0) != 0 }
}
/// Numeric value of this object (`Number(v)` on the host).
pub fn as_f64(&self) -> f64 {
// SAFETY: plain handle call.
unsafe { ffi::to_f64(self.0) }
}
/// String value of this object (`String(v)` on the host).
pub fn to_string_value(&self) -> Result<String> {
// SAFETY: plain handle call.
let len = unsafe { ffi::string_len(self.0) };
let mut buf = vec![0u8; len];
// SAFETY: `buf` has `len` writable bytes.
let n = unsafe { ffi::string_copy(self.0, buf.as_mut_ptr(), buf.len()) };
buf.truncate(n);
String::from_utf8(buf).map_err(|_| Error::Utf8)
}
/// Copies this (`Uint8Array`/`Uint8ClampedArray`) object into `dst`.
pub fn copy_bytes_to(&self, dst: &mut [u8]) -> Result<()> {
// SAFETY: `dst` is `dst.len()` writable bytes in wasm memory.
let ok = unsafe { ffi::copy_bytes(self.0, dst.as_mut_ptr(), dst.len()) };
if ok == 1 { Ok(()) } else { Err(Error::Js) }
}
}
impl Drop for Object {
fn drop(&mut self) {
// SAFETY: we own this handle.
unsafe { ffi::release(self.0) };
}
}
+76
View File
@@ -0,0 +1,76 @@
//! Freestanding log sink. Port of `ZIG/src/os/wasm/log.zig`.
//!
//! Formats `"<level>(<scope>): <message>"` (or `"<level>: <message>"` for
//! the default scope) and hands it to the host through the `env.log` import,
//! exactly like Ghostty's `Freestanding.log`.
#![allow(unsafe_code)]
use ::log::{Level, LevelFilter, Log, Metadata, Record};
mod ffi {
#[link(wasm_import_module = "env")]
unsafe extern "C" {
/// `extern "env" fn log(ptr: [*]const u8, len: usize) void`
pub fn log(ptr: *const u8, len: usize);
}
}
/// Zig `std.log.Level.asText()`.
fn level_text(level: Level) -> &'static str {
match level {
Level::Error => "error",
Level::Warn => "warning",
Level::Info => "info",
Level::Debug | Level::Trace => "debug",
}
}
/// Formats a record like the Zig log function does.
pub fn format_record(level: Level, scope: &str, args: &std::fmt::Arguments<'_>) -> String {
let prefix = if scope.is_empty() || scope == "default" {
": ".to_owned()
} else {
format!("({scope}): ")
};
format!("{}{prefix}{args}", level_text(level))
}
/// Sends a string to the host.
pub fn host_log(s: &str) {
// SAFETY: the pointer/length pair describes a live UTF-8 buffer and the
// host only reads it for the duration of the call.
unsafe { ffi::log(s.as_ptr(), s.len()) }
}
struct Logger;
impl Log for Logger {
fn enabled(&self, _: &Metadata<'_>) -> bool {
true
}
fn log(&self, record: &Record<'_>) {
host_log(&format_record(
record.level(),
record.target(),
record.args(),
));
}
fn flush(&self) {}
}
static LOGGER: Logger = Logger;
/// Installs the logger. Zig's level: debug in Debug, warn in ReleaseSmall,
/// info otherwise; here debug with `debug_assertions`, info otherwise.
pub fn init() {
let level = if cfg!(debug_assertions) {
LevelFilter::Debug
} else {
LevelFilter::Info
};
if ::log::set_logger(&LOGGER).is_ok() {
::log::set_max_level(level);
}
}
+22
View File
@@ -0,0 +1,22 @@
//! Helpers for wasm compilation. Port of `ZIG/src/os/wasm.zig` and
//! `ZIG/src/os/wasm/*.zig`.
//!
//! Only compiled for `wasm32` (Zig: `@compileError` otherwise). The module is
//! organised like the Zig directory:
//!
//! - [`log`]: the freestanding log sink (`os/wasm/log.zig`).
//! - [`target`]: the wasm target platform (`os/wasm/target.zig`).
//! - [`js`]: the JS interop layer that stands in for the `zig-js` package
//! used by the Zig font code (see that module for the import ABI).
pub mod js;
pub mod log;
pub mod target;
/// True if we're in shared memory mode. If true, then the memory buffer
/// in JS will be backed by a SharedArrayBuffer and some behaviors change.
/// (Zig: `options.wasm_shared`, here the `wasm-shared` cargo feature.)
pub const SHARED_MEM: bool = cfg!(feature = "wasm-shared");
// The Zig `alloc` (`std.heap.wasm_allocator`) is the Rust global allocator on
// wasm32 (`std::alloc::System` is the wasm allocator); nothing to port.
+14
View File
@@ -0,0 +1,14 @@
//! The wasm target platform. Port of `ZIG/src/os/wasm/target.zig`.
//!
//! This is used to toggle certain features on and off since the standard
//! triple target is often not specific enough (i.e. we can't tell
//! wasm32-freestanding is for browser or not).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Target {
Browser,
}
/// Our specific target platform. `None` on non-wasm targets (Zig); this
/// module only exists on wasm32 so it is always the browser.
pub const TARGET: Option<Target> = Some(Target::Browser);