Skip to content

Commit 0257cb2

Browse files
committed
Add per-window favicon support
1 parent 47a57d1 commit 0257cb2

3 files changed

Lines changed: 245 additions & 6 deletions

File tree

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ The current phase provides:
1212
- embedded HTML, static directories, custom resources, external URLs, and a
1313
built-in JavaScript bridge;
1414
- application-wide default static directories for windows without content;
15+
- inline and file-backed per-window favicons;
1516
- runtime content and resource-handler replacement through
1617
`Window.setContent()`;
1718
- targeted runtime content replacement through `Client.show()`;
@@ -118,6 +119,12 @@ Set `App.Options.default_directory` to let windows created without `.content`
118119
inherit one static directory. Explicit window content takes precedence. A
119120
window without either setting returns `error.MissingContent`.
120121

122+
Use `Window.setIcon(io, data, mime_type)` for in-memory favicon data or
123+
`Window.setIconFile(io, path)` for SVG, PNG, ICO, JPEG, GIF, WebP, or AVIF
124+
files. Embedded HTML receives a relative favicon link automatically.
125+
Directory and custom pages can reference `favicon.ico` relative to the window
126+
capability root.
127+
121128
`Window.setContent(&running, content)` prepares and installs new content, then
122129
navigates every connected client to it and returns the number notified. An
123130
invalid replacement leaves the current content unchanged. If client

docs/PURE_ZIG_REFACTOR.md

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,7 @@ protocol input never panics.
277277
| `Event.runClient()` | `Call.client.eval()` |
278278
| `setRootFolder()` | Initial `.directory` content or runtime `Window.setContent()`. |
279279
| `setDefaultRootFolder()` | `App.Options.default_directory` and an omitted window `content`. |
280+
| `setIcon()` / `setIconFile()` | `Window.setIcon()` / `Window.setIconFile()`. |
280281
| Global `setConfig()` | `App.Options` or `Window.Options` |
281282
| `wait()` / `clean()` | `Running.wait()` / `App.deinit()` |
282283
| `malloc/free/memcpy/encode/decode` | Zig allocators and standard library |
@@ -297,7 +298,6 @@ implementations.
297298
| Upstream API | Current gap |
298299
|---|---|
299300
| `webui_set_config(folder_monitor)` | Directory change monitoring and automatic browser reload are not implemented. |
300-
| `webui_set_icon()`, `webui_set_icon_file()` | Window icon configuration is not implemented. |
301301
| `webui_open_url()` | The internal OS URL opener is not exposed as a general public API. |
302302
| `webui_get_best_browser()`, `webui_browser_exist()`, `webui_show_browser()`, `webui_set_browser_folder()` | Browser discovery, selection, and custom executable locations are not implemented. |
303303
| `webui_set_custom_parameters()` | Custom browser command-line arguments are not implemented. |
@@ -332,6 +332,7 @@ not implementation gaps:
332332
| `webui_show_client()` | `Client.show()` replaces the window content and navigates only the selected client. |
333333
| `webui_is_shown()` | `Window.isShown()` reports whether the window has at least one connected browser client. |
334334
| `webui_set_default_root_folder()` | `App.Options.default_directory` supplies directory content to windows created without explicit content. |
335+
| `webui_set_icon()`, `webui_set_icon_file()` | `Window.setIcon()` copies inline data and MIME type; `Window.setIconFile()` loads a supported image file as the window favicon. |
335336
| `webui_wait()`, `webui_wait_async()` | `Running.wait()` used directly or through `std.Io` concurrency. |
336337
| `webui_close()`, `webui_destroy()`, `webui_exit()`, `webui_clean()` | `Window.close()`, `Running.stop()`, and `App.deinit()`. |
337338
| `webui_set_context()`, `webui_get_context()` | Binding and event-handler `user_data`. |
@@ -388,9 +389,7 @@ methods, and the public browser bridge surface.
388389
Implements asynchronous replies, per-window event scheduling, explicit
389390
connection waiting, and caller-provided logging.
390391

391-
### Dynamic content and client state
392-
393-
- Add inline and file-backed window icons.
392+
### Dynamic content and client state (complete)
394393

395394
This completes `webui_show()`, `webui_show_client()`, `webui_is_shown()`,
396395
the dynamic root and file-handler methods, `webui_set_default_root_folder()`,
@@ -483,4 +482,4 @@ zig build -Dtarget=aarch64-macos
483482

484483
Continue capability parity:
485484

486-
1. Add inline and file-backed window icons.
485+
1. Add optional directory change monitoring and browser reload.

src/app.zig

