From c711ab78deb53c150dc2f90a894a70f1e3dd3ab1 Mon Sep 17 00:00:00 2001 From: cryptofyre Date: Sun, 22 Sep 2024 21:39:39 -0500 Subject: [PATCH] Add research snapshot --- .gitignore | 31 +++ LICENSE.md | 45 ++++ README.md | 20 ++ src-tauri/.gitignore | 4 + src-tauri/Cargo.toml | 94 ++++++++ src-tauri/build.rs | 55 +++++ src-tauri/src/additional.rs | 70 ++++++ src-tauri/src/airplay/error.rs | 8 + src-tauri/src/airplay/mod.rs | 170 +++++++++++++++ src-tauri/src/config/mod.rs | 61 ++++++ src-tauri/src/discord/error.rs | 12 ++ src-tauri/src/discord/mod.rs | 261 ++++++++++++++++++++++ src-tauri/src/main.rs | 269 +++++++++++++++++++++++ src-tauri/src/plugin/mod.rs | 101 +++++++++ src-tauri/src/rpc/commands.rs | 59 +++++ src-tauri/src/rpc/mod.rs | 48 +++++ src-tauri/src/rpc/server.rs | 246 +++++++++++++++++++++ src-tauri/src/steam/callbacks.rs | 15 ++ src-tauri/src/steam/mod.rs | 132 ++++++++++++ src-tauri/src/systemtray/mod.rs | 131 +++++++++++ src-tauri/src/vibrancy/common.rs | 2 + src-tauri/src/vibrancy/mod.rs | 201 +++++++++++++++++ src-tauri/src/vibrancy/windows.rs | 348 ++++++++++++++++++++++++++++++ src-tauri/src/ws/mod.rs | 129 +++++++++++ src-tauri/tauri.conf.json | 113 ++++++++++ 25 files changed, 2625 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 src-tauri/.gitignore create mode 100644 src-tauri/Cargo.toml create mode 100644 src-tauri/build.rs create mode 100644 src-tauri/src/additional.rs create mode 100644 src-tauri/src/airplay/error.rs create mode 100644 src-tauri/src/airplay/mod.rs create mode 100644 src-tauri/src/config/mod.rs create mode 100644 src-tauri/src/discord/error.rs create mode 100644 src-tauri/src/discord/mod.rs create mode 100644 src-tauri/src/main.rs create mode 100644 src-tauri/src/plugin/mod.rs create mode 100644 src-tauri/src/rpc/commands.rs create mode 100644 src-tauri/src/rpc/mod.rs create mode 100644 src-tauri/src/rpc/server.rs create mode 100644 src-tauri/src/steam/callbacks.rs create mode 100644 src-tauri/src/steam/mod.rs create mode 100644 src-tauri/src/systemtray/mod.rs create mode 100644 src-tauri/src/vibrancy/common.rs create mode 100644 src-tauri/src/vibrancy/mod.rs create mode 100644 src-tauri/src/vibrancy/windows.rs create mode 100644 src-tauri/src/ws/mod.rs create mode 100644 src-tauri/tauri.conf.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9f5ad74 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +src-tauri/Microsoft.WebView2.FixedVersionRuntime** + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +.history/ +*.ntvs* +*.njsproj +*.sln +*.sw? +pnpm-lock.yaml +yarn.lock +*.gz +Microsoft.WebView2.FixedVersionRuntime** diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..903cd72 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,45 @@ + +# Cider Collective Research License (CCRL) + +## Version 1.0 + +### 1. **Grant of License** + +Cider Collective ("Licensor") hereby grants you ("Licensee") a non-exclusive, non-transferable, revocable license to use the provided source code ("Code") strictly for non-commercial, research, and educational purposes. The Code may **not** be used to reproduce, modify, or develop any application that mirrors or replicates the original source application ("Application"). + +### 2. **Restrictions on Use** + +The following restrictions apply: + +- **No Reproduction**: Licensee is strictly prohibited from using the Code to produce a functional or derivative copy of the Application in any form. + +- **No Piracy or Malicious Use**: The Code must not be used for any activity related to piracy, circumvention of software protections, or any malicious or illegal purposes. + +- **No Commercial Use**: The Code may not be used in any commercial product or for profit-driven endeavors. + +- **Non-functional Representation**: The Code provided herein is intended solely for research and educational purposes and does **not** represent a live or operational version of the Application. It is not suitable for any production-level deployment. + +### 3. **Permissible Use** + +Licensee may use the Code only for: + +- Research and study of the Code structure and architecture. +- Exploration of theoretical improvements or modifications for academic purposes. + +No rights are granted to Licensee to distribute, sublicense, or otherwise make the Code or any derivative works available to third parties without prior written consent from the Licensor. + +### 4. **Ownership and Copyright** + +The Code is the intellectual property of Cider Collective. All rights not expressly granted under this License are reserved. Licensee acknowledges that all ownership rights, including copyright and trademark rights, remain with the Licensor. + +### 5. **Disclaimer of Warranty** + +The Code is provided "AS IS" without any warranty of any kind. Cider Collective disclaims any and all warranties, whether express or implied, including but not limited to warranties of merchantability, fitness for a particular purpose, or non-infringement. + +### 6. **Limitation of Liability** + +In no event shall Cider Collective be liable for any direct, indirect, incidental, consequential, or special damages arising out of or in connection with the use of the Code, even if advised of the possibility of such damages. + +### 7. **Termination** + +This License will automatically terminate if Licensee violates any of the terms and conditions stated herein. Upon termination, Licensee must immediately cease all use of the Code and destroy all copies in their possession. diff --git a/README.md b/README.md new file mode 100644 index 0000000..4feac5a --- /dev/null +++ b/README.md @@ -0,0 +1,20 @@ + +# Sabiiro Snapshot + +This snapshot of **Sabiiro** represents a developmental backend for Cider based on Tauri & Rust, various chunks have been removed due to correspondance in active software. It is provided solely for **research purposes** and is not intended for compilation or deployment. Key functions and data have been omitted for security reasons, and it is no longer actively maintained or used. + +## Purpose + +Sabiiro was the backend platform used during Cider's development. It was a full scale attempt at a backend for Cider and was briefly used (not in this form) as a suitable backend for a while before being retired to due developmental difficulties. This release allows researchers to study the architectural decisions made during its development, but it is **not suitable** for operational use. + +## Security Notice + +This snapshot has been stripped of sensitive functions, and the code is safe to publish under the **Cider Collective Research License (CCRL)**. Any attempts to use this code beyond research purposes are strictly prohibited, and the snapshot is provided **as-is**. + +## License + +This project is published under the **Cider Collective Research License (CCRL)**, restricting its use to non-commercial, research-intensive purposes only. + +## Special Credit + +Huge thanks to @d3rpp who participated largely in the making of this backend, you are **AWESOME**! Good luck on your future endeavors! diff --git a/src-tauri/.gitignore b/src-tauri/.gitignore new file mode 100644 index 0000000..f4dfb82 --- /dev/null +++ b/src-tauri/.gitignore @@ -0,0 +1,4 @@ +# Generated by Cargo +# will have compiled files and executables +/target/ + diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml new file mode 100644 index 0000000..dc61405 --- /dev/null +++ b/src-tauri/Cargo.toml @@ -0,0 +1,94 @@ +[package] +name = "cider" +version = "x.x.x" +description = "Cider" +authors = ["Cider Collective"] +license = "" +repository = "" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[profile.release] +lto = true +opt-level = 2 +debug = 0 +strip = "symbols" + +[build-dependencies] +tauri-build = { version = "1.3.0", features = [] } +cfg-if = "~1.0" + +[dependencies] +tauri = { version = "~1.2", features = [ "fs-all", "window-create", "window-set-focus", "window-center", "window-set-icon", "window-request-user-attention", "window-set-title", "window-show", "process-all", "http-all", "dialog-all", "clipboard-write-text", "devtools", "notification-all", "os-all", "process-command-api", "shell-open", "shell-sidecar", "system-tray", "window-close", "window-hide", "window-maximize", "window-minimize", "window-set-always-on-top", "window-set-fullscreen", "window-set-position", "window-set-size", "window-start-dragging", "window-unmaximize"] } +serde = { version = "1.0", features = ["derive"] } +tauri-plugin-single-instance = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "dev" } +tauri-plugin-deep-link = { git = "https://github.com/FabianLars/tauri-plugin-deep-link", branch = "main" } + +tokio = { version = "~1.29", default-features = false, features = ["fs", "macros"] } +reqwest = { version = "~0.11", features = ["json", "blocking", "rustls-tls"], default-features = false } +warp = { version = "~0.3", features = [] } +rouille = "~3.6" +bytes = "~1.4" + +steamworks = { version = "~0.10", optional = true } + +thiserror = "~1.0" +lazy_static = "~1.4" +obfstr = "~0.4" +cfg-if = "~1.0" +futures = "~0.3" +dialog = "0.3.0" +native-dialog = "0.7.0" + +rustfm-scrobble = "~1.1" + +discord-rich-presence = "0.2.3" + +zip = "0.6" +sha2 = "0.10.8" +mime_guess = "2.0.3" + +tauri-plugin-localhost = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" } +portpicker = "0.1" # used in the example to pick a random free port + + +fon = "0.6.0" + +base64 = "0.21.0" +serde_json = "~1.0" +md5 = "~0.7" + +chrono = "~0.4" +chashmap = "2.2.2" +open = "~5.0" + +[target.'cfg(macos)'.dependencies] +objc = "~0.2" + +[target.'cfg(windows)'.dependencies] +# Windows Only +webview2-com = "~0.19" # DO NOT BUMP +windows = { version = "~0.39", features = [ + "Win32_Graphics_Dwm", + "Win32_Foundation", + "Win32_UI_Controls", +] } # DO NOT BUMP +widestring = "~1.0" + +[target."cfg(target_os = \"windows\")".dependencies.windows-sys] +version = "0.45.0" +features = [ + "Win32_Foundation", + "Win32_System_LibraryLoader", + "Win32_System_SystemInformation", + "Win32_Graphics_Gdi", + "Win32_Graphics_Dwm", + "Win32_UI_WindowsAndMessaging" +] + +[features] +default = ["custom-protocol"] +steamworks = ["dep:steamworks"] +# this feature is used for production builds or when `devPath` points to the filesystem +# DO NOT REMOVE!! +custom-protocol = ["tauri/custom-protocol"] diff --git a/src-tauri/build.rs b/src-tauri/build.rs new file mode 100644 index 0000000..af4b4af --- /dev/null +++ b/src-tauri/build.rs @@ -0,0 +1,55 @@ +use cfg_if::cfg_if; +use std::{env, fs, path::PathBuf}; +cfg_if! { + if #[cfg(any(windows))] { + fn main() { + + #[cfg(debug_assertions)] + let profile = "debug"; + #[cfg(not(debug_assertions))] + let profile = "release"; + + + let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); + + cfg_if! { + if #[cfg(all(any(debug_assertions, feature = "steamworks"), windows))] { + let from_path = manifest_dir.join("resource/steam_api64.dll"); + let to_path = manifest_dir.join(format!("target/{}/steam_api64.dll", profile)); + + println!("cargo:rerun-if-changed=resource/steam_api64.dll"); + println!("cargo:rerun-if-changed=target/{}/steam_api_64.dll", profile); + + fs::copy(from_path, to_path).expect("FAILED TO COPY STEAM_API DLL"); + } + } + + cfg_if! { + if #[cfg(all(not(debug_assertions), windows))] { + // create a symlink for ../../Microsoft.WebView2.FixedVersionRuntime.123.0.2420.81.x64/ to ./target/debug/Microsoft.WebView2.FixedVersionRuntime.123.0.2420.81.x64/ folder to debug and release + let from_path = manifest_dir.join("Microsoft.WebView2.FixedVersionRuntime.123.0.2420.81.x64"); + let to_path_debug = manifest_dir.join(format!("target/debug/Microsoft.WebView2.FixedVersionRuntime.123.0.2420.81.x64")); + let to_path_release = manifest_dir.join(format!("target/release/Microsoft.WebView2.FixedVersionRuntime.123.0.2420.81.x64")); + + println!("cargo:rerun-if-changed=resource/Microsoft.WebView2.FixedVersionRuntime.123.0.2420.81.x64"); + println!("cargo:rerun-if-changed=target/{}/Microsoft.WebView2.FixedVersionRuntime.123.0.2420.81.x64", profile); + + if !to_path_debug.exists() { + std::os::windows::fs::symlink_dir(&from_path, to_path_debug).expect("FAILED TO CREATE SYMLINK FOR WEBVIEW2 RUNTIME"); + } + + if !to_path_release.exists() { + std::os::windows::fs::symlink_dir(&from_path, to_path_release).expect("FAILED TO CREATE SYMLINK FOR WEBVIEW2 RUNTIME"); + } + } + } + + tauri_build::build() + } + } + else { + fn main() { + compile_error!("UNSUPPORTED PLATFORM AT THIS TIME"); + } + } +} diff --git a/src-tauri/src/additional.rs b/src-tauri/src/additional.rs new file mode 100644 index 0000000..ba285af --- /dev/null +++ b/src-tauri/src/additional.rs @@ -0,0 +1,70 @@ +use tauri::{async_runtime::Mutex, LogicalPosition, LogicalSize, Runtime}; + +/* +```js + let wm = new WindowManager("cider_main") + const factor = await wm.scaleFactor(); + const size = await wm.outerSize(); + const logical = size.toLogical(factor); + window.tempSize = logical; + await wm.setSize(new LogicalSize(296, 296)); +``` + */ + +lazy_static::lazy_static! { + static ref OLD_SIZE: Mutex<(f32, f32)> = Mutex::new((600.0, 500.0)); + static ref OLD_POS: Mutex<(f32, f32)> = Mutex::new((0.0, 0.0)); +} + +#[tauri::command] +pub async fn set_miniplayer_mode( + window: tauri::Window, + toggled: bool, +) -> Result<(), String> { + let mut s_lock = OLD_SIZE.lock().await; + let mut p_lock = OLD_POS.lock().await; + + if toggled { + let factor = window.scale_factor().map_err(|e| e.to_string())?; + let size = window.outer_size().map_err(|e| e.to_string())?; + let logical = size.to_logical::(factor); + + *s_lock = (logical.width, logical.height); + drop(s_lock); + + let position = window.outer_position().map_err(|e| e.to_string())?; + let logical_pos = position.to_logical::(factor); + + *p_lock = (logical_pos.x, logical_pos.y); + drop(p_lock); + + window + .set_title("Miniplayer - Cider") + .map_err(|e| e.to_string())?; + window.set_resizable(false).map_err(|e| e.to_string())?; // there is no benefit to changing the size, so i will disable it + window + .set_size(LogicalSize::new(296.0, 296.0)) + .map_err(|e| e.to_string())?; + window.set_always_on_top(true).map_err(|e| e.to_string())?; + + Ok(()) + } else { + let (w, h) = *s_lock; + drop(s_lock); + + let (x, y) = *p_lock; + drop(p_lock); + + window.set_title("Cider").map_err(|e| e.to_string())?; + window + .set_position(LogicalPosition::new(x, y)) + .map_err(|e| e.to_string())?; + window + .set_size(LogicalSize::new(w, h)) + .map_err(|e| e.to_string())?; + window.set_resizable(true).map_err(|e| e.to_string())?; + window.set_always_on_top(false).map_err(|e| e.to_string())?; + + Ok(()) + } +} diff --git a/src-tauri/src/airplay/error.rs b/src-tauri/src/airplay/error.rs new file mode 100644 index 0000000..c7015e6 --- /dev/null +++ b/src-tauri/src/airplay/error.rs @@ -0,0 +1,8 @@ +use serde::Serialize; +use thiserror::Error; + +#[derive(Debug, Serialize, Error)] +pub enum AirPlayError { + #[error("Initialisation Error: {0}")] + Init(String), +} diff --git a/src-tauri/src/airplay/mod.rs b/src-tauri/src/airplay/mod.rs new file mode 100644 index 0000000..e4603b2 --- /dev/null +++ b/src-tauri/src/airplay/mod.rs @@ -0,0 +1,170 @@ +#![allow(unused)] + +use tokio::sync::mpsc::Receiver; + +use tauri::{ + api::process::{Command, CommandChild, CommandEvent}, + async_runtime::{JoinHandle, RwLock}, + plugin::{Builder as PluginBuilder, TauriPlugin}, + AppHandle, Manager, Runtime, +}; + +pub mod error; +use error::AirPlayError; + +use std::convert::TryInto; + +use base64::{ + alphabet, + engine::{self, general_purpose}, + Engine as _, +}; +use fon::chan::{Ch16, Ch32}; +use fon::Audio; + +pub struct AirPlayClient { + airtunes_child: RwLock<(Option, Option>)>, +} + +impl AirPlayClient { + pub fn new() -> Self { + Self { + airtunes_child: RwLock::new((None, None)), + } + } + + pub async fn send_audio(&self, audio: String) { + let mut lock = self.airtunes_child.write().await; + + let c = &mut lock.0; + + if let Some(client) = c { + let orig_bytes = general_purpose::STANDARD.decode(audio).unwrap(); + + let mut audio0 = Vec::new(); + for sample in orig_bytes.chunks(4) { + audio0.push(f32::from_le_bytes(sample.try_into().unwrap())); + } + let audio1 = Audio::::with_f32_buffer(96000, audio0); + // Stream resampler into new audio type. + let mut audio2 = Audio::::with_audio(44100, &audio1); + // Write file as i16 buffer. + let mut bytes = Vec::new(); + for sample in audio2.as_i16_slice() { + bytes.extend(sample.to_le_bytes()); + } + client.write(&bytes).unwrap(); + } + } + + pub async fn start_client(&self) -> Result<(), String> { + let mut lock = self.airtunes_child.write().await; + if lock.0.is_some() { + return Ok(()); + } + + match Command::new_sidecar("airtunes2") { + Ok(mut airtunes) => { + let (mut rx, mut child) = airtunes.spawn().expect("Failed to spawn sidecar"); + let join_handle = tauri::async_runtime::spawn(async move { + // read events such as stdout + while let Some(event) = rx.recv().await { + if let CommandEvent::Stdout(message) = event { + println!("{}", message); + } + // else if let CommandEvent::Stderr(message) = event { + // println!("{}", message); + // } else if let CommandEvent::Error(message) = event { + // println!("{}", message); + // } + } + }); + + *lock = (Some(child), Some(join_handle)); + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + Ok(()) + } + + Err(e) => Err(e.to_string()), + } + } + + pub async fn stop_client(&self) { + let mut lock = self.airtunes_child.write().await; + + // std::mem::take replaced with defaults (`None` in this case) so we don't need to set + let c = std::mem::take(&mut lock.0); + let j = std::mem::take(&mut lock.1); + + if let Some(c) = c { + c.kill().ok(); + } + + if let Some(j) = j { + j.abort(); + } + } +} + +#[tauri::command] +fn send_query( + handle: AppHandle, + state: String, + details: String, + artwork: String, + start: i64, + end: i64, + large_image_text: String, +) where + R: Runtime, +{ +} + +#[tauri::command] +async fn send_audio(handle: AppHandle, state: String) +where + R: Runtime, +{ + tauri::async_runtime::spawn(async move { + let client = handle.state::(); + client.send_audio(state).await; + }) + .await + .unwrap(); +} + +#[tauri::command] +async fn start_client(handle: AppHandle) -> Result<(), String> +where + R: Runtime, +{ + let client = handle.state::(); + client.start_client().await +} + +#[tauri::command] +async fn stop_client(handle: AppHandle) +where + R: Runtime, +{ + let client = handle.state::(); + client.stop_client().await; +} + +pub fn init() -> TauriPlugin +where + R: Runtime, +{ + PluginBuilder::new("airtunes") + .setup(|handle| { + handle.manage(AirPlayClient::new()); + Ok(()) + }) + .invoke_handler(tauri::generate_handler![ + send_query, + send_audio, + start_client, + stop_client + ]) + .build() +} diff --git a/src-tauri/src/config/mod.rs b/src-tauri/src/config/mod.rs new file mode 100644 index 0000000..ad6d039 --- /dev/null +++ b/src-tauri/src/config/mod.rs @@ -0,0 +1,61 @@ +use tauri::{ + plugin::{Builder as PluginBuilder, TauriPlugin}, + AppHandle, Runtime, +}; + +use tokio::fs::{ + read_to_string, + write as write_str, + create_dir_all, +}; + +#[tauri::command] +async fn read(handle: AppHandle) -> Result, String> +where + R: Runtime, +{ + let cfg_path = handle + .path_resolver() + .app_config_dir() + .expect("Unknown Application Config Dir"); + let file_path = cfg_path.join("spa-config.json"); + + if file_path.exists() { + match read_to_string(file_path).await { + Ok(s) => Ok(Some(s)), + Err(e) => Err(format!("Read Config Error: {}", e.to_string())), + } + } else { + Ok(None) + } +} + +#[tauri::command] +async fn write(handle: AppHandle, content: String) -> Result<(), String> +where + R: Runtime, +{ + let cfg_path = handle + .path_resolver() + .app_config_dir() + .expect("Unknown Application Config Dir"); + + if !cfg_path.exists() { + create_dir_all(cfg_path.clone()).await.map_err(|e| format!("Write Config Error: {}", e.to_string()))?; + } + + let file_path = cfg_path.join("spa-config.json"); + + write_str(file_path, content) + .await + .map_err(|e| format!("Write Config Error: {}", e.to_string())) +} + +pub fn init() -> TauriPlugin +where + R: Runtime, +{ + PluginBuilder::new("config") + .invoke_handler(tauri::generate_handler![read, write]) + .build() +} diff --git a/src-tauri/src/discord/error.rs b/src-tauri/src/discord/error.rs new file mode 100644 index 0000000..4849f46 --- /dev/null +++ b/src-tauri/src/discord/error.rs @@ -0,0 +1,12 @@ +use serde::Serialize; +use thiserror::Error; + +#[derive(Debug, Serialize, Error)] +pub enum DiscordError { + #[error("Initialisation Error: {0}")] + Init(String), + #[error("No Client")] + NoClient, + #[error("Failed to Update Status: {0}")] + Status(String) +} diff --git a/src-tauri/src/discord/mod.rs b/src-tauri/src/discord/mod.rs new file mode 100644 index 0000000..3981e60 --- /dev/null +++ b/src-tauri/src/discord/mod.rs @@ -0,0 +1,261 @@ +use tauri::{ + async_runtime::RwLock, + plugin::{Builder as PluginBuilder, TauriPlugin}, + AppHandle, Manager, Runtime, +}; + +pub mod error; +use error::DiscordError; + +use discord_rich_presence::{ + activity::{Activity, Assets, Button, Timestamps}, + DiscordIpc, DiscordIpcClient, +}; + +pub struct DiscordRPC { + client_id: RwLock>, + inner_client: RwLock>, +} + +impl DiscordRPC { + pub fn new() -> Self { + Self { + client_id: RwLock::new(None), + inner_client: RwLock::new(Option::::None), + } + } + + pub fn init(&self, client_id: impl AsRef) -> Result<(), DiscordError> { + let actual_id = match client_id.as_ref() { + "Cider-2" => "1020414178047041627", + "AppleMusic" => "886578863147192350", + _ => "911790844204437504", + }; + + let mut lock = self.inner_client.blocking_write(); + if lock.is_some() { + // already initialised + return Ok(()); + } + + let mut l = self.client_id.blocking_write(); + *l = Some(actual_id.to_string()); + drop(l); + + if let Ok(mut client) = DiscordIpcClient::new(actual_id) { + client.connect().unwrap(); + *lock = Some(client); + drop(lock); + Ok(()) + } else { + Err(DiscordError::Init( + "Failed to initialise Discord RPC".to_string(), + )) + } + } + + pub fn remove(&self) { + let mut lock = self.inner_client.blocking_write(); + + if let Some(client) = &mut *lock { + client.clear_activity().ok(); + client.close().ok(); + } + + *lock = None; + } + + pub async fn reconnect(&self) -> bool { + if let Some(client) = &mut *self.inner_client.write().await { + let res = client.reconnect().is_ok(); + if res { + println!("CLIENT RECONNECTED"); + } else { + println!("CLIENT RECONNECTION FAILED"); + } + return res; + } + false + } + + pub async fn set_rpc<'a>( + &self, + state: String, + details: String, + artwork: String, + ts: Option<(i64, i64)>, + buttons: &'a Vec, + large_image_text: String, + ) -> Result<(), DiscordError> { + if let Some(client) = &mut *self.inner_client.write().await { + println!("DISCORD RPC UPDATING"); + + let mut tso = Timestamps::new(); + + if let Some(t) = ts { + tso = tso.start(t.0); + tso = tso.end(t.1); + } + + let buttons_converted: Vec> = { + let mut a = vec![]; + for i in buttons { + a.push(Button::new(i.label.as_str(), i.url.as_str())); + } + a + }; + + let activity_payload = Activity::new() + .state(&state) + .details(&details) + .timestamps(tso) + .buttons(buttons_converted) + .assets( + Assets::new() + .large_image(&artwork) + .large_text(&large_image_text), + ); + + if let Err(e) = client.set_activity(activity_payload) { + return Err(DiscordError::Status(e.to_string())); + } + Ok(()) + } else { + Err(DiscordError::NoClient) + } + } + + pub async fn clear_rpc(&self) { + if let Some(client) = &mut *self.inner_client.write().await { + client.clear_activity().ok(); + client.close().ok(); + } + } +} + +#[tauri::command] +pub async fn init_client(handle: AppHandle, client_id: String) -> Result<(), DiscordError> +where + R: Runtime, +{ + tauri::async_runtime::spawn_blocking(move || { + let client = handle.state::(); + client.init(client_id) + }) + .await + .unwrap()?; + + Ok(()) +} + +#[tauri::command] +async fn stop_client(handle: AppHandle) +where + R: Runtime, +{ + tauri::async_runtime::spawn_blocking(move || { + let client = handle.state::(); + client.remove(); + }) + .await + .unwrap(); +} + +#[derive(Debug, serde::Deserialize, Clone)] +pub struct RPCButton { + pub label: String, + pub url: String, +} + +#[allow(clippy::too_many_arguments)] +#[tauri::command] +async fn set_status( + handle: AppHandle, + state: String, + details: String, + artwork: String, + start: Option, + end: Option, + buttons: Option>, + large_image_text: String, +) -> Result<(), DiscordError> +where + R: Runtime, +{ + let client = handle.state::(); + + // huh? ok + let timestamps = start.and_then(|s| end.map(|e| (s, e))); + + let b = buttons.unwrap_or(vec![]); + + if let Err(_e) = client + .set_rpc( + state.clone(), + details.clone(), + artwork.clone(), + timestamps, + &b, + large_image_text.clone(), + ) + .await + { + if client.reconnect().await { + return client + .set_rpc(state, details, artwork, timestamps, &b, large_image_text) + .await; + } else { + Err(DiscordError::NoClient) + } + } else { + Ok(()) + } +} + +#[tauri::command] +async fn idle_status(handle: AppHandle) -> Result<(), String> +where + R: Runtime, +{ + let client = handle.state::(); + client + .set_rpc( + String::new(), + "Browsing Cider".to_owned(), + "https://cdn.discordapp.com/icons/843954443845238864/ffdf21ed4aa8748be2fbe411fdcf525b.webp?size=96".to_owned(), + None, + &vec![], + "".to_owned() + ) + .await + .map_err(|e| e.to_string()) +} + +#[tauri::command] +async fn clear_status(handle: AppHandle) +where + R: Runtime, +{ + let client = handle.state::(); + client.clear_rpc().await; +} + +pub fn init() -> TauriPlugin +where + R: Runtime, +{ + PluginBuilder::new("discord") + .setup(|handle| { + handle.manage(DiscordRPC::new()); + + Ok(()) + }) + .invoke_handler(tauri::generate_handler![ + init_client, + stop_client, + set_status, + clear_status, + idle_status + ]) + .build() +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs new file mode 100644 index 0000000..c50e773 --- /dev/null +++ b/src-tauri/src/main.rs @@ -0,0 +1,269 @@ +// Prevents additional console window on Windows in release, DO NOT REMOVE!! +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +use std::sync::Arc; + +use plugin::Plugins; +use tauri::{ + async_runtime::RwLock, + utils::{assets::EmbeddedAssets, config::AppUrl}, + Context, Manager, Theme, WindowBuilder, WindowUrl, Wry, +}; + +#[cfg(target_os = "macos")] +use window_vibrancy::{apply_vibrancy, NSVisualEffectMaterial}; + +#[macro_use] +extern crate obfstr; + +#[cfg(target_os = "macos")] +#[macro_use] +extern crate objc; + +#[cfg(target_os = "windows")] +#[path = "./platform/windows.rs"] +mod platform; + +#[cfg(target_os = "windows")] +use windows::{ + Win32::Graphics::Dwm::DwmExtendFrameIntoClientArea, Win32::UI::Controls::MARGINS, + Win32::UI::WindowsAndMessaging::GetWindowLongA, Win32::UI::WindowsAndMessaging::SetWindowLongA, + Win32::UI::WindowsAndMessaging::WINDOW_LONG_PTR_INDEX, +}; +use zipdist::{startup_zip_check, PROTOCOL_VERSION}; + +mod zipdist; + +mod additional; +mod airplay; +mod bridge; +mod config; +mod discord; +mod lastfm; +mod musickit; +mod plugin; +mod rpc; +#[cfg(feature = "steamworks")] +mod steam; +mod updater; +mod ws; +mod ziphttp; +#[cfg(not(feature = "steamworks"))] +mod steam { + use tauri::{ + plugin::{Builder, TauriPlugin}, + Runtime, + }; + + pub fn init() -> TauriPlugin { + Builder::new("steamworks_dud_to_make_steam_stfu").build() + } +} + +mod systemtray; +mod vibrancy; + +lazy_static::lazy_static! { + static ref ITSPOD: RwLock> = RwLock::new(None); + static ref PLUGINS: Arc>>> = Arc::new(RwLock::new(None)); +} + +#[tauri::command] +async fn set_itspod(itspod: Option) { + *ITSPOD.write().await = itspod; +} + +#[tauri::command] +async fn init_plugins() { + let pg = PLUGINS.read(); + tokio::task::spawn(async move { + let pg = pg.await; + let pg = pg.as_ref().unwrap(); + pg.load().await; + }); +} + +#[derive(Clone, serde::Serialize)] +struct Payload { + args: Vec, + cwd: String, +} + +pub const IS_DEV: bool = if cfg!(debug_assertions) { true } else { false }; + +#[tokio::main] +async fn main() { + startup_zip_check().await; + tauri_plugin_deep_link::prepare("Cider"); + + let builder = tauri::Builder::default() + .plugin(tauri_plugin_single_instance::init(|app, argv, cwd| { + println!("{}, {argv:?}, {cwd}", app.package_info().name); + app.emit_all("single-instance", Payload { args: argv, cwd }).unwrap(); + let win = app.get_window("cider_main").expect("NO WINDOW"); + app.tray_handle().get_item("hide").set_title("Minimize to Tray").unwrap(); + win.show().expect("UNABLE TO SHOW WINDOW"); + win.set_focus().expect("UNABLE TO SET FOCUS"); + })) + .plugin(discord::init()) + .plugin(lastfm::init()) + .plugin(airplay::init()) + .plugin(rpc::init()) + .plugin(config::init()) + .plugin(vibrancy::init()) + .plugin(ws::init()) + .plugin(steam::init()) + .setup(|app| { + let local_app_data_dir = app.handle() + .path_resolver() + .app_config_dir() + .expect("No Local Data Directory"); + let file = local_app_data_dir.join("plugins"); + + let runtime_env = if IS_DEV { "development" } else { "production" }; + + // Setup deep link + let h = app.handle(); + + let mut urls = ["cider"/*, "sabiiro", "itms", "itmss", "music", "musics" */]; + for url in urls.iter_mut() { + println!("Registering {}", url); + let deeplink_handle = app.handle(); + let copy_dir = local_app_data_dir.clone(); + match tauri_plugin_deep_link::register( + url, + move |request| { + let window = deeplink_handle.get_window("cider_main").unwrap(); + let command = request.split("://").collect::>()[1]; + if command.to_ascii_lowercase().contains("openappdata") { + open::that(copy_dir.to_str().unwrap()).ok(); + } else { + window.eval(format!("CiderApp.handleProtocolURL('{}')", command).as_str()).unwrap(); + } + }, + ) + { + Ok(_) => println!("Registered {}", url), + Err(err) => println!("Failed to register {}: {}", url, err) + } + } + + #[cfg(windows)] + std::env::set_var("WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS", "--disable-web-security --disable-site-isolation-trials --allow-file-access-from-files --js-flags=--expose-gc --autoplay-policy=no-user-gesture-required --enable-features=enable_same_site/true,msRefreshRateBoostOnScroll,msEnhancedTextContrast,MsControlsFluentStyle,msEdgeFluentOverlayScrollbar,MsEdgeFluentOverlayScrollbar,EdgeOverlayScrollbarsWinStyle,OverlayScrollbar,msOverlayScrollbarWinStyle:scrollbar_mode/full_mode,msOverlayScrollbarWinStyleAnimation --ignore-gpu-blocklist --enable-gpu-rasterization --disable-http2"); + + + let mut window_builder = WindowBuilder::new(app, "cider_main", WindowUrl::App("/index.html".into())) + .inner_size(1380f64, 730f64).min_inner_size(480f64, 365f64).decorations(false) + .initialization_script(" + window[\"process\"] = []; + window[\"process\"][\"versions\"] = []; + window[\"process\"][\"versions\"][\"node\"] = null; + document.addEventListener(\'DOMContentLoaded\', function() { + waitForElm(\'#main-page-container\').then((elm) => { + window.__TAURI__.invoke(\"init_plugins\", {}); + }); + }, false); + + + // stole from stack overflow :) + function waitForElm(selector) { + return new Promise(resolve => { + if (document.querySelector(selector)) { + return resolve(document.querySelector(selector)); + } + + const observer = new MutationObserver(mutations => { + if (document.querySelector(selector)) { + observer.disconnect(); + resolve(document.querySelector(selector)); + } + }); + + observer.observe(document.body, { + childList: true, + subtree: true + }); + }); + } + ") + .title("Cider").user_agent(ua.as_str()).theme(Some(Theme::Dark)) + .disable_file_drop_handler(); + + #[cfg(windows)] + if vibrancy::windows::is_at_least_build(22000) { + window_builder = window_builder.transparent(true); + } + + let window = window_builder.build().unwrap(); + tauri::async_runtime::spawn_blocking(move || ziphttp::start_ziphttp(h)); + + // apply DwmExtendFrameIntoClientArea + #[cfg(windows)] + unsafe { + let hwnd = window.hwnd().unwrap(); + + let margin_amount = 1i32; + let margins = MARGINS { cxLeftWidth: margin_amount, cxRightWidth: margin_amount, cyTopHeight: margin_amount, cyBottomHeight: margin_amount }; + DwmExtendFrameIntoClientArea(hwnd, &margins).unwrap(); + + // get rid of 0x80000 for WS_SYSMENU + let mut style = GetWindowLongA(window.hwnd().unwrap(), WINDOW_LONG_PTR_INDEX(-16)); + style &= !0x80000; + SetWindowLongA(hwnd, WINDOW_LONG_PTR_INDEX(-16), style); + + } + + //window.set_size(window.outer_size().unwrap()).unwrap(); + + #[cfg(target_os = "windows")] + window.with_webview(|wv| unsafe { platform::webview_stuff(wv)}).expect("TODO: panic message"); + + #[cfg(target_os = "macos")] + window.with_webview(|webview| { + }).expect("TODO: panic message"); + + // Load plugins + let loader = plugin::new(file.to_str().unwrap(), app.handle()); + tokio::task::spawn(async move { + *PLUGINS.write().await = Some(loader); + }); + + + Ok(()) + }) + .system_tray(systemtray::init()) + .on_system_tray_event(systemtray::system_tray_event_handle) + .invoke_handler(tauri::generate_handler![set_itspod, init_plugins, systemtray::play, systemtray::pause, systemtray::change_song, additional::set_miniplayer_mode, updater::get_zip_update]); + + let mut context: Context = tauri::generate_context!(); + let mut should_zip = false; + let use_zip = zipdist::verify_protocol_version(); + + let port = 10768; + + if use_zip && !IS_DEV { + should_zip = true; + } + + let url = format!("http://localhost:{}", port).parse().unwrap(); + let window_url = WindowUrl::External(url); + + // rewrite the config so the IPC is enabled on this URL + + if !IS_DEV { + context.config_mut().build.dist_dir = AppUrl::Url(window_url.clone()); + context.config_mut().build.dev_path = AppUrl::Url(window_url.clone()); + } + + if should_zip { + println!("Loading from ZIP"); + builder + .run(context) + .expect("error while running tauri application"); + } else { + // we aren't loading from a zip so we use tauri plugin localhost to serve the files so that the users login session is preserved + builder + .plugin(tauri_plugin_localhost::Builder::new(port).build()) + .run(context) + .expect("error while running tauri application"); + } +} diff --git a/src-tauri/src/plugin/mod.rs b/src-tauri/src/plugin/mod.rs new file mode 100644 index 0000000..90cd663 --- /dev/null +++ b/src-tauri/src/plugin/mod.rs @@ -0,0 +1,101 @@ +use std::collections::HashMap; +use std::fs; +use std::io::Read; +use std::{fs::File, io::BufReader}; + +use tauri::{AppHandle, Manager, Runtime}; + +pub struct Plugin {} + +#[derive(serde::Serialize, serde::Deserialize)] +struct Metadata { + name: String, + version: String, + description: String, + authors: Vec, + frontend_main_script: Option, + backend_main_script: Option, +} + +// TODO(freehelpdesk) - remove the _ when this hashmap is actually used +pub struct Plugins { + handle: AppHandle, + path: String, + _hashmap: HashMap>, +} + +pub fn new(path: &str, handle: AppHandle) -> Plugins { + // Create our hash map + let map: HashMap> = HashMap::new(); + Plugins { + handle, + path: path.to_string(), + _hashmap: map, + } +} + +impl Plugins { + pub async fn load(&self) { + fs::create_dir_all(&self.path).unwrap(); + for entry in fs::read_dir(&self.path).unwrap() { + let path = entry.unwrap().path(); + println!("scanning {}", &path.to_str().unwrap()); + + // Try and find the metadata in the folder + let metadata_path = path.join("metadata.json"); + let file = match File::open(&metadata_path) { + Ok(f) => f, + Err(_) => { + println!( + "Unable to load plugin, metadata.json not found at {}", + metadata_path.clone().to_str().unwrap() + ); + continue; + } + }; + let reader = BufReader::new(&file); + let metadata: Metadata = match serde_json::from_reader(reader) { + Ok(m) => m, + Err(_) => { + println!( + "Unable to load plugin, was unable to parse metadata for {}", + metadata_path.to_str().unwrap() + ); + continue; + } + }; + println!( + "Got plugin: {}:{} by {}", + metadata.name, + metadata.version, + metadata.authors.join(", ") + ); + + if let Some(frontend_path) = metadata.frontend_main_script { + let frontend_path = path.join(frontend_path); + let mut frontend = match File::open(&frontend_path) { + Ok(f) => f, + Err(_) => { + println!( + "Unable to load {}, {} not found", + &metadata.name, + &frontend_path.to_str().unwrap() + ); + continue; + } + }; + let mut reader = BufReader::new(&mut frontend); + let mut contents = String::new(); + reader + .read_to_string(&mut contents) + .expect("Unable to read config buffer"); + + self.handle + .get_window("cider_main") + .unwrap() + .eval(contents.as_str()) + .unwrap(); + } + } + } +} diff --git a/src-tauri/src/rpc/commands.rs b/src-tauri/src/rpc/commands.rs new file mode 100644 index 0000000..f58c376 --- /dev/null +++ b/src-tauri/src/rpc/commands.rs @@ -0,0 +1,59 @@ +use super::{server::create_rpc_server, RPCServerThreadState}; +use tauri::{AppHandle, Runtime, State}; + +#[tauri::command] +pub fn handle_js_return(input: serde_json::Value) { + super::server::CHANNELS + .0 + .lock() + .unwrap() + .send(input) + .unwrap(); +} + +#[tauri::command] +pub async fn start_rpc_server( + port: Option, + app_handle: AppHandle, + server_thread: State<'_, RPCServerThreadState>, +) -> Result<(), String> +where + R: Runtime, +{ + let mut lock = server_thread.lock().await; + + if lock.is_some() { + return Err("Server Already Started".into()); + } + + let server = create_rpc_server(app_handle, port.unwrap_or_else(|| 10769u16)); + let join_handle_combo = server.stoppable(); + + tauri::async_runtime::spawn_blocking(|| join_handle_combo.0.join()); + + *lock = Some(join_handle_combo.1); + + Ok(()) +} + +#[tauri::command] +pub async fn stop_rpc_server(server_thread: State<'_, RPCServerThreadState>) -> Result<(), String> { + let mut lock = server_thread.lock().await; + + match lock.as_ref() { + None => Ok(()), + + Some(sender) => { + sender.send(()).map_err(|e| e.to_string())?; + + *lock = None; + + Ok(()) + } + } +} + +#[tauri::command] +pub async fn is_rpc_server_running(server_thread: State<'_, RPCServerThreadState>) -> Result { + Ok(server_thread.lock().await.is_some()) +} diff --git a/src-tauri/src/rpc/mod.rs b/src-tauri/src/rpc/mod.rs new file mode 100644 index 0000000..e7e35df --- /dev/null +++ b/src-tauri/src/rpc/mod.rs @@ -0,0 +1,48 @@ +use std::time::Duration; + +use serde_json::Value; +use tauri::{ + plugin::{ + Builder as PluginBuilder, + TauriPlugin + }, Manager, Runtime, Window +}; +use tokio::sync::Mutex; + +pub mod server; +mod commands; + +type RPCServerThreadState = Mutex>>; + +pub fn execute_and_receive_js(window: &Window, script: &str) -> serde_json::Value +where + R: Runtime, +{ + window + .eval( + format!( + "window.__TAURI__.invoke('plugin:rpc|handle_js_return', {{input: {}}})", + script + ) + .as_str(), + ) + .unwrap(); + + server::CHANNELS + .1 + .lock() + .unwrap() + .recv_timeout(Duration::from_millis(250)) + .unwrap_or(Value::Null) +} + +pub fn init() -> TauriPlugin where R: Runtime { + PluginBuilder::new("rpc") + .invoke_handler(tauri::generate_handler![commands::handle_js_return, commands::start_rpc_server, commands::stop_rpc_server, commands::is_rpc_server_running]) + .setup(|app| { + app.manage::(Mutex::new(None)); + + Ok(()) + }) + .build() +} diff --git a/src-tauri/src/rpc/server.rs b/src-tauri/src/rpc/server.rs new file mode 100644 index 0000000..60e9bad --- /dev/null +++ b/src-tauri/src/rpc/server.rs @@ -0,0 +1,246 @@ +use std::sync::{ + mpsc::{channel, Receiver, Sender}, + Arc, Mutex, +}; + +use crate::{ + bridge::Bridge, + lastfm::{AuthState, LastFm}, +}; + +use serde_json::Value; + +use tauri::{AppHandle, Manager, Runtime}; + +use rouille::{Response, Server}; + +type ChannelChan = (Arc>>, Arc>>); + +lazy_static::lazy_static! { + pub static ref CHANNELS: ChannelChan = { + let (sender, receiver): (Sender, Receiver) = channel(); + (Arc::new(Mutex::new(sender)), Arc::new(Mutex::new(receiver))) + }; +} + +pub fn create_rpc_server( + handle: AppHandle, + port: u16, +) -> Server Response> +where + R: Runtime, +{ + let bridge = Bridge::new(handle.clone()); + + let server = Server::new(format!("localhost:{port}"), move |req| { + rouille::router!(req, + + (GET) (/handleCallbackUrl) => { + Response::empty_204() + }, + + (GET) (/last_fm_auth_callback) => { + let state = handle.state::(); + + let mut url = req.raw_url().to_string(); + + let offset = url.rfind("?token=").expect("INVALID URL"); + + url.replace_range(..offset+7, ""); + + let s = state + .inner_client + .blocking_write() + .authenticate_with_token(&url); + + match s { + Ok(_) => { + state.set_auth_state(AuthState::Authorised); + } + Err(e) => { + eprintln!("{:?}", e); + return Response::empty_400(); + } + } + + Response::empty_204() + }, + + (GET) (/active) => { + Response::empty_204() + }, + + (GET) (/currentPlayingSong) => { + #[derive(serde::Deserialize, serde::Serialize)] + struct Info { + info: serde_json::Value + } + + Response::json(&Info { + info: bridge.get_playing_song(), + }).with_additional_header("Access-Control-Allow-Origin", "*") + }, + + (GET) (/addToLibrary) => { + bridge.add_to_library(); + Response::empty_204() + }, + + (GET) (/isPlaying) => { + #[derive(serde::Deserialize, serde::Serialize)] + struct IsPlaying { + is_playing: Option + } + + Response::json(&IsPlaying { + is_playing: bridge.is_playing(), + }).with_additional_header("Access-Control-Allow-Origin", "*") + }, + + (GET) (/toggleAutoplay) => { + #[derive(serde::Deserialize, serde::Serialize)] + struct IsAutoplay { + autoplay: Option + } + + Response::json(&IsAutoplay { + autoplay: bridge.toggle_autoplay(), + }).with_additional_header("Access-Control-Allow-Origin", "*") + }, + + (POST) (/toggleShuffle) => { + Response::json(&bridge.toggle_shuffle()).with_additional_header("Access-Control-Allow-Origin", "*") + }, + + (POST) (/toggleRepeat) => { + Response::json(&bridge.toggle_repeat()).with_additional_header("Access-Control-Allow-Origin", "*") + }, + + (GET) (/playPause) => { + bridge.play_pause(); + Response::empty_204() + }, + + (GET) (/play/{kind: String}/{ids: String}) => { + let ids: Vec<&str> = ids.split(',').collect(); + bridge.play(Some(kind), Some(&ids)); + Response::empty_204() + }, + + (GET) (/play) => { + bridge.play(None, None); + Response::empty_204() + }, + + (GET) (/pause) => { + bridge.pause(); + Response::empty_204() + }, + + (GET) (/stop) => { + bridge.stop(); + Response::empty_204() + }, + + (GET) (/next) => { + bridge.next(); + Response::empty_204() + }, + + (GET) (/previous) => { + bridge.previous(); + Response::empty_204() + }, + + (GET) (/seekto/{t: u32}) => { + bridge.seekto(t); + Response::empty_204() + }, + + (GET) (/show) => { + bridge.show(); + Response::empty_204() + }, + + (GET) (/hide) => { + bridge.hide(); + Response::empty_204() + }, + + (GET) (/album/{id: String}) => { + Response::json(&bridge.album(&id)).with_additional_header("Access-Control-Allow-Origin", "*") + }, + + (GET) (/song/{id: String}) => { + Response::json(&bridge.song(&id)).with_additional_header("Access-Control-Allow-Origin", "*") + }, + + (GET) (/audio/{volume: f32}) => { + bridge.set_audio_volume(volume); + Response::empty_204() + }, + + (GET) (/audio) => { + Response::json(&bridge.get_audio_volume()).with_additional_header("Access-Control-Allow-Origin", "*") + }, + + (PUT) (/setRating/{rating: i8}) => { + bridge.set_rating(rating); + Response::empty_204() + }, + + (PUT) (/rating/{content_type: String}/{id: u64}/{rating: i8}) => { + let a = bridge.set_rating_api(content_type, id.to_string(), rating); + + if let Some(value) = a { + let mut status = 200u16; + + if let Some(code) = value.get("code") { + if let Some(actual_code) = code.as_u64() { + status = actual_code as u16 + } + } + + Response::json(&value).with_additional_header("Access-Control-Allow-Origin", "*").with_status_code(status) + } else { + // me when the delete request has no return body + // kill me + if rating != 0 { + Response::text("Unable to Set Rating").with_status_code(500) + } else { + Response::empty_204() + } + } + }, + + (GET) (/rating/{content_type: String}/{id: u64}) => { + let a = bridge.get_rating(content_type, id.to_string()); + + if let Some(value) = a { + let mut status = 200u16; + + if let Some(code) = value.get("code") { + if let Some(actual_code) = code.as_u64() { + status = actual_code as u16 + } + } + + Response::json(&value).with_additional_header("Access-Control-Allow-Origin", "*").with_status_code(status) + } else { + Response::text("Unable to Get Rating").with_status_code(500) + } + }, + + // Add a song ID to the current queue + // (GET) (/queue/{id: String}) => { + // bridge.add_trackid_to_queue(&id); + // Response::empty_404() + // }, + + _ => Response::empty_404() + ) + }) + .expect("RPC Server FAILED to Start"); + + server +} diff --git a/src-tauri/src/steam/callbacks.rs b/src-tauri/src/steam/callbacks.rs new file mode 100644 index 0000000..a9c7ac9 --- /dev/null +++ b/src-tauri/src/steam/callbacks.rs @@ -0,0 +1,15 @@ +use steamworks::{Client, UserAchievementStored, UserStatsReceived}; + +pub(super) fn set_callbacks(client: &Client) { + // called when you pull down a list of user stats including achievements. + client.register_callback(|u: UserStatsReceived| { + if let Err(e) = u.result { + eprintln!("Steam Error: {:?}", e); + } + }); + + // called when you push up a state change with an achievement state change + client.register_callback(|a: UserAchievementStored| { + println!("Unlocked Achievement: {}", &a.achievement_name); + }); +} diff --git a/src-tauri/src/steam/mod.rs b/src-tauri/src/steam/mod.rs new file mode 100644 index 0000000..680788b --- /dev/null +++ b/src-tauri/src/steam/mod.rs @@ -0,0 +1,132 @@ +use tauri::{ + async_runtime::RwLock, + plugin::{Builder, TauriPlugin}, + Manager, Runtime, +}; + +mod callbacks; + +use steamworks::{Client, SingleClient}; + +// NOTE(d3rpp) +// +// if attempting to access the client, use `try_state` instead of `state` as +// we cannot guarantee that the steam API activated correctly. + +// our steamworks app id +pub const APP_ID: u32 = 2446120u32; + +// this is moved into this continuously running function and is guaranteed to only be run once at a time +// since single is for things that can only be run once at a time +// does say it has to be the main thread but hey, idgaf +fn run_loop(single: SingleClient) { + // this allows other tasks to run for a bit, basically + // it pauses the task loop and puts it back into the + // queue as to not hog any executor threads when it + // doesnt need to. + // + // i hope + loop { + single.run_callbacks(); + std::thread::sleep(std::time::Duration::from_millis(50)); + } +} + +#[tauri::command] +async fn set_rich_presence( + app: tauri::AppHandle, + _window: tauri::Window, + key: &str, + track: &str, + album: &str, + artist: &str, +) -> Result<(), String> { + if let Some(c) = app.try_state::>() { + let friends = c.read().await.friends(); + + friends.set_rich_presence("title", Some(track)); + friends.set_rich_presence("album", Some(album)); + friends.set_rich_presence("artist", Some(artist)); + friends.set_rich_presence("status", Some(key)); + friends.set_rich_presence("steam_display", Some(key)); + + Ok(()) + } else { + Err("Steam API Not Connected".into()) + } +} + +#[tauri::command] +async fn clear_rich_presence( + app: tauri::AppHandle, + _window: tauri::Window, +) -> Result<(), String> { + if let Some(c) = app.try_state::>() { + let friends = c.read().await.friends(); + + friends.set_rich_presence("steam_display", None); + friends.set_rich_presence("status", None); + + Ok(()) + } else { + Err("Steam API Not Connected".into()) + } +} + +#[tauri::command] +fn active(app: tauri::AppHandle, _window: tauri::Window) -> bool { + app.try_state::>().is_some() +} + +// allow achievements to work +#[tauri::command] +async fn grant_achievement( + app: tauri::AppHandle, + _window: tauri::Window, + achievement: String, +) -> bool { + if let Some(c) = app.try_state::>() { + let c_lock = c.read().await; + let user = c_lock.user_stats(); + user.request_current_stats(); + + let ach = user.achievement(achievement.as_str()); + + if ach.set().is_ok() { + user.store_stats().is_ok() + } else { + false + } + } else { + false + } +} + +pub fn init() -> TauriPlugin { + Builder::new("steamworks") + .invoke_handler(tauri::generate_handler![]) + .setup(|app| { + let (client, single) = match Client::init_app(APP_ID) { + Ok(r) => r, + Err(e) => { + println!("Unable to connect to Steam: {:?}", e); + return Ok(()); + } + }; + + callbacks::set_callbacks(&client); + + tauri::async_runtime::spawn_blocking(|| run_loop(single)); + + app.manage(RwLock::new(client)); + + Ok(()) + }) + .invoke_handler(tauri::generate_handler![ + set_rich_presence, + clear_rich_presence, + active, + grant_achievement + ]) + .build() +} diff --git a/src-tauri/src/systemtray/mod.rs b/src-tauri/src/systemtray/mod.rs new file mode 100644 index 0000000..924f602 --- /dev/null +++ b/src-tauri/src/systemtray/mod.rs @@ -0,0 +1,131 @@ +use tauri::{ + AppHandle, CustomMenuItem, Manager, SystemTray, SystemTrayEvent, SystemTrayMenu, + SystemTrayMenuItem, +}; + +use crate::bridge::Bridge; + +#[tauri::command] +pub fn play(app: AppHandle) { + app.tray_handle() + .get_item("play") + .set_enabled(true) + .unwrap(); + app.tray_handle() + .get_item("play") + .set_title("Pause") + .unwrap(); + app.tray_handle() + .get_item("previous") + .set_enabled(true) + .unwrap(); + app.tray_handle() + .get_item("next") + .set_enabled(true) + .unwrap(); + app.tray_handle() + .get_item("addToLibrary") + .set_enabled(true) + .unwrap(); +} + +#[tauri::command] +pub fn change_song(app: AppHandle, song: String) { + app.tray_handle() + .get_item("songString") + .set_title(song) + .unwrap(); +} + +#[tauri::command] +pub fn pause(app: tauri::AppHandle) { + app.tray_handle() + .get_item("play") + .set_enabled(true) + .unwrap(); + app.tray_handle() + .get_item("play") + .set_title("Play") + .unwrap(); + app.tray_handle() + .get_item("previous") + .set_enabled(true) + .unwrap(); + app.tray_handle() + .get_item("next") + .set_enabled(true) + .unwrap(); + app.tray_handle() + .get_item("addToLibrary") + .set_enabled(true) + .unwrap(); +} + +pub fn init() -> SystemTray { + let tray_menu = + SystemTrayMenu::new() // insert the menu items here + .add_item(CustomMenuItem::new("songString".to_string(), "Cider").disabled()) + .add_native_item(SystemTrayMenuItem::Separator) + .add_item(CustomMenuItem::new("previous".to_string(), "Previous").disabled()) + .add_item(CustomMenuItem::new("play".to_string(), "Play/Pause").disabled()) + .add_item(CustomMenuItem::new("next".to_string(), "Next").disabled()) + .add_native_item(SystemTrayMenuItem::Separator) + .add_item(CustomMenuItem::new("addToLibrary".to_string(), "Add to Library").disabled()) + .add_native_item(SystemTrayMenuItem::Separator) + .add_item(CustomMenuItem::new("devtools".to_string(), "Open Devtools")) + .add_item(CustomMenuItem::new("hide".to_string(), "Minimize to Tray")) + .add_item(CustomMenuItem::new("quit".to_string(), "Quit")); + let system_tray: SystemTray = SystemTray::new().with_menu(tray_menu); + system_tray +} + +pub fn system_tray_event_handle(app: &AppHandle, event: SystemTrayEvent) { + let bridge = Bridge::new(app.clone()); + match event { + SystemTrayEvent::LeftClick { .. } => { + let window = app.get_window("cider_main").unwrap(); + let item_handle = app.tray_handle().get_item("hide"); + window.show().unwrap(); + window.unminimize().unwrap(); + window.set_focus().unwrap(); + item_handle.set_title("Minimize to Tray").unwrap(); + } + SystemTrayEvent::MenuItemClick { id, .. } => { + let window = app.get_window("cider_main").unwrap(); + let item_handle = app.tray_handle().get_item(&id); + match id.as_str() { + "play" => { + bridge.play_pause(); + } + "previous" => { + bridge.previous(); + } + "next" => { + bridge.next(); + } + "addToLibrary" => { + bridge.add_to_library(); + } + "devtools" => { + window.show().unwrap(); + window.set_focus().unwrap(); + window.open_devtools() + } + "hide" => { + if window.is_visible().unwrap() { + bridge.hide(); + item_handle.set_title("Show Window").unwrap(); + } else { + bridge.show(); + item_handle.set_title("Minimize to Tray").unwrap(); + } + } + "quit" => { + window.close().unwrap(); + } + _ => {} + } + } + _ => {} + } +} diff --git a/src-tauri/src/vibrancy/common.rs b/src-tauri/src/vibrancy/common.rs new file mode 100644 index 0000000..89a9e87 --- /dev/null +++ b/src-tauri/src/vibrancy/common.rs @@ -0,0 +1,2 @@ +/// a tuple of RGBA colors. Each value has minimum of 0 and maximum of 255. +pub type Color = (u8, u8, u8, u8); diff --git a/src-tauri/src/vibrancy/mod.rs b/src-tauri/src/vibrancy/mod.rs new file mode 100644 index 0000000..350e766 --- /dev/null +++ b/src-tauri/src/vibrancy/mod.rs @@ -0,0 +1,201 @@ +use std::sync::Mutex; + +use tauri::{ + plugin::{Builder as PluginBuilder, TauriPlugin}, + Manager, Runtime, +}; + +mod common; + +#[cfg(windows)] +pub mod windows; + +struct VibrancyMode(Mutex<&'static str>); + +#[tauri::command] +fn set_mode(handle: tauri::AppHandle, window: tauri::Window, variant: String) -> bool +where + R: Runtime, +{ + cfg_if::cfg_if! { + if #[cfg(windows)] { + let hwnd = window.hwnd().expect("couldn't get HWND").0; + + let current = handle.state::(); + let mut s = current.0.lock().unwrap(); + + if *s == variant.as_str() { + return true; + } + + match variant.as_str() { + "light" => { + if windows::set_light_mode(hwnd) { + *s = "acrylic"; + true + } else { + false + } + }, + + "dark" => { + if windows::set_dark_mode(hwnd) { + *s = "acrylic"; + true + } else { + false + } + }, + + _ => false + } + } else { + false + } + } +} + +#[tauri::command] +/// +/// `variant` should be one of the following +/// - `mica` => to apply mica +/// - `tabbed` => to apply Tabbed +/// - `acrylic` => to apply Acrylic +/// +/// any others will be ignored, will return true if the application worked, otherwise false +/// +fn set_vibrancy(handle: tauri::AppHandle, window: tauri::Window, variant: String) -> bool +where + R: Runtime, +{ + cfg_if::cfg_if! { + if #[cfg(windows)] { + let hwnd = window.hwnd().expect("couldn't get HWND").0; + + let current = handle.state::(); + let mut s = current.0.lock().unwrap(); + + if *s == variant.as_str() { + return true; + } + + match variant.as_str() { + "mica" => { + if windows::apply_mica(hwnd) { + *s = "mica"; + return true; + } + + false + }, + + "acrylic" => { + if windows::apply_acrylic(hwnd, None) { + *s = "acrylic"; + return true; + } + + false + }, + + "blur" => { + if windows::apply_blur(hwnd, None) { + *s = "blur"; + return true; + } + + false + }, + + "tabbed" => { + if windows::apply_tabbed(hwnd) { + *s = "tabbed"; + return true; + } + + false + } + + _ => false + } + } else { + false + } + } +} + +#[tauri::command] +fn clear_vibrancy(handle: tauri::AppHandle, window: tauri::Window) -> bool +where + R: Runtime, +{ + cfg_if::cfg_if! { + if #[cfg(windows)] { + let hwnd = window.hwnd().expect("Unable to get HWND").0; + + let current = handle.state::(); + let mut s = current.0.lock().unwrap(); + let c = *s; + + match c { + "mica" => { + if windows::clear_mica(hwnd) { + *s = "none"; + return true; + } + + false + }, + + "acrylic" => { + if windows::clear_acrylic(hwnd) { + *s = "none"; + return true; + } + + false + }, + + "blur" => { + if windows::clear_blur(hwnd) { + *s = "none"; + return true; + } + + false + }, + + "tabbed" => { + if windows::clear_tabbed(hwnd) { + *s = "none"; + return true; + } + + false + } + + _ => false + } + } else { + false + } + } +} + +pub fn init() -> TauriPlugin +where + R: Runtime, +{ + PluginBuilder::new("vibrancy") + .setup(|handle| { + handle.manage(VibrancyMode(Mutex::new("none"))); + + Ok(()) + }) + .invoke_handler(tauri::generate_handler![ + set_vibrancy, + clear_vibrancy, + set_mode, + ]) + .build() +} diff --git a/src-tauri/src/vibrancy/windows.rs b/src-tauri/src/vibrancy/windows.rs new file mode 100644 index 0000000..3d348fd --- /dev/null +++ b/src-tauri/src/vibrancy/windows.rs @@ -0,0 +1,348 @@ +#![cfg(target_os = "windows")] +#![allow(non_snake_case)] +#![allow(non_camel_case_types)] + +use std::ffi::c_void; +pub use windows_sys::Win32::{ + Foundation::*, + Graphics::{Dwm::*, Gdi::*}, + System::{LibraryLoader::*, SystemInformation::*}, +}; + +use super::common::Color; + +pub fn apply_blur(hwnd: HWND, color: Option) -> bool { + if is_win7() { + let bb = DWM_BLURBEHIND { + dwFlags: DWM_BB_ENABLE, + fEnable: true.into(), + hRgnBlur: HRGN::default(), + fTransitionOnMaximized: 0, + }; + unsafe { + let _ = DwmEnableBlurBehindWindow(hwnd, &bb); + } + } else if is_swca_supported() { + unsafe { + SetWindowCompositionAttribute(hwnd, ACCENT_STATE::ACCENT_ENABLE_BLURBEHIND, color); + } + } else { + return false; + } + + true +} + +pub fn clear_blur(hwnd: HWND) -> bool { + if is_win7() { + let bb = DWM_BLURBEHIND { + dwFlags: DWM_BB_ENABLE, + fEnable: false.into(), + hRgnBlur: HRGN::default(), + fTransitionOnMaximized: 0, + }; + unsafe { + let _ = DwmEnableBlurBehindWindow(hwnd, &bb); + } + } else if is_swca_supported() { + unsafe { + SetWindowCompositionAttribute(hwnd, ACCENT_STATE::ACCENT_DISABLED, None); + } + } else { + return false; + } + true +} + +pub fn apply_acrylic(hwnd: HWND, color: Option) -> bool { + if is_backdroptype_supported() { + unsafe { + DwmSetWindowAttribute( + hwnd, + DWMWA_SYSTEMBACKDROP_TYPE, + &DWM_SYSTEMBACKDROP_TYPE::DWMSBT_TRANSIENTWINDOW as *const _ as _, + 4, + ); + } + } else if is_swca_supported() { + unsafe { + SetWindowCompositionAttribute( + hwnd, + ACCENT_STATE::ACCENT_ENABLE_ACRYLICBLURBEHIND, + color, + ); + } + } else { + return false; + } + true +} + +pub fn clear_acrylic(hwnd: HWND) -> bool { + if is_backdroptype_supported() { + unsafe { + DwmSetWindowAttribute( + hwnd, + DWMWA_SYSTEMBACKDROP_TYPE, + &DWM_SYSTEMBACKDROP_TYPE::DWMSBT_DISABLE as *const _ as _, + 4, + ); + } + } else if is_swca_supported() { + unsafe { + SetWindowCompositionAttribute(hwnd, ACCENT_STATE::ACCENT_DISABLED, None); + } + } else { + return false; + } + true +} + +pub fn apply_mica(hwnd: HWND) -> bool { + if is_backdroptype_supported() { + unsafe { + DwmSetWindowAttribute( + hwnd, + DWMWA_SYSTEMBACKDROP_TYPE, + &DWM_SYSTEMBACKDROP_TYPE::DWMSBT_MAINWINDOW as *const _ as _, + 4, + ); + } + } else if is_undocumented_mica_supported() { + unsafe { + DwmSetWindowAttribute(hwnd, DWMWA_MICA_EFFECT, &1 as *const _ as _, 4); + } + } else { + return false; + } + true +} + +pub fn clear_mica(hwnd: HWND) -> bool { + if is_backdroptype_supported() { + unsafe { + DwmSetWindowAttribute( + hwnd, + DWMWA_SYSTEMBACKDROP_TYPE, + &DWM_SYSTEMBACKDROP_TYPE::DWMSBT_DISABLE as *const _ as _, + 4, + ); + } + } else if is_undocumented_mica_supported() { + unsafe { + DwmSetWindowAttribute(hwnd, DWMWA_MICA_EFFECT, &0 as *const _ as _, 4); + } + } else { + return false; + } + true +} + +pub fn set_light_mode(hwnd: HWND) -> bool { + if is_atleast_win10() { + unsafe { + let _ = DwmSetWindowAttribute(hwnd, 20, &0 as *const _ as _, 4); + } + return true; + } + false +} + +pub fn set_dark_mode(hwnd: HWND) -> bool { + if is_atleast_win10() { + unsafe { + let _ = DwmSetWindowAttribute(hwnd, 20, &1 as *const _ as _, 4); + } + return true; + } + false +} + +pub fn apply_tabbed(hwnd: HWND) -> bool { + if is_backdroptype_supported() { + unsafe { + DwmSetWindowAttribute( + hwnd, + DWMWA_SYSTEMBACKDROP_TYPE, + &DWM_SYSTEMBACKDROP_TYPE::DWMSBT_TABBEDWINDOW as *const _ as _, + 4, + ); + } + } else if is_tabbed_supported() { + unsafe { + DwmSetWindowAttribute(hwnd, DWMWA_MICA_EFFECT, &1 as *const _ as _, 4); + } + } else { + return false; + } + true +} + +pub fn clear_tabbed(hwnd: HWND) -> bool { + if is_backdroptype_supported() { + unsafe { + DwmSetWindowAttribute( + hwnd, + DWMWA_SYSTEMBACKDROP_TYPE, + &DWM_SYSTEMBACKDROP_TYPE::DWMSBT_DISABLE as *const _ as _, + 4, + ); + } + } else if is_tabbed_supported() { + unsafe { + DwmSetWindowAttribute(hwnd, DWMWA_MICA_EFFECT, &0 as *const _ as _, 4); + } + } else { + return false; + } + true +} + +fn get_function_impl(library: &str, function: &str) -> Option { + assert_eq!(library.chars().last(), Some('\0')); + assert_eq!(function.chars().last(), Some('\0')); + + let module = unsafe { LoadLibraryA(library.as_ptr()) }; + if module == 0 { + return None; + } + Some(unsafe { GetProcAddress(module, function.as_ptr()) }) +} + +macro_rules! get_function { + ($lib:expr, $func:ident) => { + get_function_impl(concat!($lib, '\0'), concat!(stringify!($func), '\0')) + .map(|f| std::mem::transmute::<::windows_sys::Win32::Foundation::FARPROC, $func>(f)) + }; +} + +/// Returns a tuple of (major, minor, buildnumber) +fn get_windows_ver() -> Option<(u32, u32, u32)> { + type RtlGetVersion = unsafe extern "system" fn(*mut OSVERSIONINFOW) -> i32; + let handle = unsafe { get_function!("ntdll.dll", RtlGetVersion) }; + handle.and_then(|rtl_get_version| unsafe { + let mut vi = OSVERSIONINFOW { + dwOSVersionInfoSize: 0, + dwMajorVersion: 0, + dwMinorVersion: 0, + dwBuildNumber: 0, + dwPlatformId: 0, + szCSDVersion: [0; 128], + }; + + let status = (rtl_get_version)(&mut vi as _); + + if status >= 0 { + Some((vi.dwMajorVersion, vi.dwMinorVersion, vi.dwBuildNumber)) + } else { + None + } + }) +} + +#[repr(C)] +struct ACCENT_POLICY { + AccentState: u32, + AccentFlags: u32, + GradientColor: u32, + AnimationId: u32, +} + +type WindowCompositionAttrib = u32; + +#[repr(C)] +struct WindowCompositionAttribData { + Attrib: WindowCompositionAttrib, + pvData: *mut c_void, + cbData: usize, +} + +#[derive(PartialEq)] +#[repr(C)] +enum ACCENT_STATE { + ACCENT_DISABLED = 0, + ACCENT_ENABLE_BLURBEHIND = 3, + ACCENT_ENABLE_ACRYLICBLURBEHIND = 4, +} + +unsafe fn SetWindowCompositionAttribute( + hwnd: HWND, + accent_state: ACCENT_STATE, + color: Option, +) { + type SetWindowCompositionAttribute = + unsafe extern "system" fn(HWND, *mut WindowCompositionAttribData) -> BOOL; + + if let Some(set_window_composition_attribute) = + get_function!("user32.dll", SetWindowCompositionAttribute) + { + let mut color = color.unwrap_or_default(); + + let is_acrylic = accent_state == ACCENT_STATE::ACCENT_ENABLE_ACRYLICBLURBEHIND; + if is_acrylic && color.3 == 0 { + // acrylic doesn't like to have 0 alpha + color.3 = 1; + } + + let mut policy = ACCENT_POLICY { + AccentState: accent_state as _, + AccentFlags: if is_acrylic { 0 } else { 2 }, + GradientColor: (color.0 as u32) + | (color.1 as u32) << 8 + | (color.2 as u32) << 16 + | (color.3 as u32) << 24, + AnimationId: 0, + }; + + let mut data = WindowCompositionAttribData { + Attrib: 0x13, + pvData: &mut policy as *mut _ as _, + cbData: std::mem::size_of_val(&policy), + }; + + set_window_composition_attribute(hwnd, &mut data as *mut _ as _); + } +} + +const DWMWA_MICA_EFFECT: DWMWINDOWATTRIBUTE = 1029i32; +const DWMWA_SYSTEMBACKDROP_TYPE: DWMWINDOWATTRIBUTE = 38i32; + +#[allow(unused)] +#[repr(C)] +enum DWM_SYSTEMBACKDROP_TYPE { + DWMSBT_DISABLE = 1, // None + DWMSBT_MAINWINDOW = 2, // Mica + DWMSBT_TRANSIENTWINDOW = 3, // Acrylic + DWMSBT_TABBEDWINDOW = 4, // Tabbed +} + +fn is_win7() -> bool { + let v = get_windows_ver().unwrap_or_default(); + v.0 == 6 && v.1 == 1 +} + +fn is_atleast_win10() -> bool { + let v = get_windows_ver().unwrap_or_default(); + v.0 == 10 && v.2 >= 17763 +} + +fn is_swca_supported() -> bool { + is_at_least_build(17763) +} + +fn is_undocumented_mica_supported() -> bool { + is_at_least_build(22000) +} + +fn is_tabbed_supported() -> bool { + is_at_least_build(22621) +} + +fn is_backdroptype_supported() -> bool { + is_at_least_build(22523) +} + +pub fn is_at_least_build(build: u32) -> bool { + let v = get_windows_ver().unwrap_or_default(); + v.2 >= build +} diff --git a/src-tauri/src/ws/mod.rs b/src-tauri/src/ws/mod.rs new file mode 100644 index 0000000..2fe69fd --- /dev/null +++ b/src-tauri/src/ws/mod.rs @@ -0,0 +1,129 @@ +use futures::{stream::SplitSink, SinkExt, StreamExt}; +use serde::Serialize; +use std::{collections::LinkedList, convert::Infallible, sync::Arc}; +use tauri::{ + async_runtime::{JoinHandle, Mutex, RwLock}, + plugin::{Builder as PluginBuilder, TauriPlugin}, + Manager, Runtime, State, +}; +use warp::{ + filters::ws::{Message, WebSocket}, + http::StatusCode, + Filter, +}; + +lazy_static::lazy_static! { + static ref WS_CLIENTS: Arc>>> = Arc::new(RwLock::new(LinkedList::new())); +} + +pub struct WebSocketState { + pub server_thread: Arc>>>, +} + +const PORT_NUMBER: u16 = 10766u16; + +// example error response +#[derive(Serialize, Debug)] +struct ApiErrorResult { + detail: String, +} + +async fn store_ws_sender(ws: warp::ws::WebSocket) { + let (sender, _) = ws.split(); + WS_CLIENTS.write().await.push_front(sender); +} + +async fn handle_rejection( + err: warp::reject::Rejection, +) -> Result { + let code; + let message; + + if err.is_not_found() { + code = StatusCode::NOT_FOUND; + message = "Not Found"; + } else if err + .find::() + .is_some() + { + code = StatusCode::BAD_REQUEST; + message = "Invalid Body"; + } else if err.find::().is_some() { + code = StatusCode::METHOD_NOT_ALLOWED; + message = "Method not Allowed"; + } else { + code = StatusCode::INTERNAL_SERVER_ERROR; + message = "Internal Server Error"; + } + + let json = warp::reply::json(&ApiErrorResult { + detail: message.into(), + }); + Ok(warp::reply::with_status(json, code)) +} + +#[tauri::command] +pub fn start_server(ws_state: State<'_, WebSocketState>) -> u16 { + let health_check = warp::path("health-check").map(|| "OK".to_string()); + + let ws = warp::path("ws") + .and(warp::ws()) + .map(|ws: warp::ws::Ws| ws.on_upgrade(store_ws_sender)); + + let routes = health_check + .or(ws) + .with(warp::cors().allow_any_origin()) + .recover(handle_rejection); + + let thread_ref = ws_state.server_thread.clone(); + + tokio::task::block_in_place(move || { + *thread_ref.blocking_lock() = Some(tauri::async_runtime::spawn( + warp::serve(routes).run(([127, 0, 0, 1], PORT_NUMBER)), + )); + }); + + PORT_NUMBER +} + +#[tauri::command] +pub fn stop_server(ws_state: State<'_, WebSocketState>) { + tokio::task::block_in_place(move || { + let mut ws_state_lock = ws_state.server_thread.blocking_lock(); + if let Some(handle) = ws_state_lock.as_ref() { + // literally delete the server + // this is not a clean exit + // but it's an exit + handle.abort(); + } + *ws_state_lock = None; + WS_CLIENTS.blocking_write().clear(); + }); +} + +#[tauri::command] +pub async fn send_message(message: String) { + for i in WS_CLIENTS.write().await.iter_mut() { + let _ = i.send(Message::text(message.clone())).await; + } +} + +pub fn init() -> TauriPlugin +where + R: Runtime, +{ + PluginBuilder::new("ws") + .setup(|app| { + app.manage(WebSocketState { + server_thread: Arc::new(Mutex::new(None)), + }); + + Ok(()) + }) + .invoke_handler(tauri::generate_handler![ + start_server, + stop_server, + send_message + ]) + .build() +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json new file mode 100644 index 0000000..9f0fd29 --- /dev/null +++ b/src-tauri/tauri.conf.json @@ -0,0 +1,113 @@ +{ + "build": { + "beforeDevCommand": "cd ui && npm run dev --port 9000", + "beforeBuildCommand": "cd ui && yarn build:spa", + "devPath": "http://localhost:9000", + "distDir": "../ui/dist/spa", + "withGlobalTauri": true + }, + "package": { + "productName": "Cider", + "version": "x.x.x" + }, + "tauri": { + "allowlist": { + "all": false, + "dialog": { + "all": true, + "ask": true, + "confirm": true, + "message": true, + "open": true, + "save": true + }, + "process": { + "all": true, + "exit": true, + "relaunch": true, + "relaunchDangerousAllowSymlinkMacos": true + }, + "notification": { + "all": true + }, + "os": { + "all": true + }, + "http": { + "all": true, + "request": true, + "scope": ["https://**", "http://**"] + }, + "fs": { + "all": true, + "scope": ["$APPCONFIG/**"] + }, + "window": { + "setFullscreen": true, + "maximize": true, + "minimize": true, + "unmaximize": true, + "close": true, + "startDragging": true, + "setSize": true, + "setPosition": true, + "setAlwaysOnTop": true, + "hide": true, + "show": true, + "setFocus": true, + "setTitle": true, + "create": true, + "requestUserAttention": true, + "setIcon": true, + "center": true + }, + "shell": { + "open": "^(https?://)?(mailto:)?", + "sidecar": true, + "scope": [{ "name": "resource/airtunes2", "sidecar": true }] + }, + "clipboard": { + "writeText": true + } + }, + "bundle": { + "windows": { + "certificateThumbprint": "xxx", + "digestAlgorithm": "sha256", + "timestampUrl": "http://timestamp.comodoca.com", + "webviewInstallMode": { + "type": "fixedRuntime", + "path": "./Microsoft.WebView2.FixedVersionRuntime.123.0.2420.81.x64/" + } + }, + "active": true, + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ], + "identifier": "sh.cider.sabiiro", + "targets": ["msi"], + "category": "Music", + "macOS": { + "entitlements": "../ui/resources/entitlements.mac.plist" + }, + "externalBin": [ + "resource/airtunes2" + ] + }, + "security": { + "csp": null + }, + "updater": { + "active": false + }, + "windows": [], + "systemTray": { + "iconPath": "icons/32x32.png", + "iconAsTemplate": true + } + } +}