Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Every pull request and push to main: build and test on Windows and Linux (flex.exe and
# the plugins, and a Linux .tar.gz, are kept as artifacts for two weeks). Other branches
# Every pull request and push to main: build and test on Windows and Linux (flex.exe, its
# symbols and the plugins, and a Linux .tar.gz, are kept as artifacts for two weeks). Other branches
# build through their pull request (or by hand, from the Actions tab), so a branch with a
# PR isn't built twice. A vX.Y.Z tag also packages the Windows build with libmpv and
# ffmpeg, as a zip and an installer, and publishes them with the Linux .tar.gz as a
Expand All @@ -19,6 +19,11 @@ permissions:
jobs:
windows:
runs-on: windows-latest
# Keep symbols (flex.pdb, beside the exe; the exe stays the same size) so crash
# minidumps from the TV PC can be read.
env:
CARGO_PROFILE_RELEASE_STRIP: "false"
CARGO_PROFILE_RELEASE_DEBUG: line-tables-only
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
Expand All @@ -33,6 +38,7 @@ jobs:
name: flex-windows-x64
path: |
target/release/flex.exe
target/release/flex.pdb
target/release/flex-*.exe
retention-days: 14

Expand Down Expand Up @@ -88,6 +94,9 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
run: .github/scripts/package.sh "$GITHUB_REF_NAME" build/flex.exe dist
- name: Package symbols
shell: bash
run: 7z a "dist/flex-$GITHUB_REF_NAME-symbols.zip" ./build/flex.pdb
- name: Publish the release
shell: bash
env:
Expand Down
14 changes: 7 additions & 7 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ default-members = ["crates/*"]

[workspace.package]
edition = "2024"
version = "0.20.0"
version = "0.20.1"
license = "MIT"

[profile.release]
Expand Down
8 changes: 4 additions & 4 deletions crates/flex-app/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ anyhow = "1.0.104"
chrono = { version = "0.4.45", default-features = false, features = ["clock"] }
eframe = { version = "0.36", default-features = false, features = ["glow", "default_fonts"] }
env_logger = "0.11.11"
flex-core = { version = "0.20.0", path = "../flex-core" }
flex-plugin = { version = "0.20.0", path = "../flex-plugin" }
flex-web = { version = "0.20.0", path = "../flex-web" }
flex-core = { version = "0.20.1", path = "../flex-core" }
flex-plugin = { version = "0.20.1", path = "../flex-plugin" }
flex-web = { version = "0.20.1", path = "../flex-web" }
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] }
libloading = "0.9"
log = "0.4.34"
Expand All @@ -21,7 +21,7 @@ ureq = "3"
winit = { version = "0.30", default-features = false }

[target."cfg(windows)".dependencies]
windows-sys = { version = "0.61.2", features = ["Win32_UI_WindowsAndMessaging", "Win32_UI_Input_KeyboardAndMouse", "Win32_Foundation", "Win32_Storage_FileSystem", "Win32_UI_Input", "Win32_System_Diagnostics_Debug", "Win32_System_Kernel", "Win32_System_LibraryLoader", "Win32_System_Com", "Win32_System_Registry", "Win32_System_Threading", "Win32_System_Power"] }
windows-sys = { version = "0.61.2", features = ["Win32_UI_WindowsAndMessaging", "Win32_UI_Input_KeyboardAndMouse", "Win32_Foundation", "Win32_Storage_FileSystem", "Win32_UI_Input", "Win32_System_Diagnostics_Debug", "Win32_System_Kernel", "Win32_System_LibraryLoader", "Win32_System_Memory", "Win32_System_Com", "Win32_System_Registry", "Win32_System_Threading", "Win32_System_Power"] }

