pinready 0.25.0

Cross-platform configurator and launcher supporting the adoption of Visual Pinball 10.8.1 — for testers and early adopters, not a replacement for a stable production setup
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
// Cross-platform desktop integration: app-menu shortcuts (PinReady + VPinballX)
// and `.vpx` file association.
//
// Linux: freedesktop standard — works identically on GNOME, KDE Plasma, XFCE,
// Cinnamon, MATE, LXQt. Writes user-level files under ~/.local/share so no
// sudo is required and an uninstall reverses cleanly.
//
// macOS: writes a thin ~/Applications/PinReady.app bundle that launches the
// running binary, so PinReady appears in Launchpad/Spotlight. The shipped
// VPinballX is already a .app and registers `.vpx` itself via its
// CFBundleDocumentTypes — we don't duplicate that.
//
// Windows: writes Start Menu .lnk shortcuts (per-user) and HKCU registry
// entries that bind `.vpx` to VPinballX with our icon. All HKCU — no UAC.

use std::path::PathBuf;
// `Path` is only used from the Linux and Windows arms; on macOS the
// import is unused and warns under `-D warnings`. Gate it explicitly
// rather than sprinkle `#[allow(unused_imports)]`.
#[cfg(not(target_os = "macos"))]
use std::path::Path;

// Bundled with the Linux install (writes to ~/.local/share/icons) and
// the macOS bundle Resources/ — Windows uses its own .ico below and
// never touches this asset.
#[cfg(not(target_os = "windows"))]
const PINREADY_LOGO_PNG: &[u8] = include_bytes!("../../assets/vpinball_logo.png");
#[cfg(target_os = "windows")]
const PINREADY_ICON_ICO: &[u8] = include_bytes!("../../assets/icon.ico");

// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------

/// Returns true if PinReady's desktop entry is currently installed.
/// Checked via the platform-specific marker file written by `install`.
pub(super) fn is_desktop_integration_installed() -> bool {
    pinready_marker_path().is_some_and(|p| p.exists())
}

/// Install or remove desktop shortcuts and `.vpx` association.
/// `vpx_exe_path` is the resolved VPinballX binary path (may be empty if the
/// user hasn't installed VPX yet — in that case only PinReady's own shortcut
/// is installed and the `.vpx` association is skipped).
pub(super) fn set_desktop_integration(enabled: bool, vpx_exe_path: &str) -> anyhow::Result<()> {
    if enabled {
        install(vpx_exe_path)
    } else {
        uninstall()
    }
}

// ---------------------------------------------------------------------------
// Platform-specific paths
// ---------------------------------------------------------------------------

// Windows resolves its install paths from `%APPDATA%` directly, so
// `home()` is only referenced from the Linux and macOS marker/install
// arms below. Gate accordingly to keep the Windows build warning-free.
#[cfg(not(target_os = "windows"))]
fn home() -> Option<PathBuf> {
    std::env::var("HOME")
        .or_else(|_| std::env::var("USERPROFILE"))
        .ok()
        .map(PathBuf::from)
}

/// Marker file whose presence means "we installed desktop entries".
fn pinready_marker_path() -> Option<PathBuf> {
    #[cfg(target_os = "linux")]
    {
        home().map(|h| h.join(".local/share/applications/pinready.desktop"))
    }
    #[cfg(target_os = "macos")]
    {
        home().map(|h| h.join("Applications/PinReady.app/Contents/Info.plist"))
    }
    #[cfg(target_os = "windows")]
    {
        std::env::var("APPDATA").ok().map(|appdata| {
            PathBuf::from(appdata).join(r"Microsoft\Windows\Start Menu\Programs\PinReady.lnk")
        })
    }
    #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
    {
        None
    }
}

// ---------------------------------------------------------------------------
// Linux (freedesktop — GNOME / KDE / XFCE / Cinnamon / MATE / LXQt)
// ---------------------------------------------------------------------------

