also looking at this
feat(log): --log-file, and a logger that survives being used
#78
5 files
+691
-37
| @@ -50,6 +50,15 @@ pub const WindowOptions = struct { | ||
| 50 | 50 | // — so every app launched through the shared `craft` binary called itself |
| 51 | 51 | // "craft". macOS only. |
| 52 | 52 | app_name: ?[]const u8 = null, |
| 53 | // Write craft's own log to this file, in addition to stderr. Null leaves | |
| 54 | // logging where it has always been: stderr only. | |
| 55 | log_file: ?[]const u8 = null, | |
| 56 | // Emit each record as a JSON object rather than a human-readable line. | |
| 57 | log_json: bool = false, | |
| 58 | // Lowest level that gets recorded: debug, info, warn, error, fatal. | |
| 59 | log_level: ?[]const u8 = null, | |
| 60 | // Send records only to the log file, leaving the terminal quiet. | |
| 61 | log_quiet: bool = false, | |
| 53 | 62 | // Keep the process alive after the last window closes, instead of quitting. |
| 54 | 63 | // Null means craft decides from the shape of the app: a tray or |
| 55 | 64 | // menubar-only app stays, an ordinary windowed app goes. See |
| @@ -86,6 +95,8 @@ pub fn freeOptionStrings(allocator: std.mem.Allocator, options: *WindowOptions) | ||
| 86 | 95 | if (options.icon) |s| allocator.free(s); |
| 87 | 96 | if (options.app_name) |s| allocator.free(s); |
| 88 | 97 | if (options.frame_autosave) |s| allocator.free(s); |
| 98 | if (options.log_file) |s| allocator.free(s); | |
| 99 | if (options.log_level) |s| allocator.free(s); | |
| 89 | 100 | if (options.eval_source) |s| allocator.free(s); |
| 90 | 101 | if (options.eval_file) |s| allocator.free(s); |
| 91 | 102 | options.* = WindowOptions{}; |
| @@ -171,6 +182,9 @@ const BundleConfig = struct { | ||
| 171 | 182 | titlebarHidden: ?bool = null, |
| 172 | 183 | headless: ?bool = null, |
| 173 | 184 | keepRunning: ?bool = null, |
| 185 | logFile: ?[]const u8 = null, | |
| 186 | logJson: ?bool = null, | |
| 187 | logLevel: ?[]const u8 = null, | |
| 174 | 188 | }; |
| 175 | 189 | |
| 176 | 190 | /// Absolute path of the running executable's directory. |
| @@ -245,6 +259,8 @@ fn loadBundleConfig(allocator: std.mem.Allocator) ?WindowOptions { | ||
| 245 | 259 | if (cfg.title) |v| options.title = allocator.dupe(u8, v) catch options.title; |
| 246 | 260 | if (cfg.appName) |v| options.app_name = allocator.dupe(u8, v) catch null; |
| 247 | 261 | if (cfg.frameAutosave) |v| options.frame_autosave = allocator.dupe(u8, v) catch null; |
| 262 | if (cfg.logFile) |v| options.log_file = allocator.dupe(u8, v) catch null; | |
| 263 | if (cfg.logLevel) |v| options.log_level = allocator.dupe(u8, v) catch null; | |
| 248 | 264 | if (cfg.icon) |v| options.icon = allocator.dupe(u8, v) catch null; |
| 249 | 265 | applyScalarConfig(cfg, &options); |
| 250 | 266 | |
| @@ -271,6 +287,7 @@ fn applyScalarConfig(cfg: BundleConfig, options: *WindowOptions) void { | ||
| 271 | 287 | if (cfg.titlebarHidden) |v| options.titlebar_hidden = v; |
| 272 | 288 | if (cfg.headless) |v| options.headless = v; |
| 273 | 289 | if (cfg.keepRunning) |v| options.keep_running = v; |
| 290 | if (cfg.logJson) |v| options.log_json = v; | |
| 274 | 291 | // A menubar app with a dock icon and no tray is not a menubar app, so the |
| 275 | 292 | // one flag sets all three rather than making every manifest repeat them. |
| 276 | 293 | if (cfg.menubarOnly) |v| { |
| @@ -497,6 +514,18 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) !Wind | ||
| 497 | 514 | options.hide_dock_icon = true; |
| 498 | 515 | } else if (std.mem.eql(u8, arg, "--headless")) { |
| 499 | 516 | options.headless = true; |
| 517 | } else if (std.mem.eql(u8, arg, "--log-file")) { | |
| 518 | i += 1; | |
| 519 | if (i >= args.len) return CliError.MissingValue; | |
| 520 | options.log_file = try allocator.dupe(u8, args[i]); | |
| 521 | } else if (std.mem.eql(u8, arg, "--log-json")) { | |
| 522 | options.log_json = true; | |
| 523 | } else if (std.mem.eql(u8, arg, "--log-quiet")) { | |
| 524 | options.log_quiet = true; | |
| 525 | } else if (std.mem.eql(u8, arg, "--log-level")) { | |
| 526 | i += 1; | |
| 527 | if (i >= args.len) return CliError.MissingValue; | |
| 528 | options.log_level = try allocator.dupe(u8, args[i]); | |
| 500 | 529 | } else if (std.mem.eql(u8, arg, "--keep-running")) { |
| 501 | 530 | options.keep_running = true; |
| 502 | 531 | } else if (std.mem.eql(u8, arg, "--quit-on-close")) { |
| @@ -606,6 +635,13 @@ fn printHelp() void { | ||
| 606 | 635 | \\ --system-tray Show system tray icon |
| 607 | 636 | \\ --hide-dock-icon Hide dock icon (menubar-only mode, macOS) |
| 608 | 637 | \\ --menubar-only Menubar-only mode (no window, system tray only) |
| 638 | \\ --log-file <PATH> Append craft's own log to this file as well as | |
| 639 | \\ stderr. Captures everything craft logs through | |
| 640 | \\ std.log; debug-mode std.debug.print tracing is | |
| 641 | \\ not included. | |
| 642 | \\ --log-json One JSON object per record instead of a line. | |
| 643 | \\ --log-level <LEVEL> debug, info, warn, error or fatal (default info). | |
| 644 | \\ --log-quiet Write only to --log-file, leaving stderr alone. | |
| 609 | 645 | \\ --keep-running Stay running after the last window closes. |
| 610 | 646 | \\ The default for tray and menubar-only apps; |
| 611 | 647 | \\ an ordinary windowed app quits. |
| @@ -769,3 +805,51 @@ test "the last of the two flags wins" { | ||
| 769 | 805 | const options = try parseArgs(std.testing.allocator, &args); |
| 770 | 806 | try std.testing.expectEqual(@as(?bool, false), options.keep_running); |
| 771 | 807 | } |
| 808 | ||
| 809 | test "the logging flags are parsed" { | |
| 810 | var args = [_][:0]const u8{ "craft", "--log-file", "/tmp/craft.log", "--log-json", "--log-level", "debug", "--log-quiet" }; | |
| 811 | const options = try parseArgs(std.testing.allocator, &args); | |
| 812 | defer { | |
| 813 | var o = options; | |
| 814 | freeOptionStrings(std.testing.allocator, &o); | |
| 815 | } | |
| 816 | try std.testing.expectEqualStrings("/tmp/craft.log", options.log_file.?); | |
| 817 | try std.testing.expect(options.log_json); | |
| 818 | try std.testing.expectEqualStrings("debug", options.log_level.?); | |
| 819 | try std.testing.expect(options.log_quiet); | |
| 820 | } | |
| 821 | ||
| 822 | test "logging is off unless asked for" { | |
| 823 | var args = [_][:0]const u8{"craft"}; | |
| 824 | const options = try parseArgs(std.testing.allocator, &args); | |
| 825 | try std.testing.expectEqual(@as(?[]const u8, null), options.log_file); | |
| 826 | try std.testing.expectEqual(@as(?[]const u8, null), options.log_level); | |
| 827 | try std.testing.expect(!options.log_json); | |
| 828 | try std.testing.expect(!options.log_quiet); | |
| 829 | } | |
| 830 | ||
| 831 | test "--log-file without a value is an error, not a silent default" { | |
| 832 | var args = [_][:0]const u8{ "craft", "--log-file" }; | |
| 833 | try std.testing.expectError(CliError.MissingValue, parseArgs(std.testing.allocator, &args)); | |
| 834 | } | |
| 835 | ||
| 836 | test "a manifest can ask for a log file" { | |
| 837 | const source = | |
| 838 | \\{"title":"Probe","logFile":"/var/log/app.log","logLevel":"warn","logJson":true} | |
| 839 | ; | |
| 840 | const parsed = try std.json.parseFromSlice(BundleConfig, std.testing.allocator, source, .{ | |
| 841 | .ignore_unknown_fields = true, | |
| 842 | .allocate = .alloc_always, | |
| 843 | }); | |
| 844 | defer parsed.deinit(); | |
| 845 | ||
| 846 | // The path and level are applied inside `loadBundleConfig`, which reads the | |
| 847 | // filesystem; what is checkable here is that the manifest keys decode and | |
| 848 | // that the scalar reaches the options. | |
| 849 | try std.testing.expectEqualStrings("/var/log/app.log", parsed.value.logFile.?); | |
| 850 | try std.testing.expectEqualStrings("warn", parsed.value.logLevel.?); | |
| 851 | ||
| 852 | var options = WindowOptions{}; | |
| 853 | applyScalarConfig(parsed.value, &options); | |
| 854 | try std.testing.expect(options.log_json); | |
| 855 | } | |