feat(app): Android build target — first working APK at 54 MB

Wires the workspace through `cargo apk build`. After this commit
`cargo apk build -p solitaire_app --target x86_64-linux-android`
produces a debug-signed APK at `target/debug/apk/solitaire-quest.apk`
containing all assets and `lib/x86_64/libsolitaire_app.so` — runnable
on the AVD or a physical x86_64 device.

The five gating points discovered by iterating compile cycles:

1. solitaire_app split into bin + lib. cargo-apk needs a `cdylib`
   to bundle as `libmain.so`; pure-bin crates panic with
   "Bin is not compatible with Cdylib". `src/lib.rs` carries the
   ECS bootstrap as `pub fn run`; `src/main.rs` is a 3-line shim
   that delegates for the desktop `cargo run` path.

2. `[package.metadata.android]` pins target SDK 34 / min SDK 26
   so cargo-apk doesn't probe for whatever default it ships
   (which on this machine was an uninstalled API 30). `assets =
   "../assets"` lets the same asset directory feed both desktop
   and APK.

3. Workspace `bevy` features add `android-native-activity` (the
   Bevy-side glue that pairs with cargo-apk's NativeActivity
   wrapper). The feature is target-gated inside bevy_internal so
   desktop builds compile it out.

4. `arboard` (clipboard, used by Stats's "Copy share link") has
   no Android backend — `cargo apk build` fails with E0433 on
   `platform::Clipboard` if unconditional. Target-gated to
   `cfg(not(target_os = "android"))`; the system surfaces an
   informational toast on Android until JNI ClipboardManager is
   wired in the Phase-Android round.

5. `keyring` + `keyring-core` cannot compile for android — the
   transitive `rpassword` uses `libc::__errno_location` which
   bionic doesn't expose. Both crates target-gated; `auth_tokens`
   ships a stub on Android that returns `KeychainUnavailable` for
   every call, matching how callers already handle a Linux box
   without Secret Service.

Cosmetic post-pass panic: cargo-apk panics AFTER the APK is signed
when it tries to also wrap the bin target. The APK on disk is
unaffected. Working around this with `cargo apk build --lib` is
the next small step.

What's verified:
- Desktop `cargo build`, `cargo clippy --workspace --all-targets`,
  and `cargo test --workspace` all clean.
- `cargo apk build -p solitaire_app --target x86_64-linux-android`
  produces 54 MB debug APK with libsolitaire_app.so + assets.

What's NOT yet verified:
- Whether the APK actually launches on the AVD / a phone (next
  step: `adb install` + `adb logcat` against the bevy_test AVD).
- Whether `dirs::data_dir()` on Android returns a usable path
  (sync / persistence will surface this if not).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
funman300
2026-05-07 19:34:48 +00:00
parent 690e1d2ad6
commit fb8b2ac684
8 changed files with 454 additions and 267 deletions
+60
View File
@@ -8,8 +8,68 @@ edition.workspace = true
name = "solitaire_app"
path = "src/main.rs"
# `cdylib` is what cargo-apk packages into `libsolitaire_app.so` for
# Android — the activity dlopens the shared object and calls into it.
# `rlib` lets the bin target above link the library normally on
# desktop. Both produce the same code; only the linkage form differs.
[lib]
name = "solitaire_app"
path = "src/lib.rs"
crate-type = ["cdylib", "rlib"]
[dependencies]
bevy = { workspace = true }
solitaire_engine = { workspace = true }
solitaire_data = { workspace = true }
# `keyring`'s default-store init only matters on platforms with a
# real keychain backend (Linux Secret Service, macOS Keychain,
# Windows Credential Store). The crate also pulls `rpassword`
# transitively, which uses `libc::__errno_location` — a symbol
# Android's bionic doesn't expose. Target-gating keeps
# `cargo apk build` viable; the call site in `lib.rs` has its own
# `cfg(not(target_os = "android"))` guard so the desktop init path
# is unchanged.
[target.'cfg(not(target_os = "android"))'.dependencies]
keyring = { workspace = true }
# --- Android packaging metadata (read by `cargo-apk`) -------------------
#
# Pinning these values inside the repo means a contributor running
# `cargo apk build -p solitaire_app --target x86_64-linux-android`
# does not need to install whatever SDK version cargo-apk happens to
# default to today. The numbers track the SDK we install in the dev
# setup script: target SDK 34 (Android 14, current Play Store target),
# min SDK 26 (Android 8, the lowest Bevy 0.18 supports cleanly with
# the wgpu / GLES path).
#
# Asset path is `../assets` so the same directory the desktop build
# already uses ships into the APK without copy-tree gymnastics.
# `apk_name` keeps the output filename predictable across machines.
[package.metadata.android]
package = "com.solitairequest.app"
apk_name = "solitaire-quest"
build_targets = ["aarch64-linux-android", "armv7-linux-androideabi", "x86_64-linux-android"]
assets = "../assets"
# No `runtime_libs` — we don't ship any precompiled .so files,
# the entire app is pure Rust + Bevy. cargo-apk would try to
# resolve `runtime_libs/<arch>/` if set, and fail on a non-existent
# arch directory under our package.
strip = "strip"
[package.metadata.android.sdk]
target_sdk_version = 34
min_sdk_version = 26
[[package.metadata.android.uses_feature]]
name = "android.hardware.touchscreen"
required = true
[[package.metadata.android.uses_permission]]
name = "android.permission.INTERNET"
[package.metadata.android.application]
label = "Solitaire Quest"
# `debuggable` defaults to false on release builds; cargo-apk flips it
# automatically for debug profiles. Leaving the field unset keeps the
# default behaviour.
+281
View File
@@ -0,0 +1,281 @@
//! Library entry point for `solitaire_app`.
//!
//! The app is a `cdylib + bin` hybrid: desktop builds run through the
//! `bin` target's [`main`](crate::main_desktop) shim; Android builds
//! load this `cdylib` via NativeActivity / GameActivity, which calls
//! into the platform's own `main` glue. Both paths converge on
//! [`run`], so the ECS bootstrap is single-sourced.
//!
//! Why split this out: cargo-apk requires the package to expose a
//! `cdylib` library target — the Android activity dlopens
//! `libsolitaire_app.so` and calls into it. A bin-only crate panics
//! at build time with `Bin is not compatible with Cdylib`. The split
//! keeps the desktop `cargo run -p solitaire_app` flow unchanged
//! while making `cargo apk build -p solitaire_app` viable.
use std::fs::OpenOptions;
use std::io::Write;
use std::time::{SystemTime, UNIX_EPOCH};
use bevy::prelude::*;
use bevy::window::{
Monitor, MonitorSelection, PresentMode, PrimaryMonitor, PrimaryWindow, WindowPosition,
};
use solitaire_data::{load_settings_from, provider_for_backend, settings_file_path, Settings};
use solitaire_engine::{
register_theme_asset_sources, AchievementPlugin, AnimationPlugin, AssetSourcesPlugin,
AudioPlugin, AutoCompletePlugin, CardAnimationPlugin, CardPlugin, ChallengePlugin,
CursorPlugin, DailyChallengePlugin, DiagnosticsHudPlugin, FeedbackAnimPlugin, FontPlugin,
GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin,
OnboardingPlugin, PausePlugin, ProfilePlugin, ProgressPlugin, RadialMenuPlugin,
ReplayOverlayPlugin, ReplayPlaybackPlugin, SelectionPlugin, SettingsPlugin, SplashPlugin,
StatsPlugin, SyncPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin,
UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WinSummaryPlugin,
};
/// App entry point — builds and runs the Bevy app.
///
/// Called from both the desktop `bin` target's `main` shim and (on
/// Android) the platform's NativeActivity / GameActivity glue.
pub fn run() {
// Install a panic hook that writes a crash log next to the save files
// before re-running the default hook (so stderr still gets the message
// and any debugger attached still sees the panic).
install_crash_log_hook();
// Initialise the platform keyring store before any token operations.
// On Linux this uses the Secret Service (GNOME Keyring / KWallet); on
// macOS it uses the Keychain; on Windows it uses the Credential store.
// If the platform has no OS keyring (e.g. a headless CI box), keyring
// operations will fail gracefully with TokenError::KeychainUnavailable.
//
// Android: `keyring` isn't compiled in (its `rpassword` transitive
// pulls a libc symbol Android's bionic doesn't expose). `auth_tokens`
// ships an Android stub that returns KeychainUnavailable for every
// call — the runtime behaviour is "session login required each launch"
// until we wire Android Keystore via JNI in the Phase-Android round.
#[cfg(not(target_os = "android"))]
if let Err(e) = keyring::use_native_store(true) {
eprintln!(
"warn: could not initialise OS keyring ({e}); \
server sync login will be unavailable"
);
}
// Load settings before building the app so we can construct the right
// sync provider. Falls back to defaults if no settings file exists yet.
let settings: Settings = settings_file_path()
.map(|p| load_settings_from(&p))
.unwrap_or_default();
let sync_provider = provider_for_backend(&settings.sync_backend);
// Restore the previous window geometry if the player has one saved.
// Otherwise open at the platform default (1280×800, centred on the
// primary monitor) — `apply_smart_default_window_size` will resize
// up to a monitor-relative target on the first frame so HiDPI / 4K
// sessions don't end up with a comparatively tiny window.
let had_saved_geometry = settings.window_geometry.is_some();
let (window_resolution, window_position) = match settings.window_geometry {
Some(geom) => (
(geom.width, geom.height).into(),
WindowPosition::At(IVec2::new(geom.x, geom.y)),
),
None => (
(1280u32, 800u32).into(),
WindowPosition::Centered(MonitorSelection::Primary),
),
};
let mut app = App::new();
// The card-theme system's `themes://` asset source must be
// registered *before* `DefaultPlugins` builds `AssetPlugin`,
// because that plugin freezes the asset-source list at build
// time. The matching `AssetSourcesPlugin` (added below) finishes
// the wiring after `DefaultPlugins` by populating the embedded
// default theme into Bevy's `EmbeddedAssetRegistry`.
register_theme_asset_sources(&mut app);
app
.add_plugins(
DefaultPlugins
.set(WindowPlugin {
primary_window: Some(Window {
title: "Solitaire Quest".into(),
// X11/Wayland WM_CLASS so taskbar managers group
// multiple windows of this app correctly.
name: Some("solitaire-quest".into()),
resolution: window_resolution,
position: window_position,
// AutoNoVsync prefers Mailbox (triple-buffered) and
// falls back to Immediate, eliminating the vsync stall
// that AutoVsync produces during continuous window
// resize on X11 / Wayland. The game's frame budget is
// small enough that a few stray dropped frames from
// disabling vsync are imperceptible.
present_mode: PresentMode::AutoNoVsync,
resize_constraints: bevy::window::WindowResizeConstraints {
min_width: 800.0,
min_height: 600.0,
..default()
},
..default()
}),
..default()
})
// The `assets/` directory lives at the workspace root, but
// Bevy resolves `AssetPlugin::file_path` relative to the
// binary package's `CARGO_MANIFEST_DIR` (`solitaire_app/`).
// Point one level up so `cargo run -p solitaire_app` finds
// card faces, backs, backgrounds, and the UI font.
.set(bevy::asset::AssetPlugin {
file_path: "../assets".to_string(),
..default()
}),
)
.add_plugins(AssetSourcesPlugin)
.add_plugins(ThemePlugin)
.add_plugins(ThemeRegistryPlugin)
.add_plugins(FontPlugin)
.add_plugins(GamePlugin)
.add_plugins(TablePlugin)
.add_plugins(CardPlugin)
.add_plugins(CursorPlugin)
.add_plugins(InputPlugin)
.add_plugins(RadialMenuPlugin)
.add_plugins(SelectionPlugin)
.add_plugins(AnimationPlugin)
.add_plugins(FeedbackAnimPlugin)
.add_plugins(CardAnimationPlugin)
.add_plugins(AutoCompletePlugin)
.add_plugins(ReplayPlaybackPlugin)
.add_plugins(ReplayOverlayPlugin)
.add_plugins(StatsPlugin::default())
.add_plugins(ProgressPlugin::default())
.add_plugins(AchievementPlugin::default())
.add_plugins(DailyChallengePlugin)
.add_plugins(WeeklyGoalsPlugin)
.add_plugins(ChallengePlugin)
.add_plugins(TimeAttackPlugin)
.add_plugins(HudPlugin)
.add_plugins(HelpPlugin)
.add_plugins(HomePlugin::default())
.add_plugins(ProfilePlugin)
.add_plugins(PausePlugin)
.add_plugins(SettingsPlugin::default())
.add_plugins(AudioPlugin)
.add_plugins(OnboardingPlugin)
.add_plugins(SyncPlugin::new(sync_provider))
.add_plugins(LeaderboardPlugin)
.add_plugins(WinSummaryPlugin)
.add_plugins(UiModalPlugin)
.add_plugins(UiFocusPlugin)
.add_plugins(UiTooltipPlugin)
.add_plugins(SplashPlugin)
.add_plugins(DiagnosticsHudPlugin);
// Smart default window sizing: when no saved geometry was loaded,
// resize the freshly-opened 1280×800 window to ~70 % of the primary
// monitor's logical size on the first frame. Without this, a 4K
// monitor opens the same 1280×800 window that a 1080p monitor
// does — visually tiny relative to screen. Skipped entirely when
// saved geometry was applied; the player's preference always wins.
//
// Players who specifically want the literal 1280×800 baseline on
// every fresh launch can flip `disable_smart_default_size` in
// Settings to opt out. The flag is checked once at startup; a
// mid-session change applies on the next launch.
if !had_saved_geometry && !settings.disable_smart_default_size {
app.add_systems(Update, apply_smart_default_window_size);
}
app.run();
}
/// One-shot Update system that runs only on launches without saved
/// window geometry. Resizes the primary window to a fraction of the
/// primary monitor's *logical* size — bigger monitors get bigger
/// windows automatically. Logical size already accounts for the OS's
/// HiDPI scale factor, so a 2880×1800 Retina display reporting
/// scale_factor 2.0 yields a 1440×900 logical size and a 1008×630
/// target window — same physical inches as a 1920×1080 monitor with
/// scale_factor 1.0 yielding 1344×756.
///
/// Uses `Local<bool>` to make itself one-shot rather than introducing
/// a dedicated resource. The Update tick is necessary because Bevy
/// populates the `Monitor` entities asynchronously after winit's
/// Resumed event fires; they may not exist on the first Startup pass.
fn apply_smart_default_window_size(
mut applied: Local<bool>,
monitors: Query<&Monitor, With<PrimaryMonitor>>,
mut windows: Query<&mut Window, With<PrimaryWindow>>,
) {
if *applied {
return;
}
let Ok(monitor) = monitors.single() else {
// Primary monitor not yet spawned by bevy_winit. Try again
// next frame; the cost is one early-exit per tick until
// monitors arrive (typically frame 1 or 2).
return;
};
let Ok(mut window) = windows.single_mut() else {
return;
};
let scale = monitor.scale_factor as f32;
if scale <= 0.0 {
// Defensive: a zero or negative scale factor would NaN the
// arithmetic below. Bail and accept the default size.
*applied = true;
return;
}
let logical_w = monitor.physical_width as f32 / scale;
let logical_h = monitor.physical_height as f32 / scale;
// Target 70 % of monitor in each dimension, clamped to the
// existing 800×600 minimum and the monitor's own logical size
// (so we never request a window larger than the screen).
let target_w = (logical_w * 0.7).clamp(800.0, logical_w);
let target_h = (logical_h * 0.7).clamp(600.0, logical_h);
// Resize only when the change is meaningful — at exactly 1280×800
// on a 1920×1080 monitor the new target is 1344×756 (only ~5 %
// wider), worth the resize; at the same default on an 800×600
// monitor the clamp pins us at 800×600 and we shouldn't resize.
let curr_w = window.resolution.width();
let curr_h = window.resolution.height();
if (curr_w - target_w).abs() > 8.0 || (curr_h - target_h).abs() > 8.0 {
window.resolution.set(target_w, target_h);
}
*applied = true;
}
/// Wraps the default panic hook with one that also appends a crash log
/// to `<data_dir>/crash.log` (next to `settings.json`). The default hook
/// still runs afterwards, so stderr output and debugger integration are
/// unchanged. If the data directory is unavailable, the wrapper silently
/// falls through — the default hook handles output either way.
fn install_crash_log_hook() {
let crash_log_path = settings_file_path().and_then(|p| {
p.parent()
.map(|parent| parent.join("crash.log"))
});
let default_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
if let Some(path) = crash_log_path.as_ref()
&& let Ok(mut file) = OpenOptions::new()
.create(true)
.append(true)
.open(path)
{
// Plain unix-seconds timestamp keeps the format trivially
// parseable and avoids pulling in chrono just for this.
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
let _ = writeln!(file, "----- t={secs} -----\n{info}\n");
}
default_hook(info);
}));
}
+6 -252
View File
@@ -1,255 +1,9 @@
use std::fs::OpenOptions;
use std::io::Write;
use std::time::{SystemTime, UNIX_EPOCH};
use bevy::prelude::*;
use bevy::window::{
Monitor, MonitorSelection, PresentMode, PrimaryMonitor, PrimaryWindow, WindowPosition,
};
use solitaire_data::{load_settings_from, provider_for_backend, settings_file_path, Settings};
use solitaire_engine::{
register_theme_asset_sources, AchievementPlugin, AnimationPlugin, AssetSourcesPlugin,
AudioPlugin, AutoCompletePlugin, CardAnimationPlugin, CardPlugin, ChallengePlugin,
CursorPlugin, DailyChallengePlugin, DiagnosticsHudPlugin, FeedbackAnimPlugin, FontPlugin,
GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin,
OnboardingPlugin, PausePlugin, ProfilePlugin, ProgressPlugin, RadialMenuPlugin,
ReplayOverlayPlugin, ReplayPlaybackPlugin, SelectionPlugin, SettingsPlugin, SplashPlugin,
StatsPlugin, SyncPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin,
UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WinSummaryPlugin,
};
//! Desktop entry point for `solitaire_app`.
//!
//! The body of the app lives in `lib.rs` so cargo-apk can package the
//! same code into an Android `cdylib`. This shim is the desktop /
//! `cargo run` path — it just delegates to [`solitaire_app::run`].
fn main() {
// Install a panic hook that writes a crash log next to the save files
// before re-running the default hook (so stderr still gets the message
// and any debugger attached still sees the panic).
install_crash_log_hook();
// Initialise the platform keyring store before any token operations.
// On Linux this uses the Secret Service (GNOME Keyring / KWallet); on
// macOS it uses the Keychain; on Windows it uses the Credential store.
// If the platform has no OS keyring (e.g. a headless CI box), keyring
// operations will fail gracefully with TokenError::KeychainUnavailable.
if let Err(e) = keyring::use_native_store(true) {
eprintln!(
"warn: could not initialise OS keyring ({e}); \
server sync login will be unavailable"
);
}
// Load settings before building the app so we can construct the right
// sync provider. Falls back to defaults if no settings file exists yet.
let settings: Settings = settings_file_path()
.map(|p| load_settings_from(&p))
.unwrap_or_default();
let sync_provider = provider_for_backend(&settings.sync_backend);
// Restore the previous window geometry if the player has one saved.
// Otherwise open at the platform default (1280×800, centred on the
// primary monitor) — `apply_smart_default_window_size` will resize
// up to a monitor-relative target on the first frame so HiDPI / 4K
// sessions don't end up with a comparatively tiny window.
let had_saved_geometry = settings.window_geometry.is_some();
let (window_resolution, window_position) = match settings.window_geometry {
Some(geom) => (
(geom.width, geom.height).into(),
WindowPosition::At(IVec2::new(geom.x, geom.y)),
),
None => (
(1280u32, 800u32).into(),
WindowPosition::Centered(MonitorSelection::Primary),
),
};
let mut app = App::new();
// The card-theme system's `themes://` asset source must be
// registered *before* `DefaultPlugins` builds `AssetPlugin`,
// because that plugin freezes the asset-source list at build
// time. The matching `AssetSourcesPlugin` (added below) finishes
// the wiring after `DefaultPlugins` by populating the embedded
// default theme into Bevy's `EmbeddedAssetRegistry`.
register_theme_asset_sources(&mut app);
app
.add_plugins(
DefaultPlugins
.set(WindowPlugin {
primary_window: Some(Window {
title: "Solitaire Quest".into(),
// X11/Wayland WM_CLASS so taskbar managers group
// multiple windows of this app correctly.
name: Some("solitaire-quest".into()),
resolution: window_resolution,
position: window_position,
// AutoNoVsync prefers Mailbox (triple-buffered) and
// falls back to Immediate, eliminating the vsync stall
// that AutoVsync produces during continuous window
// resize on X11 / Wayland. The game's frame budget is
// small enough that a few stray dropped frames from
// disabling vsync are imperceptible.
present_mode: PresentMode::AutoNoVsync,
resize_constraints: bevy::window::WindowResizeConstraints {
min_width: 800.0,
min_height: 600.0,
..default()
},
..default()
}),
..default()
})
// The `assets/` directory lives at the workspace root, but
// Bevy resolves `AssetPlugin::file_path` relative to the
// binary package's `CARGO_MANIFEST_DIR` (`solitaire_app/`).
// Point one level up so `cargo run -p solitaire_app` finds
// card faces, backs, backgrounds, and the UI font.
.set(bevy::asset::AssetPlugin {
file_path: "../assets".to_string(),
..default()
}),
)
.add_plugins(AssetSourcesPlugin)
.add_plugins(ThemePlugin)
.add_plugins(ThemeRegistryPlugin)
.add_plugins(FontPlugin)
.add_plugins(GamePlugin)
.add_plugins(TablePlugin)
.add_plugins(CardPlugin)
.add_plugins(CursorPlugin)
.add_plugins(InputPlugin)
.add_plugins(RadialMenuPlugin)
.add_plugins(SelectionPlugin)
.add_plugins(AnimationPlugin)
.add_plugins(FeedbackAnimPlugin)
.add_plugins(CardAnimationPlugin)
.add_plugins(AutoCompletePlugin)
.add_plugins(ReplayPlaybackPlugin)
.add_plugins(ReplayOverlayPlugin)
.add_plugins(StatsPlugin::default())
.add_plugins(ProgressPlugin::default())
.add_plugins(AchievementPlugin::default())
.add_plugins(DailyChallengePlugin)
.add_plugins(WeeklyGoalsPlugin)
.add_plugins(ChallengePlugin)
.add_plugins(TimeAttackPlugin)
.add_plugins(HudPlugin)
.add_plugins(HelpPlugin)
.add_plugins(HomePlugin::default())
.add_plugins(ProfilePlugin)
.add_plugins(PausePlugin)
.add_plugins(SettingsPlugin::default())
.add_plugins(AudioPlugin)
.add_plugins(OnboardingPlugin)
.add_plugins(SyncPlugin::new(sync_provider))
.add_plugins(LeaderboardPlugin)
.add_plugins(WinSummaryPlugin)
.add_plugins(UiModalPlugin)
.add_plugins(UiFocusPlugin)
.add_plugins(UiTooltipPlugin)
.add_plugins(SplashPlugin)
.add_plugins(DiagnosticsHudPlugin);
// Smart default window sizing: when no saved geometry was loaded,
// resize the freshly-opened 1280×800 window to ~70 % of the primary
// monitor's logical size on the first frame. Without this, a 4K
// monitor opens the same 1280×800 window that a 1080p monitor
// does — visually tiny relative to screen. Skipped entirely when
// saved geometry was applied; the player's preference always wins.
//
// Players who specifically want the literal 1280×800 baseline on
// every fresh launch can flip `disable_smart_default_size` in
// Settings to opt out. The flag is checked once at startup; a
// mid-session change applies on the next launch.
if !had_saved_geometry && !settings.disable_smart_default_size {
app.add_systems(Update, apply_smart_default_window_size);
}
app.run();
}
/// One-shot Update system that runs only on launches without saved
/// window geometry. Resizes the primary window to a fraction of the
/// primary monitor's *logical* size — bigger monitors get bigger
/// windows automatically. Logical size already accounts for the OS's
/// HiDPI scale factor, so a 2880×1800 Retina display reporting
/// scale_factor 2.0 yields a 1440×900 logical size and a 1008×630
/// target window — same physical inches as a 1920×1080 monitor with
/// scale_factor 1.0 yielding 1344×756.
///
/// Uses `Local<bool>` to make itself one-shot rather than introducing
/// a dedicated resource. The Update tick is necessary because Bevy
/// populates the `Monitor` entities asynchronously after winit's
/// Resumed event fires; they may not exist on the first Startup pass.
fn apply_smart_default_window_size(
mut applied: Local<bool>,
monitors: Query<&Monitor, With<PrimaryMonitor>>,
mut windows: Query<&mut Window, With<PrimaryWindow>>,
) {
if *applied {
return;
}
let Ok(monitor) = monitors.single() else {
// Primary monitor not yet spawned by bevy_winit. Try again
// next frame; the cost is one early-exit per tick until
// monitors arrive (typically frame 1 or 2).
return;
};
let Ok(mut window) = windows.single_mut() else {
return;
};
let scale = monitor.scale_factor as f32;
if scale <= 0.0 {
// Defensive: a zero or negative scale factor would NaN the
// arithmetic below. Bail and accept the default size.
*applied = true;
return;
}
let logical_w = monitor.physical_width as f32 / scale;
let logical_h = monitor.physical_height as f32 / scale;
// Target 70 % of monitor in each dimension, clamped to the
// existing 800×600 minimum and the monitor's own logical size
// (so we never request a window larger than the screen).
let target_w = (logical_w * 0.7).clamp(800.0, logical_w);
let target_h = (logical_h * 0.7).clamp(600.0, logical_h);
// Resize only when the change is meaningful — at exactly 1280×800
// on a 1920×1080 monitor the new target is 1344×756 (only ~5 %
// wider), worth the resize; at the same default on an 800×600
// monitor the clamp pins us at 800×600 and we shouldn't resize.
let curr_w = window.resolution.width();
let curr_h = window.resolution.height();
if (curr_w - target_w).abs() > 8.0 || (curr_h - target_h).abs() > 8.0 {
window.resolution.set(target_w, target_h);
}
*applied = true;
}
/// Wraps the default panic hook with one that also appends a crash log
/// to `<data_dir>/crash.log` (next to `settings.json`). The default hook
/// still runs afterwards, so stderr output and debugger integration are
/// unchanged. If the data directory is unavailable, the wrapper silently
/// falls through — the default hook handles output either way.
fn install_crash_log_hook() {
let crash_log_path = settings_file_path().and_then(|p| {
p.parent()
.map(|parent| parent.join("crash.log"))
});
let default_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
if let Some(path) = crash_log_path.as_ref()
&& let Ok(mut file) = OpenOptions::new()
.create(true)
.append(true)
.open(path)
{
// Plain unix-seconds timestamp keeps the format trivially
// parseable and avoids pulling in chrono just for this.
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
let _ = writeln!(file, "----- t={secs} -----\n{info}\n");
}
default_hook(info);
}));
solitaire_app::run();
}