project initiate.

This commit is contained in:
Vixalie
2025-02-26 05:39:36 +08:00
commit 4f5420b658
81 changed files with 4816 additions and 0 deletions

7
src-tauri/.gitignore vendored Normal file
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

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

@@ -0,0 +1,40 @@
[package]
name = "estim"
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 = "estim_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"
btleplug = { version = "0.11.6", features = ["serde"] }
tokio = { version = "1.40.0", features = ["full"] }
sled = "0.34.7"
anyhow = "1.0.89"
thiserror = "2.0.11"
serde_repr = "0.1.19"
bincode = "1.3.3"
uuid = { version = "1.10.0", features = ["serde"] }
tauri-plugin-dialog = "2"
futures = "0.3.31"
tauri-plugin-devtools = "2.0.0"
tauri-plugin-os = "2"
tauri-plugin-notification = "2"
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-single-instance = "2"

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

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

View File

@@ -0,0 +1,16 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": [
"main"
],
"permissions": [
"core:default",
"opener:default",
"core:window:allow-start-dragging",
"os:default",
"notification:default",
"dialog:default"
]
}

BIN
src-tauri/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

BIN
src-tauri/icons/32x32.png Normal file

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

BIN
src-tauri/icons/icon.icns Normal file

Binary file not shown.

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

134
src-tauri/src/bluetooth.rs Normal file
View File

@@ -0,0 +1,134 @@
use std::sync::Arc;
use btleplug::{
api::{Central, CentralState, Manager as _, Peripheral as _, ScanFilter},
platform::{Adapter, Manager, Peripheral},
};
use futures::StreamExt;
use tauri::{
async_runtime::{self, JoinHandle, RwLock},
AppHandle, Emitter, State,
};
use crate::{errors, state::AppState};
pub async fn handle_bluetooth_events(
app_handle: Arc<AppHandle>,
app_state: Arc<RwLock<AppState>>,
) -> Result<JoinHandle<()>, errors::AppError> {
let state = app_state.read().await;
let adapter = state.central_adapter.read().await;
let app = Arc::clone(&app_handle);
if let Some(adapter) = &*adapter {
let adapter = adapter.clone();
let app_state = Arc::clone(&app_state);
Ok(async_runtime::spawn(async move {
let mut event_stream = adapter.events().await.unwrap();
while let Some(event) = event_stream.next().await {
match event {
btleplug::api::CentralEvent::DeviceDiscovered(_id) => {
app.emit("app_state_updated", ()).unwrap();
}
btleplug::api::CentralEvent::DeviceConnected(_id) => {
let state = app_state.write().await;
let peripherals = adapter.peripherals().await;
if let Ok(peripherals) = peripherals {
for peripheral in peripherals {
if peripheral.id() == _id {
state.set_connected_peripheral(peripheral).await;
app.emit("app_state_updated", ()).unwrap();
break;
}
}
}
}
btleplug::api::CentralEvent::DeviceDisconnected(_id) => {
let state = app_state.write().await;
state.clear_connected_peripheral().await;
app.emit("app_state_updated", ()).unwrap();
}
_ => {}
}
}
}))
} else {
app.emit("app_state_updated", ()).unwrap();
Err(errors::AppError::BluetoothNotReady)
}
}
pub async fn get_central_adapter() -> Result<Adapter, errors::AppError> {
let manager = Manager::new()
.await
.map_err(|_| errors::AppError::BluetoothNotReady)?;
let adapters = manager
.adapters()
.await
.map_err(|_| errors::AppError::BluetoothAdapterNotFound)?;
let mut found_adapter = None;
for adpater in adapters {
let state = adpater.adapter_state().await;
if let Ok(state) = state {
if state == CentralState::PoweredOn {
found_adapter = Some(adpater);
break;
}
}
}
found_adapter.ok_or(errors::AppError::NoAvailableBluetoothAdapter)
}
pub async fn start_scan(
app_handle: Arc<AppHandle>,
app_state: State<'_, Arc<RwLock<AppState>>>,
) -> Result<(), errors::AppError> {
let state = app_state.read().await;
let adapter = state.get_central_adapter().await;
if let Some(adapter) = adapter {
adapter
.start_scan(ScanFilter::default())
.await
.map_err(|_| errors::AppError::UnableToStartScan)?;
app_handle.emit("app_state_updated", ()).unwrap();
state.set_scanning(true).await;
Ok(())
} else {
Err(errors::AppError::NoAvailableBluetoothAdapter)
}
}
pub async fn stop_scan(
app_handle: Arc<AppHandle>,
app_state: State<'_, Arc<RwLock<AppState>>>,
) -> Result<(), errors::AppError> {
let state = app_state.read().await;
let adapter = state.get_central_adapter().await;
if let Some(adapter) = adapter {
adapter
.stop_scan()
.await
.map_err(|_| errors::AppError::UnableToStopScan)?;
app_handle.emit("app_state_updated", ()).unwrap();
state.set_scanning(false).await;
Ok(())
} else {
Err(errors::AppError::NoAvailableBluetoothAdapter)
}
}
pub async fn get_peripherals(
app_state: State<'_, Arc<RwLock<AppState>>>,
) -> Result<Vec<Peripheral>, errors::AppError> {
let state = app_state.read().await; // Change from lock() to read()
let adapter = state.get_central_adapter().await;
if let Some(adapter) = adapter {
Ok(adapter
.peripherals()
.await
.map_err(|_| errors::AppError::UnableToRetrievePeripherals)?)
} else {
Err(errors::AppError::NoAvailableBluetoothAdapter)
}
}