[target."cfg(target_os = \"linux\")".dependencies]
# Remote buttons egui doesn't report (media keys), read from /dev/input.
Expand Down
100 changes: 86 additions & 14 deletions crates/flex-app/src/logging.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@
//!
//! - `logs/flex.log` in the data folder holds this run's log; the previous run's is kept as
//! `flex.previous.log`.
//! - A panic, or on Windows a native crash (e.g. inside mpv or the graphics driver), writes
//! `logs/crash-<time>.txt` with what happened and where.
//! - A panic, or on Windows a native crash (e.g. inside mpv or the graphics driver), writes a
//! `crash-<time>` folder with `crash.txt` (what happened and where), a copy of the log and,
//! on Windows, a minidump (`flex.dmp`). It goes in the folder chosen in Settings → "Crash
//! reports folder", or the logs folder.

use std::fs::File;
use std::io::Write;
Expand All @@ -16,6 +18,23 @@ pub fn logs_dir() -> PathBuf {

static LOG_FILE: OnceLock<Mutex<File>> = OnceLock::new();

/// Setting: the folder crash reports go in (empty = the logs folder).
pub const CRASH_DIR_SETTING: &str = "crash_dir";

/// The folder from [`CRASH_DIR_SETTING`], if one is set.
static CRASH_DIR: Mutex<Option<PathBuf>> = Mutex::new(None);

pub fn set_crash_dir(dir: Option<PathBuf>) {
*CRASH_DIR.lock().unwrap_or_else(|e| e.into_inner()) = dir;
}

/// Where crash reports go.
pub fn crash_dir() -> PathBuf {
// try_lock: never wait inside a crash.
let chosen = CRASH_DIR.try_lock().ok().and_then(|d| d.clone());
chosen.unwrap_or_else(logs_dir)
}

/// Sends log output to the log file and stderr.
struct Tee;

Expand Down Expand Up @@ -62,19 +81,31 @@ pub fn init() {
windows::install_crash_handler();
}

/// Writes a crash report and returns its path.
fn write_crash_report(what: &str) -> Option<PathBuf> {
let dir = logs_dir();
std::fs::create_dir_all(&dir).ok()?;
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs());
let path = dir.join(format!("crash-{stamp}.txt"));
/// Writes a crash report folder and returns its path. `dump` writes the minidump into the
/// path it's given (Windows only).
fn write_crash_report(what: &str, dump: impl FnOnce(&std::path::Path) -> Result<(), String>) -> Option<PathBuf> {
let name = format!("crash-{}", chrono::Local::now().format("%Y-%m-%d-%H%M%S"));
// The chosen folder may be a share that's gone away: fall back to the logs folder.
let dir = [crash_dir(), logs_dir()]
.into_iter()
.map(|d| d.join(&name))
.find(|d| std::fs::create_dir_all(d).is_ok())?;
let _ = std::fs::copy(logs_dir().join("flex.log"), dir.join("flex.log"));
let dumped = if cfg!(windows) {
match dump(&dir.join("flex.dmp")) {
Ok(()) => " The minidump is flex.dmp.".to_owned(),
Err(e) => format!(" Couldn't write a minidump: {e}"),
}
} else {
String::new()
};
let report = format!(
"Flex {} crashed on {}.\n\n{what}\n\nThe log up to the crash is in flex.log (or flex.previous.log once Flex has been restarted).\n",
"Flex {} crashed on {}.\n\n{what}\n\nThe log up to the crash is flex.log in this folder.{dumped}\n",
env!("CARGO_PKG_VERSION"),
std::env::consts::OS,
);
std::fs::write(&path, report).ok()?;
Some(path)
std::fs::write(dir.join("crash.txt"), report).ok()?;
Some(dir)
}

fn install_panic_hook() {
Expand All @@ -85,7 +116,11 @@ fn install_panic_hook() {
let what = format!("Panic in thread '{thread}': {info}\n\nBacktrace:\n{backtrace}");
log::error!("{what}");
log::logger().flush();
if let Some(path) = write_crash_report(&what) {
#[cfg(windows)]
let dump = |path: &std::path::Path| windows::write_minidump(path, std::ptr::null());
#[cfg(not(windows))]
let dump = |_: &std::path::Path| Ok(());
if let Some(path) = write_crash_report(&what, dump) {
eprintln!("crash report written to {}", path.display());
}
default(info);
Expand All @@ -94,12 +129,49 @@ fn install_panic_hook() {

#[cfg(windows)]
mod windows {
use std::os::windows::io::AsRawHandle;

use windows_sys::Win32::Foundation::HMODULE;
use windows_sys::Win32::System::Diagnostics::Debug::{EXCEPTION_POINTERS, SetUnhandledExceptionFilter};
use windows_sys::Win32::System::Diagnostics::Debug::{
EXCEPTION_POINTERS, MINIDUMP_EXCEPTION_INFORMATION, MiniDumpWithFullMemoryInfo,
MiniDumpWithIndirectlyReferencedMemory, MiniDumpWithThreadInfo, MiniDumpWithUnloadedModules, MiniDumpWriteDump,
SetUnhandledExceptionFilter,
};
use windows_sys::Win32::System::LibraryLoader::{
GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS, GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, GetModuleFileNameW,
GetModuleHandleExW,
};
use windows_sys::Win32::System::Threading::{GetCurrentProcess, GetCurrentProcessId, GetCurrentThreadId};

/// Writes a minidump of this process to `path`. `info` is the crash's exception (null
/// for a panic, where the dump just shows every thread's stack).
pub fn write_minidump(path: &std::path::Path, info: *const EXCEPTION_POINTERS) -> Result<(), String> {
let file = std::fs::File::create(path).map_err(|e| e.to_string())?;
let exception = MINIDUMP_EXCEPTION_INFORMATION {
ThreadId: unsafe { GetCurrentThreadId() },
ExceptionPointers: info as *mut _,
ClientPointers: 0,
};
// Stacks plus the memory they point at: enough for locals and a few levels of
// heap objects, while staying a few MB.
let kind = MiniDumpWithIndirectlyReferencedMemory
| MiniDumpWithThreadInfo
| MiniDumpWithUnloadedModules
| MiniDumpWithFullMemoryInfo;
// Safety: the handles are this process and a file we keep open for the call.
let ok = unsafe {
MiniDumpWriteDump(
GetCurrentProcess(),
GetCurrentProcessId(),
file.as_raw_handle(),
kind,
if info.is_null() { std::ptr::null() } else { &exception },
std::ptr::null(),
std::ptr::null(),
)
};
if ok == 0 { Err(std::io::Error::last_os_error().to_string()) } else { Ok(()) }
}

/// "libmpv-2.dll+0x1a2b3c" for a code address.
fn locate(address: usize) -> String {
Expand Down Expand Up @@ -135,7 +207,7 @@ mod windows {
};
log::error!("{what}");
log::logger().flush();
let _ = super::write_crash_report(&what);
let _ = super::write_crash_report(&what, |path| write_minidump(path, info));
0 // EXCEPTION_CONTINUE_SEARCH: let Windows handle it as usual
}

Expand Down
25 changes: 25 additions & 0 deletions crates/flex-app/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,30 @@ fn keep_com_alive() {
}
}

/// `--test-crash`: crashes a few seconds after starting (once settings are loaded), to
/// check crash reports work. A native crash on Windows, a panic elsewhere.
fn start_test_crash() {
std::thread::Builder::new()
.name("test-crash".into())
.spawn(|| {
std::thread::sleep(std::time::Duration::from_secs(5));
log::warn!("crashing on purpose (--test-crash)");
if cfg!(windows) {
// Safety: none; this is the point.
unsafe { std::ptr::null_mut::<u8>().write_volatile(1) };
}
// A panic on this thread alone wouldn't stop Flex: report it, then abort.
let _ = std::panic::catch_unwind(|| panic!("crashing on purpose (--test-crash)"));
std::process::abort();
})
.expect("spawning a thread");
}

fn main() -> eframe::Result {
let mut windowed = false;
let mut hidden = false;
let mut quit = false;
let mut test_crash = false;
let mut start = None;
for arg in std::env::args_os().skip(1) {
if arg == "--windowed" {
Expand All @@ -59,6 +79,8 @@ fn main() -> eframe::Result {
hidden = true;
} else if arg == "--quit" {
quit = true;
} else if arg == "--test-crash" {
test_crash = true;
} else {
start = Some(PathBuf::from(arg));
}
Expand Down Expand Up @@ -91,6 +113,9 @@ fn main() -> eframe::Result {
}
#[cfg(windows)]
keep_com_alive();
if test_crash {
start_test_crash();
}

let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
Expand Down
17 changes: 14 additions & 3 deletions crates/flex-app/src/screens/picker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ pub enum Purpose {
YouTubeImport,
/// A shared folder to sync watch state through (see `crate::sync`).
SyncFolder,
/// Where crash reports go (see `crate::logging`).
CrashFolder,
}

fn list_dir(dir: &Path, purpose: Purpose) -> std::io::Result<Vec<Entry>> {
Expand Down Expand Up @@ -166,9 +168,10 @@ impl FolderPicker {
self.pending = None;
}

/// Row 0 is "Use this folder" when choosing a folder (a library's or the sync folder).
/// Row 0 is "Use this folder" when choosing a folder (a library's, the sync folder or
/// the crash reports folder).
fn offset(&self) -> usize {
usize::from(self.dir.is_some() && matches!(self.purpose, Purpose::Library | Purpose::SyncFolder))
usize::from(self.dir.is_some() && matches!(self.purpose, Purpose::Library | Purpose::SyncFolder | Purpose::CrashFolder))
}
}

Expand All @@ -192,6 +195,12 @@ impl Screen for FolderPicker {
sh.toast(format!("Syncing through {}", dir.display()));
Nav::Pop
}
ListInput::Activate(0) if self.offset() == 1 && self.purpose == Purpose::CrashFolder => {
let dir = self.dir.clone().unwrap_or_default();
sh.set_crash_dir(Some(&dir));
sh.toast(format!("Crash reports will go in {}", dir.display()));
Nav::Pop
}
ListInput::Activate(0) if self.offset() == 1 => {
Nav::replace(KindPicker::new(self.dir.clone().unwrap_or_default()))
}
Expand Down Expand Up @@ -228,6 +237,7 @@ impl Screen for FolderPicker {
Purpose::PlexDatabase => ("Import from Plex", "Find com.plexapp.plugins.library.db"),
Purpose::YouTubeImport => ("Import subscriptions", "Find subscriptions.csv (Google Takeout), or a FreeTube, NewPipe or OPML export"),
Purpose::SyncFolder => ("Sync folder", "Choose a folder every device can reach, e.g. an empty folder on the array"),
Purpose::CrashFolder => ("Crash reports folder", "Choose where crash reports (log + minidump) go"),
};
let location = self.dir.as_ref().map_or(prompt.to_owned(), |d| d.display().to_string());
let subtitle = self.error.clone().unwrap_or(location);
Expand All @@ -240,6 +250,7 @@ impl Screen for FolderPicker {
if let Some(dir) = self.dir.as_ref().filter(|_| self.offset() == 1) {
let label = match self.purpose {
Purpose::SyncFolder => format!("Sync through “{}”", name_of(dir)),
Purpose::CrashFolder => format!("Put crash reports in “{}”", name_of(dir)),
_ => format!("Use “{}” as a library", name_of(dir)),
};
rows.push(Row::new(Icon::Add, label));
Expand All @@ -250,7 +261,7 @@ impl Screen for FolderPicker {
}));
if rows.is_empty() {
let what = match self.purpose {
Purpose::Library | Purpose::SyncFolder => "No folders here",
Purpose::Library | Purpose::SyncFolder | Purpose::CrashFolder => "No folders here",
Purpose::PlexDatabase => "No folders or .db files here",
Purpose::YouTubeImport => "No folders or subscription files here",
};
Expand Down
Loading
Loading