mbr-markdown-browser 0.6.2

A fast, featureful markdown viewer, browser, and (optional) static site generator
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
extern crate image;
use crate::Config;
use crate::config::MenuBarVisibility;
use crate::errors::BrowserError;
use crate::external_open::{
    SiteOrigin, apply_decision, decide_without_frame_info, mark_gui_active, open_external,
    parse_ipc_open_request,
};
use crate::server::{Server, ServerConfig};
use muda::{
    AboutMetadata, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu,
    accelerator::{Accelerator, Code, Modifiers},
};
use parking_lot::RwLock;
use std::path::PathBuf;
use std::sync::Arc;
use tao::{
    event::{ElementState, Event, StartCause, WindowEvent},
    event_loop::{ControlFlow, EventLoopBuilder, EventLoopProxy},
    keyboard::ModifiersState,
    window::{Icon, WindowBuilder},
};
// The two keyboard routes read different halves of a key event, and each half is
// dead weight on the other's platforms. Linux matches the key's *meaning*
// through `linux_shortcut_for`; everywhere else the lone Alt+arrow arm reads the
// raw key code.
#[cfg(target_os = "linux")]
use tao::keyboard::Key;
#[cfg(not(target_os = "linux"))]
use tao::keyboard::KeyCode;
use tokio::task::JoinHandle;
#[cfg(target_os = "linux")]
use wry::WebViewBuilderExtUnix;
use wry::{NewWindowResponse, WebViewBuilder};

// Scripts driving the GUI-only `<mbr-find-bar>` element from the native Edit menu.
//
// These are `&'static str` rather than a function returning `String` so the event loop
// never allocates to dispatch a keystroke.
//
// NOTE: a Rust line-continuation backslash strips the newline *and* all leading
// whitespace on the next line, so every JS statement below must end with `;`. There is
// no newline left for ASI to insert one.

/// Open the find bar, polling until the custom element has upgraded.
///
/// `templates/_scripts.html` loads the components bundle with `defer type="module"`, so
/// the element may not be defined yet when the first Cmd+F lands. The retry is bounded
/// (40 attempts x 25ms = 1s) and cancels itself on success. `open()` is idempotent on the
/// TS side, so a duplicate menu event cannot toggle the bar shut.
const FIND_OPEN_SCRIPT: &str = "(()=>{let n=0;const go=()=>{\
    const e=document.querySelector('mbr-find-bar');\
    if(e&&typeof e.open==='function'){e.open();return;}\
    if(++n<40)setTimeout(go,25);\
    };go();})()";

/// Advance to the next match. Single-shot: the bar must already be open to have matches.
const FIND_NEXT_SCRIPT: &str = "(()=>{\
    const e=document.querySelector('mbr-find-bar');\
    if(e&&typeof e.findNext==='function')e.findNext();\
    })()";

/// Step back to the previous match. Single-shot, as with `FIND_NEXT_SCRIPT`.
const FIND_PREV_SCRIPT: &str = "(()=>{\
    const e=document.querySelector('mbr-find-bar');\
    if(e&&typeof e.findPrevious==='function')e.findPrevious();\
    })()";

/// Custom user events for the event loop
enum UserEvent {
    MenuEvent(MenuEvent),
    /// The user picked a folder or a markdown file to open, from
    /// [`spawn_open_picker`]. Which one it is gets sorted out by
    /// [`start_server_for`]/[`crate::launch_url::resolve_launch_url_path`],
    /// not here — the event loop only needs somewhere to land.
    PathSelected(PathBuf),
    /// A server asked for by `Open…` is listening.
    ///
    /// Carries the `JoinHandle` so the event loop — the only place that knows
    /// which server is current — can abort the one being replaced.
    ServerReady {
        handle: JoinHandle<()>,
        url: String,
    },
    /// A server asked for by `Open…` never came up. The old one is still
    /// running, so the window keeps working; `path` is only for the message.
    ServerFailed {
        path: PathBuf,
    },
}

/// Context needed to launch and manage the browser window
pub struct BrowserContext {
    pub url: String,
    pub server_handle: JoinHandle<()>,
    pub config: Config,
    pub tokio_runtime: tokio::runtime::Handle,
}

/// What the initial window should show.
///
/// `Known` is every launch there has ever been until now: `main.rs` resolves
/// a URL and starts a server synchronously, before the window exists at all.
///
/// `Deferred` is macOS-only, and reachable in exactly the one case `Known`
/// cannot cover: `main::needs_folder_picker` found no meaningful CLI path,
/// which normally means "show the picker" — except the same launch may
/// really be Finder asking to open a specific file (right-click → Open
/// With, or a plain double-click once `MBR.app` claims the extension — see
/// `CFBundleDocumentTypes` in `macos/MBR.app-template/Contents/Info.plist`).
/// Finder's request reaches this process as a `tao::event::Event::Opened`,
/// wrapping `application(_:open:)`, which Apple documents as firing
/// *before* `applicationDidFinishLaunching`.
///
/// That ordering is what makes a race-free (no timeout, no "wait and see")
/// implementation possible, but only once one more fact is pinned down: tao
/// wires `application(_:open:)`/`applicationDidFinishLaunching:` straight
/// through to the callback installed by `AppState::set_callback`, which
/// `EventLoop::run` calls immediately before `[NSApp run]` — i.e. before
/// either can fire. So as long as a callback is installed (this function
/// always installs one, whether or not the target is known yet), tao
/// delivers `Event::Opened` synchronously ahead of
/// `Event::NewEvents(StartCause::Init)` when Finder asked for a file, and
/// only `Init` (with no preceding `Opened`) when it did not. A `Deferred`
/// launch builds its window on a placeholder page and lets the first tick of
/// the event loop decide between the two — see the `Event::Opened` /
/// `Event::NewEvents(StartCause::Init)` arms in [`launch_browser`] — then
/// hands off to the exact same `UserEvent::PathSelected` →
/// [`start_server_for`] → `UserEvent::ServerReady` pipeline `Open…`
/// already uses, so a Finder-opened file resolves to a URL through the one
/// path every other launch already goes through
/// ([`crate::launch_url::resolve_launch_url_path`]).
///
/// This type only covers the *first* decision. `Event::Opened` can arrive
/// again later, in either `Known` or `Deferred` process — see the
/// `Event::Opened` arm in [`launch_browser`] for why a later one repoints the
/// window instead of being ignored.
pub enum InitialLaunch {
    /// Boxed because `BrowserContext` (carrying the full [`Config`]) is far
    /// larger than the handful of bytes in `Deferred`, and `clippy` flags the
    /// gap.
    Known(Box<BrowserContext>),
    #[cfg(target_os = "macos")]
    Deferred {
        tokio_runtime: tokio::runtime::Handle,
    },
}

/// About metadata for the application
fn about_metadata() -> AboutMetadata {
    AboutMetadata {
        name: Some("mbr".to_string()),
        version: Some(env!("CARGO_PKG_VERSION").to_string()),
        short_version: Some(env!("CARGO_PKG_VERSION").to_string()),
        authors: Some(vec!["zmre".to_string()]),
        comments: Some("A markdown viewer and browser".to_string()),
        copyright: Some("Copyright © 2025".to_string()),
        license: Some("MIT".to_string()),
        website: Some("https://github.com/zmre/mbr".to_string()),
        website_label: Some("GitHub".to_string()),
        ..Default::default()
    }
}

/// Log (rather than panic on) a menu construction failure.
///
/// A failed append leaves that menu degraded or empty, which is a cosmetic
/// problem; crashing the GUI at startup would be far worse.
fn log_menu_result(what: &str, result: Result<(), muda::Error>) {
    if let Err(e) = result {
        tracing::error!("Failed to append {what}: {e}");
    }
}

