Cross-compile groundwork: make the tree build for Windows (3.1)

`cargo check --target x86_64-pc-windows-gnu` is now green (only the
upstream egui f32:From<f64> warnings). The Linux-only desktop deps are
target-gated so the rest of the stack — which already cross-compiles —
can build off Linux:

- Cargo.toml: ksni and rfd's xdg-portal backend move to
  [target.'cfg(target_os = "linux")'.dependencies]; non-Linux gets rfd
  with its native Win32/AppKit backend (default features).
- tray.rs: cfg-split. The ksni/SNI implementation stays Linux-only; other
  OSes get a no-op `start() -> None`, i.e. windowed-only (identical to the
  Linux no-SNI-host path). TrayEvent/TrayHandle stay cross-platform.
- plex.rs: add a Windows `symlink_file` arm to make_symlink (also fixes an
  unused-variable warning on non-unix).

The rest was already portable (disk_space/statvfs, mpv IPC UnixStream,
chmod/PermissionsExt guards all cfg(unix)). macOS is Unix so those apply
there unchanged. Still not a *shipped* binary: needs a real per-OS tray
backend and a linking CI matrix. ROADMAP 3.1 + CLAUDE.md updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Luna 2026-06-10 04:22:18 -07:00
parent ec8cf6f934
commit d797f2a698
6 changed files with 80 additions and 18 deletions

View file

@ -162,6 +162,13 @@ both the transcript indexer and the transcript viewers.
desktop code goes in `app.rs`, web handlers in `web.rs`, shared logic in the desktop code goes in `app.rs`, web handlers in `web.rs`, shared logic in the
focused modules (`downloader`, `database`, `library`, `platform`, `fingerprint`, focused modules (`downloader`, `database`, `library`, `platform`, `fingerprint`,
`vtt`, …). `vtt`, …).
- Tray (`ksni`) and file dialogs (`rfd` xdg-portal) are Linux-only/no-GTK by - Tray (`ksni`) and the `rfd` xdg-portal dialog backend are Linux-only/no-GTK
design; keep that posture (it's why packaging avoids a GTK dep). Windows/macOS by design (it's why packaging avoids a GTK dep). They're now **target-gated**
are not yet first-class — the tray would need a per-OS backend. in `Cargo.toml` (`cfg(target_os = "linux")`); off Linux `rfd` uses its native
backend and `tray::start` is a compiled-out no-op (`None`). The tree
cross-compiles — `cargo check --target x86_64-pc-windows-gnu` is green — so
keep new platform-specific code behind `cfg(unix)`/`cfg(windows)`/
`cfg(target_os = …)` with a fallback, the way `disk_space`, `plex`, the mpv
IPC, and the `chmod` guards already do. Windows/macOS still aren't *shipped*
(no tray backend, no link/CI yet), but don't reintroduce an unconditional
Linux-only dep.

13
Cargo.lock generated
View file