#[cfg(target_os = "linux")]
fn install(vpx_exe_path: &str) -> anyhow::Result<()> {
    let h = home().ok_or_else(|| anyhow::anyhow!("HOME not set"))?;
    let exe = std::env::current_exe()?;

    let apps = h.join(".local/share/applications");
    let icons = h.join(".local/share/icons/hicolor/128x128");
    let mime_pkgs = h.join(".local/share/mime/packages");
    std::fs::create_dir_all(&apps)?;
    std::fs::create_dir_all(icons.join("apps"))?;
    std::fs::create_dir_all(icons.join("mimetypes"))?;
    std::fs::create_dir_all(&mime_pkgs)?;

    // Icons (reuse the bundled vpinball logo for both menu entries and the
    // .vpx mimetype — same artwork is fine and keeps the binary smaller).
    std::fs::write(icons.join("apps/pinready.png"), PINREADY_LOGO_PNG)?;
    std::fs::write(icons.join("apps/vpinballx.png"), PINREADY_LOGO_PNG)?;
    std::fs::write(
        icons.join("mimetypes/application-x-vpinball.png"),
        PINREADY_LOGO_PNG,
    )?;

    // PinReady .desktop — points to the currently-running binary.
    let exe_quoted = shell_quote(&exe.display().to_string());
    let pinready_desktop = format!(
        "[Desktop Entry]\n\
         Type=Application\n\
         Name=PinReady\n\
         GenericName=Visual Pinball Launcher\n\
         Comment=Visual Pinball configurator and table launcher\n\
         Exec={exe_quoted}\n\
         Icon=pinready\n\
         Terminal=false\n\
         Categories=Game;ArcadeGame;\n\
         StartupNotify=true\n\
         StartupWMClass=PinReady\n"
    );
    std::fs::write(apps.join("pinready.desktop"), pinready_desktop)?;

    // MIME type definition for .vpx — installed unconditionally so the file
    // manager learns the type (and thus the icon) even if VPX isn't yet
    // installed.
    let mime_xml = "\
<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
<mime-info xmlns=\"http://www.freedesktop.org/standards/shared-mime-info\">\n\
  <mime-type type=\"application/x-vpinball\">\n\
    <comment>Visual Pinball Table</comment>\n\
    <icon name=\"application-x-vpinball\"/>\n\
    <glob pattern=\"*.vpx\"/>\n\
  </mime-type>\n\
</mime-info>\n";
    std::fs::write(mime_pkgs.join("pinready-vpx.xml"), mime_xml)?;

    // VPinballX shortcut + .vpx association — only when we know where VPX is.
    let vpx_resolved = if !vpx_exe_path.is_empty() {
        let p = crate::updater::resolve_vpx_exe(Path::new(vpx_exe_path));
        p.is_file().then_some(p)
    } else {
        None
    };
    if let Some(vpx) = vpx_resolved {
        let vpx_quoted = shell_quote(&vpx.display().to_string());
        let vpx_desktop = format!(
            "[Desktop Entry]\n\
             Type=Application\n\
             Name=Visual Pinball X\n\
             GenericName=Pinball Simulator\n\
             Comment=Play Visual Pinball X tables\n\
             Exec={vpx_quoted} -play %f\n\
             Icon=vpinballx\n\
             Terminal=false\n\
             MimeType=application/x-vpinball;\n\
             Categories=Game;ArcadeGame;\n"
        );
        std::fs::write(apps.join("vpinballx.desktop"), vpx_desktop)?;
    } else {
        // Stale entry from a previous install where VPX was set — clean up.
        let _ = std::fs::remove_file(apps.join("vpinballx.desktop"));
    }

    // Refresh system caches. These are best-effort: on a vanilla desktop
    // install they all exist, but pincab images may strip some — log and
    // continue rather than fail the whole operation.
    run_quiet("update-mime-database", &[h.join(".local/share/mime")]);
    run_quiet("update-desktop-database", std::slice::from_ref(&apps));
    // gtk-update-icon-cache only works on a theme dir with index.theme;
    // hicolor user dir typically has none, and modern desktops watch the
    // dir directly. Skip.

    // Make VPinballX the default handler for .vpx (only if we wrote it).
    if apps.join("vpinballx.desktop").is_file() {
        run_quiet(
            "xdg-mime",
            &["default", "vpinballx.desktop", "application/x-vpinball"],
        );
    }

    log::info!("Desktop integration installed under {}", h.display());
    Ok(())
}