122
src-tauri/src/cmd/mod.rs Normal file
View File

@@ -0,0 +1,122 @@
use std::sync::Arc;
use btleplug::api::{Central as _, Peripheral as _};
use state::{ApplicationState, CentralState, ChannelState, PeripheralItem};
use tauri::{async_runtime::RwLock, AppHandle, Emitter, State};
use crate::{bluetooth, errors, state::AppState};
mod state;
#[tauri::command]
pub async fn activate_central_adapter(
app_handle: AppHandle,
app_state: State<'_, Arc<RwLock<AppState>>>,
) -> Result<(), errors::AppError> {
let app = Arc::new(app_handle);
let adapter = bluetooth::get_central_adapter().await?;
{
let state = app_state.read().await;
state.set_central_adapter(adapter).await;
state.clear_central_event_handler().await;
}
let handle =
bluetooth::handle_bluetooth_events(Arc::clone(&app), Arc::clone(&app_state)).await?;
{
let state = app_state.write().await; // Changed from lock() to write()
state.set_central_event_handler(handle).await;
}
app.emit("app_state_updated", ()).unwrap();
Ok(())
}
#[tauri::command]
pub async fn refresh_application_state(
app_state: State<'_, Arc<RwLock<AppState>>>,
) -> Result<ApplicationState, errors::AppError> {
let state = app_state.read().await;
let mut peripherals: Vec<PeripheralItem> = vec![];
let mut connected_peripheral = None;
let central = state.get_central_adapter().await;
let central_state = if let Some(central) = central {
let central_device_state = central
.adapter_state()
.await
.map_err(|_| errors::AppError::BluetoothNotReady)?;
let found_peripherals = central
.peripherals()
.await
.map_err(|_| errors::AppError::UnableToRetrievePeripherals)?;
for peripheral in found_peripherals {
let properties = peripheral
.properties()
.await
.map_err(|_| errors::AppError::UnableToRetrievePeripheralProperties)?;
if let Some(properties) = properties {
let represent = properties
.local_name
.unwrap_or_else(|| properties.address.to_string());
let item = PeripheralItem {
id: peripheral.id(),
address: properties.address.to_string(),
represent,
is_connected: peripheral
.is_connected()
.await
.map_err(|_| errors::AppError::UnableToRetrievePeripheralProperties)?,
rssi: properties.rssi,
battery: properties.tx_power_level,
};
if peripheral
.is_connected()
.await
.map_err(|_| errors::AppError::UnableToRetrievePeripheralState)?
{
connected_peripheral = Some(item.clone());
}
peripherals.push(item);
}
}
CentralState {
is_ready: central_device_state == btleplug::api::CentralState::PoweredOn,
is_scanning: *state.scanning.lock().await,
connected: state
.connected_peripheral
.lock()
.await
.clone()
.map(|p| p.id()),
}
} else {
CentralState::default()
};
Ok(ApplicationState {
central: central_state,
peripherals,
connected_peripheral,
channel_a: ChannelState::default(),
channel_b: ChannelState::default(),
})
}
#[tauri::command]
pub async fn start_scan_devices(
app_handle: AppHandle,
app_state: State<'_, Arc<RwLock<AppState>>>,
) -> Result<(), errors::AppError> {
bluetooth::start_scan(Arc::new(app_handle), app_state).await?;
Ok(())
}
#[tauri::command]
pub async fn stop_scan_devices(
app_handle: AppHandle,
app_state: State<'_, Arc<RwLock<AppState>>>,
) -> Result<(), errors::AppError> {
bluetooth::stop_scan(Arc::new(app_handle), app_state).await?;
Ok(())
}