/// Menu items for history navigation
struct HistoryMenuItems {
    back: MenuItem,
    forward: MenuItem,
}

/// Menu items for find-in-page
///
/// wry wraps a bare webview with no browser chrome, so nothing claims Cmd+F.
/// These items drive the GUI-only `<mbr-find-bar>` element via `evaluate_script`.
struct FindMenuItems {
    open: MenuItem,
    next: MenuItem,
    prev: MenuItem,
}

/// Handles to menu items needed for event matching after the menu bar is built
struct MenuHandles {
    menu_bar: Menu,
    open_item: MenuItem,
    reload_item: MenuItem,
    print_item: MenuItem,
    history_items: HistoryMenuItems,
    find_items: FindMenuItems,
    window_menu: Submenu,
}

/// Build the application menu bar with standard menus
/// On macOS, creates proper app menu with About, Services, Hide, Quit
/// On Windows/Linux, puts About in Help menu and Quit in File menu
fn build_menu_bar() -> MenuHandles {
    let menu_bar = Menu::new();

    // macOS: First menu is the app menu (named after the app)
    // Contains About, Services, Hide, Hide Others, Show All, Quit
    #[cfg(target_os = "macos")]
    let app_menu = {
        let app_menu = Submenu::new("mbr", true);
        log_menu_result(
            "app menu items",
            app_menu.append_items(&[
                &PredefinedMenuItem::about(None, Some(about_metadata())),
                &PredefinedMenuItem::separator(),
                &PredefinedMenuItem::services(None),
                &PredefinedMenuItem::separator(),
                &PredefinedMenuItem::hide(None),
                &PredefinedMenuItem::hide_others(None),
                &PredefinedMenuItem::show_all(None),
                &PredefinedMenuItem::separator(),
                &PredefinedMenuItem::quit(None),
            ]),
        );
        app_menu
    };

    // File menu
    let file_menu = Submenu::new("&File", true);

    // The "command" modifier: Cmd on macOS, Ctrl elsewhere.
    //
    // `Modifiers::META` is Cmd on macOS but the *Super/Windows* key on Linux and
    // Windows, where it belongs to the desktop — a tiling compositor such as
    // Hyprland binds nearly the whole Super range, so a menu item accelerated
    // with it is not merely non-standard, it never fires. Every accelerator that
    // is Cmd-something on macOS has to make this choice; the ones below that
    // already spell it out inline predate this constant.
    #[cfg(target_os = "macos")]
    let command_modifier = Modifiers::META;
    #[cfg(not(target_os = "macos"))]
    let command_modifier = Modifiers::CONTROL;

    let open_item = MenuItem::with_id(
        "open",
        "&Open...",
        true,
        Some(Accelerator::new(command_modifier, Code::KeyO)),
    );

    let reload_item = MenuItem::with_id(
        "reload",
        "&Reload",
        true,
        Some(Accelerator::new(command_modifier, Code::KeyR)),
    );

    // Print is Cmd+P on macOS but **Ctrl+Shift+P** elsewhere. Plain Ctrl+P is
    // "previous item" in mbr's own lists (the readline pair with Ctrl+N), and a
    // native accelerator would take it away from the page it belongs to.
    #[cfg(target_os = "macos")]
    let print_modifier = command_modifier;
    #[cfg(not(target_os = "macos"))]
    let print_modifier = Modifiers::CONTROL | Modifiers::SHIFT;

    let print_item = MenuItem::with_id(
        "print",
        "&Print…",
        true,
        Some(Accelerator::new(print_modifier, Code::KeyP)),
    );

    #[cfg(target_os = "macos")]
    log_menu_result(
        "file menu items",
        file_menu.append_items(&[
            &open_item,
            &PredefinedMenuItem::separator(),
            &reload_item,
            &PredefinedMenuItem::separator(),
            &print_item,
            &PredefinedMenuItem::separator(),
            &PredefinedMenuItem::close_window(Some("Close Window")),
        ]),
    );

    #[cfg(not(target_os = "macos"))]
    log_menu_result(
        "file menu items",
        file_menu.append_items(&[
            &open_item,
            &PredefinedMenuItem::separator(),
            &reload_item,
            &PredefinedMenuItem::separator(),
            &print_item,
            &PredefinedMenuItem::separator(),
            &PredefinedMenuItem::close_window(Some("Close Window")),
            &PredefinedMenuItem::separator(),
            &PredefinedMenuItem::quit(None),
        ]),
    );

    // Find uses Cmd+F / Cmd+G / Shift+Cmd+G on macOS, Ctrl+F / F3 / Shift+F3 elsewhere.
    // F3 rather than Ctrl+G off macOS: Ctrl+G is already the info panel's binding.
    #[cfg(target_os = "macos")]
    let find_accelerator = Accelerator::new(Modifiers::META, Code::KeyF);
    #[cfg(not(target_os = "macos"))]
    let find_accelerator = Accelerator::new(Modifiers::CONTROL, Code::KeyF);

    #[cfg(target_os = "macos")]
    let find_next_accelerator = Accelerator::new(Modifiers::META, Code::KeyG);
    #[cfg(not(target_os = "macos"))]
    let find_next_accelerator = Accelerator::new(Modifiers::empty(), Code::F3);

    #[cfg(target_os = "macos")]
    let find_prev_accelerator = Accelerator::new(Modifiers::META | Modifiers::SHIFT, Code::KeyG);
    #[cfg(not(target_os = "macos"))]
    let find_prev_accelerator = Accelerator::new(Modifiers::SHIFT, Code::F3);

    let find_item = MenuItem::with_id("find", "&Find…", true, Some(find_accelerator));
    let find_next_item =
        MenuItem::with_id("find_next", "Find &Next", true, Some(find_next_accelerator));
    let find_prev_item = MenuItem::with_id(
        "find_prev",
        "Find &Previous",
        true,
        Some(find_prev_accelerator),
    );

    // Edit menu with standard clipboard operations
    let edit_menu = Submenu::new("&Edit", true);
    log_menu_result(
        "edit menu items",
        edit_menu.append_items(&[
            &PredefinedMenuItem::undo(None),
            &PredefinedMenuItem::redo(None),
            &PredefinedMenuItem::separator(),
            &PredefinedMenuItem::cut(None),
            &PredefinedMenuItem::copy(None),
            &PredefinedMenuItem::paste(None),
            &PredefinedMenuItem::select_all(None),
            &PredefinedMenuItem::separator(),
            &find_item,
            &find_next_item,
            &find_prev_item,
        ]),
    );

    // View menu
    let view_menu = Submenu::new("&View", true);
    // Cmd+Option+I on macOS, Ctrl+Shift+I elsewhere -- the inspector shortcut
    // every browser uses on that platform.
    #[cfg(target_os = "macos")]
    let devtools_modifiers = Modifiers::META | Modifiers::ALT;
    #[cfg(not(target_os = "macos"))]
    let devtools_modifiers = Modifiers::CONTROL | Modifiers::SHIFT;

    let devtools_item = MenuItem::with_id(
        "devtools",
        "Toggle Developer Tools",
        true,
        Some(Accelerator::new(devtools_modifiers, Code::KeyI)),
    );
    log_menu_result(
        "view menu items",
        view_menu.append_items(&[
            &PredefinedMenuItem::fullscreen(None),
            &PredefinedMenuItem::separator(),
            &devtools_item,
        ]),
    );

    // History menu with Back/Forward navigation
    let history_menu = Submenu::new("&History", true);
    // Cmd+[ / Cmd+] is the macOS convention; Alt+arrow is everyone else's, and
    // is also what the keyboard route below already answers, so the menu now
    // advertises the key that actually works there.
    #[cfg(target_os = "macos")]
    let (back_accelerator, forward_accelerator) = (
        Accelerator::new(Modifiers::META, Code::BracketLeft),
        Accelerator::new(Modifiers::META, Code::BracketRight),
    );
    #[cfg(not(target_os = "macos"))]
    let (back_accelerator, forward_accelerator) = (
        Accelerator::new(Modifiers::ALT, Code::ArrowLeft),
        Accelerator::new(Modifiers::ALT, Code::ArrowRight),
    );

    let back_item = MenuItem::with_id("back", "&Back", true, Some(back_accelerator));
    let forward_item = MenuItem::with_id("forward", "&Forward", true, Some(forward_accelerator));
    log_menu_result(
        "history menu items",
        history_menu.append_items(&[&back_item, &forward_item]),
    );

    let history_items = HistoryMenuItems {
        back: back_item,
        forward: forward_item,
    };

    // Window menu
    let window_menu = Submenu::new("&Window", true);
    log_menu_result(
        "window menu items",
        window_menu.append_items(&[
            &PredefinedMenuItem::minimize(None),
            &PredefinedMenuItem::maximize(None),
            &PredefinedMenuItem::separator(),
            &PredefinedMenuItem::bring_all_to_front(None),
        ]),
    );

    // Help menu - only needed on non-macOS for About
    #[cfg(not(target_os = "macos"))]
    let help_menu = {
        let help_menu = Submenu::new("&Help", true);
        log_menu_result(
            "help menu items",
            help_menu.append_items(&[&PredefinedMenuItem::about(None, Some(about_metadata()))]),
        );
        help_menu
    };

    // Build menu bar - order matters, especially on macOS
    #[cfg(target_os = "macos")]
    log_menu_result(
        "menus to menu bar",
        menu_bar.append_items(&[
            &app_menu,
            &file_menu,
            &edit_menu,
            &view_menu,
            &history_menu,
            &window_menu,
        ]),
    );

    #[cfg(not(target_os = "macos"))]
    log_menu_result(
        "menus to menu bar",
        menu_bar.append_items(&[
            &file_menu,
            &edit_menu,
            &view_menu,
            &history_menu,
            &window_menu,
            &help_menu,
        ]),
    );

    // On macOS, set the Window menu as the windows menu for proper window management
    #[cfg(target_os = "macos")]
    window_menu.set_as_windows_menu_for_nsapp();

    let find_items = FindMenuItems {
        open: find_item,
        next: find_next_item,
        prev: find_prev_item,
    };

    MenuHandles {
        menu_bar,
        open_item,
        reload_item,
        print_item,
        history_items,
        find_items,
        window_menu,
    }
}