Lines changed: 234 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@ const bridge = @embedFile("bridge.js");
77
const default_max_pending_evals = 64;
88
const default_max_pending_replies = 64;
99
const default_max_pending_events = 64;
10+
const max_icon_size = 8 << 20;
1011
const capability_len = 32;
1112
const cookie_len = 32;
1213
const cookie_name = "webui_auth";
14+
const favicon_link = "<link rel=\"icon\" href=\"favicon.ico\">";
1315

1416
pub const Tls = struct {
1517
certificate_pem: []const u8,
@@ -179,6 +181,23 @@ fn validateExternalUrl(url: []const u8) !void {
179181
}
180182
}
181183

184+
fn iconMimeType(path: []const u8) ?[]const u8 {
185+
const extension = std.fs.path.extension(path);
186+
const types = .{
187+
.{ ".svg", "image/svg+xml" },
188+
.{ ".png", "image/png" },
189+
.{ ".ico", "image/x-icon" },
190+
.{ ".jpg", "image/jpeg" },
191+
.{ ".jpeg", "image/jpeg" },
192+
.{ ".gif", "image/gif" },
193+
.{ ".webp", "image/webp" },
194+
.{ ".avif", "image/avif" },
195+
};
196+
inline for (types) |entry|
197+
if (std.ascii.eqlIgnoreCase(extension, entry[0])) return entry[1];
198+
return null;
199+
}
200+
182201
fn validateRunScript(script: []const u8, max_size: usize) !void {
183202
if (script.len == 0) return error.InvalidScript;
184203
if (script.len > max_size) return error.ScriptTooLarge;
@@ -347,9 +366,44 @@ const StoredContent = union(enum) {
347366
}
348367
};
349368