#[cfg(target_os = "linux")]
fn uninstall() -> anyhow::Result<()> {
    let h = home().ok_or_else(|| anyhow::anyhow!("HOME not set"))?;
    let apps = h.join(".local/share/applications");
    let icons = h.join(".local/share/icons/hicolor/128x128");
    let mime_pkgs = h.join(".local/share/mime/packages");

    let _ = std::fs::remove_file(apps.join("pinready.desktop"));
    let _ = std::fs::remove_file(apps.join("vpinballx.desktop"));
    let _ = std::fs::remove_file(mime_pkgs.join("pinready-vpx.xml"));
    let _ = std::fs::remove_file(icons.join("apps/pinready.png"));
    let _ = std::fs::remove_file(icons.join("apps/vpinballx.png"));
    let _ = std::fs::remove_file(icons.join("mimetypes/application-x-vpinball.png"));

    run_quiet("update-mime-database", &[h.join(".local/share/mime")]);
    run_quiet("update-desktop-database", std::slice::from_ref(&apps));

    log::info!("Desktop integration removed");
    Ok(())
}

/// POSIX-shell quote: wrap in single quotes, escape embedded `'` as `'\''`.
/// Matches the convention used in autostart.rs's Exec= line.
#[cfg(target_os = "linux")]
fn shell_quote(s: &str) -> String {
    format!("'{}'", s.replace('\'', r"'\''"))
}

#[cfg(any(target_os = "linux", target_os = "macos"))]
fn run_quiet<S: AsRef<std::ffi::OsStr>>(cmd: &str, args: &[S]) {
    match std::process::Command::new(cmd).args(args).output() {
        Ok(out) if out.status.success() => {}
        Ok(out) => log::warn!(
            "{cmd} exited {}: {}",
            out.status,
            String::from_utf8_lossy(&out.stderr).trim()
        ),
        Err(e) => log::warn!("{cmd} not available: {e}"),
    }
}

// ---------------------------------------------------------------------------
// macOS
// ---------------------------------------------------------------------------

#[cfg(target_os = "macos")]
fn install(_vpx_exe_path: &str) -> anyhow::Result<()> {
    let app = write_app_bundle()?;

    // .vpx association on macOS is owned by the shipped VPinballX.app
    // (its Info.plist declares CFBundleDocumentTypes for `.vpx`). We don't
    // duplicate that here — duti would be needed to override the user's
    // default handler and isn't preinstalled.

    log::info!("Desktop integration installed at {}", app.display());
    Ok(())
}

/// Write (or refresh) `~/Applications/PinReady.app` and return its path.
#[cfg(target_os = "macos")]
fn write_app_bundle() -> anyhow::Result<PathBuf> {
    let h = home().ok_or_else(|| anyhow::anyhow!("HOME not set"))?;
    let exe = std::env::current_exe()?;

    // Build a thin bundle at ~/Applications/PinReady.app that re-launches the
    // running binary. Launchpad/Spotlight pick up new bundles automatically.
    let app = h.join("Applications/PinReady.app");
    let macos_dir = app.join("Contents/MacOS");
    let resources = app.join("Contents/Resources");
    std::fs::create_dir_all(&macos_dir)?;
    std::fs::create_dir_all(&resources)?;

    // Launcher script — execs the real binary so dock/relaunch keeps the
    // same PID lifecycle. Symlinking the binary works too but breaks if the
    // user moves the cargo build output; a tiny shim is more robust.
    let exe_str = exe.display().to_string();
    let launcher = format!("#!/bin/sh\nexec {:?} \"$@\"\n", exe_str);
    let launcher_path = macos_dir.join("PinReady");
    std::fs::write(&launcher_path, launcher)?;
    use std::os::unix::fs::PermissionsExt;
    std::fs::set_permissions(&launcher_path, std::fs::Permissions::from_mode(0o755))?;

    // Icon: macOS wants .icns; we ship .png. Drop the PNG as a fallback —
    // Finder will use the generic icon if .icns is missing, but Spotlight
    // and Launchpad still display the bundle name.
    std::fs::write(resources.join("pinready.png"), PINREADY_LOGO_PNG)?;

    let plist = "\
<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n\
<plist version=\"1.0\">\n\
<dict>\n\
    <key>CFBundleDisplayName</key><string>PinReady</string>\n\
    <key>CFBundleExecutable</key><string>PinReady</string>\n\
    <key>CFBundleIdentifier</key><string>com.pinready.launcher</string>\n\
    <key>CFBundleName</key><string>PinReady</string>\n\
    <key>CFBundlePackageType</key><string>APPL</string>\n\
    <key>CFBundleShortVersionString</key><string>1.0</string>\n\
    <key>CFBundleIconFile</key><string>pinready.png</string>\n\
    <key>NSHighResolutionCapable</key><true/>\n\
</dict>\n\
</plist>\n";
    std::fs::write(app.join("Contents/Info.plist"), plist)?;

    // Re-register with Launch Services so Launchpad/Spotlight pick it up.
    run_quiet(
        "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister",
        &["-f", app.to_string_lossy().as_ref()],
    );
    Ok(app)
}

