@@ -7,9 +7,11 @@ const bridge = @embedFile("bridge.js");
77const default_max_pending_evals = 64 ;
88const default_max_pending_replies = 64 ;
99const default_max_pending_events = 64 ;
10+ const max_icon_size = 8 << 20 ;
1011const capability_len = 32 ;
1112const cookie_len = 32 ;
1213const cookie_name = "webui_auth" ;
14+ const favicon_link = "<link rel=\" icon\" href=\" favicon.ico\" >" ;
1315
1416pub 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+
182201fn 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+
350403const 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+
18751993fn 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 \n bad" ),
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 \n bad" ),
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