View File

@@ -0,0 +1,41 @@
use btleplug::platform::PeripheralId;
use serde::Serialize;
use crate::playlist::PlayMode;
#[derive(Debug, Clone, Default, Serialize)]
pub struct CentralState {
pub is_ready: bool,
pub is_scanning: bool,
pub connected: Option<PeripheralId>,
}
#[derive(Debug, Clone, Serialize)]
pub struct PeripheralItem {
pub id: PeripheralId,
pub address: String,
pub represent: String,
pub is_connected: bool,
pub rssi: Option<i16>,
pub battery: Option<i16>,
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct ChannelState {
pub is_playing: bool,
pub strength: u32,
pub strength_limit: u32,
pub is_boosting: bool,
pub boost_level: u32,
pub boost_limit: u32,
pub play_mode: PlayMode,
}
#[derive(Debug, Clone, Serialize)]
pub struct ApplicationState {
pub central: CentralState,
pub peripherals: Vec<PeripheralItem>,
pub connected_peripheral: Option<PeripheralItem>,
pub channel_a: ChannelState,
pub channel_b: ChannelState,
}

View File

@@ -0,0 +1,23 @@
use std::{fs::DirBuilder, path::PathBuf};
use tauri::{path::PathResolver, AppHandle, Manager, Runtime};
fn ensure_dir<R: Runtime>(resolver: &PathResolver<R>) -> anyhow::Result<PathBuf> {
let config_dir = resolver
.local_data_dir()
.map_err(|e| anyhow::anyhow!("Unable to get local data directory: {}", e))?;
if !config_dir.exists() {
let mut dir_creator = DirBuilder::new();
dir_creator
.recursive(true)
.create(&config_dir)
.map_err(|e| anyhow::anyhow!("Unable to create config directory: {}", e))?;
}
Ok(config_dir)
}
pub fn open_config_db<R: Runtime>(app_handle: &AppHandle<R>) -> anyhow::Result<sled::Db> {
let config_dir = ensure_dir(app_handle.path())?;
let db_path = config_dir.join("conf.db");
sled::open(&db_path).map_err(|e| anyhow::anyhow!("Unable to open config database: {}", e))
}

22
src-tauri/src/errors.rs Normal file
View File

@@ -0,0 +1,22 @@
use serde::Serialize;
use thiserror::Error;
#[derive(Debug, Error, Serialize)]
pub enum AppError {
#[error("Bluetooth not ready")]
BluetoothNotReady,
#[error("No Bluetooth adapter found")]
BluetoothAdapterNotFound,
#[error("No available Bluetooth adapter, maybe not powered on")]
NoAvailableBluetoothAdapter,
#[error("Unable to start scan devices")]
UnableToStartScan,
#[error("Unable to stop scan devices")]
UnableToStopScan,
#[error("Unable to retrieve peripherals")]
UnableToRetrievePeripherals,
#[error("Unable to retrieve peripheral properties")]
UnableToRetrievePeripheralProperties,
#[error("Unable to retrieve peripheral state")]
UnableToRetrievePeripheralState,
}

63
src-tauri/src/lib.rs Normal file
View File

@@ -0,0 +1,63 @@
#![allow(dead_code)]
#![feature(stmt_expr_attributes)]
use std::sync::Arc;
use tauri::{
async_runtime::{self, RwLock},
generate_handler, Manager,
};
use tauri_plugin_dialog::DialogExt;
mod bluetooth;
mod cmd;
mod config_db;
mod errors;
mod playlist;
mod state;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
#[cfg(debug_assertions)]
let devtools = tauri_plugin_devtools::init();
let mut builder = tauri::Builder::default();
#[cfg(debug_assertions)]
builder = builder.plugin(devtools);
builder
.plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_single_instance::init(|_app, _args, _cwd| {}))
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_notification::init())
.setup(|app| {
if let Err(e) = async_runtime::block_on(async {
let state = state::AppState::new(app.handle()).await?;
app.manage(Arc::new(RwLock::new(state)));
Ok::<(), anyhow::Error>(())
}) {
app.dialog()
.message(e.to_string())
.kind(tauri_plugin_dialog::MessageDialogKind::Error)
.title("Initialization error")
.blocking_show();
return Err(e.into());
}
#[cfg(debug_assertions)]
{
let window = app.get_webview_window("main").unwrap();
window.open_devtools();
}
Ok(())
})
.invoke_handler(generate_handler![
cmd::refresh_application_state,
cmd::activate_central_adapter,
cmd::start_scan_devices,
cmd::stop_scan_devices
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

6
src-tauri/src/main.rs Normal file
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() {
estim_lib::run()
}

17
src-tauri/src/playlist.rs Normal file
View File

@@ -0,0 +1,17 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum PlayMode {
#[serde(rename = "repeat")]
Repeat,
#[serde(rename = "repeat-one")]
RepeatOne,
#[serde(rename = "shuffle")]
Shuffle,
}
impl Default for PlayMode {
fn default() -> Self {
PlayMode::Repeat
}
}

86
src-tauri/src/state.rs Normal file
View File

@@ -0,0 +1,86 @@
use std::sync::Arc;
use btleplug::platform::{Adapter, Peripheral};
use tauri::{
async_runtime::{JoinHandle, Mutex, RwLock},
AppHandle, Runtime,
};
use crate::config_db;
pub struct AppState {
pub db: Arc<Mutex<sled::Db>>,
pub central_adapter: Arc<RwLock<Option<Adapter>>>,
pub central_event_handler: Arc<Mutex<Option<JoinHandle<()>>>>,
pub scanning: Arc<Mutex<bool>>,
pub connected_peripheral: Arc<Mutex<Option<Arc<Peripheral>>>>,
}
unsafe impl Send for AppState {}
unsafe impl Sync for AppState {}
impl AppState {
pub async fn new<R: Runtime>(app: &AppHandle<R>) -> anyhow::Result<Self> {
let db = config_db::open_config_db(app)?;
Ok(Self {
db: Arc::new(Mutex::new(db)),
central_adapter: Arc::new(RwLock::new(None)),
central_event_handler: Arc::new(Mutex::new(None)),
scanning: Arc::new(Mutex::new(false)),
connected_peripheral: Arc::new(Mutex::new(None)),
})
}
pub async fn set_central_adapter(&self, adapter: Adapter) {
let mut central_adapter = self.central_adapter.write().await;
*central_adapter = Some(adapter);
}
pub async fn clear_central_adapter(&self) {
let mut central_adapter = self.central_adapter.write().await;
*central_adapter = None;
}
pub async fn get_central_adapter(&self) -> Option<Adapter> {
let central_adapter = self.central_adapter.read().await;
central_adapter.clone()
}
pub async fn set_central_event_handler(&self, handler: JoinHandle<()>) {
let mut central_event_handler = self.central_event_handler.lock().await;
*central_event_handler = Some(handler);
}
pub async fn clear_central_event_handler(&self) {
let mut central_event_handler = self.central_event_handler.lock().await;
if let Some(handler) = central_event_handler.take() {
handler.abort();
}
}
pub async fn set_scanning(&self, scanning_state: bool) {
let mut scanning = self.scanning.lock().await;
*scanning = scanning_state;
}
pub async fn set_connected_peripheral(&self, peripheral: Peripheral) {
let mut connected_peripheral = self.connected_peripheral.lock().await;
*connected_peripheral = Some(Arc::new(peripheral));
}
pub async fn clear_connected_peripheral(&self) {
let mut connected_peripheral = self.connected_peripheral.lock().await;
*connected_peripheral = None;
}
pub async fn get_connected_peripheral(&self) -> Option<Arc<Peripheral>> {
let connected_peripheral = self.connected_peripheral.lock().await;
if let Some(peripheral) = connected_peripheral.clone() {
Some(Arc::clone(&peripheral))
} else {
None
}
}
}

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

@@ -0,0 +1,39 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "estim",
"version": "0.1.0",
"identifier": "net.archgrid.app.estim",
"build": {
"beforeDevCommand": "deno task dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "deno task build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"title": "estim",
"width": 1200,
"height": 800,
"resizable": false,
"hiddenTitle": true,
"titleBarStyle": "Overlay",
"theme": "Dark"
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}