#[cfg(target_os = "macos")]
fn uninstall() -> anyhow::Result<()> {
    let h = home().ok_or_else(|| anyhow::anyhow!("HOME not set"))?;
    let app = h.join("Applications/PinReady.app");
    if app.exists() {
        std::fs::remove_dir_all(&app)?;
        log::info!("Removed {}", app.display());
    }
    Ok(())
}

// ---------------------------------------------------------------------------
// Windows
// ---------------------------------------------------------------------------

#[cfg(target_os = "windows")]
fn install(vpx_exe_path: &str) -> anyhow::Result<()> {
    let appdata = std::env::var("APPDATA").map_err(|_| anyhow::anyhow!("APPDATA not set"))?;
    let exe = std::env::current_exe()?;

    // Stash a real .ico under %APPDATA%\PinReady so .lnk + registry entries
    // can reference it by stable path. The exe itself doesn't embed an icon
    // unless built with windres.
    let icon_path = write_windows_icon()?;

    let start_menu = PathBuf::from(&appdata).join(r"Microsoft\Windows\Start Menu\Programs");
    std::fs::create_dir_all(&start_menu)?;

    // PinReady shortcut.
    create_lnk(
        &start_menu.join("PinReady.lnk"),
        &exe,
        exe.parent().unwrap_or(&exe),
        &icon_path,
        "Visual Pinball configurator and table launcher",
    )?;

    // VPinballX shortcut + .vpx association — only when VPX path is known.
    let vpx_resolved = (!vpx_exe_path.is_empty())
        .then(|| crate::updater::resolve_vpx_exe(Path::new(vpx_exe_path)))
        .filter(|p| p.is_file());

    if let Some(vpx) = vpx_resolved {
        create_lnk(
            &start_menu.join("Visual Pinball X.lnk"),
            &vpx,
            vpx.parent().unwrap_or(&vpx),
            &icon_path,
            "Visual Pinball X — Pinball simulator",
        )?;

        // Per-user file association via HKCU\Software\Classes. No UAC needed.
        let vpx_str = vpx.display().to_string();
        let icon_str = icon_path.display().to_string();
        let ps = format!(
            r#"$ErrorActionPreference='Stop';
            New-Item -Path 'HKCU:\Software\Classes\.vpx' -Force | Out-Null;
            Set-ItemProperty -Path 'HKCU:\Software\Classes\.vpx' -Name '(Default)' -Value 'PinReady.VPX';
            New-Item -Path 'HKCU:\Software\Classes\PinReady.VPX' -Force | Out-Null;
            Set-ItemProperty -Path 'HKCU:\Software\Classes\PinReady.VPX' -Name '(Default)' -Value 'Visual Pinball Table';
            New-Item -Path 'HKCU:\Software\Classes\PinReady.VPX\DefaultIcon' -Force | Out-Null;
            Set-ItemProperty -Path 'HKCU:\Software\Classes\PinReady.VPX\DefaultIcon' -Name '(Default)' -Value '"{icon}"';
            New-Item -Path 'HKCU:\Software\Classes\PinReady.VPX\shell\open\command' -Force | Out-Null;
            Set-ItemProperty -Path 'HKCU:\Software\Classes\PinReady.VPX\shell\open\command' -Name '(Default)' -Value '"{vpx}" -play "%1"';
            "#,
            icon = icon_str.replace('\'', "''"),
            vpx = vpx_str.replace('\'', "''"),
        );
        run_powershell(&ps);
    } else {
        let _ = std::fs::remove_file(start_menu.join("Visual Pinball X.lnk"));
    }

    log::info!(
        "Desktop integration installed under {}",
        start_menu.display()
    );
    Ok(())
}

