Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/store/PluginLoader_web.zig
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
//! Runtime plugin loading on the web: a plugin built as a wasm *side module* (see
//! `sdk/plugin_sdk.zig`'s `create` for wasm32 and `plans/REVIEW_2026-09.md` §2), fetched and
//! linked into the page by `web/index.html`'s `loadPlugin`, which hands back one function-table
//! linked into the page by `web/fizzy.js`'s `loadPlugin`, which hands back one function-table
//! index per entry point. On wasm32 a function pointer *is* a table index, so from there this
//! runs the same sequence the desktop loader runs (`PluginLoader.loadAndRegister`): fingerprint,
//! versions, id, inject the host's globals, `fizzy_plugin_register`.
Expand Down Expand Up @@ -62,7 +62,7 @@ pub const PreRegister = struct {
arg_c: ?*anyopaque = null,
};

/// Entry points by name → table index, in the order `web/index.html`'s `pluginEntryPoints`
/// Entry points by name → table index, in the order `web/fizzy.js`'s `pluginEntryPoints`
/// lists them (the same order as `plugin_sdk.dylib_exports`).
pub const WebDynLib = struct {
indices: [entry_names.len]u32,
Expand Down
2 changes: 1 addition & 1 deletion app/store/PluginStore.zig
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ const Job = struct {
/// `std.Thread`. The worker writes `err_buf`/`err_len`/`status` back into the `Job`, so the
/// `Job` must not be freed while it is alive — and a detached thread gave `freeJob` no way to
/// know. Nothing in the native download path sets a network timeout (the web's fetches
/// have one, `web/index.html`'s `fetchPluginBytes`), so a stalled or
/// have one, `web/fizzy.js`'s `fetchPluginBytes`), so a stalled or
/// blackholed release host keeps that worker running arbitrarily long: quitting mid-download
/// left a live thread writing into freed memory. `Group.cancel` both interrupts the blocked
/// syscall (the worker returns `error.Canceled`) *and* awaits the task, so `freeJob` is
Expand Down
6 changes: 6 additions & 0 deletions build.zig.zon
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@
.url = "git+https://codeberg.org/ziglang/translate-c?ref=zig-0.17.x#02ff0c523fb92a38939b04d0145c4a9602a74d56",
.hash = "translate_c-2.0.0-Q_BUWltOBwBYABF84EUL208pIFeUIwbBs29FmVBaafN5",
},
// Applies `web/patches/dvui` to dvui for the web build's `web.js`: docs/DEPENDENCIES.md.
// The pin `backend/build.zig.zon` makes for SDL's; keep them the same.
.patch = .{
.url = "https://github.com/marler8997/zig-patch/archive/c3ad78b305ba03bcd645c0bac004d1719407c6b8.tar.gz",
.hash = "patch-0.0.0-WCKjsxixAADfwXQv4Vugyxo6PFbDFuuyM7ly3OiqZRYR",
},
.dvui_singleton_app = .{
.path = "libs/dvui-singleton-app",
},
Expand Down
3 changes: 1 addition & 2 deletions build/app.zig
Original file line number Diff line number Diff line change
Expand Up @@ -641,8 +641,7 @@ pub fn construct(
, .{cfg.branding.pixel_art})),
});

