From 0f18928b8f8ed031cac7a170557c0296916c99bc Mon Sep 17 00:00:00 2001 From: Luke Date: Wed, 26 Jun 2024 17:41:54 -0700 Subject: [PATCH] refactor: moved around platform code to condense it --- tetanes/src/main.rs | 1 + tetanes/src/nes/event.rs | 31 +-- tetanes/src/nes/renderer.rs | 70 ++++-- tetanes/src/nes/renderer/gui.rs | 155 +++--------- tetanes/src/nes/renderer/gui/keybinds.rs | 66 ++--- tetanes/src/nes/renderer/gui/lib.rs | 6 + tetanes/src/nes/renderer/gui/ppu_viewer.rs | 24 +- tetanes/src/nes/renderer/gui/preferences.rs | 31 +-- tetanes/src/nes/version.rs | 263 ++++++++++++-------- tetanes/src/platform.rs | 24 +- tetanes/src/sys.rs | 21 ++ tetanes/src/sys/info.rs | 11 + tetanes/src/sys/info/os.rs | 81 ++++++ tetanes/src/sys/info/wasm.rs | 12 + tetanes/src/sys/platform/os.rs | 10 +- tetanes/src/sys/platform/wasm.rs | 43 +--- 16 files changed, 476 insertions(+), 373 deletions(-) create mode 100644 tetanes/src/sys/info.rs create mode 100644 tetanes/src/sys/info/os.rs create mode 100644 tetanes/src/sys/info/wasm.rs diff --git a/tetanes/src/main.rs b/tetanes/src/main.rs index 61c70d65..5a367971 100644 --- a/tetanes/src/main.rs +++ b/tetanes/src/main.rs @@ -41,6 +41,7 @@ fn main() -> anyhow::Result<()> { let opts = opts::Opts::parse(); tracing::debug!("CLI Options: {opts:?}"); + opts.load()? } } diff --git a/tetanes/src/nes/event.rs b/tetanes/src/nes/event.rs index abcc4849..55309ba4 100644 --- a/tetanes/src/nes/event.rs +++ b/tetanes/src/nes/event.rs @@ -1,4 +1,5 @@ use crate::{ + feature, nes::{ action::{Action, Debug, DebugStep, Debugger, Feature, Setting, Ui}, config::Config, @@ -11,7 +12,7 @@ use crate::{ rom::RomData, Nes, Running, State, }, - platform::{self, open_file_dialog}, + platform::open_file_dialog, }; use anyhow::anyhow; use egui::ViewportId; @@ -246,7 +247,7 @@ impl Nes { match &event { Event::Resumed => { let state = if let State::Running(state) = &mut self.state { - if platform::supports(platform::Feature::Suspend) { + if feature!(Suspend) { state.renderer.recreate_window(event_loop); } state @@ -303,9 +304,9 @@ impl Nes { #[cfg(feature = "profiling")] puffin::set_scopes_on(false); - // Wasm should never be able to exit - #[cfg(target_arch = "wasm32")] - panic!("exited unexpectedly"); + if feature!(AbortOnExit) && !matches!(self.state, State::Running(_)) { + panic!("exited unexpectedly"); + } } _ => (), } @@ -350,7 +351,7 @@ impl Running { match event { Event::Suspended => { - if platform::supports(platform::Feature::Suspend) { + if feature!(Suspend) { if let Err(err) = self.renderer.drop_window() { error!("failed to suspend window: {err:?}"); event_loop.exit(); @@ -614,9 +615,9 @@ impl Running { } self.renderer.destroy(); - // Wasm should never be able to exit - #[cfg(target_arch = "wasm32")] - panic!("exited unexpectedly"); + if feature!(AbortOnExit) { + panic!("exited unexpectedly"); + } } _ => (), } @@ -849,7 +850,7 @@ impl Running { Action::Menu(menu) if released => self.event(RendererEvent::Menu(menu)), Action::Feature(feature) if is_root_window => match feature { Feature::ToggleReplayRecording if released => { - if platform::supports(platform::Feature::Filesystem) { + if feature!(Filesystem) { if self.renderer.rom_loaded() { self.replay_recording = !self.replay_recording; self.event(EmulationEvent::ReplayRecord(self.replay_recording)); @@ -862,7 +863,7 @@ impl Running { } } Feature::ToggleAudioRecording if released => { - if platform::supports(platform::Feature::Filesystem) { + if feature!(Filesystem) { if self.renderer.rom_loaded() { self.audio_recording = !self.audio_recording; self.event(EmulationEvent::AudioRecord(self.audio_recording)); @@ -875,7 +876,7 @@ impl Running { } } Feature::TakeScreenshot if released => { - if platform::supports(platform::Feature::Filesystem) { + if feature!(Filesystem) { if self.renderer.rom_loaded() { self.event(EmulationEvent::Screenshot); } @@ -991,7 +992,7 @@ impl Running { | DeckAction::ZapperAimOffscreen | DeckAction::ZapperTrigger => (), DeckAction::SetSaveSlot(slot) if released => { - if platform::supports(platform::Feature::Storage) { + if feature!(Storage) { if self.cfg.emulation.save_slot != slot { self.cfg.emulation.save_slot = slot; self.renderer.add_message( @@ -1007,7 +1008,7 @@ impl Running { } } DeckAction::SaveState if released && is_root_window => { - if platform::supports(platform::Feature::Storage) { + if feature!(Storage) { self.event(EmulationEvent::SaveState(self.cfg.emulation.save_slot)); } else { self.renderer.add_message( @@ -1017,7 +1018,7 @@ impl Running { } } DeckAction::LoadState if released && is_root_window => { - if platform::supports(platform::Feature::Storage) { + if feature!(Storage) { self.event(EmulationEvent::LoadState(self.cfg.emulation.save_slot)); } else { self.renderer.add_message( diff --git a/tetanes/src/nes/renderer.rs b/tetanes/src/nes/renderer.rs index 9a030c15..794ce0cd 100644 --- a/tetanes/src/nes/renderer.rs +++ b/tetanes/src/nes/renderer.rs @@ -1,4 +1,5 @@ use crate::{ + feature, nes::{ config::Config, event::{ @@ -6,12 +7,15 @@ use crate::{ }, input::Gamepads, renderer::{ - gui::{lib::key_from_keycode, Gui, MessageType}, + gui::{ + lib::{is_paste_command, key_from_keycode}, + Gui, MessageType, + }, shader::Shader, texture::Texture, }, }, - platform::{self, BuilderExt, Initialize}, + platform::{BuilderExt, Initialize}, thread, }; use anyhow::Context; @@ -189,7 +193,7 @@ impl Renderer { // Platforms like wasm don't easily support multiple viewports, and even if it could spawn // multiple canvases for each viewport, the async requirements of wgpu would make it // impossible to render until wasm-bindgen gets proper non-blocking async/await support. - if platform::supports(platform::Feature::Viewports) { + if feature!(Viewports) { ctx.set_embed_viewports(cfg.renderer.embed_viewports); } @@ -395,7 +399,7 @@ impl Renderer { } pub fn set_fullscreen(&mut self, fullscreen: bool, embed_viewports: bool) { - if platform::supports(platform::Feature::Viewports) { + if feature!(Viewports) { self.ctx.set_embed_viewports(fullscreen || embed_viewports); } self.ctx @@ -451,12 +455,12 @@ impl Renderer { }); } ConfigEvent::EmbedViewports(embed) => { - if platform::supports(platform::Feature::Viewports) { + if feature!(Viewports) { self.ctx.set_embed_viewports(*embed); } } ConfigEvent::Fullscreen(fullscreen) => { - if platform::supports(platform::Feature::Viewports) { + if feature!(Viewports) { self.ctx .set_embed_viewports(*fullscreen || cfg.renderer.embed_viewports); } @@ -565,21 +569,11 @@ impl Renderer { let modifiers = self.ctx.input(|i| i.modifiers); - #[cfg(target_arch = "wasm32")] - { - fn is_paste_command(modifiers: egui::Modifiers, keycode: Key) -> bool { - keycode == Key::Paste - || (modifiers.command && keycode == Key::V) - || (cfg!(target_os = "windows") - && modifiers.shift - && keycode == Key::Insert) - } - if is_paste_command(modifiers, key) { - return EventResponse { - consumed: true, - repaint: true, - }; - } + if feature!(ConsumePaste) && is_paste_command(modifiers, key) { + return EventResponse { + consumed: true, + repaint: true, + }; } if matches!(key, Key::Plus | Key::Equals | Key::Minus | Key::Num0) @@ -1068,6 +1062,37 @@ impl Renderer { }; } + #[cfg(target_arch = "wasm32")] + pub fn set_clipboard_text(state: &Rc>, text: String) -> EventResponse { + let State { viewports, .. } = &mut *state.borrow_mut(); + let egui_state = viewports + .get_mut(&egui::ViewportId::ROOT) + .and_then(|viewport| viewport.egui_state.as_mut()); + match egui_state { + Some(egui_state) => { + // Requires creating an event and setting the clipboard + // here because egui_winit internally tries to manage a + // fallback clipboard for platforms not supported by the + // clipboard crates being used. + // + // This has associated behavior in the renderer to prevent + // sending 'paste events' (ctrl/cmd+V) to egui_state to + // bypass its internal clipboard handling. + egui_state + .egui_input_mut() + .events + .push(egui::Event::Paste(text.clone())); + egui_state.set_clipboard_text(text); + EventResponse { + consumed: true, + repaint: true, + } + } + _ => EventResponse::default(), + } + } + + #[cfg(target_arch = "wasm32")] pub fn process_input( ctx: &egui::Context, state: &Rc>, @@ -1105,8 +1130,7 @@ impl Renderer { let copied_text = std::mem::take(&mut output.platform_output.copied_text); if !copied_text.is_empty() { - #[cfg(target_arch = "wasm32")] - platform::set_clipboard_text(&copied_text); + crate::platform::set_clipboard_text(&copied_text); } return EventResponse { diff --git a/tetanes/src/nes/renderer/gui.rs b/tetanes/src/nes/renderer/gui.rs index 3baf7217..b4ced1c7 100644 --- a/tetanes/src/nes/renderer/gui.rs +++ b/tetanes/src/nes/renderer/gui.rs @@ -1,4 +1,5 @@ use crate::{ + feature, nes::{ action::{Debug, DebugStep, Debugger, Feature, Setting, Ui as UiAction}, config::{Config, RendererConfig}, @@ -22,7 +23,7 @@ use crate::{ rom::{RomAsset, HOMEBREW_ROMS}, version::Version, }, - platform, + sys::{info::System, SystemInfo}, }; use egui::{ include_image, @@ -95,10 +96,7 @@ pub struct Gui { pub loaded_rom: Option, pub about_homebrew_rom_open: Option, pub start: Instant, - #[cfg(not(target_arch = "wasm32"))] - pub sys: Option, - #[cfg(not(target_arch = "wasm32"))] - pub sys_updated: Instant, + pub sys: System, pub error: Option, } @@ -118,33 +116,6 @@ impl Gui { /// Create a `Gui` instance. pub fn new(tx: NesEventProxy, texture: SizedTexture, cfg: Config) -> Self { - #[cfg(not(target_arch = "wasm32"))] - let sys = { - use sysinfo::{ProcessRefreshKind, RefreshKind, System}; - - if sysinfo::IS_SUPPORTED_SYSTEM { - let mut sys = System::new_with_specifics( - RefreshKind::new().with_processes( - ProcessRefreshKind::new() - .with_cpu() - .with_memory() - .with_disk_usage(), - ), - ); - sys.refresh_specifics( - RefreshKind::new().with_processes( - ProcessRefreshKind::new() - .with_cpu() - .with_memory() - .with_disk_usage(), - ), - ); - Some(sys) - } else { - None - } - }; - Self { initialized: false, title: Config::WINDOW_TITLE.to_string(), @@ -172,10 +143,7 @@ impl Gui { loaded_rom: None, about_homebrew_rom_open: None, start: Instant::now(), - #[cfg(not(target_arch = "wasm32"))] - sys, - #[cfg(not(target_arch = "wasm32"))] - sys_updated: Instant::now(), + sys: System::default(), error: None, } } @@ -381,44 +349,12 @@ impl Gui { // Check for update on start if self.version.requires_updates() { let notify_latest = false; - self.check_for_updates(notify_latest); + self.version.check_for_updates(&self.tx, notify_latest); } self.initialized = true; } - fn check_for_updates(&mut self, notify_latest: bool) { - #[cfg(feature = "profiling")] - puffin::profile_function!(); - - let spawn_update = std::thread::Builder::new() - .name("check_updates".into()) - .spawn({ - let version = self.version.clone(); - let tx = self.tx.clone(); - move || match version.update_available() { - Ok(Some(version)) => tx.event(UiEvent::UpdateAvailable(version)), - Ok(None) => { - if notify_latest { - tx.event(UiEvent::Message(( - MessageType::Info, - format!("TetaNES v{} is up to date!", version.current()), - ))); - } - } - Err(err) => { - tx.event(UiEvent::Message((MessageType::Error, err.to_string()))); - } - } - }); - if let Err(err) = spawn_update { - self.add_message( - MessageType::Error, - format!("Failed to check for updates: {err}"), - ); - } - } - fn show_about_window(&mut self, ctx: &Context, enabled: bool) { #[cfg(feature = "profiling")] puffin::profile_function!(); @@ -631,7 +567,7 @@ impl Gui { }); // TODO: support saves and recent games on wasm? Requires storing the data - if platform::supports(platform::Feature::Filesystem) { + if feature!(Filesystem) { ui.menu_button("🗄 Recently Played...", |ui| { use tetanes_core::fs; @@ -655,7 +591,7 @@ impl Gui { ui.separator(); } - if platform::supports(platform::Feature::Storage) { + if feature!(Storage) { ui.add_enabled_ui(self.loaded_rom.is_some(), |ui| { let button = Button::new("💾 Save State").shortcut_text(cfg.shortcut(DeckAction::SaveState)); @@ -690,7 +626,7 @@ impl Gui { }); } - if platform::supports(platform::Feature::Viewports) { + if feature!(Viewports) { ui.separator(); let button = Button::new("⎆ Quit").shortcut_text(cfg.shortcut(UiAction::Quit)); @@ -807,7 +743,7 @@ impl Gui { }; }); - if platform::supports(platform::Feature::Filesystem) { + if feature!(Filesystem) { ui.separator(); ui.add_enabled_ui(self.loaded_rom.is_some(), |ui| { @@ -1305,24 +1241,7 @@ impl Gui { grid.show(ui, |ui| { ui.ctx().request_repaint_after(Duration::from_secs(1)); - #[cfg(not(target_arch = "wasm32"))] - if let Some(sys) = &mut self.sys { - // NOTE: refreshing sysinfo is cpu-intensive if done too frequently and skews the - // results - let sys_update_interval = Duration::from_secs(1); - assert!(sys_update_interval > sysinfo::MINIMUM_CPU_UPDATE_INTERVAL); - if self.sys_updated.elapsed() >= sys_update_interval { - sys.refresh_specifics( - sysinfo::RefreshKind::new().with_processes( - sysinfo::ProcessRefreshKind::new() - .with_cpu() - .with_memory() - .with_disk_usage(), - ), - ); - self.sys_updated = Instant::now(); - } - } + self.sys.update(); let good_color = if ui.style().visuals.dark_mode { hex_color!("#b8cc52") @@ -1386,7 +1305,7 @@ impl Gui { ui.end_row(); #[cfg(not(target_arch = "wasm32"))] - if let Some(ref sys) = self.sys { + if let Some(stats) = self.sys.stats() { let cpu_color = |cpu| match cpu { cpu if cpu <= 25.0 => good_color, cpu if cpu <= 50.0 => warn_color, @@ -1399,33 +1318,33 @@ impl Gui { ui.label(""); ui.end_row(); - if let Some(proc) = sys.process(sysinfo::Pid::from_u32(std::process::id())) { - ui.strong("CPU:"); - let cpu_usage = proc.cpu_usage(); - ui.colored_label(cpu_color(cpu_usage), format!("{cpu_usage:.2}%")); - ui.end_row(); + ui.strong("CPU:"); + ui.colored_label( + cpu_color(stats.cpu_usage), + format!("{:.2}%", stats.cpu_usage), + ); + ui.end_row(); - ui.strong("Memory:"); - ui.label(format!("{} MB", bytes_to_mb(proc.memory()),)); - ui.end_row(); + ui.strong("Memory:"); + ui.label(format!("{} MB", bytes_to_mb(stats.memory))); + ui.end_row(); - let du = proc.disk_usage(); - ui.strong("Disk read new/total:"); - ui.label(format!( - "{:.2}/{:.2} MB", - bytes_to_mb(du.read_bytes), - bytes_to_mb(du.total_read_bytes) - )); - ui.end_row(); + let du = stats.disk_usage; + ui.strong("Disk read new/total:"); + ui.label(format!( + "{:.2}/{:.2} MB", + bytes_to_mb(du.read_bytes), + bytes_to_mb(du.total_read_bytes) + )); + ui.end_row(); - ui.strong("Disk written new/total:"); - ui.label(format!( - "{:.2}/{:.2} MB", - bytes_to_mb(du.written_bytes), - bytes_to_mb(du.total_written_bytes), - )); - ui.end_row(); - } + ui.strong("Disk written new/total:"); + ui.label(format!( + "{:.2}/{:.2} MB", + bytes_to_mb(du.written_bytes), + bytes_to_mb(du.total_written_bytes), + )); + ui.end_row(); } ui.label(""); @@ -1467,7 +1386,7 @@ impl Gui { if self.version.requires_updates() && ui.button("🌐 Check for Updates...").clicked() { let notify_latest = true; - self.check_for_updates(notify_latest); + self.version.check_for_updates(&self.tx, notify_latest); ui.close_menu(); } ui.toggle_value(&mut self.about_open, "ℹ About"); @@ -1496,7 +1415,7 @@ impl Gui { ui.end_row(); }); - if platform::supports(platform::Feature::Filesystem) { + if feature!(Filesystem) { ui.separator(); ui.horizontal_wrapped(|ui| { let grid = Grid::new("directories").num_columns(2).spacing([40.0, 6.0]); diff --git a/tetanes/src/nes/renderer/gui/keybinds.rs b/tetanes/src/nes/renderer/gui/keybinds.rs index 1a1ea021..1ab6c188 100644 --- a/tetanes/src/nes/renderer/gui/keybinds.rs +++ b/tetanes/src/nes/renderer/gui/keybinds.rs @@ -1,11 +1,13 @@ -use crate::nes::{ - action::Action, - config::Config, - event::{ConfigEvent, NesEventProxy}, - input::{Gamepads, Input}, - renderer::gui::lib::ViewportOptions, +use crate::{ + feature, + nes::{ + action::Action, + config::Config, + event::{ConfigEvent, NesEventProxy}, + input::{Gamepads, Input}, + renderer::gui::lib::ViewportOptions, + }, }; -use cfg_if::cfg_if; use egui::{Align2, Button, CentralPanel, Context, Grid, ScrollArea, Ui, Vec2, ViewportClass}; use parking_lot::Mutex; use std::sync::{ @@ -175,32 +177,30 @@ impl Keybinds { } } - cfg_if! { - if #[cfg(target_arch = "wasm32")] { - ctx.show_viewport_immediate(viewport_id, viewport_builder, move |ctx, class| { - viewport_cb( - ctx, - class, - &open, - opts.enabled, - &state, - &cfg, - &gamepad_state, - ); - }); - } else { - ctx.show_viewport_deferred(viewport_id, viewport_builder, move |ctx, class| { - viewport_cb( - ctx, - class, - &open, - opts.enabled, - &state, - &cfg, - &gamepad_state, - ); - }); - } + if feature!(DeferredViewport) { + ctx.show_viewport_deferred(viewport_id, viewport_builder, move |ctx, class| { + viewport_cb( + ctx, + class, + &open, + opts.enabled, + &state, + &cfg, + &gamepad_state, + ); + }); + } else { + ctx.show_viewport_immediate(viewport_id, viewport_builder, move |ctx, class| { + viewport_cb( + ctx, + class, + &open, + opts.enabled, + &state, + &cfg, + &gamepad_state, + ); + }); } } } diff --git a/tetanes/src/nes/renderer/gui/lib.rs b/tetanes/src/nes/renderer/gui/lib.rs index b28a7d5f..d3986551 100644 --- a/tetanes/src/nes/renderer/gui/lib.rs +++ b/tetanes/src/nes/renderer/gui/lib.rs @@ -84,6 +84,12 @@ pub fn input_down(ui: &mut Ui, gamepads: Option<&Gamepads>, cfg: &Config, input: }) } +pub fn is_paste_command(modifiers: egui::Modifiers, keycode: Key) -> bool { + keycode == Key::Paste + || (modifiers.command && keycode == Key::V) + || (cfg!(target_os = "windows") && modifiers.shift && keycode == Key::Insert) +} + #[must_use] pub struct ShortcutWidget<'a, T> { inner: T, diff --git a/tetanes/src/nes/renderer/gui/ppu_viewer.rs b/tetanes/src/nes/renderer/gui/ppu_viewer.rs index 6f535e6d..38e4f5e7 100644 --- a/tetanes/src/nes/renderer/gui/ppu_viewer.rs +++ b/tetanes/src/nes/renderer/gui/ppu_viewer.rs @@ -1,5 +1,7 @@ -use crate::nes::{config::Config, event::NesEventProxy, renderer::gui::lib::ViewportOptions}; -use cfg_if::cfg_if; +use crate::{ + feature, + nes::{config::Config, event::NesEventProxy, renderer::gui::lib::ViewportOptions}, +}; use egui::{CentralPanel, Context, Ui, ViewportClass}; use parking_lot::Mutex; use std::sync::{ @@ -107,16 +109,14 @@ impl PpuViewer { } } - cfg_if! { - if #[cfg(target_arch = "wasm32")] { - ctx.show_viewport_immediate(viewport_id, viewport_builder, move |ctx, class| { - viewport_cb(ctx, class, &open, opts.enabled, &state, &cfg); - }); - } else { - ctx.show_viewport_deferred(viewport_id, viewport_builder, move |ctx, class| { - viewport_cb(ctx, class, &open, opts.enabled, &state, &cfg); - }); - } + if feature!(DeferredViewport) { + ctx.show_viewport_deferred(viewport_id, viewport_builder, move |ctx, class| { + viewport_cb(ctx, class, &open, opts.enabled, &state, &cfg); + }); + } else { + ctx.show_viewport_immediate(viewport_id, viewport_builder, move |ctx, class| { + viewport_cb(ctx, class, &open, opts.enabled, &state, &cfg); + }); } } } diff --git a/tetanes/src/nes/renderer/gui/preferences.rs b/tetanes/src/nes/renderer/gui/preferences.rs index 34620488..9c587e12 100644 --- a/tetanes/src/nes/renderer/gui/preferences.rs +++ b/tetanes/src/nes/renderer/gui/preferences.rs @@ -1,4 +1,5 @@ use crate::{ + feature, nes::{ config::{AudioConfig, Config, EmulationConfig, RendererConfig}, event::{ConfigEvent, EmulationEvent, NesEventProxy, UiEvent}, @@ -10,9 +11,7 @@ use crate::{ shader::Shader, }, }, - platform, }; -use cfg_if::cfg_if; use egui::{ Align, CentralPanel, Checkbox, Context, CursorIcon, DragValue, Grid, Key, Layout, ScrollArea, Slider, TextEdit, Ui, Vec2, ViewportClass, @@ -137,16 +136,14 @@ impl Preferences { } } - cfg_if! { - if #[cfg(target_arch = "wasm32")] { - ctx.show_viewport_immediate(viewport_id, viewport_builder, move |ctx, class| { - viewport_cb(ctx, class, &open, opts.enabled, &state, &cfg); - }); - } else { - ctx.show_viewport_deferred(viewport_id, viewport_builder, move |ctx, class| { - viewport_cb(ctx, class, &open, opts.enabled, &state, &cfg); - }); - } + if feature!(DeferredViewport) { + ctx.show_viewport_deferred(viewport_id, viewport_builder, move |ctx, class| { + viewport_cb(ctx, class, &open, opts.enabled, &state, &cfg); + }); + } else { + ctx.show_viewport_immediate(viewport_id, viewport_builder, move |ctx, class| { + viewport_cb(ctx, class, &open, opts.enabled, &state, &cfg); + }); } } @@ -455,7 +452,7 @@ impl Preferences { cfg: &Config, shortcut: impl Into, ) { - if platform::supports(platform::Feature::Viewports) { + if feature!(Viewports) { ui.add_enabled_ui(!cfg.renderer.fullscreen, |ui| { let shortcut = shortcut.into(); // icon: maximize @@ -481,7 +478,7 @@ impl Preferences { mut always_on_top: bool, shortcut: impl Into, ) { - if platform::supports(platform::Feature::Viewports) { + if feature!(Viewports) { let shortcut = shortcut.into(); let icon = (!shortcut.is_empty()).then_some("🔝 ").unwrap_or_default(); let checkbox = Checkbox::new(&mut always_on_top, format!("{icon}Always on Top")) @@ -573,7 +570,7 @@ impl State { self.tx.event(event); } } - if platform::supports(platform::Feature::Storage) { + if feature!(Storage) { let data_dir = Config::default_data_dir(); if ui.button("Clear Save States").clicked() { match fs::clear_dir(data_dir) { @@ -588,9 +585,7 @@ impl State { } } } - if platform::supports(platform::Feature::Filesystem) - && ui.button("Clear Recent ROMs").clicked() - { + if feature!(Filesystem) && ui.button("Clear Recent ROMs").clicked() { self.tx.event(ConfigEvent::RecentRomsClear); } }); diff --git a/tetanes/src/nes/version.rs b/tetanes/src/nes/version.rs index 3cfa2e97..79af8067 100644 --- a/tetanes/src/nes/version.rs +++ b/tetanes/src/nes/version.rs @@ -1,16 +1,125 @@ use std::cell::RefCell; +#[cfg(not(target_arch = "wasm32"))] +mod fetcher { + use reqwest::blocking::Client; + use std::cell::Cell; + use std::time::{Duration, Instant}; + + #[derive(Debug, Clone)] + #[must_use] + pub struct Fetcher { + client: Option, + rate_limit: Duration, + last_request_time: Cell, + } + + impl Default for Fetcher { + fn default() -> Self { + Self { + client: Self::create_client(), + rate_limit: Duration::from_secs(1), + last_request_time: Cell::new(Instant::now()), + } + } + } + + impl Fetcher { + fn create_client() -> Option { + use reqwest::header::{HeaderMap, HeaderValue, USER_AGENT}; + + let mut headers = HeaderMap::new(); + headers.insert( + USER_AGENT, + HeaderValue::from_str("tetanes (me@lukeworks.tech)").ok()?, + ); + reqwest::blocking::Client::builder() + .default_headers(headers) + .build() + .ok() + } + + pub fn update_available(&self, version: &'static str) -> anyhow::Result> { + #[derive(Debug, serde::Deserialize)] + #[must_use] + struct ApiError { + detail: Option, + } + + #[derive(Debug, serde::Deserialize)] + #[must_use] + struct ApiErrors { + errors: Vec, + } + + // Partial deserialization of the full response + #[derive(Debug, serde::Deserialize)] + #[must_use] + struct Crate { + newest_version: String, + } + + // Partial deserialization of the full response + #[derive(Debug, serde::Deserialize)] + #[must_use] + struct CrateResponse { + #[serde(rename = "crate")] + cr: Crate, + } + + if self.last_request_time.get().elapsed() < self.rate_limit { + std::thread::sleep( + (self.last_request_time.get() + self.rate_limit) - Instant::now(), + ); + } + + self.last_request_time.set(Instant::now()); + let Some(client) = &self.client else { + anyhow::bail!("failed to create http client"); + }; + let content = client + .get("https://crates.io/api/v1/crates/tetanes") + .send() + .and_then(|res| res.text())?; + if let Ok(res) = serde_json::from_str::(&content) { + anyhow::bail!( + "encountered crates.io API errors: {}", + res.errors + .into_iter() + .filter_map(|error| error.detail) + .collect::>() + .join(",") + ); + } + + match serde_json::from_str::(&content) { + Ok(CrateResponse { + cr: Crate { newest_version, .. }, + }) => { + if Self::is_newer(&newest_version, version) { + Ok(Some(newest_version)) + } else { + Ok(None) + } + } + Err(err) => anyhow::bail!("failed to deserialize crates.io response: {err:?}"), + } + } + + fn is_newer(new: &str, old: &str) -> bool { + match (semver::Version::parse(old), semver::Version::parse(new)) { + (Ok(old), Ok(new)) => new > old, + _ => false, + } + } + } +} + #[derive(Debug, Clone)] #[must_use] pub struct Version { current: &'static str, latest: RefCell, - #[cfg(not(target_arch = "wasm32"))] - client: Option, - #[cfg(not(target_arch = "wasm32"))] - rate_limit: std::time::Duration, - #[cfg(not(target_arch = "wasm32"))] - last_request_time: std::cell::Cell, } impl Default for Version { @@ -24,12 +133,6 @@ impl Version { Self { current: env!("CARGO_PKG_VERSION"), latest: RefCell::new(env!("CARGO_PKG_VERSION").to_string()), - #[cfg(not(target_arch = "wasm32"))] - client: Self::create_client(), - #[cfg(not(target_arch = "wasm32"))] - rate_limit: std::time::Duration::from_secs(1), - #[cfg(not(target_arch = "wasm32"))] - last_request_time: std::cell::Cell::new(std::time::Instant::now()), } } @@ -50,49 +153,53 @@ impl Version { } #[cfg(target_arch = "wasm32")] - pub const fn update_available(&self) -> anyhow::Result> { - Ok(None) + pub fn check_for_updates( + &mut self, + _tx: &crate::nes::event::NesEventProxy, + _notify_latest: bool, + ) { } #[cfg(not(target_arch = "wasm32"))] - pub fn update_available(&self) -> anyhow::Result> { - use std::time::Instant; - - if self.last_request_time.get().elapsed() < self.rate_limit { - std::thread::sleep((self.last_request_time.get() + self.rate_limit) - Instant::now()); - } - - self.last_request_time.set(Instant::now()); - let Some(client) = &self.client else { - anyhow::bail!("failed to create http client"); - }; - let content = client - .get("https://crates.io/api/v1/crates/tetanes") - .send() - .and_then(|res| res.text())?; - if let Ok(res) = serde_json::from_str::(&content) { - anyhow::bail!( - "encountered crates.io API errors: {}", - res.errors - .into_iter() - .filter_map(|error| error.detail) - .collect::>() - .join(",") - ); - } - - match serde_json::from_str::(&content) { - Ok(CrateResponse { - cr: Crate { newest_version, .. }, - }) => { - if Self::version_is_newer(&newest_version, self.current) { - self.latest.replace(newest_version.clone()); - Ok(Some(newest_version)) - } else { - Ok(None) + pub fn check_for_updates( + &mut self, + tx: &crate::nes::event::NesEventProxy, + notify_latest: bool, + ) { + use crate::nes::{event::UiEvent, renderer::gui::MessageType}; + + #[cfg(feature = "profiling")] + puffin::profile_function!(); + + let spawn_update = std::thread::Builder::new() + .name("check_updates".into()) + .spawn({ + let current_version = self.current; + let fetcher = fetcher::Fetcher::default(); + let tx = tx.clone(); + move || { + let newest_version = fetcher.update_available(current_version); + match newest_version { + Ok(Some(version)) => tx.event(UiEvent::UpdateAvailable(version)), + Ok(None) => { + if notify_latest { + tx.event(UiEvent::Message(( + MessageType::Info, + format!("TetaNES v{current_version} is up to date!"), + ))); + } + } + Err(err) => { + tx.event(UiEvent::Message((MessageType::Error, err.to_string()))); + } + } } - } - Err(err) => anyhow::bail!("failed to deserialize crates.io response: {err:?}"), + }); + if let Err(err) = spawn_update { + tx.event(UiEvent::Message(( + MessageType::Error, + format!("Failed to check for updates: {err}"), + ))); } } @@ -100,58 +207,4 @@ impl Version { // TODO: Implement install/restart for each platform anyhow::bail!("not yet implemented"); } - - #[cfg(not(target_arch = "wasm32"))] - fn version_is_newer(new: &str, old: &str) -> bool { - match (semver::Version::parse(old), semver::Version::parse(new)) { - (Ok(old), Ok(new)) => new > old, - _ => false, - } - } - - #[cfg(not(target_arch = "wasm32"))] - fn create_client() -> Option { - use reqwest::header::{HeaderMap, HeaderValue, USER_AGENT}; - - let mut headers = HeaderMap::new(); - headers.insert( - USER_AGENT, - HeaderValue::from_str("tetanes (me@lukeworks.tech)").ok()?, - ); - reqwest::blocking::Client::builder() - .default_headers(headers) - .build() - .ok() - } -} - -#[cfg(not(target_arch = "wasm32"))] -#[derive(Debug, serde::Deserialize)] -#[must_use] -struct ApiError { - detail: Option, -} - -#[cfg(not(target_arch = "wasm32"))] -#[derive(Debug, serde::Deserialize)] -#[must_use] -struct ApiErrors { - errors: Vec, -} - -// Partial deserialization of the full response -#[cfg(not(target_arch = "wasm32"))] -#[derive(Debug, serde::Deserialize)] -#[must_use] -struct Crate { - newest_version: String, -} - -// Partial deserialization of the full response -#[cfg(not(target_arch = "wasm32"))] -#[derive(Debug, serde::Deserialize)] -#[must_use] -struct CrateResponse { - #[serde(rename = "crate")] - cr: Crate, } diff --git a/tetanes/src/platform.rs b/tetanes/src/platform.rs index 3b6fe729..f21117d1 100644 --- a/tetanes/src/platform.rs +++ b/tetanes/src/platform.rs @@ -24,11 +24,6 @@ pub trait EventLoopExt { F: FnMut(Event, &EventLoopWindowTarget) + 'static; } -/// Checks if the current platform supports a given feature. -pub const fn supports(feature: Feature) -> bool { - platform::supports_impl(feature) -} - /// Method for platforms supporting opening a file dialog. pub fn open_file_dialog( title: impl Into, @@ -46,6 +41,25 @@ pub enum Feature { Filesystem, Storage, Viewports, + DeferredViewport, Suspend, Blocking, + ConsumePaste, + AbortOnExit, +} + +/// Checks if the current platform supports a given feature. +#[macro_export] +macro_rules! feature { + ($feature: tt) => {{ + use $crate::platform::Feature::*; + match $feature { + Suspend => cfg!(target_os = "android"), + Filesystem | Storage | Viewports | Blocking | DeferredViewport => { + cfg!(not(target_arch = "wasm32")) + } + // Wasm should never be able to exit + AbortOnExit | ConsumePaste => cfg!(target_arch = "wasm32"), + } + }}; } diff --git a/tetanes/src/sys.rs b/tetanes/src/sys.rs index fa03f280..9060192d 100644 --- a/tetanes/src/sys.rs +++ b/tetanes/src/sys.rs @@ -1,3 +1,24 @@ +pub mod info; pub mod logging; pub mod platform; pub mod thread; + +#[derive(Debug)] +pub struct DiskUsage { + pub read_bytes: u64, + pub total_read_bytes: u64, + pub written_bytes: u64, + pub total_written_bytes: u64, +} + +#[derive(Debug)] +pub struct SystemStats { + pub cpu_usage: f32, + pub memory: u64, + pub disk_usage: DiskUsage, +} + +pub trait SystemInfo { + fn update(&mut self); + fn stats(&self) -> Option; +} diff --git a/tetanes/src/sys/info.rs b/tetanes/src/sys/info.rs new file mode 100644 index 00000000..1c197572 --- /dev/null +++ b/tetanes/src/sys/info.rs @@ -0,0 +1,11 @@ +use cfg_if::cfg_if; + +cfg_if! { + if #[cfg(target_arch = "wasm32")] { + mod wasm; + pub use wasm::*; + } else { + mod os; + pub use os::*; + } +} diff --git a/tetanes/src/sys/info/os.rs b/tetanes/src/sys/info/os.rs new file mode 100644 index 00000000..5ef1cb36 --- /dev/null +++ b/tetanes/src/sys/info/os.rs @@ -0,0 +1,81 @@ +use crate::sys::{DiskUsage, SystemInfo, SystemStats}; +use std::time::{Duration, Instant}; +use sysinfo::{ProcessRefreshKind, RefreshKind}; + +#[derive(Debug)] +pub struct System { + sys: Option, + updated: Instant, +} + +impl Default for System { + fn default() -> Self { + let sys = if sysinfo::IS_SUPPORTED_SYSTEM { + let mut sys = sysinfo::System::new_with_specifics( + RefreshKind::new().with_processes( + ProcessRefreshKind::new() + .with_cpu() + .with_memory() + .with_disk_usage(), + ), + ); + sys.refresh_specifics( + RefreshKind::new().with_processes( + ProcessRefreshKind::new() + .with_cpu() + .with_memory() + .with_disk_usage(), + ), + ); + Some(sys) + } else { + None + }; + + Self { + sys, + updated: Instant::now(), + } + } +} + +impl SystemInfo for System { + fn update(&mut self) { + if let Some(sys) = &mut self.sys { + // NOTE: refreshing sysinfo is cpu-intensive if done too frequently and skews the + // results + let update_interval = Duration::from_secs(1); + assert!(update_interval > sysinfo::MINIMUM_CPU_UPDATE_INTERVAL); + if self.updated.elapsed() >= update_interval { + sys.refresh_specifics( + sysinfo::RefreshKind::new().with_processes( + sysinfo::ProcessRefreshKind::new() + .with_cpu() + .with_memory() + .with_disk_usage(), + ), + ); + self.updated = Instant::now(); + } + } + } + + fn stats(&self) -> Option { + self.sys + .as_ref() + .and_then(|sys| sys.process(sysinfo::Pid::from_u32(std::process::id()))) + .map(|proc| { + let du = proc.disk_usage(); + SystemStats { + cpu_usage: proc.cpu_usage(), + memory: proc.memory(), + disk_usage: DiskUsage { + read_bytes: du.read_bytes, + total_read_bytes: du.total_read_bytes, + written_bytes: du.written_bytes, + total_written_bytes: du.total_written_bytes, + }, + } + }) + } +} diff --git a/tetanes/src/sys/info/wasm.rs b/tetanes/src/sys/info/wasm.rs new file mode 100644 index 00000000..03c61745 --- /dev/null +++ b/tetanes/src/sys/info/wasm.rs @@ -0,0 +1,12 @@ +use crate::sys::{SystemInfo, SystemStats}; + +#[derive(Default, Debug)] +pub struct System {} + +impl SystemInfo for System { + fn update(&mut self) {} + + fn stats(&self) -> Option { + None + } +} diff --git a/tetanes/src/sys/platform/os.rs b/tetanes/src/sys/platform/os.rs index 1a14e208..7614263b 100644 --- a/tetanes/src/sys/platform/os.rs +++ b/tetanes/src/sys/platform/os.rs @@ -1,6 +1,6 @@ use crate::{ nes::{event::EmulationEvent, renderer::Renderer, Running}, - platform::{BuilderExt, EventLoopExt, Feature, Initialize}, + platform::{BuilderExt, EventLoopExt, Initialize}, }; use std::path::{Path, PathBuf}; use tracing::error; @@ -10,14 +10,6 @@ use winit::{ window::WindowBuilder, }; -/// Checks if the current platform supports a given feature. -pub const fn supports_impl(feature: Feature) -> bool { - match feature { - Feature::Suspend => cfg!(target_os = "android"), - Feature::Filesystem | Feature::Storage | Feature::Viewports | Feature::Blocking => true, - } -} - /// Method for platforms supporting opening a file dialog. pub fn open_file_dialog_impl( title: impl Into, diff --git a/tetanes/src/sys/platform/wasm.rs b/tetanes/src/sys/platform/wasm.rs index 2a9679e2..08d04d15 100644 --- a/tetanes/src/sys/platform/wasm.rs +++ b/tetanes/src/sys/platform/wasm.rs @@ -1,11 +1,11 @@ use crate::{ nes::{ event::{EmulationEvent, NesEventProxy, RendererEvent, ReplayData, UiEvent}, - renderer::{Renderer, State}, + renderer::Renderer, rom::RomData, Running, }, - platform::{BuilderExt, EventLoopExt, Feature, Initialize}, + platform::{BuilderExt, EventLoopExt, Initialize}, thread, }; use anyhow::{bail, Context}; @@ -35,13 +35,8 @@ const OS_OPTIONS: [(Os, Arch, &str); 5] = [ (Os::Linux, Arch::X86_64, html_ids::LINXU_X86_LINK), ]; -/// Checks if the current platform supports a given feature. -pub const fn supports_impl(feature: Feature) -> bool { - match feature { - Feature::Storage => true, - Feature::Filesystem | Feature::Viewports | Feature::Suspend | Feature::Blocking => false, - } -} +#[derive(Debug)] +pub struct System; /// Method for platforms supporting opening a file dialog. pub fn open_file_dialog_impl( @@ -491,33 +486,11 @@ impl Initialize for Renderer { if let Ok(text) = data.get_data("text") { let text = text.replace("\r\n", "\n"); if !text.is_empty() { - let consumed = { - let State { viewports, .. } = &mut *state.borrow_mut(); - let egui_state = viewports - .get_mut(&egui::ViewportId::ROOT) - .and_then(|viewport| viewport.egui_state.as_mut()); - match egui_state { - Some(egui_state) => { - // Requires creating an event and setting the clipboard - // here because egui_winit internally tries to manage a - // fallback clipboard for platforms not supported by the - // clipboard crates being used. - // - // This has associated behavior in the renderer to prevent - // sending 'paste events' (ctrl/cmd+V) to egui_state to - // bypass its internal clipboard handling. - egui_state - .egui_input_mut() - .events - .push(egui::Event::Paste(text.clone())); - egui_state.set_clipboard_text(text); - true - } - _ => false, - } - }; - if consumed { + let res = Renderer::set_clipboard_text(&state, text); + if res.repaint { ctx.request_repaint(); + } + if res.consumed { evt.stop_propagation(); evt.prevent_default(); }