369+
const StoredIcon = struct {
370+
data: []u8,
371+
mime_type: []u8,
372+
373+
fn init(
374+
gpa: std.mem.Allocator,
375+
data: []const u8,
376+
mime_type: []const u8,
377+
) !StoredIcon {
378+
if (data.len == 0) return error.InvalidIcon;
379+
if (data.len > max_icon_size) return error.IconTooLarge;
380+
if (mime_type.len == 0) return error.InvalidIconMimeType;
381+
var validation = Response.init(gpa);
382+
defer validation.deinit();
383+
validation.setHeader("Content-Type", mime_type) catch |err|
384+
return switch (err) {
385+
error.InvalidHeader => error.InvalidIconMimeType,
386+
else => err,
387+
};
388+
const owned_data = try gpa.dupe(u8, data);
389+
errdefer gpa.free(owned_data);
390+
return .{
391+
.data = owned_data,
392+
.mime_type = try gpa.dupe(u8, mime_type),
393+
};
394+
}
395+
396+
fn deinit(self: *StoredIcon, gpa: std.mem.Allocator) void {
397+
gpa.free(self.data);
398+
gpa.free(self.mime_type);
399+
self.* = undefined;
400+
}
401+
};
402+
350403
const WindowState = struct {
351404
gpa: std.mem.Allocator,
352405
content: StoredContent,
406+
icon: ?StoredIcon = null,
353407
content_mutex: std.Io.RwLock = .init,
354408
limits: Limits,
355409
max_clients: usize,
@@ -384,6 +438,7 @@ const WindowState = struct {
384438
self.clients.deinit(self.gpa);
385439
for (self.bindings.items) |item| self.gpa.free(item.name);
386440
self.bindings.deinit(self.gpa);
441+
if (self.icon) |*icon| icon.deinit(self.gpa);
387442
self.content.deinit(self.gpa);
388443
self.gpa.destroy(self);
389444
}
@@ -406,6 +461,23 @@ const WindowState = struct {
406461
previous.deinit(self.gpa);
407462
}
408463

464+
fn replaceIcon(
465+
self: *WindowState,
466+
io: std.Io,
467+
data: []const u8,
468+
mime_type: []const u8,
469+
) !void {
470+
var replacement = try StoredIcon.init(self.gpa, data, mime_type);
471+
errdefer replacement.deinit(self.gpa);
472+
473+
self.content_mutex.lockUncancelable(io);
474+
defer self.content_mutex.unlock(io);
475+
476+
var previous = self.icon;
477+
self.icon = replacement;
478+
if (previous) |*icon| icon.deinit(self.gpa);
479+
}
480+
409481
fn binding(self: *WindowState, name: []const u8) ?Binding {
410482
// ponytail: binding counts are tiny; use a map if hundreds become normal.
411483
for (self.bindings.items) |item|
@@ -1312,6 +1384,38 @@ pub const Window = struct {
13121384
return self.navigate(running.inner.io, target_url);
13131385
}
13141386

1387+
/// Copy favicon data and its HTTP content type into this window.
1388+
pub fn setIcon(
1389+
self: Window,
1390+
io: std.Io,
1391+
data: []const u8,
1392+
mime_type: []const u8,
1393+
) !void {
1394+
try self.state.replaceIcon(io, data, mime_type);
1395+
}
1396+
1397+
/// Load favicon data from a supported image file.
1398+
pub fn setIconFile(
1399+
self: Window,
1400+
io: std.Io,
1401+
path: []const u8,
1402+
) !void {
1403+
if (path.len == 0) return error.InvalidIconPath;
1404+
const mime_type = iconMimeType(path) orelse
1405+
return error.UnsupportedIconFormat;
1406+
const data = std.Io.Dir.cwd().readFileAlloc(
1407+
io,
1408+
path,
1409+
self.state.gpa,
1410+
.limited(max_icon_size),
1411+
) catch |err| switch (err) {
1412+
error.StreamTooLong => return error.IconTooLarge,
1413+
else => return err,
1414+
};
1415+
defer self.state.gpa.free(data);
1416+
try self.state.replaceIcon(io, data, mime_type);
1417+
}
1418+
13151419
pub fn open(self: Window, io: std.Io, running: *const Running) !void {
13161420
const page_url = try self.url(running, self.state.gpa);
13171421
defer self.state.gpa.free(page_url);
@@ -1872,6 +1976,20 @@ fn route(app: *const App, path: []const u8) ?Route {
18721976
};
18731977
}
18741978

1979+
fn writeHtml(
1980+
response: *Response,
1981+
html: []const u8,
1982+
include_icon: bool,
1983+
) !void {
1984+
if (!include_icon) return response.write(html);
1985+
const insert_at = std.ascii.indexOfIgnoreCase(html, "</head>") orelse
1986+
std.ascii.indexOfIgnoreCase(html, "<body") orelse
1987+
html.len;
1988+
try response.write(html[0..insert_at]);
1989+
try response.write(favicon_link);
1990+
try response.write(html[insert_at..]);
1991+
}
1992+
18751993
fn onRequest(
18761994
request: *const Linsang.Request,
18771995
response: *Linsang.Response,
@@ -1896,6 +2014,18 @@ fn onRequest(
18962014
return .upgrade;
18972015
}
18982016
setCookie(app, window, response) catch return failResponse(response);
2017+
if (std.mem.eql(u8, resolved.resource, "favicon.ico") or
2018+
std.mem.eql(u8, resolved.resource, "favicon.svg"))
2019+
{
2020+
if (window.icon) |icon| {
2021+
response.setHeader("Content-Type", icon.mime_type) catch
2022+
return failResponse(response);
2023+
response.setHeader("X-Content-Type-Options", "nosniff") catch
2024+
return failResponse(response);
2025+
response.write(icon.data) catch return failResponse(response);
2026+
return .respond;
2027+
}
2028+
}
18992029
if (std.mem.eql(u8, resolved.resource, "webui.js")) {
19002030
response.setHeader("Content-Type", "text/javascript; charset=utf-8") catch
19012031
return failResponse(response);
@@ -1920,7 +2050,8 @@ fn onRequest(
19202050
.html => |html| if (resolved.resource.len == 0) blk: {
19212051
response.setHeader("Content-Type", "text/html; charset=utf-8") catch
19222052
break :blk failResponse(response);
1923-
response.write(html) catch break :blk failResponse(response);
2053+
writeHtml(response, html, window.icon != null) catch
2054+
break :blk failResponse(response);
19242055
break :blk .respond;
19252056
} else blk: {
19262057
response.status = .not_found;
@@ -2346,6 +2477,42 @@ test "call accessors, window creation, and routes" {
23462477
const second = try app.createWindow(.{
23472478
.content = .{ .html = "again" },
23482479
});
2480+
try std.testing.expectEqualStrings(
2481+
"image/png",
2482+
iconMimeType("icon.PNG").?,
2483+
);
2484+
try std.testing.expect(iconMimeType("icon.txt") == null);
2485+
try std.testing.expectError(
2486+
error.InvalidIcon,
2487+
window.setIcon(std.testing.io, "", "image/svg+xml"),
2488+
);
2489+
try std.testing.expectError(
2490+
error.InvalidIconMimeType,
2491+
window.setIcon(std.testing.io, "<svg/>", ""),
2492+
);
2493+
try std.testing.expectError(
2494+
error.InvalidIconMimeType,
2495+
window.setIcon(std.testing.io, "<svg/>", "image/svg+xml\r\nbad"),
2496+
);
2497+
try std.testing.expectError(
2498+
error.InvalidIconPath,
2499+
window.setIconFile(std.testing.io, ""),
2500+
);
2501+
try std.testing.expectError(
2502+
error.UnsupportedIconFormat,
2503+
window.setIconFile(std.testing.io, "icon.txt"),
2504+
);
2505+
var html_response = Response.init(gpa);
2506+
defer html_response.deinit();
2507+
try writeHtml(
2508+
&html_response,
2509+
"<html><head></head><body>page</body></html>",
2510+
true,
2511+
);
2512+
try std.testing.expectEqualStrings(
2513+
"<html><head>" ++ favicon_link ++ "</head><body>page</body></html>",
2514+
html_response.body_buf.items,
2515+
);
23492516
try std.testing.expect(window.state != second.state);
23502517
@memcpy(
23512518
&window.state.capability,
@@ -3050,12 +3217,23 @@ test "JavaScript and Zig calls complete over HTTP and WebSocket" {
30503217
.sub_path = "secret.txt",
30513218
.data = "not public",
30523219
});
3220+
const file_icon = "file png icon";
3221+
try tmp.dir.writeFile(io, .{
3222+
.sub_path = "public/window-icon.png",
3223+
.data = file_icon,
3224+
});
30533225
const directory_path = try std.fmt.allocPrint(
30543226
gpa,
30553227
".zig-cache/tmp/{s}/public",
30563228
.{tmp.sub_path},
30573229
);
30583230
defer gpa.free(directory_path);
3231+
const file_icon_path = try std.fmt.allocPrint(
3232+
gpa,
3233+
"{s}/window-icon.png",
3234+
.{directory_path},
3235+
);
3236+
defer gpa.free(file_icon_path);
30593237

30603238
var app = App.init(gpa, .{ .default_directory = directory_path });
30613239
defer app.deinit();
@@ -3075,6 +3253,13 @@ test "JavaScript and Zig calls complete over HTTP and WebSocket" {
30753253
const external_window = try app.createWindow(.{
30763254
.content = .{ .external_url = external_url },
30773255
});
3256+
const inline_icon = "<svg>inline icon</svg>";
3257+
try window.setIcon(io, inline_icon, "image/svg+xml");
3258+
try std.testing.expectError(
3259+
error.InvalidIconMimeType,
3260+
window.setIcon(io, "replacement", "image/svg+xml\r\nbad"),
3261+
);
3262+
try second_window.setIconFile(io, file_icon_path);
30783263
var primary_events: IntegrationEventState = .{
30793264
.expected_click = "primary",
30803265
};
@@ -3119,6 +3304,54 @@ test "JavaScript and Zig calls complete over HTTP and WebSocket" {
31193304
&response,
31203305
);
31213306
try std.testing.expect(std.mem.indexOf(u8, bytes, "HTTP/1.1 200 OK") != null);
3307+
try std.testing.expect(std.mem.indexOf(u8, bytes, favicon_link) != null);
3308+
}
3309+
const icons = [_]struct {
3310+
window: Window,
3311+
mime_type: []const u8,
3312+
data: []const u8,
3313+
other_data: []const u8,
3314+
}{
3315+
.{
3316+
.window = window,
3317+
.mime_type = "image/svg+xml",
3318+
.data = inline_icon,
3319+
.other_data = file_icon,
3320+
},
3321+
.{
3322+
.window = second_window,
3323+
.mime_type = "image/png",
3324+
.data = file_icon,
3325+
.other_data = inline_icon,
3326+
},
3327+
};
3328+
for (icons) |icon| {
3329+
var target: [capability_len + 13]u8 = undefined;
3330+
var response: [512]u8 = undefined;
3331+
const bytes = try getTestPath(
3332+
running.inner.address,
3333+
io,
3334+
try std.fmt.bufPrint(&target, "/{s}/favicon.ico", .{
3335+
icon.window.state.capability,
3336+
}),
3337+
icon.data,
3338+
&response,
3339+
);
3340+
var content_type: [64]u8 = undefined;
3341+
try std.testing.expect(std.mem.indexOf(
3342+
u8,
3343+
bytes,
3344+
try std.fmt.bufPrint(
3345+
&content_type,
3346+
"Content-Type: {s}\r\n",
3347+
.{icon.mime_type},
3348+
),
3349+
) != null);
3350+
try std.testing.expect(std.mem.indexOf(
3351+
u8,
3352+
bytes,
3353+
icon.other_data,
3354+
) == null);
31223355
}
31233356
{
31243357
var target: [capability_len + 10]u8 = undefined;

0 commit comments

Comments
 (0)