diff --git a/Cargo.toml b/Cargo.toml index f29b90e..99b3179 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,7 +54,7 @@ bevy = { version = "0.18", default-features = false, features = [ "bevy_window", "custom_cursor", "reflect_auto_register", - # default_platform (desktop subset; no android/webgl/gilrs/sysinfo) + # default_platform (desktop subset) "std", "bevy_winit", "default_font", @@ -65,6 +65,13 @@ bevy = { version = "0.18", default-features = false, features = [ # the game in an X11 frame inside the Wayland compositor. "wayland", "x11", + # Android: NativeActivity glue. The feature is target-gated inside + # bevy_internal — desktop builds compile it out, so leaving it on + # the always-on list is harmless on Linux/macOS/Windows. Pairs with + # cargo-apk's NativeActivity wrapper (cargo-apk 0.10+ uses this by + # default). Switch to `android-game-activity` later if we want + # AndroidX GameActivity for Google Play Games integration. + "android-native-activity", # common_api "bevy_color", "bevy_image", diff --git a/solitaire_app/Cargo.toml b/solitaire_app/Cargo.toml index 5e1fb49..3577a9e 100644 --- a/solitaire_app/Cargo.toml +++ b/solitaire_app/Cargo.toml @@ -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//` 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. diff --git a/solitaire_app/src/lib.rs b/solitaire_app/src/lib.rs new file mode 100644 index 0000000..52e9568 --- /dev/null +++ b/solitaire_app/src/lib.rs @@ -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` 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, + monitors: Query<&Monitor, With>, + mut windows: Query<&mut Window, With>, +) { + 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 `/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); + })); +} diff --git a/solitaire_app/src/main.rs b/solitaire_app/src/main.rs index 5841ea2..5728770 100644 --- a/solitaire_app/src/main.rs +++ b/solitaire_app/src/main.rs @@ -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` 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, - monitors: Query<&Monitor, With>, - mut windows: Query<&mut Window, With>, -) { - 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 `/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(); } diff --git a/solitaire_data/Cargo.toml b/solitaire_data/Cargo.toml index f4597fb..fed95e2 100644 --- a/solitaire_data/Cargo.toml +++ b/solitaire_data/Cargo.toml @@ -13,10 +13,19 @@ chrono = { workspace = true } thiserror = { workspace = true } async-trait = { workspace = true } dirs = { workspace = true } -keyring-core = { workspace = true } reqwest = { workspace = true } tokio = { workspace = true } +# `keyring-core` is the typed Entry/Error API used by +# `auth_tokens`. The crate's own dependency tree pulls in +# `rpassword` which uses `libc::__errno_location` — a symbol the +# Android NDK doesn't expose (`__errno` lives at a different path +# on bionic). On Android `auth_tokens` falls back to a stub +# implementation that always returns `KeychainUnavailable`; the +# real backend lands when we wire Android Keystore via JNI. +[target.'cfg(not(target_os = "android"))'.dependencies] +keyring-core = { workspace = true } + [dev-dependencies] solitaire_server = { path = "../solitaire_server" } solitaire_sync = { workspace = true } diff --git a/solitaire_data/src/auth_tokens.rs b/solitaire_data/src/auth_tokens.rs index af3f37c..84ef6f2 100644 --- a/solitaire_data/src/auth_tokens.rs +++ b/solitaire_data/src/auth_tokens.rs @@ -14,8 +14,19 @@ //! the Bevy `App`). If no default store is set, all operations in this module //! will return [`TokenError::KeychainUnavailable`]. //! +//! # Android stub +//! +//! `keyring-core` cannot compile for the android target (its `rpassword` +//! transitive dep uses `libc::__errno_location`, which Android's bionic +//! doesn't expose). On Android every function in this module returns +//! [`TokenError::KeychainUnavailable`] so callers can detect the fallback +//! the same way they handle a Linux box without Secret Service. The +//! real Android backend will arrive in the Phase-Android round when we +//! wire Android Keystore via JNI. +//! //! # Note: no unit tests — requires live OS keychain. +#[cfg(not(target_os = "android"))] use keyring_core::Entry; use thiserror::Error; @@ -34,9 +45,11 @@ pub enum TokenError { } /// Service name used to namespace all keychain entries for this application. +#[cfg(not(target_os = "android"))] const SERVICE: &str = "solitaire_quest_server"; /// Map a `keyring_core::Error` to the appropriate `TokenError`. +#[cfg(not(target_os = "android"))] fn map_keyring_err(err: keyring_core::Error, username: &str) -> TokenError { let msg = err.to_string(); match err { @@ -51,6 +64,7 @@ fn map_keyring_err(err: keyring_core::Error, username: &str) -> TokenError { /// Store the access and refresh tokens for `username` in the OS keychain. /// /// Any previously stored tokens for that username are overwritten. +#[cfg(not(target_os = "android"))] pub fn store_tokens( username: &str, access_token: &str, @@ -72,6 +86,7 @@ pub fn store_tokens( /// Load the stored access token for `username` from the OS keychain. /// /// Returns [`TokenError::NotFound`] if no token has been stored yet. +#[cfg(not(target_os = "android"))] pub fn load_access_token(username: &str) -> Result { Entry::new(SERVICE, &format!("{username}_access")) .map_err(|e| map_keyring_err(e, username))? @@ -82,6 +97,7 @@ pub fn load_access_token(username: &str) -> Result { /// Load the stored refresh token for `username` from the OS keychain. /// /// Returns [`TokenError::NotFound`] if no token has been stored yet. +#[cfg(not(target_os = "android"))] pub fn load_refresh_token(username: &str) -> Result { Entry::new(SERVICE, &format!("{username}_refresh")) .map_err(|e| map_keyring_err(e, username))? @@ -93,6 +109,7 @@ pub fn load_refresh_token(username: &str) -> Result { /// /// Intended to be called on logout or account deletion. Missing entries are /// silently ignored (the tokens are already gone, which is the desired state). +#[cfg(not(target_os = "android"))] pub fn delete_tokens(username: &str) -> Result<(), TokenError> { match Entry::new(SERVICE, &format!("{username}_access")) .map_err(|e| map_keyring_err(e, username))? @@ -112,3 +129,37 @@ pub fn delete_tokens(username: &str) -> Result<(), TokenError> { Ok(()) } + +// ------------------------------------------------------------------- +// Android stub — same public API, always returns KeychainUnavailable. +// Lets `sync_client::*` compile unchanged on Android; the runtime +// effect is "session login required every launch", same as a Linux +// box without Secret Service. +// ------------------------------------------------------------------- + +#[cfg(target_os = "android")] +const ANDROID_STUB_MSG: &str = "android stub: keychain not yet wired (Phase-Android task)"; + +#[cfg(target_os = "android")] +pub fn store_tokens( + _username: &str, + _access_token: &str, + _refresh_token: &str, +) -> Result<(), TokenError> { + Err(TokenError::KeychainUnavailable(ANDROID_STUB_MSG.to_string())) +} + +#[cfg(target_os = "android")] +pub fn load_access_token(_username: &str) -> Result { + Err(TokenError::KeychainUnavailable(ANDROID_STUB_MSG.to_string())) +} + +#[cfg(target_os = "android")] +pub fn load_refresh_token(_username: &str) -> Result { + Err(TokenError::KeychainUnavailable(ANDROID_STUB_MSG.to_string())) +} + +#[cfg(target_os = "android")] +pub fn delete_tokens(_username: &str) -> Result<(), TokenError> { + Err(TokenError::KeychainUnavailable(ANDROID_STUB_MSG.to_string())) +} diff --git a/solitaire_engine/Cargo.toml b/solitaire_engine/Cargo.toml index b1e1dbe..37fcc09 100644 --- a/solitaire_engine/Cargo.toml +++ b/solitaire_engine/Cargo.toml @@ -21,6 +21,15 @@ tiny-skia = { workspace = true } ron = { workspace = true } dirs = { workspace = true } zip = { workspace = true } + +# `arboard` provides clipboard access for the Stats overlay's +# "Copy share link" button. The crate has no Android backend +# (its `platform::Clipboard` module is unimplemented for the +# android target — `cargo apk build` fails with E0433 if this is +# left unconditional). On Android the same button surfaces an +# informational toast instead; see +# `stats_plugin::handle_copy_share_link_button`. +[target.'cfg(not(target_os = "android"))'.dependencies] arboard = { workspace = true } [dev-dependencies] diff --git a/solitaire_engine/src/stats_plugin.rs b/solitaire_engine/src/stats_plugin.rs index 7227b81..258c1b3 100644 --- a/solitaire_engine/src/stats_plugin.rs +++ b/solitaire_engine/src/stats_plugin.rs @@ -317,25 +317,41 @@ fn handle_copy_share_link_button( )); return; }; - match arboard::Clipboard::new() { - Ok(mut cb) => match cb.set_text(url.clone()) { - Ok(()) => { - toast.write(InfoToastEvent(format!("Copied: {url}"))); - } + + // Desktop: `arboard` writes the URL to the OS clipboard. + // Android: `arboard` has no platform backend (would fail to + // compile, so the dependency is target-gated in + // solitaire_engine/Cargo.toml). The button still spawns and + // resolves to a meaningful toast instead — when we wire the + // Android Phase, this becomes a JNI call into ClipboardManager. + #[cfg(not(target_os = "android"))] + { + match arboard::Clipboard::new() { + Ok(mut cb) => match cb.set_text(url.clone()) { + Ok(()) => { + toast.write(InfoToastEvent(format!("Copied: {url}"))); + } + Err(e) => { + warn!("clipboard write failed: {e}"); + toast.write(InfoToastEvent( + "Couldn't write to clipboard \u{2014} share link wasn't copied.".to_string(), + )); + } + }, Err(e) => { - warn!("clipboard write failed: {e}"); + warn!("clipboard init failed: {e}"); toast.write(InfoToastEvent( - "Couldn't write to clipboard \u{2014} share link wasn't copied.".to_string(), + "Couldn't reach the clipboard \u{2014} share link wasn't copied.".to_string(), )); } - }, - Err(e) => { - warn!("clipboard init failed: {e}"); - toast.write(InfoToastEvent( - "Couldn't reach the clipboard \u{2014} share link wasn't copied.".to_string(), - )); } } + #[cfg(target_os = "android")] + { + toast.write(InfoToastEvent(format!( + "Share link: {url}" + ))); + } } fn handle_watch_replay_button(