an experimental irc client
at 6de3d13bd97e746ea173c2d17a94149dcd885c77 748 lines 27 kB view raw
1const std = @import("std"); 2const builtin = @import("builtin"); 3const comlink = @import("comlink.zig"); 4const vaxis = @import("vaxis"); 5const zeit = @import("zeit"); 6const ziglua = @import("ziglua"); 7const Scrollbar = @import("Scrollbar.zig"); 8const main = @import("main.zig"); 9const format = @import("format.zig"); 10 11const irc = comlink.irc; 12const lua = comlink.lua; 13const mem = std.mem; 14const vxfw = vaxis.vxfw; 15 16const assert = std.debug.assert; 17 18const Allocator = std.mem.Allocator; 19const Base64Encoder = std.base64.standard.Encoder; 20const Bind = comlink.Bind; 21const Completer = comlink.Completer; 22const Event = comlink.Event; 23const Lua = ziglua.Lua; 24const TextInput = vaxis.widgets.TextInput; 25const WriteRequest = comlink.WriteRequest; 26 27const log = std.log.scoped(.app); 28 29const State = struct { 30 buffers: struct { 31 count: usize = 0, 32 width: u16 = 16, 33 } = .{}, 34 paste: struct { 35 pasting: bool = false, 36 has_newline: bool = false, 37 38 fn showDialog(self: @This()) bool { 39 return !self.pasting and self.has_newline; 40 } 41 } = .{}, 42}; 43 44pub const App = struct { 45 config: comlink.Config, 46 explicit_join: bool, 47 alloc: std.mem.Allocator, 48 /// System certificate bundle 49 bundle: std.crypto.Certificate.Bundle, 50 /// List of all configured clients 51 clients: std.ArrayList(*irc.Client), 52 /// if we have already called deinit 53 deinited: bool, 54 /// Process environment 55 env: std.process.EnvMap, 56 /// Local timezone 57 tz: zeit.TimeZone, 58 59 state: State, 60 61 completer: ?Completer, 62 63 binds: std.ArrayList(Bind), 64 65 paste_buffer: std.ArrayList(u8), 66 67 lua: *Lua, 68 69 write_queue: comlink.WriteQueue, 70 write_thread: std.Thread, 71 72 view: vxfw.SplitView, 73 buffer_list: vxfw.ListView, 74 unicode: *const vaxis.Unicode, 75 76 title_buf: [128]u8, 77 78 // Only valid during an event handler 79 ctx: ?*vxfw.EventContext, 80 last_height: u16, 81 82 /// Whether the application has focus or not 83 has_focus: bool, 84 85 fg: ?[3]u8, 86 bg: ?[3]u8, 87 yellow: ?[3]u8, 88 89 const default_rhs: vxfw.Text = .{ .text = "TODO: update this text" }; 90 91 /// initialize vaxis, lua state 92 pub fn init(self: *App, gpa: std.mem.Allocator, unicode: *const vaxis.Unicode) !void { 93 self.* = .{ 94 .alloc = gpa, 95 .config = .{}, 96 .state = .{}, 97 .clients = std.ArrayList(*irc.Client).init(gpa), 98 .env = try std.process.getEnvMap(gpa), 99 .binds = try std.ArrayList(Bind).initCapacity(gpa, 16), 100 .paste_buffer = std.ArrayList(u8).init(gpa), 101 .tz = try zeit.local(gpa, null), 102 .lua = undefined, 103 .write_queue = .{}, 104 .write_thread = undefined, 105 .view = .{ 106 .width = self.state.buffers.width, 107 .lhs = self.buffer_list.widget(), 108 .rhs = default_rhs.widget(), 109 }, 110 .explicit_join = false, 111 .bundle = .{}, 112 .deinited = false, 113 .completer = null, 114 .buffer_list = .{ 115 .children = .{ 116 .builder = .{ 117 .userdata = self, 118 .buildFn = App.bufferBuilderFn, 119 }, 120 }, 121 .draw_cursor = false, 122 }, 123 .unicode = unicode, 124 .title_buf = undefined, 125 .ctx = null, 126 .last_height = 0, 127 .has_focus = true, 128 .fg = null, 129 .bg = null, 130 .yellow = null, 131 }; 132 133 self.lua = try Lua.init(self.alloc); 134 self.write_thread = try std.Thread.spawn(.{}, writeLoop, .{ self.alloc, &self.write_queue }); 135 136 try lua.init(self); 137 138 try self.binds.append(.{ 139 .key = .{ .codepoint = 'c', .mods = .{ .ctrl = true } }, 140 .command = .quit, 141 }); 142 try self.binds.append(.{ 143 .key = .{ .codepoint = vaxis.Key.up, .mods = .{ .alt = true } }, 144 .command = .@"prev-channel", 145 }); 146 try self.binds.append(.{ 147 .key = .{ .codepoint = vaxis.Key.down, .mods = .{ .alt = true } }, 148 .command = .@"next-channel", 149 }); 150 try self.binds.append(.{ 151 .key = .{ .codepoint = 'l', .mods = .{ .ctrl = true } }, 152 .command = .redraw, 153 }); 154 155 // Get our system tls certs 156 try self.bundle.rescan(gpa); 157 } 158 159 /// close the application. This closes the TUI, disconnects clients, and cleans 160 /// up all resources 161 pub fn deinit(self: *App) void { 162 if (self.deinited) return; 163 self.deinited = true; 164 // Push a join command to the write thread 165 self.write_queue.push(.join); 166 167 // clean up clients 168 { 169 // Loop first to close connections. This will help us close faster by getting the 170 // threads exited 171 for (self.clients.items) |client| { 172 client.close(); 173 } 174 for (self.clients.items) |client| { 175 client.deinit(); 176 self.alloc.destroy(client); 177 } 178 self.clients.deinit(); 179 } 180 181 self.bundle.deinit(self.alloc); 182 183 if (self.completer) |*completer| completer.deinit(); 184 self.binds.deinit(); 185 self.paste_buffer.deinit(); 186 self.tz.deinit(); 187 188 // Join the write thread 189 self.write_thread.join(); 190 self.env.deinit(); 191 self.lua.deinit(); 192 } 193 194 pub fn widget(self: *App) vxfw.Widget { 195 return .{ 196 .userdata = self, 197 .captureHandler = App.typeErasedCaptureHandler, 198 .eventHandler = App.typeErasedEventHandler, 199 .drawFn = App.typeErasedDrawFn, 200 }; 201 } 202 203 fn typeErasedCaptureHandler(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void { 204 const self: *App = @ptrCast(@alignCast(ptr)); 205 // Rewrite the ctx pointer every frame. We don't actually need to do this with the current 206 // vxfw runtime, because the context pointer is always valid. But for safe keeping, we will 207 // do it this way. 208 // 209 // In general, this is bad practice. But we need to be able to access this from lua 210 // callbacks 211 self.ctx = ctx; 212 switch (event) { 213 .color_scheme => { 214 // On a color scheme event, we request the colors again 215 try ctx.queryColor(.fg); 216 try ctx.queryColor(.bg); 217 try ctx.queryColor(.{ .index = 3 }); 218 }, 219 .color_report => |color| { 220 switch (color.kind) { 221 .fg => self.fg = color.value, 222 .bg => self.bg = color.value, 223 .index => |index| { 224 switch (index) { 225 3 => self.yellow = color.value, 226 else => {}, 227 } 228 }, 229 .cursor => {}, 230 } 231 if (self.fg != null and self.bg != null) { 232 for (self.clients.items) |client| { 233 client.text_field.style.bg = self.blendBg(10); 234 for (client.channels.items) |channel| { 235 channel.text_field.style.bg = self.blendBg(10); 236 } 237 } 238 } 239 ctx.redraw = true; 240 }, 241 .key_press => |key| { 242 if (self.state.paste.pasting) { 243 ctx.consume_event = true; 244 // Always ignore enter key 245 if (key.codepoint == vaxis.Key.enter) return; 246 if (key.text) |text| { 247 try self.paste_buffer.appendSlice(text); 248 } 249 return; 250 } 251 if (key.matches('c', .{ .ctrl = true })) { 252 ctx.quit = true; 253 } 254 for (self.binds.items) |bind| { 255 if (key.matches(bind.key.codepoint, bind.key.mods)) { 256 switch (bind.command) { 257 .quit => ctx.quit = true, 258 .@"next-channel" => self.nextChannel(), 259 .@"prev-channel" => self.prevChannel(), 260 .redraw => try ctx.queueRefresh(), 261 .lua_function => |ref| try lua.execFn(self.lua, ref), 262 else => {}, 263 } 264 return ctx.consumeAndRedraw(); 265 } 266 } 267 }, 268 .paste_start => self.state.paste.pasting = true, 269 .paste_end => { 270 self.state.paste.pasting = false; 271 if (std.mem.indexOfScalar(u8, self.paste_buffer.items, '\n')) |_| { 272 log.debug("paste had line ending", .{}); 273 return; 274 } 275 defer self.paste_buffer.clearRetainingCapacity(); 276 if (self.selectedBuffer()) |buffer| { 277 switch (buffer) { 278 .client => {}, 279 .channel => |channel| { 280 try channel.text_field.insertSliceAtCursor(self.paste_buffer.items); 281 return ctx.consumeAndRedraw(); 282 }, 283 } 284 } 285 }, 286 .focus_out => self.has_focus = false, 287 288 .focus_in => { 289 self.has_focus = true; 290 ctx.redraw = true; 291 }, 292 293 else => {}, 294 } 295 } 296 297 fn typeErasedEventHandler(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void { 298 const self: *App = @ptrCast(@alignCast(ptr)); 299 self.ctx = ctx; 300 switch (event) { 301 .init => { 302 const title = try std.fmt.bufPrint(&self.title_buf, "comlink", .{}); 303 try ctx.setTitle(title); 304 try ctx.tick(8, self.widget()); 305 try ctx.queryColor(.fg); 306 try ctx.queryColor(.bg); 307 try ctx.queryColor(.{ .index = 3 }); 308 if (self.clients.items.len > 0) { 309 try ctx.requestFocus(self.clients.items[0].text_field.widget()); 310 } 311 }, 312 .tick => { 313 for (self.clients.items) |client| { 314 if (client.status.load(.unordered) == .disconnected and 315 client.retry_delay_s == 0) 316 { 317 ctx.redraw = true; 318 try irc.Client.retryTickHandler(client, ctx, .tick); 319 } 320 client.drainFifo(ctx); 321 client.checkTypingStatus(ctx); 322 } 323 try ctx.tick(8, self.widget()); 324 }, 325 else => {}, 326 } 327 } 328 329 fn typeErasedDrawFn(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface { 330 const self: *App = @ptrCast(@alignCast(ptr)); 331 const max = ctx.max.size(); 332 self.last_height = max.height; 333 if (self.selectedBuffer()) |buffer| { 334 switch (buffer) { 335 .client => |client| self.view.rhs = client.view(), 336 .channel => |channel| self.view.rhs = channel.view.widget(), 337 } 338 } else self.view.rhs = default_rhs.widget(); 339 340 var children = std.ArrayList(vxfw.SubSurface).init(ctx.arena); 341 342 // UI is a tree of splits 343 // │ │ │ │ 344 // │ │ │ │ 345 // │ buffers │ buffer content │ members │ 346 // │ │ │ │ 347 // │ │ │ │ 348 // │ │ │ │ 349 // │ │ │ │ 350 351 const sub: vxfw.SubSurface = .{ 352 .origin = .{ .col = 0, .row = 0 }, 353 .surface = try self.view.widget().draw(ctx), 354 }; 355 try children.append(sub); 356 357 for (self.clients.items) |client| { 358 if (client.list_modal.is_shown) { 359 const padding: u16 = 8; 360 const modal_ctx = ctx.withConstraints(ctx.min, .{ 361 .width = max.width -| padding * 2, 362 .height = max.height -| padding, 363 }); 364 const border: vxfw.Border = .{ .child = client.list_modal.widget() }; 365 try children.append(.{ 366 .origin = .{ .row = padding / 2, .col = padding }, 367 .surface = try border.draw(modal_ctx), 368 }); 369 break; 370 } 371 } 372 373 return .{ 374 .size = ctx.max.size(), 375 .widget = self.widget(), 376 .buffer = &.{}, 377 .children = children.items, 378 }; 379 } 380 381 fn bufferBuilderFn(ptr: *const anyopaque, idx: usize, cursor: usize) ?vxfw.Widget { 382 const self: *const App = @ptrCast(@alignCast(ptr)); 383 var i: usize = 0; 384 for (self.clients.items) |client| { 385 if (i == idx) return client.nameWidget(i == cursor); 386 i += 1; 387 for (client.channels.items) |channel| { 388 if (i == idx) return channel.nameWidget(i == cursor); 389 i += 1; 390 } 391 } 392 return null; 393 } 394 395 pub fn connect(self: *App, cfg: irc.Client.Config) !void { 396 const client = try self.alloc.create(irc.Client); 397 try client.init(self.alloc, self, &self.write_queue, cfg); 398 try self.clients.append(client); 399 } 400 401 pub fn nextChannel(self: *App) void { 402 if (self.ctx) |ctx| { 403 self.buffer_list.nextItem(ctx); 404 if (self.selectedBuffer()) |buffer| { 405 switch (buffer) { 406 .client => |client| { 407 ctx.requestFocus(client.text_field.widget()) catch {}; 408 }, 409 .channel => |channel| { 410 ctx.requestFocus(channel.text_field.widget()) catch {}; 411 }, 412 } 413 } 414 } 415 } 416 417 pub fn prevChannel(self: *App) void { 418 if (self.ctx) |ctx| { 419 self.buffer_list.prevItem(ctx); 420 if (self.selectedBuffer()) |buffer| { 421 switch (buffer) { 422 .client => |client| { 423 ctx.requestFocus(client.text_field.widget()) catch {}; 424 }, 425 .channel => |channel| { 426 ctx.requestFocus(channel.text_field.widget()) catch {}; 427 }, 428 } 429 } 430 } 431 } 432 433 pub fn selectChannelName(self: *App, cl: *irc.Client, name: []const u8) void { 434 var i: usize = 0; 435 for (self.clients.items) |client| { 436 i += 1; 437 for (client.channels.items) |channel| { 438 if (cl == client) { 439 if (std.mem.eql(u8, name, channel.name)) { 440 self.selectBuffer(.{ .channel = channel }); 441 } 442 } 443 i += 1; 444 } 445 } 446 } 447 448 /// Blend fg and bg, otherwise return index 8. amt will be clamped to [0,100]. amt will be 449 /// interpreted as percentage of fg to blend into bg 450 pub fn blendBg(self: *App, amt: u8) vaxis.Color { 451 const bg = self.bg orelse return .{ .index = 8 }; 452 const fg = self.fg orelse return .{ .index = 8 }; 453 // Clamp to (0,100) 454 if (amt == 0) return .{ .rgb = bg }; 455 if (amt >= 100) return .{ .rgb = fg }; 456 457 const fg_r: u16 = std.math.mulWide(u8, fg[0], amt); 458 const fg_g: u16 = std.math.mulWide(u8, fg[1], amt); 459 const fg_b: u16 = std.math.mulWide(u8, fg[2], amt); 460 461 const bg_multiplier: u8 = 100 - amt; 462 const bg_r: u16 = std.math.mulWide(u8, bg[0], bg_multiplier); 463 const bg_g: u16 = std.math.mulWide(u8, bg[1], bg_multiplier); 464 const bg_b: u16 = std.math.mulWide(u8, bg[2], bg_multiplier); 465 466 return .{ 467 .rgb = .{ 468 @intCast((fg_r + bg_r) / 100), 469 @intCast((fg_g + bg_g) / 100), 470 @intCast((fg_b + bg_b) / 100), 471 }, 472 }; 473 } 474 475 /// Blend fg and bg, otherwise return index 8. amt will be clamped to [0,100]. amt will be 476 /// interpreted as percentage of fg to blend into bg 477 pub fn blendYellow(self: *App, amt: u8) vaxis.Color { 478 const bg = self.bg orelse return .{ .index = 3 }; 479 const yellow = self.yellow orelse return .{ .index = 3 }; 480 // Clamp to (0,100) 481 if (amt == 0) return .{ .rgb = bg }; 482 if (amt >= 100) return .{ .rgb = yellow }; 483 484 const yellow_r: u16 = std.math.mulWide(u8, yellow[0], amt); 485 const yellow_g: u16 = std.math.mulWide(u8, yellow[1], amt); 486 const yellow_b: u16 = std.math.mulWide(u8, yellow[2], amt); 487 488 const bg_multiplier: u8 = 100 - amt; 489 const bg_r: u16 = std.math.mulWide(u8, bg[0], bg_multiplier); 490 const bg_g: u16 = std.math.mulWide(u8, bg[1], bg_multiplier); 491 const bg_b: u16 = std.math.mulWide(u8, bg[2], bg_multiplier); 492 493 return .{ 494 .rgb = .{ 495 @intCast((yellow_r + bg_r) / 100), 496 @intCast((yellow_g + bg_g) / 100), 497 @intCast((yellow_b + bg_b) / 100), 498 }, 499 }; 500 } 501 502 /// handle a command 503 pub fn handleCommand(self: *App, buffer: irc.Buffer, cmd: []const u8) !void { 504 const lua_state = self.lua; 505 const command: comlink.Command = blk: { 506 const start: u1 = if (cmd[0] == '/') 1 else 0; 507 const end = mem.indexOfScalar(u8, cmd, ' ') orelse cmd.len; 508 if (comlink.Command.fromString(cmd[start..end])) |internal| 509 break :blk internal; 510 if (comlink.Command.user_commands.get(cmd[start..end])) |ref| { 511 const str = if (end == cmd.len) "" else std.mem.trim(u8, cmd[end..], " "); 512 return lua.execUserCommand(lua_state, str, ref); 513 } 514 return error.UnknownCommand; 515 }; 516 var buf: [1024]u8 = undefined; 517 const client: *irc.Client = switch (buffer) { 518 .client => |client| client, 519 .channel => |channel| channel.client, 520 }; 521 const channel: ?*irc.Channel = switch (buffer) { 522 .client => null, 523 .channel => |channel| channel, 524 }; 525 switch (command) { 526 .quote => { 527 const start = mem.indexOfScalar(u8, cmd, ' ') orelse return error.InvalidCommand; 528 const msg = try std.fmt.bufPrint( 529 &buf, 530 "{s}\r\n", 531 .{cmd[start + 1 ..]}, 532 ); 533 return client.queueWrite(msg); 534 }, 535 .join => { 536 const start = std.mem.indexOfScalar(u8, cmd, ' ') orelse return error.InvalidCommand; 537 const chan_name = cmd[start + 1 ..]; 538 for (client.channels.items) |chan| { 539 if (std.mem.eql(u8, chan.name, chan_name)) { 540 client.app.selectBuffer(.{ .channel = chan }); 541 return; 542 } 543 } 544 const msg = try std.fmt.bufPrint( 545 &buf, 546 "JOIN {s}\r\n", 547 .{ 548 chan_name, 549 }, 550 ); 551 552 // Check 553 // Ensure buffer exists 554 self.explicit_join = true; 555 return client.queueWrite(msg); 556 }, 557 .list => { 558 client.list_modal.expecting_response = true; 559 return client.queueWrite("LIST\r\n"); 560 }, 561 .me => { 562 if (channel == null) return error.InvalidCommand; 563 const msg = try std.fmt.bufPrint( 564 &buf, 565 "PRIVMSG {s} :\x01ACTION {s}\x01\r\n", 566 .{ 567 channel.?.name, 568 cmd[4..], 569 }, 570 ); 571 return client.queueWrite(msg); 572 }, 573 .msg => { 574 //syntax: /msg <nick> <msg> 575 const s = std.mem.indexOfScalar(u8, cmd, ' ') orelse return error.InvalidCommand; 576 const e = std.mem.indexOfScalarPos(u8, cmd, s + 1, ' ') orelse return error.InvalidCommand; 577 const msg = try std.fmt.bufPrint( 578 &buf, 579 "PRIVMSG {s} :{s}\r\n", 580 .{ 581 cmd[s + 1 .. e], 582 cmd[e + 1 ..], 583 }, 584 ); 585 return client.queueWrite(msg); 586 }, 587 .query => { 588 const s = std.mem.indexOfScalar(u8, cmd, ' ') orelse return error.InvalidCommand; 589 const e = std.mem.indexOfScalarPos(u8, cmd, s + 1, ' ') orelse cmd.len; 590 if (cmd[s + 1] == '#') return error.InvalidCommand; 591 592 const ch = try client.getOrCreateChannel(cmd[s + 1 .. e]); 593 try client.requestHistory(.after, ch); 594 self.selectChannelName(client, ch.name); 595 //handle sending the message 596 if (cmd.len - e > 1) { 597 const msg = try std.fmt.bufPrint( 598 &buf, 599 "PRIVMSG {s} :{s}\r\n", 600 .{ 601 cmd[s + 1 .. e], 602 cmd[e + 1 ..], 603 }, 604 ); 605 return client.queueWrite(msg); 606 } 607 }, 608 .names => { 609 if (channel == null) return error.InvalidCommand; 610 const msg = try std.fmt.bufPrint(&buf, "NAMES {s}\r\n", .{channel.?.name}); 611 return client.queueWrite(msg); 612 }, 613 .@"next-channel" => self.nextChannel(), 614 .@"prev-channel" => self.prevChannel(), 615 .quit => { 616 if (self.ctx) |ctx| ctx.quit = true; 617 }, 618 .who => { 619 if (channel == null) return error.InvalidCommand; 620 const msg = try std.fmt.bufPrint( 621 &buf, 622 "WHO {s}\r\n", 623 .{ 624 channel.?.name, 625 }, 626 ); 627 return client.queueWrite(msg); 628 }, 629 .part, .close => { 630 if (channel == null) return error.InvalidCommand; 631 var it = std.mem.tokenizeScalar(u8, cmd, ' '); 632 633 // Skip command 634 _ = it.next(); 635 const target = it.next() orelse channel.?.name; 636 637 if (target[0] != '#') { 638 for (client.channels.items, 0..) |search, i| { 639 if (!mem.eql(u8, search.name, target)) continue; 640 client.app.prevChannel(); 641 var chan = client.channels.orderedRemove(i); 642 chan.deinit(self.alloc); 643 self.alloc.destroy(chan); 644 break; 645 } 646 } else { 647 const msg = try std.fmt.bufPrint( 648 &buf, 649 "PART {s}\r\n", 650 .{ 651 target, 652 }, 653 ); 654 return client.queueWrite(msg); 655 } 656 }, 657 .redraw => {}, 658 // .redraw => self.vx.queueRefresh(), 659 .version => { 660 if (channel == null) return error.InvalidCommand; 661 const msg = try std.fmt.bufPrint( 662 &buf, 663 "NOTICE {s} :\x01VERSION comlink {s}\x01\r\n", 664 .{ 665 channel.?.name, 666 main.version, 667 }, 668 ); 669 return client.queueWrite(msg); 670 }, 671 .lua_function => {}, // we don't handle these from the text-input 672 } 673 } 674 675 pub fn selectedBuffer(self: *App) ?irc.Buffer { 676 var i: usize = 0; 677 for (self.clients.items) |client| { 678 if (i == self.buffer_list.cursor) return .{ .client = client }; 679 i += 1; 680 for (client.channels.items) |channel| { 681 if (i == self.buffer_list.cursor) return .{ .channel = channel }; 682 i += 1; 683 } 684 } 685 return null; 686 } 687 688 pub fn selectBuffer(self: *App, buffer: irc.Buffer) void { 689 var i: u32 = 0; 690 switch (buffer) { 691 .client => |target| { 692 for (self.clients.items) |client| { 693 if (client == target) { 694 if (self.ctx) |ctx| { 695 ctx.requestFocus(client.text_field.widget()) catch {}; 696 } 697 self.buffer_list.cursor = i; 698 self.buffer_list.ensureScroll(); 699 return; 700 } 701 i += 1; 702 for (client.channels.items) |_| i += 1; 703 } 704 }, 705 .channel => |target| { 706 for (self.clients.items) |client| { 707 i += 1; 708 for (client.channels.items) |channel| { 709 if (channel == target) { 710 self.buffer_list.cursor = i; 711 self.buffer_list.ensureScroll(); 712 channel.doSelect(); 713 if (self.ctx) |ctx| { 714 ctx.requestFocus(channel.text_field.widget()) catch {}; 715 } 716 return; 717 } 718 i += 1; 719 } 720 } 721 }, 722 } 723 } 724}; 725 726/// this loop is run in a separate thread and handles writes to all clients. 727/// Message content is deallocated when the write request is completed 728fn writeLoop(alloc: std.mem.Allocator, queue: *comlink.WriteQueue) !void { 729 log.debug("starting write thread", .{}); 730 while (true) { 731 const req = queue.pop(); 732 switch (req) { 733 .write => |w| { 734 try w.client.write(w.msg); 735 alloc.free(w.msg); 736 }, 737 .join => { 738 while (queue.tryPop()) |r| { 739 switch (r) { 740 .write => |w| alloc.free(w.msg), 741 else => {}, 742 } 743 } 744 return; 745 }, 746 } 747 } 748}