// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! The application: the configuration, the platform, the GPU, the fonts, //! and the windows, and the loop that runs them. const std = @import("std"); const builtin = @import("builtin"); const Allocator = std.mem.Allocator; const Io = std.Io; const vt = @import("ghostty-vt"); const serial = @import("serial"); const build_options = @import("build_options"); const Config = @import("config/Config.zig"); const load = @import("config/load.zig"); const plat_mod = @import("aerie").platform; const Device = @import("aerie").gpu.Device; const Grid = @import("aerie").text.Family; const Fonts = @import("aerie").text.Fonts; const TextShaper = @import("aerie").text.Shaper; const TitleBar = @import("decor/TitleBar.zig"); const Glyphs = @import("aerie").text.Glyphs; const bindings_mod = @import("input/bindings.zig"); const Backend = @import("backend/Backend.zig"); const Window = @import("Window.zig"); const StatusWindow = @import("StatusWindow.zig"); const l10n = @import("l10n.zig"); const Tab = @import("Tab.zig"); const Pane = @import("Pane.zig"); const desktop = @import("desktop.zig"); const Activation = desktop.Activation; const req = @import("request.zig"); const search = @import("shell_search.zig"); const location = @import("location.zig"); const jump_list = @import("jump_list.zig"); const ProfileSelector = @import("ProfileSelector.zig"); const settings_mod = @import("settings.zig"); const bell_mod = @import("BellSounds.zig"); const BellSounds = bell_mod.BellSounds; const Portal = desktop.Portal; const Dnd = @import("Dnd.zig"); const ClipboardTransfers = @import("ClipboardTransfers.zig"); const links = @import("links.zig"); const Scopes = desktop.Scopes; const startup = @import("startup.zig"); const host = @import("os/host.zig"); const Session = @import("termio/Session.zig"); const Wake = @import("os/Wake.zig"); const prog_status = @import("status.zig"); const PaneConfigs = @import("PaneConfigs.zig"); const foreground = @import("os/foreground.zig"); const App = @This(); const log = std.log.scoped(.app); gpa: Allocator, io: Io, env: *const std.process.Environ.Map, loaded: load.Loaded, theme: Config.Theme, bindings: []bindings_mod.Binding, /// The configuration as each pane's foreground program sees it, for one /// that reads `ctx.foreground`. pane_configs: PaneConfigs, /// When every pane's foreground program is next looked for. foreground_poll_at: ?Io.Timestamp = null, /// The configuration's global key bindings, caught by the compositor or /// the desktop portal wherever the focus is; see `registerGlobals`. globals: []bindings_mod.Global = &.{}, /// Global hotkeys waiting on an activation token to act with, by cookie. hotkey_waiting: std.AutoHashMapUnmanaged(u32, Config.Action) = .empty, platform: *plat_mod.Platform, device: *Device, /// What `grid` was resolved among, and keeps its faces. fonts: Fonts, grid: Grid, glyphs: Glyphs, /// The title bar's family, among `fonts`; see `titleFamily`. title_family: ?Grid = null, /// Its size in points, as the desktop named it. title_points: f32 = 0, title_shaper: TextShaper, /// Scratch for the title bar, kept between frames. title_shaped: std.ArrayList(TextShaper.Glyph) = .empty, /// The font size in points, which the font-size bindings change. font_points: f32, /// The scale the grid is made for: the last one a window was given. scale: f32 = 1, windows: std.ArrayList(*Window) = .empty, /// The window most recently opened or focused: where a new tab asked for /// from outside goes. last_window: ?*Window = null, /// The last pane ID handed out. last_pane_id: u64 = 0, /// A hash of every pane and its title when they were last published to /// the shell search, to publish again only on a change. published: u64 = 0, /// This harrier keeps the jump list: on Windows, the primary that is not /// elevated. An elevated one, sharing harrier's taskbar button, leaves it /// alone. jump_list_keeper: bool = false, /// A hash of what the jump list was last given, its panes and profiles. jump_list_digest: u64 = 0, /// When it was last replaced, and when it is next to be, if a change came /// too soon after. jump_list_at: ?Io.Timestamp = null, jump_list_due: ?Io.Timestamp = null, /// A pane's progress or status changed this turn of the loop, which the /// status window shows. status_changed: bool = false, /// The status window, while it is open. status_window: ?*StatusWindow = null, /// A tab dragged out of its window and not yet dropped. tab_drag: ?TabDrag = null, /// What the loop waits on, kept to save allocating it every turn: to read, /// and to write. fds: std.ArrayList(plat_mod.Handle) = .empty, fds_out: std.ArrayList(plat_mod.Handle) = .empty, /// Drag and drop for the programs in the panes. dnd: Dnd, /// Programs' clipboard reads, under way. clipboard: ClipboardTransfers, /// A cgroup for each program; see `Config.Cgroup`. scopes: Scopes, /// That a program could not have one has been said. scopes_warned: bool = false, /// Links clicked, waiting for an activation token to open them with, by /// the cookie the token was asked for with. Each is owned. opening: std.AutoHashMapUnmanaged(u32, []u8) = .empty, last_cookie: u32 = 0, /// This machine, for kitty's drag and drop protocol: a program that says /// it is on the same one is given dropped files' paths rather than their /// contents. machine_id: ?Session.dnd.MachineId = null, /// On Windows, the one wakeup every session signals, waited on once for all /// of them: a wait there takes at most 64 handles. Null elsewhere, where /// each session has its own. session_notify: ?Wake = null, /// The name on the session bus, when this harrier owns it. server: ?*Activation.Server = null, /// The desktop's settings, watched; null without a portal. portal: ?*Portal = null, /// The settings the configuration was last evaluated with, and which of /// the portal's changes that was. settings: settings_mod.Settings, settings_generation: u64 = 0, /// The configuration file named with `--config`, read again on a reload; /// null for the default one. config_path: ?[]const u8 = null, /// `--no-config`: the built-in defaults, on every reload too. no_config: bool = false, /// Running as the systemd user service: started with no window, and kept /// running a while after the last one closes. service: bool = false, /// The bell sounds the configuration names as files, and the connection to /// the sound output they play through. bell_sounds: BellSounds, pub const Options = struct { validation: bool = false, service: bool = false, /// See `App.config_path`. config_path: ?[]const u8 = null, /// See `App.no_config`. no_config: bool = false, /// See `App.portal`; owned by the app from here on. portal: ?*Portal = null, /// What `loaded` was evaluated with, and as of which change; owned by /// the app from here on. settings: ?settings_mod.Settings = null, settings_generation: u64 = 0, /// The process's environment, which on Linux says where PipeWire is, /// for the bell sounds. environ: ?std.process.Environ = null, }; /// Takes ownership of `loaded`, `options.portal` and `options.settings`, /// also on error. pub fn init(gpa: Allocator, io: Io, env: *const std.process.Environ.Map, loaded: load.Loaded, options: Options) !*App { var owned = loaded; errdefer owned.deinit(); errdefer if (options.portal) |p| p.destroy(); var owned_settings = options.settings orelse settings_mod.Settings.init(gpa); errdefer owned_settings.deinit(); const app = try gpa.create(App); errdefer gpa.destroy(app); const cfg = loaded.config(); var problem: []const u8 = ""; const theme = cfg.theme(&problem) catch { log.err("{s}: not a color", .{problem}); return error.InvalidConfig; }; try l10n.init(gpa, env, cfg.language); errdefer l10n.deinit(); // The fonts, which take the longest of anything here and need nothing // else, on a thread of their own while the compositor and the GPU are // got ready. var fonts: FontsInit = .{ .gpa = gpa, .io = io, .env = env, .family = cfg.font.family, .size_px = pixels(cfg.font.size, 1) }; const fonts_thread = std.Thread.spawn(.{}, FontsInit.run, .{&fonts}) catch null; // Waited for on every way out, so that it never outlives `fonts`. var fonts_waited = false; defer if (!fonts_waited) { if (fonts_thread) |t| t.join(); if (fonts.result) |*g| g.deinit() else |_| {} }; var menu: [window_menu.len][]const u8 = undefined; for (window_menu, &menu) |item, *label| label.* = l10n.text(item[0]); const plat = try plat_mod.Platform.init(gpa, io, env, .{ .app_id = req.app_id, .menu = &menu }); errdefer plat.deinit(); startup.mark("window system"); const main_device = plat.mainDevice(); const device = try Device.init(gpa, .{ .application_name = "harrier", .main_device = main_device, .validation = options.validation, .name = cfg.gpu }); errdefer device.deinit(); startup.mark("vulkan device"); if (fonts_thread) |t| t.join() else fonts.run(); fonts_waited = true; var grid_fonts = try fonts.result; errdefer grid_fonts.deinit(); startup.mark("fonts"); var glyphs = try Glyphs.init(gpa); errdefer glyphs.deinit(); startup.mark("glyphs"); var bell_sounds: BellSounds = try .init(gpa, io, options.environ); errdefer bell_sounds.deinit(); bell_sounds.apply(cfg, loaded.path, bell_mod.homeDir(env)); startup.mark("bell sounds"); app.* = .{ .gpa = gpa, .io = io, .env = env, .loaded = loaded, .theme = theme, .bindings = try bindings_mod.load(gpa, cfg.keybind), .pane_configs = .init(gpa), .globals = try bindings_mod.globals(gpa, cfg.keybind), .service = options.service, .platform = plat, .device = device, .fonts = grid_fonts.fonts, .grid = grid_fonts.grid, .glyphs = glyphs, .title_shaper = try .init(gpa, .{}), .portal = options.portal, .settings = owned_settings, .settings_generation = options.settings_generation, .config_path = options.config_path, .no_config = options.no_config, .font_points = cfg.font.size, .dnd = .{ .app = app }, .clipboard = .{ .app = app }, .scopes = .init(gpa, io, env), .machine_id = machineId(), .session_notify = if (builtin.os.tag == .windows) try Wake.init() else null, .bell_sounds = bell_sounds, }; if (builtin.os.tag == .windows) plat.setModalTurn(.{ .context = app, .run = modalTurn }); return app; } /// The fonts fontconfig knows, scanned, and the grid's family resolved /// among them: made afresh when the family changes, so that a font /// installed since is found. const GridFonts = struct { fonts: Fonts, grid: Grid, const InitError = Fonts.InitError || Grid.InitError; fn init(gpa: Allocator, io: Io, env: *const std.process.Environ.Map, family: []const u8, size_px: f32) InitError!GridFonts { var fonts: Fonts = try .init(gpa, io, env, .{ .windows_conf_dir = "harrier" }); errdefer fonts.deinit(); startup.mark("fonts: scan"); // The family keeps the loader, which the scan's value only points // to, so the two can be moved apart. const grid: Grid = try .init(gpa, &fonts, family, size_px); startup.mark("fonts: the regular style resolved"); return .{ .fonts = fonts, .grid = grid }; } fn deinit(g: *GridFonts) void { g.grid.deinit(); g.fonts.deinit(); } }; /// `GridFonts.init`, on a thread of its own. const FontsInit = struct { gpa: Allocator, io: Io, env: *const std.process.Environ.Map, family: []const u8, size_px: f32, result: GridFonts.InitError!GridFonts = error.NoFont, fn run(f: *FontsInit) void { f.result = GridFonts.init(f.gpa, f.io, f.env, f.family, f.size_px); } }; /// This machine's identity for kitty's drag and drop protocol, from the /// OS's machine ID; none where there is none, and then every program is /// taken to be on another machine. fn machineId() ?Session.dnd.MachineId { var buf: [128]u8 = undefined; return Session.dnd.machineId(host.machineId(&buf) orelse return null); } pub fn deinit(app: *App) void { defer l10n.deinit(); if (app.status_window) |sw| sw.destroy(); for (app.windows.items) |w| w.destroy(); app.windows.deinit(app.gpa); // After the windows, whose panes hold its entries. app.pane_configs.deinit(); app.dnd.deinit(); app.clipboard.deinit(); // After the windows, whose sessions signal it. if (app.session_notify) |*n| n.deinit(); app.scopes.deinit(); var it = app.opening.valueIterator(); while (it.next()) |u| app.gpa.free(u.*); app.opening.deinit(app.gpa); app.fds.deinit(app.gpa); app.fds_out.deinit(app.gpa); // Windows keeps the jump list after harrier exits: the profiles stay, // and the panes, all gone, go. if (builtin.os.tag == .windows and app.jump_list_keeper) app.commitJumpList(false); if (app.server) |s| s.destroy(); if (app.portal) |p| p.destroy(); app.settings.deinit(); app.bell_sounds.deinit(); app.glyphs.deinit(); app.dropTitleFamily(); app.title_shaper.deinit(); app.title_shaped.deinit(app.gpa); app.grid.deinit(); app.fonts.deinit(); app.device.deinit(); app.platform.deinit(); app.gpa.free(app.bindings); app.gpa.free(app.globals); app.hotkey_waiting.deinit(app.gpa); app.loaded.deinit(); app.gpa.destroy(app); } pub fn config(app: *const App) *const Config { return app.loaded.config(); } /// The configuration as `p` sees it: evaluated again with its foreground /// program, for a configuration that reads `ctx.foreground`, else the /// app's. Only `Config.pane_fields` are to be read from it. pub fn configFor(app: *const App, p: *const Pane) *const Config { return if (p.pane_config) |e| e.config() else app.config(); } /// The key bindings in `p`, but the global ones. pub fn bindingsFor(app: *const App, p: *const Pane) []const bindings_mod.Binding { return if (p.pane_config) |e| e.bindings else app.bindings; } /// Whether panes' foreground programs are looked for at all: only for a /// configuration that can say something different for one. pub fn watchesForeground(app: *const App) bool { return foreground.supported and app.loaded.readsForeground(); } /// Looks at what `p`'s terminal belongs to, and if that is another /// program than before, gives the pane the configuration as that program /// sees it. Without `thorough` only the process group is compared, which /// is one system call; with it, the group's leader is read too, to see an /// `exec` or a `setuid` within the group. True if it changed. pub fn checkForeground(app: *App, p: *Pane, thorough: bool) bool { // Windows' pseudoconsole has no foreground process group. if (comptime !foreground.supported) return false; if (!app.watchesForeground()) return false; const master = switch (p.session.backend) { .pty => |*pty| pty.handle(), else => return false, }; if (p.exited) return false; // None: the program has gone, or is going. What it last was stays. const pgrp = foreground.group(master) orelse return false; if (!thorough) if (p.fg_identity) |id| if (id.pgrp == pgrp) return false; // A leader that has gone, its group living on, stays what it was. const id = foreground.identity(pgrp) orelse return false; if (p.fg_identity) |old| if (old.eql(&id)) return false; p.fg_identity = id; const fg = (foreground.describe(app.gpa, &id) catch return false) orelse return false; if (p.foreground) |old| if (foreground.eql(old, fg)) { foreground.free(app.gpa, fg); return false; }; if (p.foreground) |old| foreground.free(app.gpa, old); p.foreground = fg; app.resolvePaneConfig(p); return true; } /// Gives `p` the entry for its foreground program, or none, and its /// terminal what that says. fn resolvePaneConfig(app: *App, p: *Pane) void { const was = p.pane_config; p.pane_config = if (p.foreground) |fg| if (app.loaded.readsForeground()) app.pane_configs.acquire(&app.loaded, fg) else null else null; if (was) |e| app.pane_configs.release(e); if (p.pane_config == was) return; if (p.pane_config) |e| app.bell_sounds.also(e.config().bell.sound, app.loaded.path, bell_mod.homeDir(app.env)); app.applyTerminal(p); } /// Whether any pane's cursor blinks: the app's configuration's, or a pane's /// own for its foreground program. fn anyBlinks(app: *const App) bool { if (app.config().cursor.blink) return true; for (app.pane_configs.entries.items) |e| if (e.refs > 0 and e.config().cursor.blink) return true; return false; } /// Every pane's foreground program looked for again, thoroughly. fn pollForeground(app: *App) void { for (app.windows.items) |w| { var changed = false; for (w.tabs.items) |t| { var buf: [Tab.max_panes]*Pane = undefined; for (t.panes(&buf)) |p| if (app.checkForeground(p, true)) { changed = true; }; } if (changed) w.dirty = true; } } /// Gives `p`'s terminal the configuration's colors and cursor as its /// defaults: what it shows until its program sets its own, and goes back /// to when the program resets them. Colors the program set stay set. pub fn applyTerminal(app: *App, p: *Pane) void { const cfg = app.configFor(p); const theme = app.themeFor(p); p.session.lock(); defer p.session.unlock(); const t = &p.session.terminal; t.colors.foreground.default = theme.foreground; t.colors.background.default = theme.background; t.colors.cursor.default = theme.cursor; t.colors.palette.changeDefault(t.gpa(), theme.palette) catch |err| log.warn("the palette: {t}", .{err}); t.setDefaultCursorStyle(switch (cfg.cursor.style) { .block => .block, .bar => .bar, .underline => .underline, }); t.setDefaultCursorBlink(cfg.cursor.blink); p.session.setDragAndDrop(cfg.drag_and_drop); t.setKittyGraphicsSizeLimit(t.gpa(), if (cfg.images.enabled) cfg.images.storage_bytes else 0); // A file named by a program on another machine would be one of ours. const local = p.session.backend == .pty; t.setKittyGraphicsLoadingLimits(if (cfg.images.files and local) .allWithTempDir(app.env.get("TMPDIR") orelse app.env.get("TEMP") orelse "/tmp") else .direct); // Every row, drawn again in the new colors. t.flags.dirty.palette = true; p.rows.stale = true; } /// `p`'s colors: the configuration's, with those of the profile it /// was opened from over them. pub fn themeFor(app: *App, p: *const Pane) Config.Theme { const base = if (p.pane_config) |pc| pc.theme else app.theme; const e = app.config().profile(p.profile orelse return base) orelse return base; var diag: []const u8 = ""; return Config.overlay(base, e.colors, &diag) catch { log.warn("profile \"{s}\": {s}: not a color", .{ e.name, diag }); return base; }; } /// Opens the profile `e`: in a window of its own, or a tab of `w`, as /// it says, or the other when `flip`. pub fn launchProfile(app: *App, w: *Window, e: *const Config.Profile, flip: bool) void { app.launchProfileOr(w, e, flip) catch |err| log.err("profile \"{s}\": {t}", .{ e.name, err }); } fn launchProfileOr(app: *App, w: *Window, e: *const Config.Profile, flip: bool) !void { if (builtin.os.tag == .windows and e.elevate) { const elevation = @import("windows/elevation.zig"); if (!elevation.elevated()) return app.launchElevated(e.name, (e.open == .tab) != flip, e.cwd); } else if (e.elevate) { log.warn("profile \"{s}\": elevate is for Windows; opening it as it is", .{e.name}); } var r: req.Request = .{ .hold = .unsuccessful }; defer r.deinit(app.gpa); r.profile = try app.gpa.dupe(u8, e.name); r.title = try app.gpa.dupe(u8, e.name); if (e.cwd) |c| r.cwd = try app.gpa.dupe(u8, c); if ((e.open == .tab) != flip) try w.newTab(&r) else try app.newWindow(&r); } /// Opens the profile `name` as administrator: harrier again, elevated, /// which Windows asks the user about first, opening it in a window /// of its own, or in a tab of the elevated harrier's last window. It starts /// in `cwd`, or where this harrier would start it. fn launchElevated(app: *App, name: []const u8, tab: bool, cwd: ?[]const u8) !void { const elevation = @import("windows/elevation.zig"); var arena_state: std.heap.ArenaAllocator = .init(app.gpa); defer arena_state.deinit(); const arena = arena_state.allocator(); var args: std.ArrayList([]const u8) = .empty; try args.appendSlice(arena, &.{ "--profile", name }); if (tab) try args.append(arena, "--tab"); const dir = cwd orelse @import("main.zig").launchDir(app.io, arena); if (dir) |d| try args.appendSlice(arena, &.{ "--working-directory", d }); elevation.relaunch(app.gpa, args.items) catch |err| switch (err) { error.Cancelled => log.info("profile \"{s}\": not run as administrator, as the user said", .{name}), else => return err, }; } /// Makes `r`, a tab or split being opened from `from`, like it: the same /// profile, its program run again — except a serial port's, which /// cannot be opened twice, so the shell runs with only the profile's colors. pub fn likeProfile(app: *App, r: *req.Request, from: *const Pane) Allocator.Error!void { const name = from.profile orelse return; r.profile = try app.gpa.dupe(u8, name); const e = app.config().profile(name) orelse return; r.profile_program = from.profile_program and e.serial == null; if (!r.profile_program) return; // An unreachable host says why, as it did the first time. r.hold = .unsuccessful; r.title = try app.gpa.dupe(u8, e.name); } /// Reads and evaluates the configuration again — on a change to the /// desktop's settings, or when asked — and applies it to everything open. /// A configuration with a mistake in it is reported and the one in use /// kept. pub fn reloadConfig(app: *App) void { var problem: load.Problem = .{}; const loaded = load.load(app.gpa, app.io, app.env, app.config_path, .{ .settings = &app.settings, .no_file = app.no_config }, &problem) catch |err| switch (err) { error.OutOfMemory => { log.err("reloading the configuration: out of memory", .{}); return; }, else => { log.err("reloading the configuration: {f}", .{&problem}); return; }, }; app.applyConfig(loaded); } fn applyConfig(app: *App, loaded: load.Loaded) void { var new = loaded; const cfg = new.config(); var diag: []const u8 = ""; const theme = cfg.theme(&diag) catch { log.err("reloading the configuration: {s}: not a color", .{diag}); new.deinit(); return; }; const new_bindings = bindings_mod.load(app.gpa, cfg.keybind) catch { new.deinit(); return; }; const old = app.config(); // Another language: everything harrier says is said again in it. const language_changed = if (old.language) |was| (if (cfg.language) |is| !std.mem.eql(u8, was, is) else true) else cfg.language != null; if (language_changed) { l10n.init(app.gpa, app.env, cfg.language) catch |err| log.err("changing language: {t}", .{err}); app.status_changed = true; if (app.status_window) |sw| sw.pw.setTitle(l10n.text("status-window-title")); if (builtin.os.tag == .windows) { var menu: [window_menu.len][]const u8 = undefined; for (window_menu, &menu) |item, *label| label.* = l10n.text(item[0]); app.platform.setMenu(&menu) catch |err| log.err("changing the window menu's language: {t}", .{err}); } } const family_changed = !std.mem.eql(u8, old.font.family, cfg.font.family); const size_changed = old.font.size != cfg.font.size; const shaping_changed = family_changed or old.font.ligatures != cfg.font.ligatures or old.font.builtin_glyphs != cfg.font.builtin_glyphs or !sameStrings(old.font.features, cfg.font.features); // A new family is a new grid, made before anything is given up so that // a family that cannot be found leaves the old one in use. var new_grid: ?GridFonts = null; if (family_changed) new_grid = GridFonts.init(app.gpa, app.io, app.env, cfg.font.family, pixels(cfg.font.size, app.scale)) catch |err| blk: { log.err("reloading the configuration: the font \"{s}\": {t}", .{ cfg.font.family, err }); break :blk null; }; // And a shaper for the title bar, which would know the old fonts' faces. var new_title_shaper: ?TextShaper = null; if (new_grid) |*g| new_title_shaper = TextShaper.init(app.gpa, .{}) catch |err| blk: { log.err("reloading the configuration: the title bar's shaper: {t}", .{err}); g.deinit(); new_grid = null; break :blk null; }; // Every pane's evaluation goes with the configuration it was made from. for (app.windows.items) |w| for (w.tabs.items) |t| { var buf: [Tab.max_panes]*Pane = undefined; for (t.panes(&buf)) |p| if (p.pane_config) |e| { app.pane_configs.release(e); p.pane_config = null; }; }; app.pane_configs.clear(); var old_loaded = app.loaded; app.loaded = new; app.gpa.free(app.bindings); app.bindings = new_bindings; app.theme = theme; // Bound again only if they changed, so that a desktop that asks the // user about them does not ask again for nothing. if (bindings_mod.globals(app.gpa, cfg.keybind)) |g| { const same = sameGlobals(app.globals, g); app.gpa.free(app.globals); app.globals = g; if (!same) app.registerGlobals(); } else |_| {} // The shapers hold the old grid's faces, so they go before it does. if (shaping_changed) for (app.windows.items) |w| w.rebuildShaper(); if (new_grid) |g| { // The title's family is among the fonts that go, and the shaper // knows their faces. app.dropTitleFamily(); app.title_shaper.deinit(); app.title_shaper = new_title_shaper.?; var old_fonts: GridFonts = .{ .fonts = app.fonts, .grid = app.grid }; app.fonts = g.fonts; app.grid = g.grid; old_fonts.deinit(); app.glyphs.clear(); } if (size_changed or new_grid != null) { app.font_points = cfg.font.size; app.resizeFont(); } app.bell_sounds.apply(cfg, new.path, bell_mod.homeDir(app.env)); for (app.windows.items) |w| { for (w.tabs.items) |t| { var buf: [Tab.max_panes]*Pane = undefined; for (t.panes(&buf)) |p| { if (app.watchesForeground()) { // A configuration that did not read it before may now: // a pane whose program was never looked for is. if (p.foreground == null) _ = app.checkForeground(p, true); app.resolvePaneConfig(p); } app.applyTerminal(p); } } w.applyConfig(); } old_loaded.deinit(); app.publishProfiles(); log.info("configuration reloaded", .{}); } fn sameStrings(a: []const []const u8, b: []const []const u8) bool { if (a.len != b.len) return false; for (a, b) |x, y| if (!std.mem.eql(u8, x, y)) return false; return true; } /// Points to pixels, at the 96 dots per inch Wayland takes a logical pixel /// to be, times the output's scale. /// The family a title bar is drawn in: the desktop's interface font, as /// GNOME names it to the portal — `Cantarell 11` — or else sans-serif at /// 10.5 points. Made the first time a window draws its own title bar, so /// that under a compositor that draws them it costs nothing; the terminal's /// grid, should no such font be found. pub fn titleFamily(app: *App) *Grid { if (app.title_family) |*f| return f; const font = titleFont(&app.settings); if (Grid.init(app.gpa, &app.fonts, font.family, pixels(font.points, app.scale))) |f| { app.title_family = f; app.title_points = font.points; return &app.title_family.?; } else |err| { log.warn("the title bar's font \"{s}\": {t}", .{ font.family, err }); return &app.grid; } } fn dropTitleFamily(app: *App) void { if (app.title_family) |*f| f.deinit(); app.title_family = null; } /// What a window's title bar is drawn with. pub fn titleText(app: *App) TitleBar.Text { return .{ .family = app.titleFamily(), .shaper = &app.title_shaper, .shaped = &app.title_shaped, .glyphs = &app.glyphs, .grid = &app.grid, }; } const TitleFont = struct { family: []const u8, points: f32 }; /// The desktop's interface font, from `org.gnome.desktop.interface` /// `font-name`: a family, and its size in points after the last space. fn titleFont(settings: *const settings_mod.Settings) TitleFont { const fallback: TitleFont = .{ .family = "sans-serif", .points = 10.5 }; const v = settings.get("org.gnome.desktop.interface", "font-name") orelse return fallback; if (v != .string) return fallback; return parseFontName(v.string) orelse fallback; } fn parseFontName(s: []const u8) ?TitleFont { const trimmed = std.mem.trim(u8, s, " "); const space = std.mem.findScalarLast(u8, trimmed, ' ') orelse return null; const points = std.fmt.parseFloat(f32, trimmed[space + 1 ..]) catch return null; if (!(points > 0 and points < 200)) return null; const family = std.mem.trimEnd(u8, trimmed[0..space], " ,"); if (family.len == 0) return null; return .{ .family = family, .points = points }; } test "the desktop's interface font, as GNOME names it" { const f = parseFontName("Cantarell 11").?; try std.testing.expectEqualStrings("Cantarell", f.family); try std.testing.expectEqual(@as(f32, 11), f.points); const g = parseFontName("Noto Sans, 10.5").?; try std.testing.expectEqualStrings("Noto Sans", g.family); try std.testing.expectEqual(@as(?TitleFont, null), parseFontName("Cantarell")); try std.testing.expectEqual(@as(?TitleFont, null), parseFontName(" 11")); } fn pixels(points: f32, scale: f32) f32 { return points * 96.0 / 72.0 * scale; } /// Owns the name on the session bus, making this harrier the primary that /// later launches hand off to. Only the service must have it. pub fn serve(app: *App) !void { app.server = Activation.Server.start(app.gpa, app.io, app.env) catch |err| { if (app.service) return err; log.debug("not serving {s}: {t}", .{ req.app_id, err }); return; }; // Clicks on its notifications can come through the name now. if (app.portal) |p| p.setOwnsName(true); app.registerGlobals(); app.publishProfiles(); if (builtin.os.tag == .windows) app.jump_list_keeper = !@import("windows/elevation.zig").elevated(); } // --------------------------------------------------------------------------- // Global hotkeys // --------------------------------------------------------------------------- /// Binds the global key bindings: through the compositor, with /// xx-hotkey-v1 or else vicinae-hotkey-v1, where it has either; through /// the desktop portal's GlobalShortcuts otherwise. Only the harrier that /// owns the name binds them — or one with no session bus at all — so /// that a second harrier does not take them from the first. fn registerGlobals(app: *App) void { if (app.server == null and app.portal != null) return; if (app.platform.hotkeyProtocol()) |proto| { var arena_state: std.heap.ArenaAllocator = .init(app.gpa); defer arena_state.deinit(); const arena = arena_state.allocator(); const specs = arena.alloc(plat_mod.event.HotkeySpec, app.globals.len) catch return; for (app.globals, specs) |g, *spec| { const t = g.trigger; spec.* = .{ .keysym = t.keysym().?, .shift = t.shift, .ctrl = t.ctrl, .alt = t.alt, .super = t.super, .description = describeAction(arena, g.action) catch return, }; } app.platform.setHotkeys(req.app_id, specs); if (app.globals.len > 0) log.info("binding {d} global hotkeys with {s}-hotkey-v1", .{ app.globals.len, @tagName(proto) }); return; } if (app.portal) |p| { var arena_state: std.heap.ArenaAllocator = .init(app.gpa); defer arena_state.deinit(); const arena = arena_state.allocator(); const shortcuts = arena.alloc(Portal.Shortcut, app.globals.len) catch return; for (app.globals, shortcuts, 0..) |g, *s, i| { const buf = arena.alloc(u8, 96) catch return; s.* = .{ .id = globalId(arena, app.globals, i) catch return, .description = describeAction(arena, g.action) catch return, .trigger = g.trigger.xdgTrigger(buf).?, }; } p.bindShortcuts(shortcuts); if (app.globals.len > 0) log.info("binding {d} global shortcuts through the desktop portal", .{app.globals.len}); return; } if (app.globals.len > 0) log.warn("global keybinds need a compositor with xx-hotkey-v1 or vicinae-hotkey-v1, or a desktop portal with GlobalShortcuts", .{}); } /// Global binding `i`'s ID for the portal, which may remember what the user /// chose for it: its action, numbered when the action is bound more than /// once. fn globalId(arena: Allocator, globals: []const bindings_mod.Global, i: usize) ![]const u8 { var n: usize = 0; for (globals[0..i]) |g| { if (g.action == globals[i].action) n += 1; } const name = @tagName(globals[i].action); return if (n == 0) name else std.fmt.allocPrint(arena, "{s}-{d}", .{ name, n + 1 }); } /// What an action does, for a compositor or a desktop to show beside its /// hotkey: its name, in words. fn describeAction(arena: Allocator, action: Config.Action) ![:0]const u8 { const id = try std.fmt.allocPrint(arena, "action-{s}", .{@tagName(action)}); return arena.dupeSentinel(u8, try l10n.format(arena, id, &.{}), 0); } fn sameGlobals(a: []const bindings_mod.Global, b: []const bindings_mod.Global) bool { if (a.len != b.len) return false; for (a, b) |x, y| if (!std.meta.eql(x.trigger, y.trigger) or x.action != y.action) return false; return true; } /// An event for the program rather than a window: a hotkey, what became /// of one, or the token one asked for. fn handleAppEvent(app: *App, data: plat_mod.event.Data) void { switch (data) { .hotkey => |h| { if (!h.pressed or h.index >= app.globals.len) return; log.info("global hotkey {s} fired", .{app.globals[h.index].keys}); // Acted on once a token has come, so that the window it raises // or opens can take the focus. app.last_cookie +%= 1; app.hotkey_waiting.put(app.gpa, app.last_cookie, app.globals[h.index].action) catch return; app.platform.requestActivation(h.serial, app.last_cookie); }, .hotkey_state => |s| { if (s.index >= app.globals.len) return; const keys = app.globals[s.index].keys; switch (s.state) { .bound => log.info("global hotkey {s}: bound", .{keys}), .denied => log.warn("global hotkey {s}: the compositor refused it{s}{s}", .{ keys, if (s.message.len > 0) ": " else "", s.message }), .revoked => log.warn("global hotkey {s}: the compositor withdrew it{s}{s}", .{ keys, if (s.message.len > 0) ": " else "", s.message }), } }, .activation_token => |t| if (app.hotkey_waiting.fetchRemove(t.cookie)) |kv| app.runGlobal(kv.value, t.token), else => {}, } } /// Does what a global hotkey is bound to, raising a window with `token`: /// a new window or tab, or anything else in the window last used, which is /// raised — or, with no window at all, a new one. fn runGlobal(app: *App, action: Config.Action, token: ?[]const u8) void { switch (action) { .new_window => app.open(&.{ .activation_token = token }) catch |err| log.err("new window: {t}", .{err}), .new_tab => app.open(&.{ .new_tab = true, .activation_token = token }) catch |err| log.err("new tab: {t}", .{err}), else => { const w = app.last_window orelse if (app.windows.items.len > 0) app.windows.items[app.windows.items.len - 1] else { app.open(&.{ .activation_token = token }) catch |err| log.err("new window: {t}", .{err}); return; }; if (token) |t| w.pw.activate(t) else w.pw.raise(); if (action != .present_window) app.perform(w, action); }, } } pub fn newWindow(app: *App, request: *const req.Request) !void { const w = try Window.create(app, request); errdefer w.destroy(); try app.windows.append(app.gpa, w); app.last_window = w; } pub fn nextPaneId(app: *App) u64 { app.last_pane_id += 1; return app.last_pane_id; } pub const TabDrag = struct { /// The window it came from, and still is in until it is dropped. window: *Window, tab: *Tab, /// A window of this program took it. dropped: bool = false, }; /// The tab being dragged was dropped on `target` at `x`, `y`, physical /// pixels: onto its tab bar, the tab goes there; anywhere else, it gets a /// window of its own — unless it is alone in its window already, when it /// stays where it is. pub fn dropTab(app: *App, target: *Window, x: f32, y: f32) void { const d = &(app.tab_drag orelse return); d.dropped = true; for (app.windows.items) |w| w.tab_bar.drop_slot = null; target.dirty = true; const src = d.window; const from = std.mem.findScalar(*Tab, src.tabs.items, d.tab) orelse return; const slot = target.tab_bar.insertionAt(x, y) orelse { if (src.tabs.items.len > 1) app.detach(src, from); return; }; if (target == src) { // Back onto its own bar: a move, counting the places without it. src.moveTabTo(from, if (slot > from) slot - 1 else slot); src.select(std.mem.findScalar(*Tab, src.tabs.items, d.tab).?); return; } const t = src.takeTab(from).?; target.insertTab(t, slot) catch |err| { log.err("moving a tab: {t}", .{err}); // Back where it came from, or a window of its own if that is gone. if (!src.closing) src.insertTab(t, from) catch { app.forgetTab(t); t.destroy(); } else app.adopt(t); return; }; app.last_window = target; } /// The drag is over. Dropped nowhere that took it — outside every window /// of this program, or cancelled — the tab gets a window of its own. pub fn endTabDrag(app: *App) void { const d = app.tab_drag orelse return; app.tab_drag = null; for (app.windows.items) |w| { w.tab_bar.drop_slot = null; w.dirty = true; } if (d.dropped) return; const from = std.mem.findScalar(*Tab, d.window.tabs.items, d.tab) orelse return; if (d.window.tabs.items.len > 1) app.detach(d.window, from); } /// Tab `i` of `w`, in a window of its own. fn detach(app: *App, w: *Window, i: usize) void { const t = w.takeTab(i) orelse return; app.adopt(t); } /// A window for `t`, which belongs to none; closed if one cannot be made. fn adopt(app: *App, t: *Tab) void { const w = Window.createWith(app, t, null) catch |err| { log.err("a window for a tab: {t}", .{err}); app.forgetTab(t); t.destroy(); return; }; app.windows.append(app.gpa, w) catch { w.destroy(); return; }; app.last_window = w; } /// `t` is about to be destroyed: nothing is to point at it any more. /// Shows the desktop notification `p`'s program asked for, unless the /// configuration says not to while the pane is being looked at. A /// notification with no title is titled with the pane's. /// Whether a notification from `p`, in tab `tab_index` of `w`, goes out: /// by default, only when the pane is not being looked at. fn wantsNotification(app: *App, w: *Window, tab_index: usize, p: *Pane) bool { return switch (app.configFor(p).notifications) { .never => false, .always => true, .unfocused => !(w.focused and tab_index == w.active and w.tab().focused == p), }; } pub fn notify(app: *App, w: *Window, tab_index: usize, p: *Pane, n: Session.Notification) void { const portal = app.portal orelse return; if (!app.wantsNotification(w, tab_index, p)) return; const title = if (n.title.len > 0) n.title else if (p.title.items.len > 0) p.title.items else "harrier"; portal.notify(p.id, title, n.body); p.notified = true; } /// A program status record that has come into `working`, `blocked` or /// `done` (OSC 7501), as a desktop notification: titled with the record's /// title, or its program, or the pane's; saying what happened, and the /// record's message. A pane sends only so many in a while. pub fn notifyStatus(app: *App, w: *Window, tab_index: usize, p: *Pane, n: prog_status.Notice) void { const portal = app.portal orelse return; if (!app.wantsNotification(w, tab_index, p)) return; if (!p.mayNotice(Io.Clock.awake.now(app.io))) return; const title = n.title orelse n.app orelse if (p.title.items.len > 0) p.title.items else "harrier"; var buf: [2400]u8 = undefined; var out: std.Io.Writer = .fixed(&buf); const state = prog_status.describe(n.state, n.kind); var line_buf: [2400]u8 = undefined; var line: std.Io.Writer = .fixed(&line_buf); if (n.progress) |pct| { l10n.write(&line, "notification-status-percent", &.{ l10n.str("state", state), l10n.num("percent", pct) }) catch {}; } else l10n.write(&line, "notification-status", &.{l10n.str("state", state)}) catch {}; if (n.msg) |m| { l10n.write(&out, "notification-with-message", &.{ l10n.str("text", line.buffered()), l10n.str("message", m) }) catch {}; } else out.writeAll(line.buffered()) catch {}; portal.notify(p.id, title, out.buffered()); p.notified = true; } /// A pane's bell rang unseen, as a desktop notification: titled with the /// pane's title. A pane sends only so many in a while. pub fn notifyBell(app: *App, w: *Window, tab_index: usize, p: *Pane) void { const portal = app.portal orelse return; if (!app.wantsNotification(w, tab_index, p)) return; if (!p.mayNotice(Io.Clock.awake.now(app.io))) return; portal.notify(p.id, if (p.title.items.len > 0) p.title.items else "harrier", l10n.text("notification-bell")); p.notified = true; } /// Opens `uri`, a link clicked in `w`, which the app now owns: first asking /// the compositor for a token for whatever opens it to take the focus with. pub fn openLink(app: *App, w: *Window, uri: []u8) void { if (builtin.os.tag == .windows) { defer app.gpa.free(uri); return openWithShell(app.gpa, uri); } if (app.portal == null) { log.warn("no desktop portal to open {s} with", .{uri}); app.gpa.free(uri); return; } app.last_cookie +%= 1; app.opening.put(app.gpa, app.last_cookie, uri) catch { app.gpa.free(uri); return; }; w.pw.requestToken(app.last_cookie); } /// The token for a link clicked has come: opens it, through the portal — /// a file on this machine as itself, anything else as a URI. pub fn openWithToken(app: *App, cookie: u32, token: ?[]const u8) void { const uri = (app.opening.fetchRemove(cookie) orelse return).value; defer app.gpa.free(uri); if (builtin.os.tag != .linux) return; const portal = app.portal orelse return; var host_buf: [64]u8 = undefined; const host_name = host.name(&host_buf); if (links.localPath(uri, host_name)) |encoded| { var path: std.ArrayList(u8) = .empty; defer path.deinit(app.gpa); links.percentDecode(app.gpa, &path, encoded) catch return; path.append(app.gpa, 0) catch return; const z: [*:0]const u8 = @ptrCast(path.items.ptr); const fd = std.os.linux.open(z, .{ .PATH = true, .CLOEXEC = true }, 0); if (std.os.linux.errno(fd) != .SUCCESS) { log.warn("opening {s}: {t}", .{ uri, std.os.linux.errno(fd) }); return; } portal.openFile(@intCast(fd), uri, token); return; } if (std.ascii.startsWithIgnoreCase(uri, "file:")) { log.warn("not opening {s}: a file on another machine", .{uri}); return; } portal.openUri(uri, token); } /// Opens `uri` as Explorer would: a file on this machine by its path, and /// anything else as the URI it is, with whatever is registered for it. fn openWithShell(gpa: Allocator, uri: []const u8) void { const win32 = @import("win32").everything; var host_buf: [256]u8 = undefined; var target: std.ArrayList(u8) = .empty; defer target.deinit(gpa); if (links.localPath(uri, host.name(&host_buf))) |encoded| { links.percentDecode(gpa, &target, encoded) catch return; // `/C:/Users/...` names `C:\Users\...`. const path = target.items; if (path.len >= 3 and path[0] == '/' and path[2] == ':') _ = target.orderedRemove(0); for (target.items) |*c| if (c.* == '/') { c.* = '\\'; }; } else if (std.ascii.startsWithIgnoreCase(uri, "file:")) { log.warn("not opening {s}: a file on another machine", .{uri}); return; } else target.appendSlice(gpa, uri) catch return; const wide = std.unicode.utf8ToUtf16LeAllocZ(gpa, target.items) catch return; defer gpa.free(wide); const rc = win32.ShellExecuteW(null, std.unicode.utf8ToUtf16LeStringLiteral("open"), wide, null, null, win32.SW_SHOWNORMAL); // Anything over 32 is success. const code = if (rc) |r| @intFromPtr(r) else 0; if (code <= 32) log.warn("opening {s}: ShellExecute said {d}", .{ uri, code }); } /// Withdraws `p`'s notification, if it has one showing. pub fn withdraw(app: *App, p: *Pane) void { if (!p.notified) return; p.notified = false; if (app.portal) |portal| portal.withdraw(p.id); } pub fn forgetTab(app: *App, t: *Tab) void { if (app.tab_drag) |d| if (d.tab == t) { app.tab_drag = null; }; } /// What a request asks for: a window, or a tab in the last window. pub fn open(app: *App, asked: *const req.Request) !void { var named = asked.asProfile(); const request = &named; if (request.profile) |name| if (app.config().profile(name)) |e| { // Where the profile says, when the request does not. if (named.cwd == null) named.cwd = e.cwd; // One that runs as administrator, asked for by name of a harrier // that is not. if (builtin.os.tag == .windows and e.elevate and request.profile_program) { const elevation = @import("windows/elevation.zig"); if (!elevation.elevated()) return app.launchElevated(name, request.new_tab, request.cwd); } } else log.warn("no profile is named \"{s}\"; opening the shell", .{name}); if (request.focus_pane) |id| { // A pane of another primary — one gone, whose jump list Windows // kept — is none of these, whatever its ID. const ours = if (request.focus_primary) |pid| pid == host.pid() else true; if (ours) for (app.windows.items) |w| for (w.tabs.items, 0..) |t, i| { var buf: [Tab.max_panes]*Pane = undefined; for (t.panes(&buf)) |p| if (p.id == id) { w.select(i); w.focusPane(p); // A token from whoever asked raises it; without one, it // asks for one of its own. if (request.activation_token) |token| w.pw.activate(token) else w.pw.raise(); app.last_window = w; return; }; }; // Closed since it was found, or never this primary's: from a // search, nothing to show; from the command line, the last window // brought forward, or a window of its own. if (request.focus_primary == null) return; if (app.last_window) |w| { if (request.activation_token) |token| w.pw.activate(token) else w.pw.raise(); return; } var plain = request.*; plain.focus_pane = null; plain.focus_primary = null; return app.newWindow(&plain); } if (request.new_tab) if (app.last_window) |w| { try w.newTab(request); // Its window is raised by the activation token, when there is one, // or else when it is asked to be. if (request.activation_token) |t| w.pw.activate(t) else if (request.raise) w.pw.raise(); return; }; try app.newWindow(request); } /// Remakes the grid for `scale`, when a window says it is now drawn at one /// different from the last. pub fn useScale(app: *App, scale: f32) void { if (scale == app.scale) return; app.scale = scale; app.resizeFont(); } fn resizeFont(app: *App) void { app.grid.setSize(pixels(app.font_points, app.scale)) catch |err| { log.err("resizing the font: {t}", .{err}); return; }; if (app.title_family) |*f| f.setSize(pixels(app.title_points, app.scale)) catch |err| { log.err("resizing the title bar's font: {t}", .{err}); app.dropTitleFamily(); }; app.glyphs.clear(); for (app.windows.items) |w| w.layout(); } /// What a request runs: its profile's program or port, or else what /// it asks for, or else what the configuration says. pub const Program = struct { /// With no `serial`, the program and its arguments; null for the /// user's shell. command: ?[]const []const u8 = null, serial: ?Config.Serial = null, env: []const Config.EnvVar = &.{}, }; pub fn program(app: *App, request: *const req.Request) Program { const cfg = app.config(); if (request.profile) |name| if (cfg.profile(name)) |e| { if (!request.profile_program) return .{ .command = cfg.command }; return .{ .command = e.command, .serial = e.serial, .env = e.env }; }; if (request.serial_path) |path| { var s: Config.Serial = cfg.serial orelse .{ .path = path }; s.path = path; if (request.baud) |b| s.baud = b; return .{ .serial = s }; } if (request.command) |c| return .{ .command = c }; if (cfg.serial) |s| return .{ .serial = s }; return .{ .command = cfg.command }; } /// The backend a new window runs: the serial port when there is one, the /// command otherwise, and the user's shell when there is neither — as the /// request says, and the configuration where it says nothing. pub fn makeBackend(app: *App, size: Backend.Size, request: *const req.Request) !Backend.Backend { const prog = app.program(request); if (prog.serial) |s| { return .{ .serial = try Backend.Serial.open(app.gpa, .{ .path = s.path, .settings = .{ .baud = s.baud, .data_bits = switch (s.data_bits) { 5 => .five, 6 => .six, 7 => .seven, else => .eight, }, .parity = switch (s.parity) { inline else => |p| @field(serial.Parity, @tagName(p)), }, .stop_bits = switch (s.stop_bits) { inline else => |b| @field(serial.StopBits, @tagName(b)), }, .flow = switch (s.flow) { inline else => |f| @field(serial.Flow, @tagName(f)), }, }, }) }; } // The environment of whoever asked for the window, so that a window // opened from a shell inherits it; harrier's own otherwise. var env: std.process.Environ.Map = if (request.environ) |list| blk: { var m: std.process.Environ.Map = .init(app.gpa); errdefer m.deinit(); for (list) |kv| { const name, const value = splitEnv(kv) orelse continue; try m.put(name, value); } break :blk m; } else try app.env.clone(app.gpa); defer env.deinit(); // Activation tokens are for the window that was asked for, not for // whatever runs in it. _ = env.swapRemove("XDG_ACTIVATION_TOKEN"); _ = env.swapRemove("DESKTOP_STARTUP_ID"); try env.put("TERM", app.config().term); // harrier's own terminfo entry, installed beside it, found first: what // makes `TERM=harrier` work without installing it anywhere else. var terminfo_buf: [std.fs.max_path_bytes]u8 = undefined; if (app.ownTerminfo(&terminfo_buf)) |dir| try env.put("TERMINFO", dir); try env.put("COLORTERM", "truecolor"); try env.put("TERM_PROGRAM", "harrier"); try env.put("TERM_PROGRAM_VERSION", build_options.version); // What the request and its profile set, last, so that either // can say `TERM` for a host that has no entry for harrier. if (request.environ_set) |list| for (list) |kv| { const name, const value = splitEnv(kv) orelse continue; try env.put(name, value); }; for (prog.env) |v| { if (!std.process.Environ.Map.validateKeyForPut(v.name)) { log.warn("not setting {s}: not a name for a variable", .{v.name}); continue; } try env.put(v.name, v.value); } if (builtin.os.tag == .windows) { const graphemes = Pane.graphemeWidth(app.config(), request.profile) == .unicode; // With no directory, the user's home, not wherever the primary was // started: the system directory, when that was from a shortcut. const cwd = request.cwd orelse @import("windows/directories.zig").home(&env); return .{ .pty = try spawnWindows(app.gpa, prog.command, &env, cwd, size, graphemes) }; } const shell: []const []const u8 = prog.command orelse &.{env.get("SHELL") orelse app.env.get("SHELL") orelse "/bin/sh"}; var placing: Placing = .{ .app = app, .program = shell[0] }; return .{ .pty = try Backend.Pty.spawn(app.gpa, .{ .argv = shell, .env = &env, .cwd = request.cwd, .size = size, .place = if (app.config().cgroup.enabled) .{ .ctx = &placing, .func = Placing.place } else null, }) }; } /// `command` on a pseudoconsole; with none, the first shell there is of /// PowerShell, Windows PowerShell and `COMSPEC`, as Windows Terminal /// chooses. /// A `NAME=value` from an environment, split where the OS splits it; null /// for one with no name that can be set. On Windows a name can start with /// `=`: cmd keeps each drive's directory in one, `=C:=C:\Users\me`, and /// PowerShell passes them on. fn splitEnv(kv: []const u8) ?struct { []const u8, []const u8 } { const from: usize = if (builtin.os.tag == .windows) 1 else 0; const eq = std.mem.findScalarPos(u8, kv, @min(from, kv.len), '=') orelse return null; const name = kv[0..eq]; if (!std.process.Environ.Map.validateKeyForPut(name)) return null; return .{ name, kv[eq + 1 ..] }; } fn spawnWindows(gpa: Allocator, command: ?[]const []const u8, env: *const std.process.Environ.Map, cwd: ?[]const u8, size: Backend.Size, graphemes: bool) !Backend.Pty { if (command) |argv| return Backend.Pty.spawn(gpa, .{ .argv = argv, .env = env, .cwd = cwd, .size = size, .grapheme_cluster = graphemes }); const comspec = env.get("COMSPEC") orelse env.get("ComSpec") orelse "cmd.exe"; for ([_][]const u8{ "pwsh.exe", "powershell.exe", comspec }) |shell| { return Backend.Pty.spawn(gpa, .{ .argv = &.{shell}, .env = env, .cwd = cwd, .size = size, .grapheme_cluster = graphemes }) catch |err| switch (err) { error.FileNotFound => continue, else => return err, }; } return error.FileNotFound; } /// Puts a program in a scope of its own, between its fork and its exec. const Placing = struct { app: *App, program: []const u8, fn place(ctx: *anyopaque, pid: std.os.linux.pid_t) bool { const pl: *Placing = @ptrCast(@alignCast(ctx)); const cfg = pl.app.config().cgroup; var buf: [128]u8 = undefined; const description = std.fmt.bufPrint(&buf, "harrier: {s}", .{std.fs.path.basename(pl.program)}) catch "harrier"; pl.app.scopes.place(pid, description, .{ .memory_high = cfg.memory_high, .tasks_max = cfg.tasks_max }) catch |err| { if (cfg.required) { log.err("{s} not started: no cgroup of its own ({t}), and cgroup.required is set", .{ pl.program, err }); return false; } // Said once, rather than for every tab, when it will never work. if (err != error.NoSystemd or !pl.app.scopes_warned) { log.warn("{s} runs in harrier's own cgroup: {t}", .{ pl.program, err }); pl.app.scopes_warned = true; } }; return true; } }; /// harrier's items in the window system's own menu for a window — on /// Windows, the window menu — by their text's ID, in order: a choice comes /// back as its index. pub const window_menu = [_]struct { []const u8, Config.Action }{ .{ "action-new_window", .new_window }, .{ "action-new_tab", .new_tab }, .{ "action-toggle_profile_selector", .toggle_profile_selector }, }; pub fn perform(app: *App, w: *Window, action: Config.Action) void { switch (action) { .copy => w.copy(.standard), .paste => w.paste(.standard), .paste_selection => w.paste(.primary), .scroll_page_up, .scroll_page_down, .scroll_top, .scroll_bottom, .scroll_line_up, .scroll_line_down => { const s = w.pane().session; s.lock(); defer s.unlock(); const t = &s.terminal; const page: isize = @intCast(t.rows -| 1); t.scrollViewport(switch (action) { .scroll_page_up => .{ .delta = -page }, .scroll_page_down => .{ .delta = page }, .scroll_line_up => .{ .delta = -1 }, .scroll_line_down => .{ .delta = 1 }, .scroll_top => .top, else => .bottom, }); w.dirty = true; }, .scroll_to_prompt_previous, .scroll_to_prompt_next => { const s = w.pane().session; s.lock(); defer s.unlock(); s.terminal.screens.active.scroll(.{ .delta_prompt = if (action == .scroll_to_prompt_previous) -1 else 1 }); w.dirty = true; }, .font_size_increase, .font_size_decrease, .font_size_reset => { app.font_points = switch (action) { .font_size_increase => @min(app.font_points + 1, 72), .font_size_decrease => @max(app.font_points - 1, 4), else => app.config().font.size, }; app.resizeFont(); }, .new_window => app.newWindow(&.{}) catch |err| log.err("new window: {t}", .{err}), .close_window => w.closing = true, .reload_config => app.reloadConfig(), .new_tab => w.newTabHere(), .close_tab => w.closeTab(w.active), .next_tab => w.cycle(1), .previous_tab => w.cycle(-1), .move_tab_left => w.moveTab(-1), .move_tab_right => w.moveTab(1), .goto_tab_1, .goto_tab_2, .goto_tab_3, .goto_tab_4, .goto_tab_5, .goto_tab_6, .goto_tab_7, .goto_tab_8 => w.select(@backingInt(action) - @backingInt(Config.Action.goto_tab_1)), .last_tab => w.select(w.tabs.items.len - 1), .close_pane => _ = w.closePane(w.active, w.pane()), .new_split_right => w.newSplit(.horizontal), .new_split_down => w.newSplit(.vertical), .goto_pane_left => w.gotoPane(.left), .goto_pane_right => w.gotoPane(.right), .goto_pane_up => w.gotoPane(.up), .goto_pane_down => w.gotoPane(.down), .goto_pane_next => w.cyclePane(1), .goto_pane_previous => w.cyclePane(-1), .resize_split_left => w.resizeSplit(.left), .resize_split_right => w.resizeSplit(.right), .resize_split_up => w.resizeSplit(.up), .resize_split_down => w.resizeSplit(.down), .equalize_splits => w.equalizeSplits(), .toggle_pane_zoom => w.toggleZoom(), .toggle_status => app.toggleStatusWindow(w), .start_search => w.startSearch(), .toggle_profile_selector => w.toggleProfileSelector(), .search_next => w.searchNext(.older), .search_previous => w.searchNext(.newer), .present_window => w.pw.raise(), .unbind => {}, } } /// Runs until the last window closes — or, as the service, until it has /// been without a window for as long as the configuration says. pub fn run(app: *App) !void { var blink_at = Io.Clock.awake.now(app.io); var idle_since: ?Io.Timestamp = null; while (true) { if (app.windows.items.len == 0) { if (!app.service) break; // A service nobody can reach any more has nothing to wait for. if (app.server == null or app.server.?.gone.load(.acquire)) break; const now = Io.Clock.awake.now(app.io); const since = idle_since orelse now; idle_since = since; if (app.config().service.idle_exit_seconds) |limit| { if (since.durationTo(now).toMilliseconds() >= @as(i64, limit) * 1000) { log.info("no window for {d}s; exiting", .{limit}); break; } } } else idle_since = null; // Every tab's session, shown or not: a hidden tab's program still // has its output read, its title kept, and its exit noticed. app.fds.clearRetainingCapacity(); app.fds_out.clearRetainingCapacity(); try app.dnd.handles(app.gpa, &app.fds, &app.fds_out); try app.clipboard.handles(app.gpa, &app.fds, &app.fds_out); if (app.server) |s| try app.fds.append(app.gpa, s.wake.handle); if (app.portal) |p| try app.fds.append(app.gpa, p.wake.handle); if (app.session_notify) |n| { try app.fds.append(app.gpa, n.handle); } else for (app.windows.items) |w| for (w.tabs.items) |t| { var buf: [Tab.max_panes]*Pane = undefined; for (t.panes(&buf)) |p| try app.fds.append(app.gpa, p.session.notify.handle); }; // Wake for the cursor's blink, half a second at a time, and for the // idle limit once a second. const now = Io.Clock.awake.now(app.io); const until_blink = now.durationTo(blink_at).toMilliseconds(); var timeout: ?i32 = if (app.anyBlinks() and app.windows.items.len > 0) @intCast(std.math.clamp(until_blink, 0, 500)) else null; if (idle_since != null and app.config().service.idle_exit_seconds != null) timeout = @min(timeout orelse 1000, 1000); // And for a pane's size, shown for a moment, to go. for (app.windows.items) |w| if (w.nextDeadline()) |d| { const ms: i32 = @intCast(std.math.clamp(now.durationTo(d).toMilliseconds(), 0, 60_000)); timeout = @min(timeout orelse ms, ms); }; // And for the jump list, held back after a change too soon. if (app.jump_list_due) |d| { const ms: i32 = @intCast(std.math.clamp(now.durationTo(d).toMilliseconds(), 0, 60_000)); timeout = @min(timeout orelse ms, ms); } // And for a clipboard read's source that has gone quiet. if (app.clipboard.timeout()) |ms| timeout = @min(timeout orelse ms, ms); // And for the panes' foreground programs to be looked at again. if (app.watchesForeground() and app.config().foreground_poll_ms > 0 and app.windows.items.len > 0) { const at = app.foreground_poll_at orelse now; const ms: i32 = @intCast(std.math.clamp(now.durationTo(at).toMilliseconds(), 0, 60_000)); timeout = @min(timeout orelse ms, ms); } // And not at all while a drop is still being read from memory. if (app.dnd.busy()) timeout = 0; app.platform.wait(app.fds.items, app.fds_out.items, timeout) catch |err| switch (err) { // The compositor has gone, and every window with it. error.ConnectionLost => { log.info("the Wayland connection is gone; exiting", .{}); break; }, }; // The desktop's settings changed: the configuration may say // something different now. if (app.portal) |p| if (p.takeIfNewer(app.gpa, app.settings_generation) catch null) |fresh| { app.settings.deinit(); app.settings = fresh.settings; app.settings_generation = fresh.generation; // The desktop's interface font may be another; the title bar's // is made again when it is next wanted. app.dropTitleFamily(); app.reloadConfig(); }; // A global shortcut fired, through the portal. if (app.portal) |p| while (p.takeFired()) |f| { defer f.deinit(app.gpa); var arena_state: std.heap.ArenaAllocator = .init(app.gpa); defer arena_state.deinit(); for (app.globals, 0..) |g, i| { const id = globalId(arena_state.allocator(), app.globals, i) catch break; if (std.mem.eql(u8, id, f.id)) { log.info("global shortcut {s} fired", .{f.id}); app.runGlobal(g.action, f.activation_token); break; } } }; // A notification clicked: show the pane it came from. if (app.portal) |p| while (p.takeClick()) |click| { defer if (click.activation_token) |t| app.gpa.free(t); app.open(&.{ .focus_pane = click.pane, .activation_token = click.activation_token }) catch |err| log.err("showing a notification's pane: {t}", .{err}); }; if (app.server) |s| while (s.take()) |request| { var r = request; defer r.deinit(app.gpa); app.open(&r) catch |err| log.err("a window asked for over the bus: {t}", .{err}); }; app.dnd.pump(); app.clipboard.pump(); app.handleEvents(); if (app.watchesForeground() and app.config().foreground_poll_ms > 0) { const at_now = Io.Clock.awake.now(app.io); if (app.foreground_poll_at == null or !at_now.compare(.lt, app.foreground_poll_at.?)) { app.foreground_poll_at = at_now.addDuration(.fromMilliseconds(app.config().foreground_poll_ms)); app.pollForeground(); } } if (app.anyBlinks() and !Io.Clock.awake.now(app.io).compare(.lt, blink_at)) { blink_at = Io.Clock.awake.now(app.io).addDuration(.fromMilliseconds(500)); for (app.windows.items) |w| if (w.focused) { w.cursor_on = !w.cursor_on; w.dirty = true; }; } var i: usize = 0; while (i < app.windows.items.len) { const w = app.windows.items[i]; w.update(); if (w.closing) { _ = app.windows.orderedRemove(i); if (app.last_window == w) app.last_window = if (app.windows.items.len > 0) app.windows.items[app.windows.items.len - 1] else null; if (app.tab_drag) |d| if (d.window == w) { app.tab_drag = null; }; // The status window goes with the last terminal window, and // is otherwise handed to another. if (app.status_window) |sw| { if (app.windows.items.len == 0) { sw.closing = true; } else if (sw.parent == w) sw.reparent(app.last_window); } w.destroy(); continue; } if (w.wantsDraw()) w.draw() catch |err| { log.err("drawing: {t}", .{err}); if (@errorReturnTrace()) |t| std.debug.dumpErrorReturnTrace(t); }; i += 1; } // The status button, in every window, says what is being // reported; the status window shows it all. const ind = StatusWindow.indicator(app); for (app.windows.items) |w| if (!std.meta.eql(w.title_bar.status, ind)) { w.title_bar.status = ind; w.tab_bar.status = ind; w.dirty = true; }; if (app.status_window) |sw| { if (app.status_changed) sw.dirty = true; if (sw.closing) { sw.destroy(); app.status_window = null; } else if (sw.wantsDraw()) sw.draw() catch |err| log.err("drawing the status window: {t}", .{err}); } app.status_changed = false; app.publishPanes(); if (builtin.os.tag == .windows) app.publishJumpList(); app.publishLauncher(); app.platform.flush(); } } /// Hands every event the platform has queued to the window it is for, or /// to the app. fn handleEvents(app: *App) void { while (app.platform.nextEvent()) |e| { const user = e.window orelse { app.handleAppEvent(e.data); continue; }; if (app.status_window) |sw| if (user == @as(*anyopaque, sw)) { sw.handle(e.data); continue; }; const w: *Window = @ptrCast(@alignCast(user)); if (!app.owns(w)) continue; w.handle(e.data); } } /// A turn of the loop from inside Windows' own, while a window's frame is /// dragged: the events so far, and every window brought up to date and /// drawn — at the size it is being dragged to. A window asked to close is /// left for `run`, which is not running, to close. fn modalTurn(context: *anyopaque) void { const app: *App = @ptrCast(@alignCast(context)); app.handleEvents(); for (app.windows.items) |w| { w.update(); if (!w.closing and w.wantsDraw()) w.draw() catch |err| log.err("drawing: {t}", .{err}); } } /// Tells a dock how far along the programs are, on harrier's icon: the /// mean of every pane's progress that says how far, and urgent while any /// reports an error, or, on Linux, while a pane is rung and the bell asks /// for attention. The server says it only when it has changed, and the /// progress is rounded to a percent so that a busy program does not keep /// it talking. On Windows, each window's taskbar button shows it. fn publishLauncher(app: *App) void { if (builtin.os.tag != .windows and app.server == null) return; var l: req.Launcher = .{}; if (app.config().window.dock_progress) { var sum: u32 = 0; var n: u32 = 0; for (app.windows.items) |w| for (w.tabs.items) |t| { var buf: [Tab.max_panes]*Pane = undefined; for (t.panes(&buf)) |p| if (p.progress) |pr| { if (pr.state == .@"error" or pr.state == .blocked) l.urgent = true; if (pr.percent) |pct| { sum += @min(pct, 100); n += 1; } }; }; if (n > 0) { l.visible = true; l.progress = @as(f64, @floatFromInt(sum / n)) / 100; } } // On Windows an urgent taskbar button is a red one, as for an error; // there the window's button flashes for the bell instead. if (builtin.os.tag != .windows) { for (app.windows.items) |w| if (w.anyRung(.attention)) { l.urgent = true; }; } if (builtin.os.tag == .windows) { app.platform.showProgress(.{ .progress = l.progress, .visible = l.visible, .urgent = l.urgent }); } else if (app.server) |server| server.publishLauncher(l); } /// Tells the shell search what profiles there are: when it starts serving, /// and whenever the configuration is read again. fn publishProfiles(app: *App) void { const server = app.server orelse return; var arena_state: std.heap.ArenaAllocator = .init(app.gpa); defer arena_state.deinit(); const a = arena_state.allocator(); var list: std.ArrayList(search.Profile) = .empty; for (app.config().profiles) |*e| { var runs_buf: [512]u8 = undefined; const runs = a.dupe(u8, ProfileSelector.describe(e, &runs_buf)) catch return; var text_buf: [1024]u8 = undefined; var text: std.Io.Writer = .fixed(&text_buf); l10n.write(&text, "search-profile", &.{l10n.str("runs", runs)}) catch {}; list.append(a, .{ .name = e.name, .runs = runs, .description = a.dupe(u8, text.buffered()) catch return, .tab = e.open == .tab, }) catch return; } server.publishProfiles(list.items) catch return; } /// Tells the shell search what panes there are, when that has changed: a /// pane opened, closed or moved, a title set, or a location changed. fn publishPanes(app: *App) void { const server = app.server orelse return; var h = std.hash.Wyhash.init(0); for (app.windows.items) |w| for (w.tabs.items) |t| { var buf: [Tab.max_panes]*Pane = undefined; for (t.panes(&buf)) |p| { h.update(std.mem.asBytes(&p.id)); h.update(p.title.items); h.update(&.{0}); if (p.location) |l| { h.update(l.host); h.update(&.{0}); h.update(l.path); } h.update(&.{0}); } }; const digest = h.final(); if (digest == app.published) return; var arena: std.heap.ArenaAllocator = .init(app.gpa); defer arena.deinit(); const a = arena.allocator(); var list: std.ArrayList(search.Pane) = .empty; for (app.windows.items) |w| for (w.tabs.items) |t| { var buf: [Tab.max_panes]*Pane = undefined; for (t.panes(&buf)) |p| { var text_buf: [std.fs.max_path_bytes]u8 = undefined; const text = if (p.location) |l| location.format(&text_buf, l, location.homeOf(app.env)) else ""; list.append(a, .{ .id = p.id, .title = p.title.items, .location = a.dupe(u8, text) catch return }) catch return; } }; server.publishPanes(list.items) catch return; app.published = digest; } /// Gives the jump list the open panes and the profiles when they have /// changed, at most once in `jump_list.min_interval_ms`; a change too soon /// after the last is given once that has passed. fn publishJumpList(app: *App) void { if (!app.jump_list_keeper) return; const digest = app.jumpListDigest(); if (digest == app.jump_list_digest) { app.jump_list_due = null; return; } const now = Io.Clock.awake.now(app.io); if (app.jump_list_at) |at| { const next = at.addDuration(.fromMilliseconds(jump_list.min_interval_ms)); if (now.compare(.lt, next)) { app.jump_list_due = next; return; } } app.commitJumpList(true); app.jump_list_digest = digest; app.jump_list_at = now; app.jump_list_due = null; } /// What the jump list shows, hashed: each pane's ID, title and location, /// and each profile's name. fn jumpListDigest(app: *App) u64 { var h = std.hash.Wyhash.init(0); for (app.windows.items) |w| for (w.tabs.items) |t| { var buf: [Tab.max_panes]*Pane = undefined; for (t.panes(&buf)) |p| { h.update(std.mem.asBytes(&p.id)); h.update(p.title.items); h.update(&.{0}); if (p.location) |l| { h.update(l.host); h.update(&.{0}); h.update(l.path); } h.update(&.{0}); } }; for (app.config().profiles) |e| { h.update(e.name); h.update(&.{0}); } return h.final(); } /// Replaces the jump list: the profiles, and with `with_panes` the open /// panes too. fn commitJumpList(app: *App, with_panes: bool) void { var arena_state: std.heap.ArenaAllocator = .init(app.gpa); defer arena_state.deinit(); const a = arena_state.allocator(); var panes: std.ArrayList(jump_list.Item) = .empty; if (with_panes) for (app.windows.items) |w| for (w.tabs.items) |t| { var buf: [Tab.max_panes]*Pane = undefined; for (t.panes(&buf)) |p| { var text_buf: [std.fs.max_path_bytes]u8 = undefined; const where = if (p.location) |l| location.format(&text_buf, l, location.homeOf(app.env)) else ""; panes.append(a, jump_list.pane(a, host.pid(), p.id, p.title.items, a.dupe(u8, where) catch return) catch return) catch return; } }; var profiles: std.ArrayList(jump_list.Item) = .empty; for (app.config().profiles) |*e| { var what_buf: [512]u8 = undefined; const what = a.dupe(u8, ProfileSelector.describe(e, &what_buf)) catch return; profiles.append(a, jump_list.profile(a, e.name, what) catch return) catch return; } @import("windows/JumpList.zig").commit(app.gpa, l10n.text("jump-list-panes"), panes.items, profiles.items); } /// The pane with `id`, and the window it is in, wherever that is now. pub fn findPane(app: *const App, id: u64) ?struct { window: *Window, pane: *Pane } { for (app.windows.items) |w| for (w.tabs.items) |t| { var buf: [Tab.max_panes]*Pane = undefined; for (t.panes(&buf)) |p| if (p.id == id) return .{ .window = w, .pane = p }; }; return null; } /// The `share/terminfo` installed beside harrier, if it holds harrier's /// entry; in `buf`. fn ownTerminfo(app: *const App, buf: *[std.fs.max_path_bytes]u8) ?[]const u8 { const n = std.process.executableDirPath(app.io, buf) catch return null; const suffix = "/../share/terminfo"; const entry = "/h/harrier"; if (n + suffix.len + entry.len > buf.len) return null; @memcpy(buf[n..][0..suffix.len], suffix); @memcpy(buf[n + suffix.len ..][0..entry.len], entry); Io.Dir.cwd().access(app.io, buf[0 .. n + suffix.len + entry.len], .{}) catch return null; return buf[0 .. n + suffix.len]; } /// Opens the status window, a child of `from`, or closes it. pub fn toggleStatusWindow(app: *App, from: ?*Window) void { if (app.status_window) |sw| { sw.closing = true; return; } app.status_window = StatusWindow.create(app, from) catch |err| { log.err("opening the status window: {t}", .{err}); return; }; } fn owns(app: *const App, w: *Window) bool { for (app.windows.items) |x| if (x == w) return true; return false; } test splitEnv { const t = std.testing; const plain = splitEnv("TERM=xterm=ish").?; try t.expectEqualStrings("TERM", plain[0]); try t.expectEqualStrings("xterm=ish", plain[1]); try t.expectEqual(null, splitEnv("no value")); if (builtin.os.tag == .windows) { const drive = splitEnv("=C:=C:\\Users\\me").?; try t.expectEqualStrings("=C:", drive[0]); try t.expectEqualStrings("C:\\Users\\me", drive[1]); try t.expectEqual(null, splitEnv("=")); } else { try t.expectEqual(null, splitEnv("=x")); } }