/// Hand an off-site URL to the operating system's default handler.
///
/// A failure is logged rather than surfaced: the in-window navigation has
/// already been refused by the time we get here, and there is nothing useful a
/// dialog could offer for "no application claims `zoommtg:`".
fn open_with_system_handler(url: &str) {
    tracing::debug!("Opening {url} with the system default handler");
    if let Err(e) = open_external(url) {
        tracing::warn!("Could not open {url} externally: {e}");
    }
}

/// Spawn a thread to show the open picker and send the result via the event
/// loop proxy.
///
/// A thread, not a direct call, because the picker must not block the tao
/// event loop thread while the user is looking at the dialog — the window
/// backing it (and every other window's paint/input) is pumped from here.
/// `crate::open_picker::pick_file_or_folder` is itself safe to call off the
/// main thread on every platform: its macOS path already dispatches through
/// rfd's `run_on_main`, which this code relied on even before there was a
/// combined file-or-folder picker to call.
fn spawn_open_picker(proxy: EventLoopProxy<UserEvent>) {
    std::thread::spawn(move || {
        let markdown_extensions = Config::default().markdown_extensions;
        if let Some(path) = crate::open_picker::pick_file_or_folder(
            "Open a Markdown Folder or File",
            &markdown_extensions,
        ) {
            let _ = proxy.send_event(UserEvent::PathSelected(path));
        }
    });
}

/// Start a server for `path` and resolve once it is listening.
///
/// **Async on purpose.** This used to be a synchronous `reinit_server` that
/// spawned the server and then called `Handle::block_on` to wait for its port —
/// from the tao event-loop callback, which `main` runs *inside* `#[tokio::main]`'s
/// runtime. Blocking a runtime thread on that runtime is an immediate panic:
///
/// > Cannot start a runtime from within a runtime. This happens because a
/// > function (like `block_on`) attempted to block the current thread while the
/// > thread is being used to drive asynchronous tasks.
///
/// so **Open… aborted the process on every platform**, the moment a folder
/// was chosen. The port now comes back through the event loop instead
/// ([`UserEvent::ServerReady`]), which also keeps the window responsive while a
/// large repository is scanned.
///
/// `path` may now name a markdown file as well as a folder — the picker
/// behind `Open…` can return either — so the returned URL is not always the
/// bare repository root: [`crate::launch_url::resolve_launch_url_path`] picks
/// the same directory/media-viewer/markdown-page URL `main.rs` resolves for
/// the initial launch, so a picked file opens directly rather than dropping
/// the user at the repository root they'd have to navigate away from.
async fn start_server_for(path: PathBuf) -> Result<(JoinHandle<()>, String), BrowserError> {
    let absolute_path = path.canonicalize().map_err(|e| {
        tracing::error!("Failed to canonicalize path: {e}");
        BrowserError::ServerStartFailed
    })?;

    let config = Config::read(&absolute_path).map_err(|e| {
        tracing::error!("Failed to read config: {e}");
        BrowserError::ServerStartFailed
    })?;

    let is_directory = absolute_path.is_dir();
    // `find_root_dir` (via `Config::read`) always derives `root_dir` from an
    // ancestor of `absolute_path`, so this can only fail if that invariant is
    // ever broken — treated as "no meaningful relative path" rather than a
    // hard error, since a wrong root URL is far less disruptive than refusing
    // to open the folder at all.
    let relative_path = pathdiff::diff_paths(&absolute_path, &config.root_dir).unwrap_or_default();

    let (ready_tx, ready_rx) = tokio::sync::oneshot::channel::<u16>();

    let config_copy = config.clone();
    let handle = tokio::spawn(async move {
        let server_config = ServerConfig::from(&config_copy).with_gui_mode(true);
        match Server::init(server_config) {
            Ok(mut s) => {
                if let Err(e) = s.start_with_port_retry(Some(ready_tx), 10).await {
                    tracing::error!("Server error: {e}");
                }
            }
            Err(e) => {
                tracing::error!("Server init failed: {e}");
                // Dropping the sender is what tells the awaiter below that this
                // will never be ready; without it that await would hang forever.
                drop(ready_tx);
            }
        }
    });

    let port = ready_rx.await.map_err(|_| {
        // The task dropped the sender, so it has already logged the cause.
        handle.abort();
        BrowserError::ServerStartFailed
    })?;

    let url_path = crate::launch_url::resolve_launch_url_path(
        &relative_path,
        is_directory,
        &config.markdown_extensions,
    );
    let base_url = url::Url::parse(&format!("http://{}:{}/", config.host, port)).map_err(|e| {
        tracing::error!("Failed to build server URL: {e}");
        BrowserError::ServerStartFailed
    })?;
    let url = base_url.join(&url_path).map_err(|e| {
        tracing::error!("Failed to build launch URL: {e}");
        BrowserError::ServerStartFailed
    })?;

    Ok((handle, url.to_string()))
}

/// Whether the platform shows the menu bar by default under
/// [`MenuBarVisibility::Auto`].
///
/// False on Linux and true everywhere else, because the *cost* of the bar
/// differs by platform rather than its usefulness. macOS renders the menu in
/// the system-wide bar at the top of the screen, which the window does not pay
/// for; Windows treats an in-window bar as the native convention. Only on Linux
/// is it a `GtkMenuBar` stacked above the page — chrome no other GTK app of this
/// shape has shown since the header-bar era, and under a tiling Wayland
/// compositor there is no global-menu protocol to move it to.
const MENU_BAR_AUTO_VISIBLE: bool = !cfg!(target_os = "linux");

