use std::path::PathBuf;
#[cfg(not(target_os = "macos"))]
use std::path::Path;
#[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");
pub(super) fn is_desktop_integration_installed() -> bool {
pinready_marker_path().is_some_and(|p| p.exists())
}
pub(super) fn set_desktop_integration(enabled: bool, vpx_exe_path: &str) -> anyhow::Result<()> {
if enabled {
install(vpx_exe_path)
} else {
uninstall()
}
}
#[cfg(not(target_os = "windows"))]
fn home() -> Option<PathBuf> {
std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.ok()
.map(PathBuf::from)
}
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
}
}
#[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)?;
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,
)?;
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)?;
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)?;
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 {
let _ = std::fs::remove_file(apps.join("vpinballx.desktop"));
}
run_quiet("update-mime-database", &[h.join(".local/share/mime")]);
run_quiet("update-desktop-database", std::slice::from_ref(&apps));
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(())
}
#[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}"),
}
}
#[cfg(target_os = "macos")]
fn install(_vpx_exe_path: &str) -> anyhow::Result<()> {
let app = write_app_bundle()?;
log::info!("Desktop integration installed at {}", app.display());
Ok(())
}
#[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()?;
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)?;
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))?;
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)?;
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(())
}
#[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()?;
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)?;
create_lnk(
&start_menu.join("PinReady.lnk"),
&exe,
exe.parent().unwrap_or(&exe),
&icon_path,
"Visual Pinball configurator and table launcher",
)?;
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",
)?;
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(())
}
#[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<()> {
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}"),
}
}
#[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(())
}
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))
}
pub(super) fn is_desktop_shortcut_installed() -> bool {
desktop_shortcut_path().is_some_and(|p| std::fs::symlink_metadata(p).is_ok())
}
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(());
}
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);
}
}
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))?;
}
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",
)?;
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(());
}
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")
}
}
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
}
}
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
}
}
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"))?;
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")
}
}
#[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())
}