// `zig build web` produces `zig-out/web/{web.wasm, web.js, index.html,
// NotoSansKR-Regular.ttf}`,
// `zig build web` produces `zig-out/web/{web.wasm, fizzy.js, web.js, index.html, …}`,
// deployable as-is to a static host.
const w = web.build(
b,
Expand Down
83 changes: 57 additions & 26 deletions build/web.zig
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@ pub const Web = struct {
wasm: *std.Build.Step.Compile,
/// The whole site, deployable as it is: `files` and `page`.
site: std.Build.LazyPath,
/// Everything but the page: the wasm, `web.js`, the worker, the font, the OAuth page, the
/// Everything but the page: the wasm, `fizzy.js`, `web.js`, the worker, the font, the OAuth page, the
/// hosted demo tapes under `demos/` (fizzy's own build) and each plugin's pages under
/// `plugins/<id>/`.
files: std.Build.LazyPath,
/// `index.html`, stamped with the wasm's hash and the storage name.
/// `index.html`, stamped with `fizzy.js`'s hash.
page: std.Build.LazyPath,
/// Fizzy's own build: a native-built plugin graph re-homed to wasm32 and compiled apart from
/// the app, for `check-web`.
Expand Down Expand Up @@ -49,8 +49,8 @@ pub const Framework = struct {

pub fn framework(b: *std.Build, optimize: std.lang.OptimizeMode) Framework {
const web_target = target(b);
// The web backend is fizzy's own (`src/backend/WebBackend.zig` + `web/web.js`), started as a
// copy of dvui's: dvui is built in its `custom` mode and our backend linked to it, as dvui's
// The web backend is fizzy's own (`src/backend/WebBackend.zig` + dvui's `web.js`, patched:
// `patchedWebJs`), started as a copy of dvui's: dvui is built in its `custom` mode and our backend linked to it, as dvui's
// build does for its own (`linkBackend`). Every option is the one dvui's `web` mode sets, so
// dvui's shape — and with it the plugin fingerprint — is exactly what it was.
const dvui_web_dep = sdk.dvuiDependency(b, .{
Expand All @@ -72,7 +72,7 @@ pub fn framework(b: *std.Build, optimize: std.lang.OptimizeMode) Framework {
.optimize = optimize,
.single_threaded = true,
});
// What the page calls into (`web/web.js`): dvui's web exports.
// What the page calls into (`web.js`): dvui's web exports.
web_backend.export_symbol_names = &.{
"dvui_init",
"dvui_deinit",
Expand Down Expand Up @@ -162,7 +162,7 @@ pub fn build(
// Extra wasm exports beyond dvui's own (`dvui_init`/`dvui_update`/etc.). The wasm
// linker only emits symbols listed here, so `export fn` in Zig isn't enough on its
// own — without this line our trackpad pinch entry point would compile cleanly but
// be missing from `instance.exports`, and the JS bootstrap in `web/index.html`
// be missing from `instance.exports`, and the JS bootstrap in `web/fizzy.js`
// would never be able to forward pinch deltas into the canvas widget. The image
// trio is the other half of that seam: JS rasterizes a remote URL and writes the
// pixels back through these.
Expand All @@ -183,7 +183,7 @@ pub fn build(
"FizzyWebOAuthAlloc",
"FizzyWebOAuthResult",
"FizzyWebOAuthFailed",
// Plugins loaded at runtime (wasm side modules — `web/index.html`'s `loadPlugin`):
// Plugins loaded at runtime (wasm side modules — `web/fizzy.js`'s `loadPlugin`):
// the host's side of the handshake, and what a side module links against — the
// stack pointer it shares, dvui's C shims and stb, which it imports from `env`.
"FizzyWebPluginAlloc",
Expand Down Expand Up @@ -352,7 +352,7 @@ pub fn build(
web_exe.root_module,
);

// Cache-buster: stamps a 64-char hash into the index.html / web.js placeholders so
// Cache-buster: stamps a 64-char hash into the index.html / fizzy.js placeholders so
// the browser picks up new wasm builds without manual hard-reloads. Re-implements
// upstream DVUI's `addWebExample` machinery so we don't have to invoke its step.
const cb = b.addExecutable(.{
Expand All @@ -362,16 +362,23 @@ pub fn build(
.target = b.graph.host,
}),
});
const cb_run = b.addRunArtifact(cb);
cb_run.addFileArg(stampStorage(b));
cb_run.addFileArg(b.path("web/web.js"));
cb_run.addFileArg(b.path("web/fizzy-worker.js"));
cb_run.addFileArg(web_exe.getEmittedBin());
const index_html_with_hash = cb_run.captureStdOut(.{});
const web_js = patchedWebJs(b, dvui_web_dep);
// `fizzy.js` names what it loads by their hash, and the page names `fizzy.js` by its own.
const cb_js = b.addRunArtifact(cb);
cb_js.addFileArg(stampStorage(b));
cb_js.addFileArg(web_js);
cb_js.addFileArg(b.path("web/fizzy-worker.js"));
cb_js.addFileArg(web_exe.getEmittedBin());
const fizzy_js = cb_js.captureStdOut(.{});
const cb_page = b.addRunArtifact(cb);
cb_page.addFileArg(b.path("web/index.html"));
cb_page.addFileArg(fizzy_js);
const index_html_with_hash = cb_page.captureStdOut(.{});

const dir = b.addWriteFiles();
_ = dir.addCopyFile(web_exe.getEmittedBin(), web_exe.out_filename);
_ = dir.addCopyFile(b.path("web/web.js"), "web.js");
_ = dir.addCopyFile(fizzy_js, "fizzy.js");
_ = dir.addCopyFile(web_js, "web.js");
// Worker mode (`?worker=1`): the editor in a worker, drawing through an OffscreenCanvas.
_ = dir.addCopyFile(b.path("web/fizzy-worker.js"), "fizzy-worker.js");
_ = dir.addCopyFile(dvui_web_dep.path("src/fonts/NotoSansKR-Regular.ttf"), "NotoSansKR-Regular.ttf");
Expand Down Expand Up @@ -474,10 +481,34 @@ pub fn install(b: *std.Build, web: Web) void {
).dependOn(&serve_web_cmd.step);
}

/// A plugin package's `"plugin"` module as this web build's own: the same root file and the
/// plugin's own imports (manifest options, its dependencies), with `dvui`/`core`/`fizzy_sdk`
/// swapped for the web build's. A module cannot import two frameworks, so it must be a copy.
/// `web/index.html` with the name the page files what it keeps in the browser under stamped in
/// dvui's `web.js` with `web/patches/dvui/*.patch` applied, in name order, by zig-patch: what
/// fizzy's web backend (`src/backend/WebBackend.zig`) needs of it, each patch a dvui commit
/// written to go upstream. How they are made: docs/DEPENDENCIES.md.
fn patchedWebJs(b: *std.Build, dvui_dep: *std.Build.Dependency) std.Build.LazyPath {
const io = b.graph.io;
const patches_dir = "web/patches/dvui";
b.dependOnDirectoryContents(b.path(patches_dir));
var names: std.ArrayList([]const u8) = .empty;
var dir = b.root.openDir(io, patches_dir, .{ .iterate = true }) catch |err|
std.debug.panic("fizzy web: open {s}: {t}", .{ patches_dir, err });
defer dir.close(io);
var it = dir.iterate();
while (it.next(io) catch |err| std.debug.panic("fizzy web: list {s}: {t}", .{ patches_dir, err })) |entry| {
if (entry.kind != .file or !std.mem.endsWith(u8, entry.name, ".patch")) continue;
names.append(b.allocator, b.dupe(entry.name)) catch @panic("OOM");
}
std.mem.sort([]const u8, names.items, {}, struct {
fn lessThan(_: void, l: []const u8, r: []const u8) bool {
return std.mem.lessThan(u8, l, r);
}
}.lessThan);
const patches = b.allocator.alloc(std.Build.LazyPath, names.items.len) catch @panic("OOM");
for (patches, names.items) |*p, name| p.* = b.path(b.pathJoin(&.{ patches_dir, name }));
const patched = b.dependency("patch", .{ .source = dvui_dep.path(""), .patches = patches }).namedLazyPath("patched");
return patched.path(b, "src/backends/web.js");
}

/// `web/fizzy.js` with the name the app files what it keeps in the browser under stamped in
/// (`storageName` there): `-Dweb-storage`, or "fizzy". Every copy of the app on one site shares
/// the browser's storage for it, so a test build served beside the real one — fizzyed.it/testapp/
/// beside /app/ — is built with a name of its own and keeps out of the real one's settings.
Expand All @@ -493,17 +524,17 @@ fn stampStorage(b: *std.Build) std.Build.LazyPath {
else => std.debug.panic("-Dweb-storage={s}: only a-z, 0-9, '-' and '_'", .{name}),
};
const token = "__FIZZY_WEB_STORAGE__";
b.dependOnFileContents(b.path("web/index.html"));
const html = std.Io.Dir.cwd().readFileAlloc(
b.dependOnFileContents(b.path("web/fizzy.js"));
const js = std.Io.Dir.cwd().readFileAlloc(
b.graph.io,
b.root.joinString(b.allocator, "web/index.html") catch @panic("OOM"),
b.root.joinString(b.allocator, "web/fizzy.js") catch @panic("OOM"),
b.allocator,
.unlimited,
) catch |err|
std.debug.panic("web/index.html: {t}", .{err});
if (std.mem.find(u8, html, token) == null) std.debug.panic("web/index.html: no {s} to stamp", .{token});
const stamped = std.mem.replaceOwned(u8, b.allocator, html, token, name) catch @panic("OOM");
return b.addWriteFiles().add("index.html", stamped);
std.debug.panic("web/fizzy.js: {t}", .{err});
if (std.mem.find(u8, js, token) == null) std.debug.panic("web/fizzy.js: no {s} to stamp", .{token});
const stamped = std.mem.replaceOwned(u8, b.allocator, js, token, name) catch @panic("OOM");
return b.addWriteFiles().add("fizzy.js", stamped);
}

/// Every tape fizzy hosts, built on the host (`scripts/demos.zig`): the bundled demos from their
Expand Down
3 changes: 2 additions & 1 deletion core/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ paths, fuzzy matching, the LSP client, the profiler.
A lifted finger hovers nothing: dvui puts the frame's `.position` event off every window, so a
hover look needs no touch check of its own. The native backend gives only fingers on the screen
touch buttons (`backend/src/touch_slots.zig`); a trackpad's fingers move the mouse.
The web backend does the same: a stylus touch (an Apple Pencil) is the mouse (`web/web.js`).
The web backend does the same: a stylus touch (an Apple Pencil) is the mouse (dvui's `web.js`,
`web/patches/dvui/0003`).
- **A cursor that must win asks with a priority.** `dvui.cursorSet` keeps the first asked in a
frame; `dvui.cursorSetPriority` the highest. Splits and a float's corner ask at `.resize`, a
carried view at `.drag`, so a hover cursor (a tab's hand) never takes their arrow. The cursor
Expand Down
41 changes: 38 additions & 3 deletions docs/DEPENDENCIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ bump it; keep it current whenever a pin or a patch moves.
| Dependency | Upstream | Fizzy's changes | Pinned in | Carries |
|---|---|---|---|---|
| [`foxnne/dvui-dev`](https://github.com/foxnne/dvui-dev) | [`david-vanderson/dvui`](https://github.com/david-vanderson/dvui) | branch `zig-0.17` (on 0.16, `fizzy-dev`) | `sdk/build.zig.zon` | dvui changes fizzy needs |
| dvui's `web.js` | the dvui `sdk/build.zig.zon` pins | `web/patches/dvui` | `build.zig.zon` (`.patch`) | the web backend's JavaScript, patched by zig-patch for the web build (below) |
| SDL | [`libsdl-org/SDL`](https://github.com/libsdl-org/SDL) `release-3.4.16` | `backend/patches/sdl` | `backend/build.zig.zon` (`.sdl_source`) | SDL C patches, applied by [zig-patch](https://github.com/marler8997/zig-patch) (below) |
| [`fizzyedit/sdl_zig`](https://github.com/fizzyedit/sdl_zig) | [`Games-By-Mason/sdl_zig`](https://github.com/Games-By-Mason/sdl_zig) | branch `fizzy` | `backend/build.zig.zon` (`.sdl`) | the Zig build of SDL, taking the patched tree |
| [`fizzyedit/nightwatch`](https://github.com/fizzyedit/nightwatch) | [`neurocyte/nightwatch`](https://github.com/neurocyte/nightwatch) | branch `fizzy` | `build.zig.zon` | a fix to its macOS FSEvents backend |
Expand Down Expand Up @@ -175,9 +176,10 @@ pinned); `fizzy-sdk-0.2.30-4` → `44010a3`; `fizzy-sdk-0.2.30-5` →
`fizzy-sdk-0.2.30-8` → `3d90fdd` (item 14), what `sdk/build.zig.zon` pins now.

Fizzy builds dvui in its `custom` backend mode on every target and links its own backends
(`backend/`, `src/backend/WebBackend.zig` + `web/web.js`), so dvui's `sdl3` and `web`
backends are only built by `-Dnative-backend=sdl3`. A dvui change to those backends is not a
change to fizzy: anything fizzy wants from one is merged into its own by hand.
(`backend/`, `src/backend/WebBackend.zig`), so dvui's `sdl3` and `web` backends are only built
by `-Dnative-backend=sdl3`. A dvui change to those backends is not a change to fizzy: anything
fizzy wants from one is merged into its own by hand. The exception is the web backend's
JavaScript: fizzy's web build ships dvui's own `web.js`, with `web/patches/dvui` (below).

Local copies of dvui, kept in fizzy until upstream has them (diff these on every dvui bump):

Expand All @@ -196,6 +198,39 @@ Fizzy adapts to two upstream behaviours rather than patching them:
first frame, whatever width it was given. The markdown preview treats a block holding one as
not yet measured (`render_ast.Stats.fresh_text_layouts`).

## dvui's `web.js`: `web/patches/dvui`

The web build ships `src/backends/web.js` of the dvui `sdk/build.zig.zon` pins, patched at build
time: `build/web.zig` (`patchedWebJs`) hands dvui and `web/patches/dvui/*.patch`, in name order,
to zig-patch and takes `web.js` from the tree it writes. The page and `fizzy-worker.js` import it
and drive its `Dvui` class. Each patch is a commit on dvui, `git format-patch` output whose header
says what it does and why, written to go upstream: where dvui's own `web.zig` has a counterpart,
the patch changes it too, and nothing dvui's own web backend uses is taken away
(`wasm_renderGeometry` stays beside `wasm_renderStream`).

- `0001` **highp float in the shaders.** mediump is a 16-bit float on Apple Silicon and most
mobile GPUs, too coarse for BlurBackdrop's sub-texel taps.
- `0002` **Mouse events carry their own modifier state** (and `web.zig` reads it), so a lost keyup
no longer leaves a modifier held.
- `0003` **A pencil is the mouse, and a cancelled touch ends** (`core/AGENTS.md`).
- `0004` **Send only the GL state a draw changes**, and a framebuffer per render target.
- `0005` **Clearing a target keeps the target that was bound.**
- `0006` **`textureBlend`** (`dvui.Backend.textureBlend`), with `web.zig`'s.
- `0007` **Precise render targets** (`dvui.Backend.support_precise_targets`), with `web.zig`'s.
- `0008` **`wasm_renderStream`**, a flush's draws in one call: what `WebBackend.flushBatch` sends.
- `0009` **Custom fragment programs in the draw stream** (`core.gfx.programs`).

The patches are the commits of a dvui checkout's branch, from the pinned commit. To change one,
or to carry them to a new dvui pin: rebase the branch (`git rebase <new pin>`), fix what
conflicts, and export it again over the old files:

```sh
git -c diff.noprefix=false -c diff.mnemonicPrefix=false format-patch -U8 --zero-commit \
--no-signature --no-numbered --src-prefix=a/ --dst-prefix=b/ -o <fizzy>/web/patches/dvui <pin>..HEAD
```

A patch that dvui merges comes out empty at the rebase and is dropped.

## SDL: `backend/patches/sdl`

Upstream `release-3.4.16`, patched at build time: `backend/build.zig` (`patchedSdl`) hands the
Expand Down
4 changes: 2 additions & 2 deletions plugins/markdown/src/md/net_image.zig
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
//!
//! Native uses a worker thread + `std.http.Client` and stores encoded bytes (stb decodes
//! them later). Web has neither threads nor that HTTP client — the browser loads the URL
//! instead (`new Image`, see `web/index.html`). When CORS allows it, the pixels are copied
//! instead (`new Image`, see `web/fizzy.js`). When CORS allows it, the pixels are copied
//! into a dvui texture. When it does not — GitHub Actions badges are the usual case —
//! the browser still has the image, so the preview positions an `<img>` over the canvas
//! rather than drawing the "open" fallback link. Same ownership shape as fizzy's own
Expand Down Expand Up @@ -75,7 +75,7 @@ var gpa: ?std.mem.Allocator = null;
var next_id: u32 = 1;

const wasm = if (is_wasm) struct {
/// Starts a browser load for `url`. Wired in `web/index.html` by adding a `fizzy`
/// Starts a browser load for `url`. Wired in `web/fizzy.js` by adding a `fizzy`
/// import namespace at instantiate time — dvui's `web.js` only provides `dvui.*`.
extern "fizzy" fn fizzy_web_image_request(id: u32, url_ptr: [*]const u8, url_len: usize) void;
} else struct {};
Expand Down
3 changes: 2 additions & 1 deletion scripts/compile-shaders.sh
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
#
# The GLSL is complete source, for the web backend's programs (`core.gfx.programs`): its inputs
# are named as the web's vertex shader writes them (`vColor`, `vTextureCoord`), its samplers by
# unit (`uSampler` 0, `uTex1` 1), its uniforms `uData`, the names `web/web.js` binds.
# unit (`uSampler` 0, `uTex1` 1), its uniforms `uData`, the names dvui's `web.js` binds
# (`web/patches/dvui/0009`).
#
# Needs `shadercross` and `spirv-cross` on PATH. Exits non-zero if any shader does not compile.
set -euo pipefail
Expand Down
2 changes: 1 addition & 1 deletion sdk/plugin_sdk.zig
Original file line number Diff line number Diff line change
Expand Up @@ -532,7 +532,7 @@ pub fn create(b: *std.Build, opts: CreateOptions) PluginArtifact {
// A wasm *side module* (Emscripten's term): position-independent, no entry, no
// threads, loaded into the web host's memory and function table at runtime — the
// dylib model with table indices for function pointers. See `plans/REVIEW_2026-09.md`
// §2 and `web/index.html`'s loader.
// §2 and `web/fizzy.js`'s loader.
lib.root_module.pic = true;
lib.root_module.single_threaded = true;
lib.entry = .disabled;
Expand Down
Loading
Loading