/// Resolve `gui_menu_bar` against the platform default.
///
/// `auto` is a parameter rather than a read of [`MENU_BAR_AUTO_VISIBLE`] so the
/// mapping is testable for both platforms on either one.
fn menu_bar_starts_visible(setting: MenuBarVisibility, auto: bool) -> bool {
    match setting {
        MenuBarVisibility::Auto => auto,
        MenuBarVisibility::Always => true,
        MenuBarVisibility::Never => false,
    }
}

/// Whether F10 may reveal or dismiss the bar at runtime.
///
/// Linux-only, like the key it guards: macOS hangs the menu off the application
/// and Windows keeps its bar unconditionally, so neither has a toggle to gate.
///
/// `never` means never: a user who has turned the bar off in `config.toml` did
/// not ask for a key that brings it back. `auto` and `always` both toggle, so
/// the Linux default — hidden — is still one keystroke from discoverable.
#[cfg(target_os = "linux")]
fn menu_bar_toggle_allowed(setting: MenuBarVisibility) -> bool {
    !matches!(setting, MenuBarVisibility::Never)
}

/// Show or hide the GTK menu bar attached to `window`.
///
/// Hiding the bar does **not** disable any of its actions. `muda` adds its
/// `GtkAccelGroup` to the *window* in `init_for_gtk_window` and never removes it
/// in `hide_for_gtk_window`, so Ctrl+O, Ctrl+P, Ctrl+R, Ctrl+F and the rest keep
/// firing with nothing on screen. That is the entire argument for hiding by
/// default rather than dropping the menu.
///
/// `set_no_show_all(true)` is what makes a hide durable. `gtk_widget_show_all`
/// recurses through the whole tree, so any later `show_all()` on the window —
/// tao's, a theme reload's — would undo a plain `hide()`. The flag is cleared
/// again when showing, so the bar's own `show_all()` in `muda` still works.
/// A window action reachable both from a menu item and from a key.
///
/// One enum for both routes so each action has exactly one implementation in
/// [`perform_shortcut`]. The menu route exists on every platform; the keyboard
/// route is Linux-only, for the reason given on [`linux_shortcut_for`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Shortcut {
    /// Opens the picker, which can return either a folder or a markdown file
    /// — see [`spawn_open_picker`].
    Open,
    Reload,
    Print,
    Back,
    Forward,
    FindOpen,
    FindNext,
    FindPrev,
    /// Quit, or close the only window — the same thing in a one-window app.
    ///
    /// Linux-only, and the one variant with no menu item behind it: Quit and
    /// Close Window are `PredefinedMenuItem`s that the platform activates
    /// itself. It exists because those items go down with a hidden menu bar
    /// along with everything else on it, so the keyboard route has to carry
    /// them — and nothing else does.
    #[cfg(target_os = "linux")]
    Quit,
}

/// The menu-item ids that map to a [`Shortcut`].
///
/// `Quit` has no entry: it is a `PredefinedMenuItem`, which the platform
/// activates itself and which never reaches our handler. It is in `Shortcut`
/// only because the keyboard route has to cover it when the bar is hidden.
struct ShortcutIds {
    open: muda::MenuId,
    reload: muda::MenuId,
    print: muda::MenuId,
    back: muda::MenuId,
    forward: muda::MenuId,
    find: muda::MenuId,
    find_next: muda::MenuId,
    find_prev: muda::MenuId,
}

fn shortcut_for_menu_id(id: &muda::MenuId, ids: &ShortcutIds) -> Option<Shortcut> {
    let table = [
        (&ids.open, Shortcut::Open),
        (&ids.reload, Shortcut::Reload),
        (&ids.print, Shortcut::Print),
        (&ids.back, Shortcut::Back),
        (&ids.forward, Shortcut::Forward),
        (&ids.find, Shortcut::FindOpen),
        (&ids.find_next, Shortcut::FindNext),
        (&ids.find_prev, Shortcut::FindPrev),
    ];
    table
        .into_iter()
        .find(|(candidate, _)| *candidate == id)
        .map(|(_, shortcut)| shortcut)
}

/// Carry out an action, whichever route asked for it.
///
/// `evaluate_script` failures are dropped the way the menu handler always has:
/// a webview that cannot run `history.back()` has already gone, and there is
/// nothing a message could offer the user.
fn perform_shortcut(
    shortcut: Shortcut,
    webview: &wry::WebView,
    current_url: &str,
    proxy: &EventLoopProxy<UserEvent>,
    // Read by the `Quit` arm alone, which is Linux-only: everywhere else Quit and
    // Close Window are `PredefinedMenuItem`s the platform activates itself, so
    // nothing that reaches this function ends the event loop. Scoped to this one
    // parameter rather than the whole function, so a genuinely unused argument
    // added later is still a hard error.
    #[cfg_attr(not(target_os = "linux"), allow(unused_variables))] control_flow: &mut ControlFlow,
) {
    match shortcut {
        Shortcut::Open => {
            tracing::debug!("Open requested");
            spawn_open_picker(proxy.clone());
        }
        Shortcut::Reload => {
            tracing::debug!("Reload requested");
            let _ = webview.load_url(current_url);
        }
        Shortcut::Print => {
            tracing::debug!("Print requested");
            if let Err(e) = webview.print() {
                tracing::error!("Print failed: {e}");
            }
        }
        Shortcut::Back => {
            tracing::debug!("History back requested");
            let _ = webview.evaluate_script("history.back()");
        }
        Shortcut::Forward => {
            tracing::debug!("History forward requested");
            let _ = webview.evaluate_script("history.forward()");
        }
        Shortcut::FindOpen => {
            tracing::debug!("Find requested");
            let _ = webview.evaluate_script(FIND_OPEN_SCRIPT);
        }
        Shortcut::FindNext => {
            tracing::debug!("Find next requested");
            let _ = webview.evaluate_script(FIND_NEXT_SCRIPT);
        }
        Shortcut::FindPrev => {
            tracing::debug!("Find previous requested");
            let _ = webview.evaluate_script(FIND_PREV_SCRIPT);
        }
        #[cfg(target_os = "linux")]
        Shortcut::Quit => {
            tracing::debug!("Quit requested");
            *control_flow = ControlFlow::Exit;
        }
    }
}

