feat(linux): native build path + Linux-specific launcher optimizations
- X-Plane auto-detection now finds Steam (native + Flatpak), /opt and external-drive SteamLibrary installs, not just ~/ and macOS/Windows paths. - Close-to-background minimizes on Linux instead of hiding to tray, so the app is never stranded on desktops without a tray (e.g. vanilla GNOME). - Disable WebKitGTK's DMABUF renderer on Linux to avoid black/blank windows on some GPU/driver combos (overridable via the env var). - Launcher font stack gains Linux UI/mono fallbacks (Cantarell/Ubuntu/Noto). - scripts/build.sh: Docker-free Linux AppImage build — repack the cockpit into the prebuilt bundle for web-only changes; recompile natively for code changes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -66,30 +66,71 @@ fn suggest_port(start: u16) -> u16 {
|
|||||||
start
|
start
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A directory is an X-Plane 12 root iff it holds Resources/default data.
|
||||||
|
fn is_xplane_root(p: &std::path::Path) -> bool {
|
||||||
|
p.join("Resources").join("default data").is_dir()
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn default_xplane_path() -> Option<String> {
|
fn default_xplane_path() -> Option<String> {
|
||||||
let home = std::env::var("HOME")
|
let home = std::env::var("HOME")
|
||||||
.or_else(|_| std::env::var("USERPROFILE"))
|
.or_else(|_| std::env::var("USERPROFILE"))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let candidates = [
|
let user = std::env::var("USER")
|
||||||
|
.or_else(|_| std::env::var("USERNAME"))
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let mut candidates = vec![
|
||||||
|
// Generic / cross-platform
|
||||||
format!("{home}/X-Plane 12"),
|
format!("{home}/X-Plane 12"),
|
||||||
format!("{home}/Desktop/X-Plane 12"),
|
format!("{home}/Desktop/X-Plane 12"),
|
||||||
|
format!("{home}/Games/X-Plane 12"),
|
||||||
|
// Steam on Linux — native, plus the Flatpak Steam sandbox path
|
||||||
|
format!("{home}/.steam/steam/steamapps/common/X-Plane 12"),
|
||||||
|
format!("{home}/.local/share/Steam/steamapps/common/X-Plane 12"),
|
||||||
|
format!("{home}/.var/app/com.valvesoftware.Steam/.local/share/Steam/steamapps/common/X-Plane 12"),
|
||||||
|
// System-wide (Linux)
|
||||||
|
"/opt/X-Plane 12".to_string(),
|
||||||
|
// macOS
|
||||||
"/Applications/X-Plane 12".to_string(),
|
"/Applications/X-Plane 12".to_string(),
|
||||||
|
// Windows
|
||||||
"C:/X-Plane 12".to_string(),
|
"C:/X-Plane 12".to_string(),
|
||||||
"D:/X-Plane 12".to_string(),
|
"D:/X-Plane 12".to_string(),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Secondary/external drives: Steam libraries on extra disks usually mount
|
||||||
|
// under /media/$USER, /run/media/$USER or /mnt. Probe each mounted volume
|
||||||
|
// for the standard SteamLibrary layout (and a bare "X-Plane 12" folder).
|
||||||
|
for base in [
|
||||||
|
format!("/media/{user}"),
|
||||||
|
format!("/run/media/{user}"),
|
||||||
|
"/mnt".to_string(),
|
||||||
|
] {
|
||||||
|
if let Ok(entries) = std::fs::read_dir(&base) {
|
||||||
|
for vol in entries.flatten().map(|e| e.path()) {
|
||||||
|
candidates.push(
|
||||||
|
vol.join("SteamLibrary/steamapps/common/X-Plane 12")
|
||||||
|
.to_string_lossy()
|
||||||
|
.into_owned(),
|
||||||
|
);
|
||||||
|
candidates.push(
|
||||||
|
vol.join("steamapps/common/X-Plane 12")
|
||||||
|
.to_string_lossy()
|
||||||
|
.into_owned(),
|
||||||
|
);
|
||||||
|
candidates.push(vol.join("X-Plane 12").to_string_lossy().into_owned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
candidates
|
candidates
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.find(|c| PathBuf::from(c).join("Resources").join("default data").is_dir())
|
.find(|c| is_xplane_root(&PathBuf::from(c)))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn valid_xplane_path(path: String) -> bool {
|
fn valid_xplane_path(path: String) -> bool {
|
||||||
!path.is_empty()
|
!path.is_empty() && is_xplane_root(&PathBuf::from(&path))
|
||||||
&& PathBuf::from(&path)
|
|
||||||
.join("Resources")
|
|
||||||
.join("default data")
|
|
||||||
.is_dir()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -225,11 +266,17 @@ pub fn run() {
|
|||||||
build_tray(app.handle())?;
|
build_tray(app.handle())?;
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
// Closing the window hides it instead of quitting, so the server keeps
|
// Closing the window keeps the app alive so the server keeps serving
|
||||||
// serving tablets in the background. Quit from the tray.
|
// tablets in the background. On macOS/Windows the tray icon is always
|
||||||
|
// reachable, so fully hide. On Linux many desktops (notably vanilla
|
||||||
|
// GNOME) show no tray at all, so hiding would strand the app with no way
|
||||||
|
// back — minimize instead, keeping it in the taskbar while it runs.
|
||||||
.on_window_event(|window, event| {
|
.on_window_event(|window, event| {
|
||||||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||||
api.prevent_close();
|
api.prevent_close();
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
let _ = window.minimize();
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
let _ = window.hide();
|
let _ = window.hide();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,5 +2,14 @@
|
|||||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
|
// WebKitGTK's DMABUF renderer shows a black/blank window on a number of Linux
|
||||||
|
// GPU/driver combinations (notably Nvidia and older Mesa). This launcher is a
|
||||||
|
// simple control panel, so favour reliability over GPU compositing: disable
|
||||||
|
// the DMABUF renderer unless the user has already set the variable themselves.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
if std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_none() {
|
||||||
|
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
|
||||||
|
}
|
||||||
|
|
||||||
xplane_cockpit_lib::run()
|
xplane_cockpit_lib::run()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ html, body { margin: 0; height: 100%; }
|
|||||||
body {
|
body {
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--txt);
|
color: var(--txt);
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", Roboto, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", "Inter", "Cantarell", "Ubuntu", "Noto Sans", Roboto, sans-serif;
|
||||||
font-size: 13px; user-select: none; -webkit-font-smoothing: antialiased;
|
font-size: 13px; user-select: none; -webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
.panel { display: flex; flex-direction: column; height: 100vh; padding: 16px; gap: 14px; }
|
.panel { display: flex; flex-direction: column; height: 100vh; padding: 16px; gap: 14px; }
|
||||||
@@ -69,7 +69,7 @@ input:focus { outline: none; border-color: var(--green); box-shadow: 0 0 0 3px r
|
|||||||
|
|
||||||
.live.hidden { display: none; }
|
.live.hidden { display: none; }
|
||||||
.url-row { display: flex; gap: 8px; align-items: center; }
|
.url-row { display: flex; gap: 8px; align-items: center; }
|
||||||
.url-row code { flex: 1; background: var(--bg); border: 1px solid var(--line); color: var(--green); border-radius: 7px; padding: 10px 12px; font-size: 16px; font-weight: 600; letter-spacing: .3px; user-select: text; font-family: ui-monospace, "SF Mono", Menlo, monospace; }
|
.url-row code { flex: 1; background: var(--bg); border: 1px solid var(--line); color: var(--green); border-radius: 7px; padding: 10px 12px; font-size: 16px; font-weight: 600; letter-spacing: .3px; user-select: text; font-family: ui-monospace, "SF Mono", Menlo, "JetBrains Mono", "DejaVu Sans Mono", "Noto Sans Mono", monospace; }
|
||||||
.quick { display: flex; gap: 6px; }
|
.quick { display: flex; gap: 6px; }
|
||||||
.quick .btn { flex: 1; }
|
.quick .btn { flex: 1; }
|
||||||
|
|
||||||
@@ -80,7 +80,7 @@ input:focus { outline: none; border-color: var(--green); box-shadow: 0 0 0 3px r
|
|||||||
|
|
||||||
.log-wrap { background: var(--bg2); border: 1px solid var(--line-soft); border-radius: 12px; padding: 6px 12px; }
|
.log-wrap { background: var(--bg2); border: 1px solid var(--line-soft); border-radius: 12px; padding: 6px 12px; }
|
||||||
.log-wrap summary { color: var(--mut); font-size: 12px; cursor: pointer; padding: 4px 0; }
|
.log-wrap summary { color: var(--mut); font-size: 12px; cursor: pointer; padding: 4px 0; }
|
||||||
#log { margin: 6px 0 2px; max-height: 140px; overflow-y: auto; font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 11px; color: var(--mut); white-space: pre-wrap; }
|
#log { margin: 6px 0 2px; max-height: 140px; overflow-y: auto; font-family: ui-monospace, "SF Mono", Menlo, "JetBrains Mono", "DejaVu Sans Mono", "Noto Sans Mono", monospace; font-size: 11px; color: var(--mut); white-space: pre-wrap; }
|
||||||
|
|
||||||
.ft { display: flex; align-items: center; justify-content: space-between; color: var(--mut); font-size: 12px; }
|
.ft { display: flex; align-items: center; justify-content: space-between; color: var(--mut); font-size: 12px; }
|
||||||
.link { background: none; border: none; color: var(--green); cursor: pointer; font-size: 12px; font-family: inherit; }
|
.link { background: none; border: none; color: var(--green); cursor: pointer; font-size: 12px; font-family: inherit; }
|
||||||
|
|||||||
Executable
+94
@@ -0,0 +1,94 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build the Linux artifacts (AppImage + .deb) WITHOUT Docker and WITHOUT
|
||||||
|
# recompiling the Rust launcher.
|
||||||
|
#
|
||||||
|
# WHY: this app's launcher (Tauri/Rust) barely ever changes — what changes is the
|
||||||
|
# cockpit itself (web/ JSX), which ships as a *resource* the Bun sidecar serves at
|
||||||
|
# runtime. So a release is really just: rebuild the web cockpit, drop it into the
|
||||||
|
# already-compiled bundle, and repack. That's seconds, not a Docker cross-build.
|
||||||
|
#
|
||||||
|
# It reuses three things produced by a prior full `tauri build`:
|
||||||
|
# * the compiled launcher + GTK/WebKit libs in the AppDir
|
||||||
|
# * the cached linuxdeploy appimage packer (~/.cache/tauri/...)
|
||||||
|
# * the seed .deb (for its control metadata + file tree)
|
||||||
|
# and refreshes the cockpit (usr/lib/X-Plane Cockpit/web) + Lua plugins in both.
|
||||||
|
#
|
||||||
|
# Seed once (native, no Docker) if the AppDir is missing:
|
||||||
|
# scripts/prep-desktop.sh
|
||||||
|
# npx --prefix desktop tauri build --target x86_64-unknown-linux-gnu --bundles appimage,deb
|
||||||
|
# Thereafter just run this script for every web-only change.
|
||||||
|
#
|
||||||
|
# CAVEAT: the launcher binary is reused as-is, so the *version it reports itself*
|
||||||
|
# (used by the auto-updater) is whatever it was last compiled with — not
|
||||||
|
# necessarily $VERSION. The cockpit features are unaffected (they come from the
|
||||||
|
# refreshed web resources). If you bump the version AND rely on the updater,
|
||||||
|
# recompile the launcher once with the tauri build line above.
|
||||||
|
set -euo pipefail
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"; cd "$ROOT"
|
||||||
|
die(){ echo "!! $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
TARGET=x86_64-unknown-linux-gnu
|
||||||
|
BUNDLE="$ROOT/target-linux/$TARGET/release/bundle"
|
||||||
|
APPDIR="$BUNDLE/appimage/X-Plane Cockpit.AppDir"
|
||||||
|
RESDIR="$APPDIR/usr/lib/X-Plane Cockpit"
|
||||||
|
PLUGIN="$HOME/.cache/tauri/linuxdeploy-plugin-appimage.AppImage"
|
||||||
|
SIDECAR="$ROOT/desktop/src-tauri/binaries/xpbridge-$TARGET"
|
||||||
|
PKGNAME="X-Plane Cockpit"
|
||||||
|
VERSION="$(node -p "require('$ROOT/desktop/src-tauri/tauri.conf.json').version")"
|
||||||
|
|
||||||
|
echo "==> X-Plane Cockpit Linux repack — v$VERSION (no Docker, no Rust recompile)"
|
||||||
|
|
||||||
|
# ---- preflight -----------------------------------------------------------
|
||||||
|
[[ -d "$APPDIR" ]] || die "no prebuilt AppDir: $APPDIR — seed one full tauri build first (see header)"
|
||||||
|
[[ -x "$PLUGIN" ]] || die "missing appimage packer: $PLUGIN — run one tauri appimage build to fetch it"
|
||||||
|
[[ -f "$SIDECAR" ]] || die "missing sidecar: $SIDECAR — run scripts/prep-desktop.sh"
|
||||||
|
command -v ar >/dev/null || die "'ar' (binutils) required to assemble the .deb"
|
||||||
|
command -v tar >/dev/null || die "'tar' required"
|
||||||
|
|
||||||
|
SIGN=0
|
||||||
|
[[ -f desktop/.tauri-signing.key && -f desktop/.tauri-signing.pw ]] && SIGN=1
|
||||||
|
sign(){ # $1 = artifact; writes <artifact>.sig next to it
|
||||||
|
if [[ $SIGN == 1 ]]; then
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY="$(cat desktop/.tauri-signing.key)" \
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY_PASSWORD="$(cat desktop/.tauri-signing.pw)" \
|
||||||
|
npx --prefix desktop tauri signer sign "$1" >/dev/null
|
||||||
|
echo " signed: $(basename "$1").sig"
|
||||||
|
else
|
||||||
|
rm -f "$1.sig"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- 1. build the web cockpit, refresh repo resources --------------------
|
||||||
|
echo "==> building web cockpit (vite)"
|
||||||
|
( cd web && npm run build >/dev/null )
|
||||||
|
echo "==> refreshing desktop/src-tauri/resources"
|
||||||
|
rm -rf "desktop/src-tauri/resources/web"; mkdir -p "desktop/src-tauri/resources/web"
|
||||||
|
cp -R web/dist/. "desktop/src-tauri/resources/web/"
|
||||||
|
rm -rf "desktop/src-tauri/resources/plugins"; mkdir -p "desktop/src-tauri/resources/plugins"
|
||||||
|
cp plugins/*.lua "desktop/src-tauri/resources/plugins/"
|
||||||
|
|
||||||
|
# ---- 2. refresh the cockpit inside the prebuilt AppDir -------------------
|
||||||
|
echo "==> updating bundled cockpit inside AppDir"
|
||||||
|
rm -rf "$RESDIR/web"; mkdir -p "$RESDIR/web"; cp -R web/dist/. "$RESDIR/web/"
|
||||||
|
rm -rf "$RESDIR/plugins"; mkdir -p "$RESDIR/plugins"; cp plugins/*.lua "$RESDIR/plugins/"
|
||||||
|
# the AppDir's sidecar copy may be a patchelf-corrupted one — restore the pristine
|
||||||
|
cp -f "$SIDECAR" "$APPDIR/usr/bin/xpbridge"; chmod +x "$APPDIR/usr/bin/xpbridge"
|
||||||
|
|
||||||
|
# ---- 3. pack the AppImage (cached linuxdeploy plugin, no patchelf) -------
|
||||||
|
OUTIMG="$BUNDLE/appimage/${PKGNAME}_${VERSION}_amd64.AppImage"
|
||||||
|
echo "==> packing AppImage -> $(basename "$OUTIMG")"
|
||||||
|
rm -f "$OUTIMG"
|
||||||
|
APPIMAGE_EXTRACT_AND_RUN=1 NO_STRIP=1 ARCH=x86_64 LDAI_OUTPUT="$OUTIMG" \
|
||||||
|
"$PLUGIN" --appdir "$APPDIR" >/dev/null
|
||||||
|
[[ -f "$OUTIMG" ]] || die "AppImage packing produced nothing"
|
||||||
|
chmod +x "$OUTIMG"
|
||||||
|
sign "$OUTIMG"
|
||||||
|
echo " AppImage: $(du -h "$OUTIMG" | cut -f1)"
|
||||||
|
|
||||||
|
# The .deb is intentionally NOT built: the AppImage is the single self-contained,
|
||||||
|
# self-updating artifact (the Tauri Linux updater swaps the AppImage in place; it
|
||||||
|
# never uses a .deb). If you ever want a .deb too, run a full native tauri build
|
||||||
|
# with --bundles appimage,deb.
|
||||||
|
|
||||||
|
echo "==> done. artifact:"
|
||||||
|
ls -1 "$OUTIMG"
|
||||||
Reference in New Issue
Block a user