Add Windows support via a Tauri (WebView2) shell

zero-native has no Windows target yet, so add a parallel Tauri shell
for Windows that reuses the same frontend:

- src-tauri/: Rust commands (yt_search, yt_streams, store_load,
  store_save) mirroring the Zig bridge; spawns bundled yt-dlp.exe with
  CREATE_NO_WINDOW; data stored in app_data_dir
- frontend bridge now auto-detects window.__TAURI__ vs window.zero and
  routes accordingly; CSP updated for Tauri IPC
- tauri.conf.json bundles yt-dlp.exe as a resource and builds nsis/msi
- scripts/make-icon.js generates appicon.png for 'tauri icon'
- README: native Windows build steps + WSLg fallback note
This commit is contained in:
Jonathan Sykes
2026-06-14 10:04:27 +08:00
parent a171cae417
commit bac6665b3d
12 changed files with 527 additions and 18 deletions

6
.gitignore vendored
View File

@@ -12,6 +12,12 @@ build.zig
build.zig.zon build.zig.zon
src/runner.zig src/runner.zig
# tauri / rust build artifacts
src-tauri/target/
src-tauri/gen/
# generated by `npm run tauri icon ./appicon.png`
src-tauri/icons/
# node # node
node_modules/ node_modules/

View File

@@ -24,21 +24,32 @@ or Node runtime.
## Architecture ## Architecture
The **same frontend** drives two native shells:
- **Linux / macOS** → zero-native (Zig + system WebView)
- **Windows** → Tauri (Rust + WebView2) — see [Run on Windows](#run-on-windows-tauri)
``` ```
frontend/ Static web UI (no framework, no build step) frontend/ Static web UI (no framework, no build step) — shared
index.html index.html
styles.css styles.css
app.js Player engine + UI; calls window.zero.invoke(...) app.js Player engine + UI; auto-detects Tauri vs zero-native
src/ src/ zero-native (Linux/macOS) shell
main.zig App definition + bridge handler registration main.zig App definition + bridge handler registration
bridge.zig Native handlers: spawn yt-dlp, slim its JSON, local store bridge.zig Native handlers: spawn yt-dlp, slim its JSON, local store
src-tauri/ Tauri (Windows) shell
src/main.rs Same handlers as Rust commands
tauri.conf.json Window, CSP, bundle (icons, yt-dlp resource)
Cargo.toml
scripts/ scripts/
setup-ytdlp.js Downloads the standalone yt-dlp binary into ./bin setup-ytdlp.js Downloads the standalone yt-dlp binary into ./bin
make-icon.js Generates appicon.png for `tauri icon`
bin/ yt-dlp lands here (gitignored) bin/ yt-dlp lands here (gitignored)
app.zon App manifest (window, engine, permissions, frontend dir) app.zon zero-native manifest
``` ```
The web UI talks to the Zig side over the zero-native bridge: The web UI talks to the native side over whichever bridge is present
(`window.__TAURI__.core.invoke` on Windows, `window.zero.invoke` elsewhere):
| `window.zero.invoke(...)` | Native handler | Returns | | `window.zero.invoke(...)` | Native handler | Returns |
|---|---|---| |---|---|---|
@@ -58,8 +69,44 @@ and return only the compact fields the UI needs.
- No system `yt-dlp` needed — the setup script bundles it. (On Linux/macOS the - No system `yt-dlp` needed — the setup script bundles it. (On Linux/macOS the
bundled build is self-contained; Python is not required.) bundled build is self-contained; Python is not required.)
## Run on Windows (Tauri)
zero-native does **not** target Windows yet, so the Windows build uses a Tauri
shell (Rust + the WebView2 runtime that ships with Windows 10/11). The result is
a small standalone `.exe`/installer — no bundled Chromium or Node.
**Build natively on the Windows machine** (cross-compiling a WebView2 app from
WSL/Linux is unreliable, so do this on Windows):
```powershell
# Prerequisites (one time):
# • Rust https://rustup.rs (MSVC toolchain)
# • Microsoft C++ Build Tools (Desktop development with C++)
# • WebView2 runtime — preinstalled on Win11; on Win10 grab the Evergreen runtime
# • Node.js (to run the helper scripts + Tauri CLI)
npm install # installs the Tauri CLI (@tauri-apps/cli)
npm run setup # downloads yt-dlp.exe into .\bin
npm run make-icon # writes appicon.png
npm run tauri icon .\appicon.png # expands it into src-tauri\icons\*
npm run tauri:dev # hot dev window
npm run tauri:build # produces the installer (see below)
```
The installer lands in
`src-tauri\target\release\bundle\` (`nsis\*-setup.exe` and `msi\*.msi`).
`yt-dlp.exe` is bundled as an app resource, so the installed app is self-contained.
> Already in WSL and just want it running fast? Your WSL is WSLg-enabled, so you
> can instead build the **Linux** (zero-native) target and its window appears on
> your Windows desktop — see [Setup & run](#setup--run). That needs WSL running
> each time; the Tauri build above is a true standalone Windows app.
## Setup & run ## Setup & run
> Linux / macOS (zero-native). For Windows see [Run on Windows](#run-on-windows-tauri).
```bash ```bash
# 1. Download the yt-dlp binary into ./bin # 1. Download the yt-dlp binary into ./bin
npm run setup # or: node scripts/setup-ytdlp.js npm run setup # or: node scripts/setup-ytdlp.js

BIN
appicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@@ -9,19 +9,25 @@
* ========================================================================== */ * ========================================================================== */
// ---------- Native bridge adapter ---------- // ---------- Native bridge adapter ----------
async function invoke(command, payload = {}) { // Works against two shells from the same frontend:
if (window.zero && typeof window.zero.invoke === 'function') { // • Tauri (Windows / WebView2): window.__TAURI__.core.invoke, snake_case commands
return await window.zero.invoke(command, payload); // • zero-native (Linux / macOS): window.zero.invoke, dotted commands
} const TAURI = window.__TAURI__ && window.__TAURI__.core ? window.__TAURI__.core : null;
throw new Error('Native bridge unavailable — run this inside zero-native (zig build run).'); const ZERO = window.zero && typeof window.zero.invoke === 'function' ? window.zero : null;
// call(zeroName, tauriName, payload) — routes to whichever shell is present.
async function call(zeroName, tauriName, payload = {}) {
if (TAURI) return await TAURI.invoke(tauriName, payload);
if (ZERO) return await ZERO.invoke(zeroName, payload);
throw new Error('No native bridge available — run inside the YT Player app.');
} }
const API = { const API = {
search: (query) => invoke('yt.search', { query }), search: (query) => call('yt.search', 'yt_search', { query }),
getStreams: (videoId) => invoke('yt.streams', { videoId }), getStreams: (videoId) => call('yt.streams', 'yt_streams', { videoId }),
loadData: () => invoke('store.load', {}), loadData: () => call('store.load', 'store_load', {}),
// data is sent pre-stringified so the native side can write it verbatim. // data is sent pre-stringified so the native side can write it verbatim.
saveData: (data) => invoke('store.save', { data: JSON.stringify(data) }), saveData: (data) => call('store.save', 'store_save', { data: JSON.stringify(data) }),
}; };
// ---------- State ---------- // ---------- State ----------

View File

@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta <meta
http-equiv="Content-Security-Policy" http-equiv="Content-Security-Policy"
content="default-src 'self'; img-src 'self' https: data:; media-src https: blob:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com data:; script-src 'self'; connect-src 'self' https:;" content="default-src 'self'; img-src 'self' https: data: asset: http://asset.localhost; media-src 'self' https: blob:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com data:; script-src 'self'; connect-src 'self' https: ipc: http://ipc.localhost;"
/> />
<title>YT Player</title> <title>YT Player</title>
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />

View File

@@ -1,11 +1,18 @@
{ {
"name": "ytplayer", "name": "ytplayer",
"version": "1.0.0", "version": "1.0.0",
"description": "Ad-free YouTube player with local playlists. No login, no ads, direct streams via yt-dlp. Built on zero-native (Zig + system WebView).", "description": "Ad-free YouTube player with local playlists. No login, no ads, direct streams via yt-dlp. zero-native (Linux/macOS) and Tauri (Windows) shells.",
"scripts": { "scripts": {
"setup": "node scripts/setup-ytdlp.js", "setup": "node scripts/setup-ytdlp.js",
"update-ytdlp": "node scripts/setup-ytdlp.js --force" "update-ytdlp": "node scripts/setup-ytdlp.js --force",
"make-icon": "node scripts/make-icon.js",
"tauri": "tauri",
"tauri:dev": "tauri dev",
"tauri:build": "tauri build"
}, },
"author": "", "author": "",
"license": "MIT" "license": "MIT",
"devDependencies": {
"@tauri-apps/cli": "^2"
}
} }

92
scripts/make-icon.js Normal file
View File

@@ -0,0 +1,92 @@
#!/usr/bin/env node
/**
* Generates appicon.png (512x512) — a vermilion tile with a white play glyph.
* No image libraries: encodes a PNG by hand with Node's zlib.
*
* After running this, expand it into all platform sizes with the Tauri CLI:
* npm run tauri icon ./appicon.png
*/
const fs = require('fs');
const path = require('path');
const zlib = require('zlib');
const SIZE = 512;
// CRC32 (PNG chunk checksums)
const CRC_TABLE = (() => {
const t = new Uint32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
t[n] = c >>> 0;
}
return t;
})();
function crc32(buf) {
let c = 0xffffffff;
for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
function chunk(type, data) {
const len = Buffer.alloc(4);
len.writeUInt32BE(data.length, 0);
const typeBuf = Buffer.from(type, 'ascii');
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0);
return Buffer.concat([len, typeBuf, data, crc]);
}
// --- paint pixels (RGBA) ---
const px = Buffer.alloc(SIZE * SIZE * 4);
function set(x, y, r, g, b, a = 255) {
const i = (y * SIZE + x) * 4;
px[i] = r; px[i + 1] = g; px[i + 2] = b; px[i + 3] = a;
}
// Background: subtle vertical vermilion gradient (#ff6a52 -> #d4321d)
for (let y = 0; y < SIZE; y++) {
const t = y / SIZE;
const r = Math.round(0xff + (0xd4 - 0xff) * t);
const g = Math.round(0x6a + (0x32 - 0x6a) * t);
const b = Math.round(0x52 + (0x1d - 0x52) * t);
for (let x = 0; x < SIZE; x++) set(x, y, r, g, b);
}
// White play triangle, centered, slightly right-nudged for optical balance.
const ax = 196, ay = 150, bx = 196, by = 362, cx = 384, cy = 256;
function edge(px1, py1, px2, py2, x, y) {
return (x - px1) * (py2 - py1) - (y - py1) * (px2 - px1);
}
for (let y = 120; y < 392; y++) {
for (let x = 170; x < 400; x++) {
const w0 = edge(bx, by, cx, cy, x, y);
const w1 = edge(cx, cy, ax, ay, x, y);
const w2 = edge(ax, ay, bx, by, x, y);
if ((w0 <= 0 && w1 <= 0 && w2 <= 0) || (w0 >= 0 && w1 >= 0 && w2 >= 0)) {
set(x, y, 255, 255, 255);
}
}
}
// --- encode ---
const raw = Buffer.alloc((SIZE * 4 + 1) * SIZE);
for (let y = 0; y < SIZE; y++) {
raw[y * (SIZE * 4 + 1)] = 0; // filter: none
px.copy(raw, y * (SIZE * 4 + 1) + 1, y * SIZE * 4, (y + 1) * SIZE * 4);
}
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(SIZE, 0);
ihdr.writeUInt32BE(SIZE, 4);
ihdr[8] = 8; // bit depth
ihdr[9] = 6; // color type RGBA
const png = Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
chunk('IHDR', ihdr),
chunk('IDAT', zlib.deflateSync(raw, { level: 9 })),
chunk('IEND', Buffer.alloc(0)),
]);
const out = path.join(__dirname, '..', 'appicon.png');
fs.writeFileSync(out, png);
console.log(`Wrote ${out} (${SIZE}x${SIZE}).`);
console.log('Now run: npm run tauri icon ./appicon.png');

22
src-tauri/Cargo.toml Normal file
View File

@@ -0,0 +1,22 @@
[package]
name = "ytplayer"
version = "1.0.0"
description = "Ad-free YouTube player with local playlists"
authors = ["Jonathan Sykes"]
edition = "2021"
rust-version = "1.77"
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
[profile.release]
opt-level = "s"
lto = true
strip = true
panic = "abort"
codegen-units = 1

3
src-tauri/build.rs Normal file
View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View File

@@ -0,0 +1,7 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Core capabilities for the main window. App-defined commands (yt_search, yt_streams, store_load, store_save) are not gated by the ACL.",
"windows": ["main"],
"permissions": ["core:default"]
}

280
src-tauri/src/main.rs Normal file
View File

@@ -0,0 +1,280 @@
// YT Player — Tauri (Windows / WebView2) backend.
//
// Mirrors the zero-native Zig bridge, but as Tauri commands. The same frontend
// in ../frontend calls these via window.__TAURI__.core.invoke(...). Returns the
// exact JSON shapes the UI expects:
// yt_search { query } -> { ok, results:[…] }
// yt_streams { videoId } -> { ok, data:{ meta, audioUrl, qualities[] } }
// store_load {} -> playlists / history / settings
// store_save { data } -> { ok }
//
// Unlike the size-limited zero-native bridge, Tauri's IPC has no fixed buffer,
// but we still return only the slim fields the UI needs.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use serde_json::{json, Value};
use std::collections::HashSet;
use std::path::PathBuf;
use std::process::Command;
use tauri::Manager;
const SEARCH_LIMIT: u32 = 25;
/// Locate yt-dlp: prefer the bundled resource copy, then a dev ./bin, then PATH.
fn ytdlp_path(app: &tauri::AppHandle) -> PathBuf {
let name = if cfg!(windows) { "yt-dlp.exe" } else { "yt-dlp" };
if let Ok(res) = app.path().resource_dir() {
let p = res.join("bin").join(name);
if p.exists() {
return p;
}
}
let dev = PathBuf::from("bin").join(name);
if dev.exists() {
return dev;
}
PathBuf::from(name)
}
fn run_ytdlp(app: &tauri::AppHandle, args: &[&str]) -> Result<String, String> {
let exe = ytdlp_path(app);
let mut cmd = Command::new(&exe);
cmd.args(args);
// Don't flash a console window when spawning yt-dlp on Windows.
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
cmd.creation_flags(CREATE_NO_WINDOW);
}
let output = cmd
.output()
.map_err(|e| format!("failed to launch yt-dlp ({}): {e}", exe.display()))?;
if !output.status.success() {
let err = String::from_utf8_lossy(&output.stderr);
return Err(if err.trim().is_empty() {
"yt-dlp failed".to_string()
} else {
err.trim().to_string()
});
}
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
fn str_or<'a>(v: &'a Value, key: &str, fallback: &'a str) -> &'a str {
v.get(key).and_then(|x| x.as_str()).unwrap_or(fallback)
}
#[tauri::command]
async fn yt_search(app: tauri::AppHandle, query: String) -> Value {
let q = query.trim();
if q.is_empty() {
return json!({ "ok": false, "error": "empty query" });
}
let search = format!("ytsearch{}:{}", SEARCH_LIMIT, q);
let out = match run_ytdlp(
&app,
&[
&search,
"--dump-json",
"--flat-playlist",
"--no-warnings",
"--ignore-errors",
],
) {
Ok(o) => o,
Err(e) => return json!({ "ok": false, "error": e }),
};
let mut results = Vec::new();
for line in out.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let j: Value = match serde_json::from_str(line) {
Ok(v) => v,
Err(_) => continue,
};
let id = str_or(&j, "id", "");
if id.is_empty() {
continue;
}
let channel = {
let c = str_or(&j, "channel", "");
if c.is_empty() {
str_or(&j, "uploader", "")
} else {
c
}
};
results.push(json!({
"id": id,
"title": str_or(&j, "title", "(untitled)"),
"channel": channel,
"duration": j.get("duration").and_then(|x| x.as_f64()).unwrap_or(0.0),
"thumbnail": format!("https://i.ytimg.com/vi/{}/mqdefault.jpg", id),
}));
}
json!({ "ok": true, "results": results })
}
#[tauri::command]
async fn yt_streams(app: tauri::AppHandle, video_id: String) -> Value {
if video_id.is_empty() {
return json!({ "ok": false, "error": "missing videoId" });
}
let url = format!("https://www.youtube.com/watch?v={}", video_id);
let out = match run_ytdlp(&app, &["-J", "--no-warnings", &url]) {
Ok(o) => o,
Err(e) => return json!({ "ok": false, "error": e }),
};
let info: Value = match serde_json::from_str(&out) {
Ok(v) => v,
Err(e) => return json!({ "ok": false, "error": format!("parse error: {e}") }),
};
let empty: Vec<Value> = Vec::new();
let formats = info.get("formats").and_then(|f| f.as_array()).unwrap_or(&empty);
// Best audio-only stream (prefer m4a/mp4a, then by bitrate).
let mut best_audio_url = String::new();
let mut best_audio_score = -1.0_f64;
for f in formats {
let vcodec = str_or(f, "vcodec", "none");
let acodec = str_or(f, "acodec", "none");
let furl = str_or(f, "url", "");
if furl.is_empty() {
continue;
}
let has_video = vcodec != "none" && !vcodec.is_empty();
let has_audio = acodec != "none" && !acodec.is_empty();
if !has_video && has_audio {
let mut score = f.get("abr").and_then(|x| x.as_f64()).unwrap_or(0.0);
if acodec.contains("mp4a") {
score += 1000.0;
}
if score > best_audio_score {
best_audio_score = score;
best_audio_url = furl.to_string();
}
}
}
// Quality list: adaptive video-only first, then progressive; dedupe by height.
let mut qualities = Vec::new();
let mut seen: HashSet<i64> = HashSet::new();
for want_progressive in [false, true] {
for f in formats {
let vcodec = str_or(f, "vcodec", "none");
let acodec = str_or(f, "acodec", "none");
let furl = str_or(f, "url", "");
if furl.is_empty() {
continue;
}
let has_video = vcodec != "none" && !vcodec.is_empty();
let has_audio = acodec != "none" && !acodec.is_empty();
if !has_video {
continue;
}
let is_progressive = has_audio;
if is_progressive != want_progressive {
continue;
}
let height = f.get("height").and_then(|x| x.as_i64()).unwrap_or(0);
if height <= 0 || seen.contains(&height) {
continue;
}
seen.insert(height);
qualities.push(json!({
"label": format!("{}p", height),
"height": height,
"hasAudio": is_progressive,
"url": furl,
"ext": str_or(f, "ext", ""),
}));
}
}
qualities.sort_by(|a, b| {
b["height"]
.as_i64()
.unwrap_or(0)
.cmp(&a["height"].as_i64().unwrap_or(0))
});
let channel = {
let c = str_or(&info, "channel", "");
if c.is_empty() {
str_or(&info, "uploader", "")
} else {
c
}
};
json!({
"ok": true,
"data": {
"meta": {
"id": video_id,
"title": str_or(&info, "title", "(untitled)"),
"channel": channel,
"duration": info.get("duration").and_then(|x| x.as_f64()).unwrap_or(0.0),
"thumbnail": format!("https://i.ytimg.com/vi/{}/hqdefault.jpg", video_id),
},
"audioUrl": if best_audio_url.is_empty() { Value::Null } else { Value::String(best_audio_url) },
"qualities": qualities,
}
})
}
fn data_file(app: &tauri::AppHandle) -> Result<PathBuf, String> {
let dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
std::fs::create_dir_all(&dir).ok();
Ok(dir.join("ytplayer-data.json"))
}
#[tauri::command]
fn store_load(app: tauri::AppHandle) -> Value {
let default = json!({
"playlists": [],
"history": [],
"settings": { "quality": "auto", "volume": 1, "audioOnly": false }
});
let path = match data_file(&app) {
Ok(p) => p,
Err(_) => return default,
};
match std::fs::read_to_string(&path) {
Ok(s) => serde_json::from_str::<Value>(&s).unwrap_or(default),
Err(_) => default,
}
}
#[tauri::command]
fn store_save(app: tauri::AppHandle, data: String) -> Value {
let path = match data_file(&app) {
Ok(p) => p,
Err(e) => return json!({ "ok": false, "error": e }),
};
match std::fs::write(&path, data) {
Ok(_) => json!({ "ok": true }),
Err(e) => json!({ "ok": false, "error": e.to_string() }),
}
}
fn main() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![
yt_search,
yt_streams,
store_load,
store_save
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

39
src-tauri/tauri.conf.json Normal file
View File

@@ -0,0 +1,39 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "YT Player",
"version": "1.0.0",
"identifier": "com.ytplayer.app",
"build": {
"frontendDist": "../frontend"
},
"app": {
"withGlobalTauri": true,
"windows": [
{
"title": "YT Player",
"width": 1280,
"height": 820,
"minWidth": 900,
"minHeight": 600,
"resizable": true
}
],
"security": {
"csp": "default-src 'self'; img-src 'self' https: data: asset: http://asset.localhost; media-src 'self' https: blob:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com data:; script-src 'self'; connect-src 'self' https: ipc: http://ipc.localhost"
}
},
"bundle": {
"active": true,
"targets": ["nsis", "msi"],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"resources": {
"../bin/yt-dlp.exe": "bin/yt-dlp.exe"
}
}
}