/// The action a key press should perform **while the menu bar is hidden**.
///
/// This table exists because hiding a `GtkMenuBar` disables every accelerator
/// hanging off it. `gtk_menu_item_can_activate_accel` chains up the widget
/// ancestry and refuses when any ancestor is not visible, so a hidden bar takes
/// Ctrl+O, Ctrl+R, Ctrl+P, Ctrl+F, F3 and Ctrl+Q down with it — the accelerator
/// group stays attached to the window, but nothing on it will activate.
/// Confirmed by measurement, not by reading: with the bar hidden a synthesised
/// Ctrl+O produced no menu event, and the identical keystroke after F10 did.
///
/// So on Linux the keyboard has to be handled twice over, and the caller picks
/// which half is live by menu-bar visibility — never both, or every shortcut
/// would fire twice the moment the bar came back.
///
/// Pure, and takes the modifier state as a parameter, so the whole table is
/// unit-testable without a window.
#[cfg(target_os = "linux")]
fn linux_shortcut_for(key: &Key<'_>, modifiers: ModifiersState) -> Option<Shortcut> {
    // Exact comparisons, not `contains`: Ctrl+Shift+O is not Ctrl+O, and a
    // shortcut that fires on any superset would steal keys from the page.
    let ctrl = modifiers == ModifiersState::CONTROL;
    let ctrl_shift = modifiers == ModifiersState::CONTROL | ModifiersState::SHIFT;
    let alt = modifiers == ModifiersState::ALT;
    let shift = modifiers == ModifiersState::SHIFT;
    let bare = modifiers.is_empty();

    match key {
        // `logical_key` is the keyval, which Ctrl does not alter, so this is
        // still `Character("o")` with Ctrl held. Lowercased anyway: Caps Lock
        // reaches the keyval, and Ctrl+O should not depend on it.
        Key::Character(c) if ctrl => match c.to_ascii_lowercase().as_str() {
            "o" => Some(Shortcut::Open),
            "r" => Some(Shortcut::Reload),
            "f" => Some(Shortcut::FindOpen),
            // Close Window and Quit are the same thing in a one-window app, and
            // both are `PredefinedMenuItem`s, so both are lost with the bar.
            "w" | "q" => Some(Shortcut::Quit),
            _ => None,
        },
        // Shift is part of the chord *and* of the keyval, so this arrives as
        // `Character("P")`; the lowercasing below is what makes the two spellings
        // one case. Plain Ctrl+P is left to the page, where it means "previous".
        Key::Character(c) if ctrl_shift && c.eq_ignore_ascii_case("p") => Some(Shortcut::Print),
        Key::F3 if bare => Some(Shortcut::FindNext),
        Key::F3 if shift => Some(Shortcut::FindPrev),
        Key::ArrowLeft if alt => Some(Shortcut::Back),
        Key::ArrowRight if alt => Some(Shortcut::Forward),
        _ => None,
    }
}

/// Stop GTK from claiming F10 for menu-bar traversal.
///
/// `gtk-menu-bar-accel` defaults to `F10`, and a `GtkMenuBar` that is *visible*
/// consumes that key to move focus into itself. That leaves the toggle working
/// in one direction only — F10 reveals the bar, and the next F10 opens the File
/// menu instead of putting it away. Clearing the setting hands the key back, so
/// the same press means the same thing in both states.
///
/// What is given up is GTK's keyboard route *into* a visible menu bar. The bar
/// is still reachable by pointer, every item still has its own accelerator, and
/// on this application the setting is otherwise unused, so the trade is one
/// consistent toggle against a traversal shortcut nothing else here depends on.
///
/// The property is deprecated in GTK 3.10 and unwrapped by gtk-rs, hence the
/// string form; `find_property` first because `set_property` panics on a name
/// the object does not have, and a future GTK that finally drops it should
/// change nothing here.
#[cfg(target_os = "linux")]
fn release_gtk_menu_bar_accel() {
    use gtk::prelude::*;

    let Some(settings) = gtk::Settings::default() else {
        return;
    };
    if settings.find_property("gtk-menu-bar-accel").is_some() {
        settings.set_property("gtk-menu-bar-accel", None::<String>);
    }
}

#[cfg(target_os = "linux")]
fn set_gtk_menu_bar_visible(menu_bar: &Menu, window: &tao::window::Window, visible: bool) {
    use gtk::prelude::WidgetExt;
    use tao::platform::unix::WindowExtUnix;

    let gtk_window = window.gtk_window();

    // `gtk_menubar_for_gtk_window` consumes the `Menu`; `Menu` is a handle
    // around a shared inner, so the clone is a refcount bump, not a rebuild.
    if let Some(bar) = menu_bar.clone().gtk_menubar_for_gtk_window(gtk_window) {
        bar.set_no_show_all(!visible);
    }

    let result = if visible {
        menu_bar.show_for_gtk_window(gtk_window)
    } else {
        menu_bar.hide_for_gtk_window(gtk_window)
    };
    if let Err(e) = result {
        tracing::warn!("Failed to set menu bar visibility: {e}");
    }
}