@ -223,6 +223,8 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39" checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39"
dependencies = [ dependencies = [
"async-fs",
"async-net",
"enumflags2", "enumflags2",
"futures-channel", "futures-channel",
"futures-util", "futures-util",
@ -328,6 +330,17 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "async-net"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7"
dependencies = [
"async-io",
"blocking",
"futures-lite",
]
[[package]] [[package]]
name = "async-process" name = "async-process"
version = "2.5.0" version = "2.5.0"

View file

@ -32,12 +32,20 @@ tokio-stream = "0.1"
tower-http = { version = "0.5", features = ["cors", "fs", "compression-gzip"] } tower-http = { version = "0.5", features = ["cors", "fs", "compression-gzip"] }
argon2 = "0.5" argon2 = "0.5"
rand = "0.8" rand = "0.8"
# File-picker dialog for the desktop GUI. xdg-portal backend keeps it pure-Rust
# (zbus, no GTK/libdbus build dep), consistent with notify-rust above.
rfd = { version = "0.15", default-features = false, features = ["xdg-portal", "tokio"] }
ksni = { version = "0.3.4", features = ["tokio"] }
libc = "0.2.186" libc = "0.2.186"
# Platform-specific desktop deps. On Linux the tray (ksni — StatusNotifierItem
# over zbus) and the rfd file picker (xdg-portal backend) stay pure-Rust with no
# GTK build dep, matching notify-rust. On Windows/macOS there's no SNI tray yet
# (tray::start is a compiled-out no-op) and rfd falls back to its native backend
# (Win32 / AppKit) via its default features.
[target.'cfg(target_os = "linux")'.dependencies]
ksni = { version = "0.3.4", features = ["tokio"] }
rfd = { version = "0.15", default-features = false, features = ["xdg-portal", "tokio"] }
[target.'cfg(not(target_os = "linux"))'.dependencies]
rfd = { version = "0.15", features = ["tokio"] }
[profile.release] [profile.release]
opt-level = 3 opt-level = 3
# Thin LTO does cross-crate inlining without the rust-lld + bundled-sqlite # Thin LTO does cross-crate inlining without the rust-lld + bundled-sqlite

View file

@ -126,13 +126,25 @@ instead of cascading one handler's panic into a dead server.
Once we're at parity, we push past Tartube on its own ground. Once we're at parity, we push past Tartube on its own ground.
### 3.1 Cross-compile macOS + Windows binaries ### 3.1 Cross-compile macOS + Windows binaries — GROUNDWORK DONE
The Linux packaging (1.8) is done; this is the natural next reach. Blocked The compile blockers are cleared: `cargo check --target
on abstracting the Linux-only bits behind a per-OS backend — the `ksni` x86_64-pc-windows-gnu` is green (only the upstream egui `f32: From<f64>`
tray and the `rfd` xdg-portal file dialog have no Windows/macOS path yet. warnings). The Linux-only deps are now target-gated in `Cargo.toml`
Once the tray is a trait with per-OS impls, the rest of the stack `ksni` and `rfd`'s `xdg-portal` backend are `cfg(target_os = "linux")`
(eframe/wgpu, axum, rusqlite-bundled) already cross-compiles. only, with `rfd` falling back to its native Win32/AppKit backend
elsewhere. `tray::start` is `cfg`-split: the SNI/ksni implementation on
Linux, a no-op `None` stub on other OSes (windowed-only, exactly the
no-SNI-host behavior). The rest was already portable — `disk_space`
(`statvfs`), `plex` (symlinks; now with a Windows `symlink_file` path),
the mpv IPC `UnixStream`, and the `chmod`/`PermissionsExt` guards are all
`cfg(unix)`-gated. macOS is Unix, so those paths apply there unchanged.
Remaining for a shipped binary: a real per-OS tray backend (e.g.
`tray-icon`) if the tray is wanted off Linux; a CI matrix that actually
*links* (mingw-w64 / an osxcross or macOS runner) and produces artifacts;
and runtime testing on each OS. The hard part — making the tree compile
off Linux — is done.
### 3.2 Android client ### 3.2 Android client

View file

@ -118,8 +118,17 @@ fn make_symlink(target: &Path, link: &Path) -> Result<(), String> {
#[cfg(unix)] #[cfg(unix)]
std::os::unix::fs::symlink(target, link) std::os::unix::fs::symlink(target, link)
.map_err(|e| format!("{}: {e}", link.display()))?; .map_err(|e| format!("{}: {e}", link.display()))?;
#[cfg(not(unix))] // Windows distinguishes file vs dir symlinks; the Plex tree only links to
return Err("symlinks are not supported on this platform".to_string()); // video files. Requires Developer Mode or admin at runtime — a failure
// here surfaces as the usual per-link error rather than aborting.
#[cfg(windows)]
std::os::windows::fs::symlink_file(target, link)
.map_err(|e| format!("{}: {e}", link.display()))?;
#[cfg(not(any(unix, windows)))]
{
let _ = target;
return Err("symlinks are not supported on this platform".to_string());
}
Ok(()) Ok(())
} }

View file

@ -21,7 +21,7 @@
//! the tray entirely optional — if `TrayController::start` fails the app //! the tray entirely optional — if `TrayController::start` fails the app
//! continues normally. //! continues normally.
use std::sync::mpsc::{self, Receiver, Sender}; use std::sync::mpsc::Receiver;
/// Menu events emitted by the tray. The main app drains these in /// Menu events emitted by the tray. The main app drains these in
/// [`eframe::App::update`] and translates them into viewport commands or /// [`eframe::App::update`] and translates them into viewport commands or
@ -49,8 +49,9 @@ pub struct TrayHandle {
/// by ksni when the SNI host needs a property; menu activations call the /// by ksni when the SNI host needs a property; menu activations call the
/// boxed closures we store in `MenuItem::activate`, which forward into /// boxed closures we store in `MenuItem::activate`, which forward into
/// the channel back to the main thread. /// the channel back to the main thread.
#[cfg(target_os = "linux")]
struct TrayModel { struct TrayModel {
tx: Sender<TrayEvent>, tx: std::sync::mpsc::Sender<TrayEvent>,
/// PNG bytes for the icon, decoded once at construction. ksni asks /// PNG bytes for the icon, decoded once at construction. ksni asks
/// for raw ARGB so we keep both forms. /// for raw ARGB so we keep both forms.
icon_argb: Vec<u8>, icon_argb: Vec<u8>,
@ -58,6 +59,7 @@ struct TrayModel {
icon_h: i32, icon_h: i32,
} }
#[cfg(target_os = "linux")]
impl ksni::Tray for TrayModel { impl ksni::Tray for TrayModel {
fn id(&self) -> String { fn id(&self) -> String {
"yt-offline".into() "yt-offline".into()
@ -127,6 +129,7 @@ impl ksni::Tray for TrayModel {
/// Returns `None` if the runtime / channel can't be set up. A `Some(_)` /// Returns `None` if the runtime / channel can't be set up. A `Some(_)`
/// return does *not* guarantee the icon is actually visible — that depends /// return does *not* guarantee the icon is actually visible — that depends
/// on whether a StatusNotifier host is registered on the user's desktop. /// on whether a StatusNotifier host is registered on the user's desktop.
#[cfg(target_os = "linux")]
pub fn start(icon_png_bytes: &[u8]) -> Option<TrayHandle> { pub fn start(icon_png_bytes: &[u8]) -> Option<TrayHandle> {
// Decode the PNG eagerly so we fail fast if the embedded asset is bad. // Decode the PNG eagerly so we fail fast if the embedded asset is bad.
let img = image::load_from_memory(icon_png_bytes).ok()?; let img = image::load_from_memory(icon_png_bytes).ok()?;
@ -141,7 +144,7 @@ pub fn start(icon_png_bytes: &[u8]) -> Option<TrayHandle> {
argb.push(px[2]); argb.push(px[2]);
} }
let (tx, rx) = mpsc::channel(); let (tx, rx) = std::sync::mpsc::channel();
let model = TrayModel { let model = TrayModel {
tx, tx,
icon_argb: argb, icon_argb: argb,
@ -175,3 +178,13 @@ pub fn start(icon_png_bytes: &[u8]) -> Option<TrayHandle> {
Some(TrayHandle { events: rx }) Some(TrayHandle { events: rx })
} }
/// Non-Linux stub: there's no StatusNotifierItem tray backend on Windows or
/// macOS yet (ksni is Linux/SNI-only), so the tray is simply absent and the
/// app runs windowed-only. A real implementation would need a per-OS backend
/// (e.g. `tray-icon`) behind this same signature. Returning `None` makes
/// `App` behave exactly as it does on Linux when no SNI host is registered.
#[cfg(not(target_os = "linux"))]
pub fn start(_icon_png_bytes: &[u8]) -> Option<TrayHandle> {
None
}