/// Stash the .ico under `%APPDATA%\PinReady` and return its path.
#[cfg(target_os = "windows")]
fn write_windows_icon() -> anyhow::Result<PathBuf> {
    let appdata = std::env::var("APPDATA").map_err(|_| anyhow::anyhow!("APPDATA not set"))?;
    let pinready_dir = PathBuf::from(&appdata).join("PinReady");
    std::fs::create_dir_all(&pinready_dir)?;
    let icon_path = pinready_dir.join("pinready.ico");
    std::fs::write(&icon_path, PINREADY_ICON_ICO)?;
    Ok(icon_path)
}

#[cfg(target_os = "windows")]
fn uninstall() -> anyhow::Result<()> {
    let appdata = std::env::var("APPDATA").map_err(|_| anyhow::anyhow!("APPDATA not set"))?;
    let start_menu = PathBuf::from(&appdata).join(r"Microsoft\Windows\Start Menu\Programs");
    let _ = std::fs::remove_file(start_menu.join("PinReady.lnk"));
    let _ = std::fs::remove_file(start_menu.join("Visual Pinball X.lnk"));

    let _ = std::fs::remove_file(PathBuf::from(&appdata).join(r"PinReady\pinready.ico"));

    let ps = r#"$ErrorActionPreference='SilentlyContinue';
        Remove-Item -Path 'HKCU:\Software\Classes\.vpx' -Force -Recurse;
        Remove-Item -Path 'HKCU:\Software\Classes\PinReady.VPX' -Force -Recurse;
        exit 0;
        "#;
    run_powershell(ps);

    log::info!("Desktop integration removed");
    Ok(())
}

#[cfg(target_os = "windows")]
fn create_lnk(
    lnk_path: &Path,
    target: &Path,
    workdir: &Path,
    icon: &Path,
    description: &str,
) -> anyhow::Result<()> {
    // PowerShell single-quote escape — duplicate any embedded apostrophe.
    let q = |p: &Path| p.display().to_string().replace('\'', "''");
    let ps = format!(
        r#"$ws = New-Object -ComObject WScript.Shell;
        $s = $ws.CreateShortcut('{lnk}');
        $s.TargetPath = '{target}';
        $s.WorkingDirectory = '{workdir}';
        $s.IconLocation = '{icon},0';
        $s.Description = '{desc}';
        $s.Save();
        "#,
        lnk = q(lnk_path),
        target = q(target),
        workdir = q(workdir),
        icon = q(icon),
        desc = description.replace('\'', "''"),
    );
    run_powershell(&ps);
    Ok(())
}

#[cfg(target_os = "windows")]
fn run_powershell(script: &str) {
    match crate::win_process::powershell()
        .args(["-NoProfile", "-NonInteractive", "-Command", script])
        .output()
    {
        Ok(out) if out.status.success() => {}
        Ok(out) => log::warn!(
            "powershell exited {}: {}",
            out.status,
            String::from_utf8_lossy(&out.stderr).trim()
        ),
        Err(e) => log::warn!("powershell not available: {e}"),
    }
}

// ---------------------------------------------------------------------------
// Other platforms (BSD, etc.) — no-op
// ---------------------------------------------------------------------------

#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
fn install(_vpx_exe_path: &str) -> anyhow::Result<()> {
    Ok(())
}

#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
fn uninstall() -> anyhow::Result<()> {
    Ok(())
}

// ---------------------------------------------------------------------------
// Desktop shortcut and dock pinning
//
// Both are conveniences the app-menu entry above does not cover: a launcher
// sitting on the desktop, and the app pinned to the dock/taskbar. They are
// deliberately separate toggles — someone running a cabinet wants the desktop
// icon, someone using PinReady from a workstation usually wants neither.
//
// The desktop shortcut exists on all three systems (a .desktop file, a .lnk,
// an alias of the .app bundle). The desktop file is standard freedesktop, but pinning is
// shell-specific: GNOME keeps its list in a GSettings key, KDE and XFCE have
// their own panel configuration with no shared interface. Rather than pretend
// otherwise, `dock_pinning_available` reports whether we can do it here, and
// the UI hides the option when we can't.
// ---------------------------------------------------------------------------

