feat: add Cleanflix Connector Tauri desktop app

Move the Mac Tauri UI (pro.cleanflix.connector) into this repo under desktop/. Bundled Node runtime stays gitignored; package scripts fetch it when building.
This commit is contained in:
2026-08-12 10:10:26 +00:00
parent 84db4b766c
commit 74deabd4b1
44 changed files with 6225 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
# Generated by Cargo
# will have compiled files and executables
/target/
# Generated by Tauri
# will have schema files for capabilities auto-completion
/gen/schemas
+5131
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
[package]
name = "desktop"
version = "0.1.0"
description = "A Tauri App"
authors = ["you"]
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
# The `_lib` suffix may seem redundant but it is necessary
# to make the lib name unique and wouldn't conflict with the bin name.
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
name = "desktop_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rfd = "0.15"
if-addrs = "0.13"
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
@@ -0,0 +1,10 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"opener:default"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 974 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 903 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+143
View File
@@ -0,0 +1,143 @@
use std::fs;
use std::process::Command;
use if_addrs::{get_if_addrs, IfAddr};
use tauri::Manager;
const CONNECTOR_PORT: u16 = 8791;
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct ConnectorConfig { media_root: String, pairing_password: String }
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct ConnectorSetup { media_root: String, password_configured: bool, public_base_url: String }
fn public_base_url() -> Result<String, String> {
let address = get_if_addrs().map_err(|_| "Could not determine this computer's LAN address.".to_string())?
.into_iter()
.filter_map(|interface| match interface.addr {
IfAddr::V4(address) if address.ip.is_private() && !matches!(address.ip.octets()[3], 0 | 255) => Some((interface.name, address.ip)),
_ => None,
})
.filter(|(name, _)| !["bridge", "docker", "veth", "utun", "lo"].iter().any(|prefix| name.starts_with(prefix)))
// Prefer common home-LAN subnets over container bridge networks.
.max_by_key(|(name, address)| (
if name.starts_with("en") { 1 } else { 0 },
match address.octets() {
[192, 168, ..] => 3,
[172, second, ..] if (16..=31).contains(&second) => 2,
[10, ..] => 1,
_ => 0,
},
))
.map(|(_, address)| address)
.ok_or_else(|| "Connect this computer to your home network first.".to_string())?;
Ok(format!("http://{address}:{CONNECTOR_PORT}"))
}
fn connector_command(app: &tauri::AppHandle) -> Result<Command, String> {
let resource_dir = app.path().resource_dir().map_err(|e| e.to_string())?.join("sidecar");
let config = app.path().app_data_dir().map_err(|e| e.to_string())?.join("config.env");
let mut command = Command::new(resource_dir.join("distribution-common/start-connector.sh"));
command
.env("CLEANFLIX_APP_ROOT", &resource_dir)
.env("CLEANFLIX_CONFIG_PATH", config)
.env("CLEANFLIX_NODE", resource_dir.join("node/bin/node"))
// TVs discover the connector through an IPv4 LAN address.
.env("HOST", "0.0.0.0")
.env("PORT", CONNECTOR_PORT.to_string());
Ok(command)
}
#[tauri::command]
fn pick_media_folder() -> Option<String> {
rfd::FileDialog::new().pick_folder().map(|path| path.display().to_string())
}
#[tauri::command]
fn get_connector_setup(app: tauri::AppHandle) -> Result<ConnectorSetup, String> {
let config = app.path().app_data_dir().map_err(|e| e.to_string())?.join("config.env");
let content = fs::read_to_string(config).unwrap_or_default();
let value = |key: &str| content.lines()
.find_map(|line| line.strip_prefix(&format!("{key}=")))
.unwrap_or_default()
.to_string();
Ok(ConnectorSetup {
media_root: value("MEDIA_ROOT"),
password_configured: !value("PAIRING_PASSWORD_HASH").is_empty(),
public_base_url: value("PUBLIC_BASE_URL"),
})
}
#[tauri::command]
fn get_cleanflix_link_url(app: tauri::AppHandle) -> Result<String, String> {
let setup = get_connector_setup(app)?;
if setup.media_root.is_empty() || !setup.password_configured {
return Err("Save your media folder and pairing password before signing in.".into());
}
if setup.public_base_url.is_empty() { return Err("Start the connector before signing in.".into()); }
Ok(format!("{}/account/link", setup.public_base_url))
}
#[tauri::command]
fn save_connector_config(app: tauri::AppHandle, config: ConnectorConfig) -> Result<(), String> {
if config.media_root.trim().is_empty() {
return Err("Choose a media folder.".into());
}
let dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let config_path = dir.join("config.env");
let existing = fs::read_to_string(&config_path).unwrap_or_default();
let existing_hash = existing.lines().find_map(|line| line.strip_prefix("PAIRING_PASSWORD_HASH=")).unwrap_or_default();
let resource_dir = app.path().resource_dir().map_err(|e| e.to_string())?.join("sidecar");
let password_hash = if config.pairing_password.is_empty() {
if existing_hash.is_empty() { return Err("Choose a password of at least 12 characters.".into()); }
existing_hash.to_string()
} else {
if config.pairing_password.len() < 12 { return Err("Choose a password of at least 12 characters.".into()); }
let hash = Command::new(resource_dir.join("node/bin/node"))
.arg("hash-password.js")
.arg(&config.pairing_password)
.current_dir(&resource_dir)
.output()
.map_err(|_| "The bundled connector runtime is unavailable.".to_string())?;
if !hash.status.success() { return Err("Could not secure the pairing password.".into()); }
String::from_utf8(hash.stdout).map_err(|e| e.to_string())?.trim().to_string()
};
let public_base_url = public_base_url()?;
let content = format!("MEDIA_ROOT={}\nPUBLIC_BASE_URL={}\nAPPROVAL_BASE_URL={}\nCLEANFLIX_API_URL=https://cleanflix.pro\nPAIRING_PASSWORD_HASH={}\n", config.media_root, public_base_url, public_base_url, password_hash);
fs::write(config_path, content).map_err(|e| e.to_string())
}
#[tauri::command]
fn start_connector(app: tauri::AppHandle) -> Result<(), String> {
connector_command(&app)?.spawn().map_err(|e| e.to_string())?;
Ok(())
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![pick_media_folder, get_connector_setup, get_cleanflix_link_url, save_connector_config, start_connector])
.setup(|app| {
let config = app.path().app_data_dir()?.join("config.env");
if config.exists() {
let content = fs::read_to_string(&config)?;
let mut updated = content.lines().map(|line| {
if line.starts_with("PUBLIC_BASE_URL=") || line.starts_with("APPROVAL_BASE_URL=") {
format!("{}={}",&line[..line.find('=').unwrap()], public_base_url().unwrap_or_else(|_| "http://cleanflix-connector.local:8787".into()))
} else { line.to_string() }
}).collect::<Vec<_>>();
if !updated.iter().any(|line| line.starts_with("CLEANFLIX_API_URL=")) {
updated.push("CLEANFLIX_API_URL=https://cleanflix.pro".into());
}
fs::write(&config, format!("{}\n", updated.join("\n")))?;
connector_command(&app.handle()).map_err(std::io::Error::other)?.spawn()?;
}
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running Tauri application");
}
+6
View File
@@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
desktop_lib::run()
}
+35
View File
@@ -0,0 +1,35 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Cleanflix Connector",
"version": "0.1.0",
"identifier": "pro.cleanflix.connector",
"build": {
"frontendDist": "../src"
},
"app": {
"withGlobalTauri": true,
"windows": [
{
"title": "Cleanflix Connector",
"width": 900,
"height": 780,
"minHeight": 700
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"resources": {"../sidecar": "sidecar"},
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}