/// Launch the browser window.
///
/// `launch` decides what the window shows first — see [`InitialLaunch`] for
/// the macOS-only ambiguity that requires deferring the decision into the
/// event loop itself.
pub fn launch_browser(launch: InitialLaunch) -> Result<(), BrowserError> {
    // The only place in the codebase that arms `open_external`. Until this runs,
    // handing a URL to the operating system fails closed with
    // `ExternalOpenError::GuiOnly`, so a server-mode process — which links this
    // same code, since the `gui` feature is on by default — cannot be talked
    // into starting an application on its host no matter who calls the launcher.
    //
    // Set here rather than next to the handlers so it is unambiguously before
    // the WebView exists: every request to launch something originates in that
    // WebView, so nothing can ask before the latch is set.
    mark_gui_active();

    // Create event loop with user events for menu handling
    let event_loop = EventLoopBuilder::<UserEvent>::with_user_event().build();

    // Set up menu event handler
    let proxy = event_loop.create_proxy();
    MenuEvent::set_event_handler(Some(move |event| {
        let _ = proxy.send_event(UserEvent::MenuEvent(event));
    }));

    // Build the menu bar
    let MenuHandles {
        menu_bar,
        open_item,
        reload_item,
        print_item,
        history_items,
        find_items,
        window_menu: _window_menu,
    } = build_menu_bar();

    // Initialize menu for macOS (global app menu)
    #[cfg(target_os = "macos")]
    menu_bar.init_for_nsapp();

    // Resolved before the window exists so both the initial state and the F10
    // handler read one decision. For a `Known` launch this is the merged
    // config, so an `MBR_GUI_MENU_BAR` env var and `.mbr/config.toml` are
    // already folded in. A `Deferred` launch has no config yet — nothing has
    // been read from disk, because no path is known yet either — so it falls
    // back to the compiled-in default, the same bootstrapping value
    // `main::needs_folder_picker`'s own picker already uses for its file
    // filter. That value never changes once the real config is known, which
    // matches `Open…`: a repoint already leaves this setting where the
    // *original* launch left it.
    //
    // The rest of the initial state — server handle, URL, and the runtime
    // used to start a server for it — comes from the same match. A
    // `Deferred` launch has no server yet, so it starts with a placeholder
    // page and an already-finished no-op task in `server_handle`'s place;
    // `UserEvent::ServerReady`'s unconditional `server_handle.abort()` is a
    // harmless no-op against a task that has already completed, so nothing
    // downstream needs to know the difference.
    let (
        menu_bar_setting,
        initial_url,
        initial_server_handle,
        tokio_runtime,
        mut awaiting_open_decision,
    ) = match launch {
        InitialLaunch::Known(ctx) => (
            ctx.config.gui_menu_bar,
            ctx.url,
            ctx.server_handle,
            ctx.tokio_runtime,
            false,
        ),
        #[cfg(target_os = "macos")]
        InitialLaunch::Deferred { tokio_runtime } => {
            let placeholder_handle = tokio_runtime.spawn(async {});
            (
                Config::default().gui_menu_bar,
                "about:blank".to_string(),
                placeholder_handle,
                tokio_runtime,
                true,
            )
        }
    };
    let mut menu_bar_visible = menu_bar_starts_visible(menu_bar_setting, MENU_BAR_AUTO_VISIBLE);

    let icon = load_icon()?;
    let window = WindowBuilder::new()
        .with_title("mbr")
        .with_window_icon(Some(icon))
        .build(&event_loop)
        .map_err(BrowserError::WindowCreationFailed)?;

    // Only the Linux path consults these: macOS hangs the menu off the
    // application, not the window, and Windows has no equivalent of
    // `hide_for_hwnd` wired up here.
    #[cfg(not(target_os = "linux"))]
    let _ = (menu_bar_setting, &mut menu_bar_visible);

    // Initialize menu for Windows (per-window menu bar)
    #[cfg(target_os = "windows")]
    unsafe {
        use tao::platform::windows::WindowExtWindows;
        if let Err(e) = menu_bar.init_for_hwnd(window.hwnd()) {
            tracing::warn!("Failed to attach menu bar to window: {e}");
        }
    }

    // Initialize menu for Linux (GTK-based).
    //
    // The bar is packed into `default_vbox()` — the `GtkBox` tao puts inside the
    // window — and so is the WebView further down. That is not a preference:
    // a `GtkApplicationWindow` is a `GtkBin` and holds exactly one child, which
    // is already that box. Adding either widget to the window itself makes GTK
    // refuse the second one with
    //
    //   Gtk-WARNING: Attempting to add a widget with type WebKitWebView to a
    //   GtkApplicationWindow, but as a GtkBin subclass [it] can only contain one
    //   widget at a time; it already contains a widget of type GtkBox
    //
    // and the window then shows the menu bar with no page under it. `muda`
    // `reorder_child`s the bar to position 0, so the box orders itself.
    #[cfg(target_os = "linux")]
    {
        use tao::platform::unix::WindowExtUnix;
        if let Err(e) = menu_bar.init_for_gtk_window(window.gtk_window(), window.default_vbox()) {
            tracing::warn!("Failed to attach menu bar to window: {e}");
        }
        // `init_for_gtk_window` always `show()`s the bar, so a hidden start is a
        // hide immediately after. `set_no_show_all` is the part that makes it
        // stick: GTK's `show_all()` walks the whole widget tree, and anything
        // that calls it on the window later — tao, or a theme change — would
        // otherwise bring the bar back.
        release_gtk_menu_bar_accel();
        set_gtk_menu_bar_visible(&menu_bar, &window, menu_bar_visible);
    }

    // Shared because the policy has to follow the server: `Open…` restarts
    // it, usually on a different port, and a stale origin would send every
    // internal link to the system browser.
    let site_origin = Arc::new(RwLock::new(SiteOrigin::new(&initial_url)));
    let new_window_origin = Arc::clone(&site_origin);
    let ipc_origin = Arc::clone(&site_origin);

    let builder = WebViewBuilder::new()
        .with_devtools(true)
        .with_url(&initial_url)
        // Without a handler wry allows every navigation, so an application
        // scheme silently did nothing: WKWebView, unlike UIKit, does not fall
        // back to NSWorkspace for a scheme it cannot render.
        //
        // `decide_without_frame_info` is the weaker of the two policies in
        // `external_open`, and its doc comment explains why at length. The short
        // version: wry hands this closure a URL and nothing else, and WebKit
        // calls it for `<iframe>` loads as well as document navigations, so it
        // deliberately lets *all* http(s) through — cancelling cross-origin
        // http(s) here would blank YouTube embeds. Clicked cross-origin links
        // are caught by the IPC handler below instead.
        .with_navigation_handler(|url| {
            apply_decision(
                decide_without_frame_info(&url),
                &url,
                open_with_system_handler,
            )
        })
        // The full origin-aware policy *is* safe here: this handler is only
        // consulted for window.open()/target="_blank", never for a frame.
        // Same-origin popups keep their linked webview so the Reveal.js
        // speaker-notes view stays in sync with its opener, while an external
        // one would otherwise open a second mbr-chrome window around somebody
        // else's site.
        //
        // The origin is copied out rather than read through a held guard: the
        // hand-off calls into AppKit/GTK, and nothing that can spin a run loop
        // should run while a lock this event loop also writes is held. One small
        // allocation per popup, which is a user action.
        .with_new_window_req_handler(move |url, _features| {
            let origin = new_window_origin.read().clone();
            if apply_decision(origin.decide(&url), &url, open_with_system_handler) {
                NewWindowResponse::Allow
            } else {
                NewWindowResponse::Deny
            }
        })
        // The other half of the external-link fix. `mbr-link-enhancement.ts`
        // runs only in GUI mode and only in the main frame, so it can tell a
        // clicked link from an embed — which the navigation handler cannot. It
        // cancels the click and posts the resolved URL here.
        //
        // The payload is page-controlled: anything that can run script in the
        // webview can post to this channel, including raw HTML in somebody
        // else's markdown. `parse_ipc_open_request` therefore re-runs the full
        // policy instead of trusting it.
        .with_ipc_handler(move |request| {
            let origin = ipc_origin.read().clone();
            match parse_ipc_open_request(&origin, request.body()) {
                Some(url) => open_with_system_handler(url),
                None => tracing::debug!("Ignoring unrecognized IPC message from the page"),
            }
        });

    #[cfg(not(target_os = "linux"))]
    let webview = builder
        .build(&window)
        .map_err(BrowserError::WebViewCreationFailed)?;
    #[cfg(target_os = "linux")]
    let webview = {
        use tao::platform::unix::WindowExtUnix;
        // Into the vbox, next to the menu bar — see the comment on
        // `init_for_gtk_window` above for why the window itself will not take it.
        // `build_gtk` recognises a `gtk::Box` and packs with
        // `pack_start(webview, true, true, 0)`, so the page expands and the menu
        // bar (packed `false, false`) keeps its natural height.
        //
        // `default_vbox()` is `None` only when the window was built with
        // `with_default_vbox(false)`, which this code never does; falling back to
        // the window keeps the match exhaustive without a panic.
        match window.default_vbox() {
            Some(vbox) => builder.build_gtk(vbox),
            None => builder.build_gtk(window.gtk_window()),
        }
        .map_err(BrowserError::WebViewCreationFailed)?
    };

    // Store menu item IDs for event matching
    let shortcut_ids = ShortcutIds {
        open: open_item.id().clone(),
        reload: reload_item.id().clone(),
        print: print_item.id().clone(),
        back: history_items.back.id().clone(),
        forward: history_items.forward.id().clone(),
        find: find_items.open.id().clone(),
        find_next: find_items.next.id().clone(),
        find_prev: find_items.prev.id().clone(),
    };

    // Track modifier state for Alt+arrow handling
    let mut modifiers = ModifiersState::empty();

    // Mutable state for server management
    let mut server_handle = initial_server_handle;
    let mut current_url = initial_url;

    // Create proxy for the open picker
    let event_proxy = event_loop.create_proxy();

    event_loop.run(move |event, _target, control_flow| {
        *control_flow = ControlFlow::Wait;

        match event {
            Event::UserEvent(UserEvent::MenuEvent(menu_event)) => {
                // `PredefinedMenuItem` events (quit, close, clipboard, about)
                // are activated by the platform and never arrive here.
                if let Some(shortcut) = shortcut_for_menu_id(&menu_event.id, &shortcut_ids) {
                    perform_shortcut(shortcut, &webview, &current_url, &event_proxy, control_flow);
                }
            }
            Event::UserEvent(UserEvent::PathSelected(new_path)) => {
                tracing::info!("Opening: {}", new_path.display());

                // Hand the work to the runtime and return to the event loop
                // immediately; the result arrives as `ServerReady`/`ServerFailed`.
                // Waiting here is what used to abort the process — see
                // `start_server_for`.
                //
                // The current server is deliberately left running. It is aborted
                // only once a replacement is listening, so a path that fails to
                // open leaves the window exactly as it was — which is what the
                // error message below has always claimed.
                let proxy = event_proxy.clone();
                tokio_runtime.spawn(async move {
                    let event = match start_server_for(new_path.clone()).await {
                        Ok((handle, url)) => UserEvent::ServerReady { handle, url },
                        Err(e) => {
                            tracing::error!("Failed to open {}: {e}", new_path.display());
                            UserEvent::ServerFailed { path: new_path }
                        }
                    };
                    // Fails only once the event loop is gone, which means the
                    // window is closing and nothing wants this result.
                    let _ = proxy.send_event(event);
                });
            }
            Event::UserEvent(UserEvent::ServerReady { handle, url }) => {
                server_handle.abort();
                server_handle = handle;
                current_url = url;
                // The new server rarely lands on the old port, and the
                // navigation handler compares against this.
                *site_origin.write() = SiteOrigin::new(&current_url);
                tracing::info!("Server restarted at {}", current_url);
                let _ = webview.load_url(&current_url);
            }
            Event::UserEvent(UserEvent::ServerFailed { path }) => {
                // Off-thread because a modal dialog spins its own run loop, and
                // this one is the event loop it would spin inside.
                std::thread::spawn(move || {
                    rfd::MessageDialog::new()
                        .set_level(rfd::MessageLevel::Error)
                        .set_title("Failed to Open")
                        .set_description(format!(
                            "Could not open: {}\n\nThe current folder will remain active.",
                            path.display()
                        ))
                        .set_buttons(rfd::MessageButtons::Ok)
                        .show();
                });
            }
            // macOS only in practice: tao's Windows and Linux backends never
            // construct `Event::Opened`, so `awaiting_open_decision` is always
            // `false` there (only `InitialLaunch::Known` exists off macOS) and
            // the `awaiting_open_decision` branch below never runs. See
            // `InitialLaunch::Deferred` for the launch-ordering guarantee the
            // *first* `Event::Opened` relies on: if one is coming at all for
            // this launch, it is delivered before this event loop ever sees
            // `Event::NewEvents(StartCause::Init)` below — never after, so
            // there is no race to lose.
            //
            // This arm is not one-shot, and that is deliberate, not an
            // oversight: measured on this system, `open -a MBR.app <file>`
            // against an *already-running* mbr — even with
            // `LSMultipleInstancesProhibited` explicitly `false` — does not
            // always launch a second process. It sometimes redelivers the
            // request to the running one as a second `Event::Opened`
            // (`open`'s own `-n` flag, "open a new instance even if one is
            // already running", only makes sense because its absence is a
            // real, observed code path). A second `Event::Opened` therefore
            // has to do something other than silently vanish, so past the
            // initial decision this behaves exactly like `Open…`/Cmd+O: hand
            // the path to the same `PathSelected` pipeline and repoint this
            // window, rather than dropping a click the user can see happen.
            Event::Opened { urls } => match crate::macos_open::first_local_path(&urls) {
                Some(path) => {
                    awaiting_open_decision = false;
                    tracing::info!("Finder asked to open: {}", path.display());
                    let _ = event_proxy.send_event(UserEvent::PathSelected(path));
                }
                // No usable local file: fall back to the picker only if this
                // was the launch decision. A later `Opened` with nothing
                // openable (a bare `mbr://` deep link, say) has no window
                // state to change, so it is ignored rather than reopening the
                // picker out of nowhere on an already-running window.
                None if awaiting_open_decision => {
                    awaiting_open_decision = false;
                    tracing::warn!(
                        "Received Event::Opened with no local file among {urls:?}; showing the picker instead"
                    );
                    spawn_open_picker(event_proxy.clone());
                }
                None => {
                    tracing::debug!("Ignoring Event::Opened with no local file: {urls:?}");
                }
            },
            // Fires exactly once, right after `applicationDidFinishLaunching`
            // — the deterministic "no `Opened` is coming" signal a `Deferred`
            // launch waits for before showing the picker. Reaching here with
            // `awaiting_open_decision` still `true` means Finder did not ask
            // for a file, so this is the ordinary "open the picker" launch
            // path, just resolved one tick later than every other launch mode.
            Event::NewEvents(StartCause::Init) => {
                if awaiting_open_decision {
                    awaiting_open_decision = false;
                    tracing::debug!(
                        "No Event::Opened arrived by the end of launch; showing the open picker"
                    );
                    spawn_open_picker(event_proxy.clone());
                }
            }
            Event::WindowEvent {
                event: WindowEvent::CloseRequested,
                ..
            } => {
                tracing::debug!("The close button was pressed; stopping");
                *control_flow = ControlFlow::Exit
            }
            Event::WindowEvent {
                event: WindowEvent::ModifiersChanged(new_modifiers),
                ..
            } => {
                modifiers = new_modifiers;
            }
            // F10 reveals or dismisses the Linux menu bar. F10 is the GTK
            // convention for exactly this (Firefox, Nautilus, and every app that
            // ships `gtk-menu-bar-accel` unchanged), so it needs no teaching.
            //
            // Placed before the Alt+arrow arm because match arms are tried in
            // order and that arm's guard would otherwise be the only keyboard
            // branch consulted. Bare F10 only: a modified F10 belongs to the page.
            //
            // Acts on **release**, not press. WebKitGTK hands a key press to the
            // web process and, when the page leaves it unhandled, re-dispatches
            // the same press to the toplevel so window accelerators still work —
            // so a `Pressed` arm here fires *twice* for exactly the keys nothing
            // in the page claims, and a toggle would land back where it started.
            // Measured on this stack: a page-ignored key arrives as two
            // `Pressed` and one `Released`; a page-handled one as one of each.
            // The release is the event that arrives exactly once either way.
            //
            // Matched on `logical_key`, not `physical_key`. What we want is the
            // key's *meaning*, which is what a user reads off their keycap and
            // what survives a remapped layout; `physical_key` is tao's mapping of
            // the raw hardware keycode, and any input source that synthesises
            // events on a keymap of its own (a virtual keyboard, a remote-desktop
            // agent, `wtype`) fills it with whatever slot it happened to use —
            // observed reporting `Escape` for both F10 and `f`, while
            // `logical_key` stayed correct for both.
            #[cfg(target_os = "linux")]
            Event::WindowEvent {
                event:
                    WindowEvent::KeyboardInput {
                        event: key_event, ..
                    },
                ..
            } if key_event.state == ElementState::Released
                && key_event.logical_key == Key::F10
                && modifiers.is_empty()
                && menu_bar_toggle_allowed(menu_bar_setting) =>
            {
                menu_bar_visible = !menu_bar_visible;
                tracing::debug!("Menu bar toggled to visible={menu_bar_visible} via F10");
                set_gtk_menu_bar_visible(&menu_bar, &window, menu_bar_visible);
            }
            // Everything else the menu bar offers, while the menu bar is not
            // there to offer it.
            //
            // Gated on `!menu_bar_visible` because both routes are live on
            // Linux: with the bar on screen GTK's accelerator group activates
            // the menu item, and handling the key here as well would perform
            // every action twice. See `linux_shortcut_for` for why the bar's
            // visibility decides this at all.
            #[cfg(target_os = "linux")]
            Event::WindowEvent {
                event:
                    WindowEvent::KeyboardInput {
                        event: key_event, ..
                    },
                ..
            } if key_event.state == ElementState::Released
                && !menu_bar_visible
                && linux_shortcut_for(&key_event.logical_key, modifiers).is_some() =>
            {
                // The guard proved this is `Some`; the table is consulted twice
                // rather than restructured because a match guard cannot bind.
                if let Some(shortcut) = linux_shortcut_for(&key_event.logical_key, modifiers) {
                    perform_shortcut(shortcut, &webview, &current_url, &event_proxy, control_flow);
                }
            }
            // History navigation off Linux, where the menu bar is always present
            // and only the Alt+arrow pair has no accelerator of its own.
            #[cfg(not(target_os = "linux"))]
            Event::WindowEvent {
                event:
                    WindowEvent::KeyboardInput {
                        event: key_event, ..
                    },
                ..
            } if key_event.state == ElementState::Released && modifiers.alt_key() => {
                match key_event.physical_key {
                    KeyCode::ArrowLeft => {
                        perform_shortcut(
                            Shortcut::Back,
                            &webview,
                            &current_url,
                            &event_proxy,
                            control_flow,
                        );
                    }
                    KeyCode::ArrowRight => {
                        perform_shortcut(
                            Shortcut::Forward,
                            &webview,
                            &current_url,
                            &event_proxy,
                            control_flow,
                        );
                    }
                    _ => {}
                }
            }
            _ => (),
        }
    });
}