/// Path of the desktop launcher, if the platform has a desktop folder.
fn desktop_shortcut_path() -> Option<PathBuf> {
    let name = if cfg!(target_os = "windows") {
        "PinReady.lnk"
    } else if cfg!(target_os = "macos") {
        "PinReady.app"
    } else {
        "pinready.desktop"
    };
    dirs::desktop_dir().map(|d| d.join(name))
}

/// Whether a launcher currently sits on the desktop.
pub(super) fn is_desktop_shortcut_installed() -> bool {
    // symlink_metadata: on macOS the shortcut is a symlink, and a dangling one
    // still has to be found so it can be replaced or removed.
    desktop_shortcut_path().is_some_and(|p| std::fs::symlink_metadata(p).is_ok())
}

/// Put a launcher on the desktop, or take it away.
///
/// The file is a copy of the app-menu entry, marked executable — GNOME 42+
/// still shows it as an untrusted launcher until the user picks "Allow
/// launching" once, which is the shell's decision to make, not ours.
pub(super) fn set_desktop_shortcut(enabled: bool) -> anyhow::Result<()> {
    #[cfg(target_os = "linux")]
    {
        let target = desktop_shortcut_path().ok_or_else(|| anyhow::anyhow!("no desktop folder"))?;
        if !enabled {
            if target.exists() {
                std::fs::remove_file(&target)?;
            }
            return Ok(());
        }
        // `Icon=pinready` only resolves once the artwork sits in the icon
        // theme, which is the menu integration's job — and this toggle is
        // independent of it. Write the icon here too (same bytes, same path,
        // so enabling both is idempotent) or the launcher shows up blank.
        if let Some(h) = home() {
            let icons = h.join(".local/share/icons/hicolor/128x128/apps");
            if std::fs::create_dir_all(&icons).is_ok() {
                let _ = std::fs::write(icons.join("pinready.png"), PINREADY_LOGO_PNG);
            }
        }

        // Reuse the menu entry when it exists so both always agree; write a
        // standalone one otherwise, since the desktop icon has to work even
        // if the user never enabled the menu integration.
        let source = pinready_marker_path().filter(|p| p.exists());
        let contents = match source {
            Some(p) => std::fs::read_to_string(p)?,
            None => {
                let exe = std::env::current_exe()?;
                format!(
                    "[Desktop Entry]\n\
                     Type=Application\n\
                     Name=PinReady\n\
                     GenericName=Visual Pinball Launcher\n\
                     Comment=Visual Pinball configurator and table launcher\n\
                     Exec={}\n\
                     Icon=pinready\n\
                     Terminal=false\n\
                     Categories=Game;ArcadeGame;\n\
                     StartupNotify=true\n\
                     StartupWMClass=PinReady\n",
                    shell_quote(&exe.display().to_string())
                )
            }
        };
        if let Some(parent) = target.parent() {
            std::fs::create_dir_all(parent)?;
        }
        std::fs::write(&target, contents)?;
        {
            use std::os::unix::fs::PermissionsExt as _;
            std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755))?;
        }

        // Executable is not enough: GNOME 42+ shows a launcher it doesn't
        // trust as inert until the user right-clicks "Allow launching". What
        // it looks at is a GIO metadata flag, stored in the desktop
        // environment's own database rather than in the file, so `gio` is the
        // way to set it — the same kind of best-effort shell-out as the dock
        // favourites. A desktop without it just keeps asking once, which is
        // the behaviour we had.
        match std::process::Command::new("gio")
            .args([
                "set",
                &target.display().to_string(),
                "metadata::trusted",
                "true",
            ])
            .output()
        {
            Ok(out) if out.status.success() => {
                log::info!("Desktop launcher marked as trusted");
            }
            Ok(out) => log::debug!(
                "Could not mark the launcher as trusted: {}",
                String::from_utf8_lossy(&out.stderr).trim()
            ),
            Err(e) => log::debug!("`gio` unavailable, launcher left untrusted: {e}"),
        }

        Ok(())
    }
    #[cfg(target_os = "windows")]
    {
        let target = desktop_shortcut_path().ok_or_else(|| anyhow::anyhow!("no desktop folder"))?;
        if !enabled {
            if target.exists() {
                std::fs::remove_file(&target)?;
            }
            return Ok(());
        }
        let exe = std::env::current_exe()?;
        create_lnk(
            &target,
            &exe,
            exe.parent().unwrap_or(&exe),
            &write_windows_icon()?,
            "Visual Pinball configurator and table launcher",
        )?;
        // create_lnk only logs a PowerShell failure; the box must not stay
        // ticked over a shortcut that was never written.
        anyhow::ensure!(target.exists(), "could not create {}", target.display());
        Ok(())
    }
    #[cfg(target_os = "macos")]
    {
        let target = desktop_shortcut_path().ok_or_else(|| anyhow::anyhow!("no desktop folder"))?;
        if std::fs::symlink_metadata(&target).is_ok() {
            std::fs::remove_file(&target)?;
        }
        if !enabled {
            return Ok(());
        }
        // A symlink to the bundle is what Finder shows as an alias: it opens
        // the same app the menu integration installs, icon included.
        let app = write_app_bundle()?;
        std::os::unix::fs::symlink(&app, &target)?;
        Ok(())
    }
    #[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
    {
        let _ = enabled;
        anyhow::bail!("desktop shortcuts are not supported on this platform")
    }
}

