ReviewOS

also looking at this

craft-native/craft

feat(cli): --app-name, so an app can be itself without packaging

#55
Merged glennmichael123 wants to merge feat/app-name-flag into main
7 files +199 -2
packages/typescript/src/__tests__/app-name.test.tsadded+63-0
Changes to packages/typescript/src/__tests__/app-name.test.ts
@@ -0,0 +1,63 @@
1/**
2 * `appName` — the name macOS shows in the menu bar (craft-native/craft#50).
3 *
4 * The App menu title and the About/Hide/Quit items come from
5 * `NSProcessInfo.processName`, which for a bare binary is the executable — so
6 * every app launched through the shared `craft` binary called itself "craft".
7 * The workaround downstream was to symlink the binary under the app's name and
8 * spawn through the symlink.
9 */
10
11import type { AppConfig } from '../types'
12import { describe, expect, it } from 'bun:test'
13
14async function argsFor(config: AppConfig): Promise<string[]> {
15 const { CraftApp } = await import('../index')
16 const app = new (CraftApp as unknown as { new (c: AppConfig): { buildArgs: () => string[] } })(config)
17 return (app as unknown as { buildArgs: () => string[] }).buildArgs()
18}
19
20function valueOf(args: string[], flag: string): string | undefined {
21 const index = args.indexOf(flag)
22 return index === -1 ? undefined : args[index + 1]
23}
24
25describe('appName', () => {
26 it('passes the name through to the binary', async () => {
27 const args = await argsFor({ appName: 'Harness', html: '<h1>hi</h1>' })
28 expect(valueOf(args, '--app-name')).toBe('Harness')
29 })
30
31 it('is absent when not configured, so the binary keeps its own default', async () => {
32 const args = await argsFor({ html: '<h1>hi</h1>' })
33 expect(args).not.toContain('--app-name')
34 })
35
36 it('is independent of the window title', async () => {
37 // A note-taking app is "Notes" in the menu bar whatever the open document
38 // has put in the title bar. Conflating the two is the reason `--title`
39 // could not be used for this.
40 const args = await argsFor({
41 appName: 'Notes',
42 window: { title: 'Untitled — edited' },
43 })
44 expect(valueOf(args, '--app-name')).toBe('Notes')
45 expect(valueOf(args, '--title')).toBe('Untitled — edited')
46 })
47
48 it('survives a name containing spaces as a single argument', async () => {
49 // argv, not a shell string: the name must arrive whole rather than as
50 // three arguments the parser would read as a URL.
51 const args = await argsFor({ appName: 'My Great App' })
52 expect(valueOf(args, '--app-name')).toBe('My Great App')
53 expect(args.filter(a => a === 'My Great App')).toHaveLength(1)
54 })
55
56 it('is emitted even in menubar-only mode', async () => {
57 // A menubar app has no window at all, and the App menu is the only place
58 // its name is ever shown — so this is the mode that needs it most.
59 const args = await argsFor({ appName: 'Hush', window: { menubarOnly: true } })
60 expect(valueOf(args, '--app-name')).toBe('Hush')
61 expect(args).toContain('--menubar-only')
62 })
63})
packages/typescript/src/index.tsmodified+7-1
Changes to packages/typescript/src/index.ts
@@ -366,7 +366,13 @@ export class CraftApp {
366366
367367 private buildArgs(): string[] {
368368 const args: string[] = []
369 const { window, html, url } = this.config
369 const { window, html, url, appName } = this.config
370
371 // Before the window options: this names the *app*, not the window, and
372 // the two are independent — a note-taking app is "Notes" in the menu bar
373 // whatever the open document has put in the title bar.
374 if (appName)
375 args.push('--app-name', appName)
370376
371377 // Menubar-only mode doesn't need content
372378 if (!window?.menubarOnly) {
packages/typescript/src/types.tsmodified+14-0
Changes to packages/typescript/src/types.ts
@@ -538,6 +538,20 @@ export interface AppConfig {
538538 */
539539 window?: WindowOptions
540540
541 /**
542 * The name macOS shows for the app: the App menu title, and the
543 * "About X" / "Hide X" / "Quit X" items.
544 *
545 * Without it those read the executable's name, so every app launched
546 * through the shared `craft` binary calls itself "craft" in the menu bar.
547 * A packaged `.app` gets this from `CFBundleName` instead; this is what
548 * gives a dev-mode app its identity back without a packaging step.
549 *
550 * Not the name `ps` and Activity Monitor show — that comes from the
551 * executable and cannot be changed from inside the process.
552 */
553 appName?: string
554
541555 /**
542556 * Path to Craft binary (auto-detected if not provided)
543557 */
packages/zig/src/cli.zigmodified+33-0
Changes to packages/zig/src/cli.zig
@@ -40,6 +40,12 @@ pub const WindowOptions = struct {
4040 // process. NSImage decodes everything Cocoa can render, so any common
4141 // raster format works; .icns is preferred for crispness across sizes.
4242 icon: ?[]const u8 = null,
43 // The name AppKit shows for the app: the App menu title and the
44 // "About X" / "Hide X" / "Quit X" items. Without it those read
45 // `NSProcessInfo.processName`, which for a bare binary is the executable
46 // — so every app launched through the shared `craft` binary called itself
47 // "craft". macOS only.
48 app_name: ?[]const u8 = null,
4349};
4450
4551pub const CliError = error{
@@ -66,6 +72,7 @@ pub fn freeOptionStrings(allocator: std.mem.Allocator, options: *WindowOptions)
6672 if (!std.mem.eql(u8, options.title, "Craft App")) allocator.free(options.title);
6773 if (options.sidebar_config) |s| allocator.free(s);
6874 if (options.icon) |s| allocator.free(s);
75 if (options.app_name) |s| allocator.free(s);
6976 if (options.eval_source) |s| allocator.free(s);
7077 if (options.eval_file) |s| allocator.free(s);
7178 options.* = WindowOptions{};
@@ -133,6 +140,7 @@ const BundleConfig = struct {
133140 html: ?[]const u8 = null,
134141 url: ?[]const u8 = null,
135142 title: ?[]const u8 = null,
143 appName: ?[]const u8 = null,
136144 width: ?u32 = null,
137145 height: ?u32 = null,
138146 icon: ?[]const u8 = null,
@@ -219,6 +227,7 @@ fn loadBundleConfig(allocator: std.mem.Allocator) ?WindowOptions {
219227 }
220228 if (cfg.url) |v| options.url = allocator.dupe(u8, v) catch null;
221229 if (cfg.title) |v| options.title = allocator.dupe(u8, v) catch options.title;
230 if (cfg.appName) |v| options.app_name = allocator.dupe(u8, v) catch null;
222231 if (cfg.icon) |v| options.icon = allocator.dupe(u8, v) catch null;
223232 applyScalarConfig(cfg, &options);
224233
@@ -299,6 +308,23 @@ test "a manifest parses into the fields it names and no others" {
299308 try std.testing.expect(parsed.value.devTools == null);
300309}
301310
311test "a manifest can name the app without naming the window" {
312 // Two different strings: `title` is the window's, `appName` is the one in
313 // the menu bar. A packaged app usually gets the latter from CFBundleName,
314 // but a manifest may override it.
315 const source =
316 \\{"title":"Untitled — Hush","appName":"Hush"}
317 ;
318 const parsed = try std.json.parseFromSlice(BundleConfig, std.testing.allocator, source, .{
319 .ignore_unknown_fields = true,
320 .allocate = .alloc_always,
321 });
322 defer parsed.deinit();
323
324 try std.testing.expectEqualStrings("Untitled — Hush", parsed.value.title.?);
325 try std.testing.expectEqualStrings("Hush", parsed.value.appName.?);
326}
327
302328test "unknown manifest keys are ignored rather than failing the launch" {
303329 const source =
304330 \\{"title":"Hush","somethingNewer":true}
@@ -455,6 +481,10 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) !Wind
455481 i += 1;
456482 if (i >= args.len) return CliError.MissingValue;
457483 options.icon = try allocator.dupe(u8, args[i]);
484 } else if (std.mem.eql(u8, arg, "--app-name")) {
485 i += 1;
486 if (i >= args.len) return CliError.MissingValue;
487 options.app_name = try allocator.dupe(u8, args[i]);
458488 } else if (!std.mem.startsWith(u8, arg, "--")) {
459489 // Treat as positional URL argument
460490 if (options.url == null) {
@@ -519,6 +549,8 @@ fn printHelp() void {
519549 \\ --sidebar-config <J> Sidebar JSON configuration
520550 \\ --sidebar-width <W> Sidebar width in pixels (default: 220)
521551 \\ --icon <PATH> Path to dock icon image (PNG/JPG/ICNS)
552 \\ --app-name <NAME> Name in the menu bar and in About/Hide/Quit
553 \\ (macOS; defaults to the executable name)
522554 \\
523555 \\Debugging:
524556 \\ --debug Enable debug output
@@ -542,6 +574,7 @@ fn printHelp() void {
542574 \\ craft http://localhost:3000 --dark --hot-reload
543575 \\ craft http://localhost:3000 --system-tray --light
544576 \\ craft http://localhost:3000 --native-sidebar --sidebar-width 250
577 \\ craft http://localhost:3000 --app-name "My App"
545578 \\
546579 \\For more information, visit: https://github.com/home-lang/craft
547580 \\
packages/zig/src/macos.zigmodified+28-1
Changes to packages/zig/src/macos.zig
@@ -6162,8 +6162,35 @@ const default_menus = [_]DefaultMenu{
61626162 .{ .title = "Window", .items = &window_menu_items, .is_windows_menu = true },
61636163};
61646164
6165/// Set the name AppKit shows for this app.
6166///
6167/// `-[NSProcessInfo processName]` is what the App menu title and the
6168/// "About X" / "Hide X" / "Quit X" items are built from, and for a bare binary
6169/// it is the executable's name — so every app launched through the shared
6170/// `craft` binary called itself "craft" in the menu bar. Setting it is the
6171/// whole of `--app-name`, and it is what makes a dev-mode app look like itself
6172/// without a packaging step.
6173///
6174/// Not the *kernel's* idea of the process name: `ps` and Activity Monitor read
6175/// that from the executable, and nothing running inside the process can change
6176/// it. Renaming there needs either a real `.app` bundle or an exec under a
6177/// different name.
6178///
6179/// Must run before `createApplicationMenu`, which reads the name once.
6180pub fn setProcessName(name: []const u8) void {
6181 // An empty name would leave the menu bar with a blank App menu and no way
6182 // to tell it apart from a missing one.
6183 if (name.len == 0) return;
6184
6185 const NSProcessInfo = getClass("NSProcessInfo");
6186 const info = msgSend0(NSProcessInfo, "processInfo");
6187 if (info == null) return;
6188 msgSendVoid1(info, "setProcessName:", createNSString(name));
6189}
6190
61656191/// The app's display name: `-[NSProcessInfo processName]`, which is the
6166/// executable name for a bare binary and CFBundleName inside a `.app`.
6192/// executable name for a bare binary, CFBundleName inside a `.app`, and
6193/// whatever `--app-name` said if it was given.
61676194///
61686195/// Copied into `buf` because the titles built from it are formatted, and a
61696196/// name too long to fit is reported as absent rather than truncated — a cut
packages/zig/src/minimal.zigmodified+6-0
Changes to packages/zig/src/minimal.zig
@@ -42,6 +42,12 @@ pub fn main(init: std.process.Init) !void {
4242 cli.freeOptionStrings(allocator, &owned);
4343 }
4444
45 // Before anything builds a menu bar: `createApplicationMenu` reads the
46 // process name once, and both branches below reach it.
47 if (comptime builtin.os.tag == .macos) {
48 if (options.app_name) |name| craft.macos.setProcessName(name);
49 }
50
4551 // In benchmark mode, disable dev_tools for lower overhead
4652 const effective_dev_tools = if (options.benchmark) false else options.dev_tools;
4753
packages/zig/test/cli_test.zigmodified+48-0
Changes to packages/zig/test/cli_test.zig
@@ -230,3 +230,51 @@ test "WindowOptions - all boolean flags true" {
230230 try testing.expect(options.hot_reload);
231231 try testing.expect(options.system_tray);
232232}
233
234// =============================================================================
235// parseArgs
236// =============================================================================
237//
238// These drive the real parser rather than constructing a `WindowOptions` by
239// hand, which is what the tests above do. A flag that is declared but never
240// wired into the argument loop passes every struct-shaped test in this file
241// and does nothing at all on the command line.
242
243fn parse(args: []const [:0]const u8) !cli.WindowOptions {
244 return cli.parseArgs(testing.allocator, args);
245}
246
247fn freeOptions(options: *cli.WindowOptions) void {
248 cli.freeOptionStrings(testing.allocator, options);
249}
250
251test "parseArgs - --app-name is read and owned" {
252 var options = try parse(&.{ "craft", "--app-name", "Harness" });
253 defer freeOptions(&options);
254 try testing.expectEqualStrings("Harness", options.app_name.?);
255 // The window title is a separate thing and must not be touched by it.
256 try testing.expectEqualStrings("Craft App", options.title);
257}
258
259test "parseArgs - --app-name and --title are independent" {
260 var options = try parse(&.{ "craft", "--app-name", "Hush", "--title", "Untitled" });
261 defer freeOptions(&options);
262 try testing.expectEqualStrings("Hush", options.app_name.?);
263 try testing.expectEqualStrings("Untitled", options.title);
264}
265
266test "parseArgs - --app-name defaults to absent" {
267 var options = try parse(&.{ "craft", "http://localhost:3000" });
268 defer freeOptions(&options);
269 try testing.expect(options.app_name == null);
270}
271
272test "parseArgs - --app-name without a value is an error, not a silent skip" {
273 try testing.expectError(cli.CliError.MissingValue, parse(&.{ "craft", "--app-name" }));
274}
275
276test "parseArgs - a name with spaces survives as one argument" {
277 var options = try parse(&.{ "craft", "--app-name", "My Great App" });
278 defer freeOptions(&options);
279 try testing.expectEqualStrings("My Great App", options.app_name.?);
280}