fn load_icon() -> Result<Icon, BrowserError> {
    let (icon_rgba, icon_width, icon_height) = {
        let image_bytes = include_bytes!("../mbr-icon.png");
        let image = image::load_from_memory(image_bytes)
            .map_err(|e| BrowserError::IconLoadFailed(e.to_string()))?
            .into_rgba8();
        let (width, height) = image.dimensions();
        let rgba = image.into_raw();
        (rgba, width, height)
    };
    Icon::from_rgba(icon_rgba, icon_width, icon_height).map_err(BrowserError::IconCreationFailed)
}

#[cfg(test)]
mod tests {
    use super::*;

    // `auto` is passed in rather than read from `MENU_BAR_AUTO_VISIBLE`, so both
    // platform conventions are covered no matter which host runs the suite.
    #[test]
    fn auto_follows_the_platform_convention() {
        assert!(!menu_bar_starts_visible(MenuBarVisibility::Auto, false));
        assert!(menu_bar_starts_visible(MenuBarVisibility::Auto, true));
    }

    #[test]
    fn always_and_never_ignore_the_platform() {
        for auto in [false, true] {
            assert!(menu_bar_starts_visible(MenuBarVisibility::Always, auto));
            assert!(!menu_bar_starts_visible(MenuBarVisibility::Never, auto));
        }
    }