/// Whether this session exposes a dock we know how to pin to (GNOME).
pub(super) fn dock_pinning_available() -> bool {
    #[cfg(target_os = "linux")]
    {
        gsettings(&["get", "org.gnome.shell", "favorite-apps"]).is_some()
    }
    #[cfg(not(target_os = "linux"))]
    {
        false
    }
}

/// Whether PinReady is pinned to the dock.
pub(super) fn is_pinned_to_dock() -> bool {
    #[cfg(target_os = "linux")]
    {
        gsettings(&["get", "org.gnome.shell", "favorite-apps"])
            .is_some_and(|list| list.contains("'pinready.desktop'"))
    }
    #[cfg(not(target_os = "linux"))]
    {
        false
    }
}

/// Pin PinReady to the dock, or unpin it.
pub(super) fn set_pinned_to_dock(enabled: bool) -> anyhow::Result<()> {
    #[cfg(target_os = "linux")]
    {
        let current = gsettings(&["get", "org.gnome.shell", "favorite-apps"])
            .ok_or_else(|| anyhow::anyhow!("no GNOME shell settings on this session"))?;
        // The value is a GVariant string array: ['a.desktop', 'b.desktop'].
        // Parse it back to entries rather than string-splice, so a list we
        // did not write stays intact.
        let mut apps: Vec<String> = current
            .trim()
            .trim_start_matches('@')
            .trim_start_matches("as")
            .trim()
            .trim_start_matches('[')
            .trim_end_matches(']')
            .split(',')
            .map(|s| s.trim().trim_matches('\'').to_string())
            .filter(|s| !s.is_empty())
            .collect();
        let entry = "pinready.desktop".to_string();
        apps.retain(|a| a != &entry);
        if enabled {
            apps.push(entry);
        }
        let value = format!(
            "[{}]",
            apps.iter()
                .map(|a| format!("'{a}'"))
                .collect::<Vec<_>>()
                .join(", ")
        );
        gsettings(&["set", "org.gnome.shell", "favorite-apps", &value])
            .ok_or_else(|| anyhow::anyhow!("could not write the favourites list"))?;
        Ok(())
    }
    #[cfg(not(target_os = "linux"))]
    {
        let _ = enabled;
        anyhow::bail!("dock pinning is only implemented for GNOME")
    }
}

/// Run `gsettings`, returning its stdout on success.
///
/// This is the one place PinReady shells out: GNOME's favourites live in
/// dconf, and reaching them otherwise means speaking the dconf D-Bus protocol
/// by hand for a purely cosmetic feature. Absent binary or failed call simply
/// means "no dock pinning here", which the UI treats as the option not
/// existing.
#[cfg(target_os = "linux")]
fn gsettings(args: &[&str]) -> Option<String> {
    let out = std::process::Command::new("gsettings")
        .args(args)
        .output()
        .ok()?;
    out.status
        .success()
        .then(|| String::from_utf8_lossy(&out.stdout).into_owned())
}