    #[test]
    fn linux_is_the_only_platform_that_hides_by_default() {
        assert_eq!(MENU_BAR_AUTO_VISIBLE, !cfg!(target_os = "linux"));
    }

    // `never` is a decision, not a starting position: F10 must not undo it.
    // Linux-gated with the function it exercises.
    #[cfg(target_os = "linux")]
    #[test]
    fn only_never_refuses_the_f10_toggle() {
        assert!(menu_bar_toggle_allowed(MenuBarVisibility::Auto));
        assert!(menu_bar_toggle_allowed(MenuBarVisibility::Always));
        assert!(!menu_bar_toggle_allowed(MenuBarVisibility::Never));
    }

    // Regression test for the crash that made `Open…` unusable on every
    // platform: the old synchronous `reinit_server` called `Handle::block_on`
    // from the event-loop callback, which `main` runs inside `#[tokio::main]`'s
    // runtime, and tokio aborts the process for that.
    //
    // `#[tokio::test]` reproduces the condition exactly — this body runs *on* a
    // runtime — so the old code panicked here and the new code cannot.
    #[tokio::test]
    async fn start_server_for_does_not_block_its_own_runtime() {
        let dir = tempfile::tempdir().expect("tempdir");
        std::fs::write(dir.path().join("index.md"), "# hi\n").expect("write");

        let (handle, url) = super::start_server_for(dir.path().to_path_buf())
            .await
            .expect("server should come up for a plain markdown folder");

        assert!(
            url.starts_with("http://"),
            "expected an http url, got {url}"
        );
        // A real port, not the placeholder: the URL is built from the port the
        // server reported through the readiness channel.
        let port: u16 = url
            .rsplit(':')
            .next()
            .and_then(|p| p.trim_end_matches('/').parse().ok())
            .unwrap_or_else(|| panic!("no port in {url}"));
        assert!(port > 0);

        handle.abort();
    }

    // A path that cannot be canonicalized must come back as an error the caller
    // can turn into a dialog, not a panic and not a hang on the readiness
    // channel.
    #[tokio::test]
    async fn start_server_for_reports_a_missing_folder() {
        let dir = tempfile::tempdir().expect("tempdir");
        let missing = dir.path().join("no-such-folder");

        assert!(super::start_server_for(missing).await.is_err());
    }

    // The keyboard table only exists on Linux, and only it can reach these
    // actions while the bar is hidden -- so every entry is worth pinning.
    #[cfg(target_os = "linux")]
    mod shortcuts {
        use super::super::*;

        fn look(key: Key<'_>, modifiers: ModifiersState) -> Option<Shortcut> {
            linux_shortcut_for(&key, modifiers)
        }

        const CTRL: ModifiersState = ModifiersState::CONTROL;
        const ALT: ModifiersState = ModifiersState::ALT;
        const SHIFT: ModifiersState = ModifiersState::SHIFT;
        const NONE: ModifiersState = ModifiersState::empty();

        #[test]
        fn every_menu_shortcut_has_a_keyboard_route() {
            assert_eq!(look(Key::Character("o"), CTRL), Some(Shortcut::Open));
            assert_eq!(look(Key::Character("r"), CTRL), Some(Shortcut::Reload));
            assert_eq!(
                look(Key::Character("P"), CTRL | SHIFT),
                Some(Shortcut::Print)
            );
            assert_eq!(look(Key::Character("f"), CTRL), Some(Shortcut::FindOpen));
            assert_eq!(look(Key::F3, NONE), Some(Shortcut::FindNext));
            assert_eq!(look(Key::F3, SHIFT), Some(Shortcut::FindPrev));
            assert_eq!(look(Key::ArrowLeft, ALT), Some(Shortcut::Back));
            assert_eq!(look(Key::ArrowRight, ALT), Some(Shortcut::Forward));
        }

        // Both are `PredefinedMenuItem`s, so both stop working with the bar.
        #[test]
        fn close_and_quit_both_quit() {
            assert_eq!(look(Key::Character("w"), CTRL), Some(Shortcut::Quit));
            assert_eq!(look(Key::Character("q"), CTRL), Some(Shortcut::Quit));
        }

        // Caps Lock reaches the keyval, and Ctrl+O should not depend on it.
        #[test]
        fn character_matching_ignores_case() {
            assert_eq!(look(Key::Character("O"), CTRL), Some(Shortcut::Open));
        }

        // A superset of the modifiers is a *different* chord, and claiming it
        // would steal a key the page may want.
        // Ctrl+P is "previous item" in mbr's lists; the native menu must not
        // take it. Shift-less Ctrl+P has to reach the page.
        #[test]
        fn plain_ctrl_p_is_left_to_the_page() {
            assert_eq!(look(Key::Character("p"), CTRL), None);
            // And the print chord works whichever case the keyval carries.
            assert_eq!(
                look(Key::Character("p"), CTRL | SHIFT),
                Some(Shortcut::Print)
            );
        }

        #[test]
        fn extra_modifiers_do_not_match() {
            assert_eq!(look(Key::Character("o"), CTRL | SHIFT), None);
            assert_eq!(look(Key::ArrowLeft, ALT | CTRL), None);
            assert_eq!(look(Key::F3, ALT), None);
        }

        #[test]
        fn bare_letters_are_left_to_the_page() {
            // `o` alone is not Open; the document may bind it.
            assert_eq!(look(Key::Character("o"), NONE), None);
            assert_eq!(look(Key::Character("z"), CTRL), None);
            assert_eq!(look(Key::ArrowLeft, NONE), None);
        }

        // F10 is deliberately absent: it toggles the bar and must keep working
        // in *both* states, so it has its own ungated arm.
        #[test]
        fn f10_is_not_in_the_gated_table() {
            assert_eq!(look(Key::F10, NONE), None);
        }
    }

    // A config that pins the bar off should still start hidden after a toggle
    // check, and one that pins it on should start shown regardless of platform.
    #[cfg(target_os = "linux")]
    #[test]
    fn starting_state_and_toggle_permission_agree_for_never() {
        let setting = MenuBarVisibility::Never;
        assert!(!menu_bar_starts_visible(setting, true));
        assert!(!menu_bar_toggle_allowed(setting));
    }
}