init: CryptoCoin desktop ticker
Co-authored-by: Cursor <cursoragent@cursor.com>
7
src-tauri/.gitignore
vendored
Normal 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
|
||||
4883
src-tauri/Cargo.lock
generated
Normal file
25
src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,25 @@
|
||||
[package]
|
||||
name = "cryptocoin"
|
||||
version = "0.1.0"
|
||||
description = "CryptoCoin"
|
||||
authors = ["smy"]
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "cryptocoin_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["tray-icon"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
urlencoding = "2"
|
||||
|
||||
[profile.release]
|
||||
codegen-units = 1
|
||||
lto = true
|
||||
strip = true
|
||||
3
src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
18
src-tauri/capabilities/default.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-show",
|
||||
"core:window:allow-hide",
|
||||
"core:window:allow-set-focus",
|
||||
"core:window:allow-set-size",
|
||||
"core:window:allow-close",
|
||||
"allow-fetch-tickers",
|
||||
"allow-coin-settings",
|
||||
"allow-api-settings"
|
||||
]
|
||||
}
|
||||
BIN
src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 974 B |
BIN
src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 903 B |
BIN
src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
src-tauri/icons/icon.icns
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
4
src-tauri/permissions/allow-api-settings.toml
Normal file
@@ -0,0 +1,4 @@
|
||||
[[permission]]
|
||||
identifier = "allow-api-settings"
|
||||
description = "Enables API config commands"
|
||||
commands.allow = ["get_api_config"]
|
||||
4
src-tauri/permissions/allow-coin-settings.toml
Normal file
@@ -0,0 +1,4 @@
|
||||
[[permission]]
|
||||
identifier = "allow-coin-settings"
|
||||
description = "Enables coin list and refresh interval commands"
|
||||
commands.allow = ["list_coins", "set_coin_enabled", "get_refresh_interval", "get_background_theme"]
|
||||
4
src-tauri/permissions/allow-fetch-tickers.toml
Normal file
@@ -0,0 +1,4 @@
|
||||
[[permission]]
|
||||
identifier = "allow-fetch-tickers"
|
||||
description = "Enables the fetch_tickers command"
|
||||
commands.allow = ["fetch_tickers"]
|
||||
7
src-tauri/settings.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"enabled": ["BTCUSDT", "ETHUSDT"],
|
||||
"proxyBaseUrl": "https://bn-api-proxy.smyhub.com",
|
||||
"useProxy": true,
|
||||
"refreshIntervalMs": 5000,
|
||||
"backgroundTheme": "dark"
|
||||
}
|
||||
254
src-tauri/src/coins.rs
Normal file
@@ -0,0 +1,254 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CoinInfo {
|
||||
pub symbol: String,
|
||||
pub label: String,
|
||||
pub theme: String,
|
||||
pub enabled: bool,
|
||||
pub sort_order: u8,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ApiMode {
|
||||
Direct,
|
||||
Proxy,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DataSource {
|
||||
BinanceSpot,
|
||||
BinanceFutures,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ApiConfigDto {
|
||||
pub mode: String,
|
||||
pub proxy_base_url: String,
|
||||
pub use_proxy: bool,
|
||||
pub settings_path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct CoinDef {
|
||||
pub symbol: &'static str,
|
||||
pub label: &'static str,
|
||||
pub theme: &'static str,
|
||||
pub sort_order: u8,
|
||||
pub default_enabled: bool,
|
||||
pub source: DataSource,
|
||||
}
|
||||
|
||||
pub const ALL_COINS: &[CoinDef] = &[
|
||||
// ── 加密货币(币安现货)────────────────────────────────────────────────
|
||||
CoinDef {
|
||||
symbol: "BTCUSDT",
|
||||
label: "BTC",
|
||||
theme: "btc",
|
||||
sort_order: 0,
|
||||
default_enabled: true,
|
||||
source: DataSource::BinanceSpot,
|
||||
},
|
||||
CoinDef {
|
||||
symbol: "ETHUSDT",
|
||||
label: "ETH",
|
||||
theme: "eth",
|
||||
sort_order: 1,
|
||||
default_enabled: true,
|
||||
source: DataSource::BinanceSpot,
|
||||
},
|
||||
CoinDef {
|
||||
symbol: "DAIUSDT",
|
||||
label: "USDT",
|
||||
theme: "usdt",
|
||||
sort_order: 2,
|
||||
default_enabled: false,
|
||||
source: DataSource::BinanceSpot,
|
||||
},
|
||||
CoinDef {
|
||||
symbol: "USDCUSDT",
|
||||
label: "USDC",
|
||||
theme: "usdc",
|
||||
sort_order: 3,
|
||||
default_enabled: false,
|
||||
source: DataSource::BinanceSpot,
|
||||
},
|
||||
CoinDef {
|
||||
symbol: "BNBUSDT",
|
||||
label: "BNB",
|
||||
theme: "bnb",
|
||||
sort_order: 4,
|
||||
default_enabled: false,
|
||||
source: DataSource::BinanceSpot,
|
||||
},
|
||||
CoinDef {
|
||||
symbol: "XRPUSDT",
|
||||
label: "XRP",
|
||||
theme: "xrp",
|
||||
sort_order: 5,
|
||||
default_enabled: false,
|
||||
source: DataSource::BinanceSpot,
|
||||
},
|
||||
CoinDef {
|
||||
symbol: "SOLUSDT",
|
||||
label: "SOL",
|
||||
theme: "sol",
|
||||
sort_order: 6,
|
||||
default_enabled: false,
|
||||
source: DataSource::BinanceSpot,
|
||||
},
|
||||
CoinDef {
|
||||
symbol: "DOGEUSDT",
|
||||
label: "DOGE",
|
||||
theme: "doge",
|
||||
sort_order: 7,
|
||||
default_enabled: false,
|
||||
source: DataSource::BinanceSpot,
|
||||
},
|
||||
// ── 美股代币(币安现货 bStocks)────────────────────────────────────────
|
||||
CoinDef {
|
||||
symbol: "NVDABUSDT",
|
||||
label: "NVDA",
|
||||
theme: "nvda",
|
||||
sort_order: 8,
|
||||
default_enabled: false,
|
||||
source: DataSource::BinanceSpot,
|
||||
},
|
||||
CoinDef {
|
||||
symbol: "MUBUSDT",
|
||||
label: "MU",
|
||||
theme: "mu",
|
||||
sort_order: 9,
|
||||
default_enabled: false,
|
||||
source: DataSource::BinanceSpot,
|
||||
},
|
||||
CoinDef {
|
||||
symbol: "TSLABUSDT",
|
||||
label: "TSLA",
|
||||
theme: "tsla",
|
||||
sort_order: 10,
|
||||
default_enabled: false,
|
||||
source: DataSource::BinanceSpot,
|
||||
},
|
||||
CoinDef {
|
||||
symbol: "CRCLBUSDT",
|
||||
label: "CRCL",
|
||||
theme: "crcl",
|
||||
sort_order: 11,
|
||||
default_enabled: false,
|
||||
source: DataSource::BinanceSpot,
|
||||
},
|
||||
CoinDef {
|
||||
symbol: "SNDKBUSDT",
|
||||
label: "SNDK",
|
||||
theme: "sndk",
|
||||
sort_order: 12,
|
||||
default_enabled: false,
|
||||
source: DataSource::BinanceSpot,
|
||||
},
|
||||
CoinDef {
|
||||
symbol: "SPCXBUSDT",
|
||||
label: "SPCX",
|
||||
theme: "spcx",
|
||||
sort_order: 13,
|
||||
default_enabled: false,
|
||||
source: DataSource::BinanceSpot,
|
||||
},
|
||||
// ── 美股(币安永续合约)────────────────────────────────────────────────
|
||||
CoinDef {
|
||||
symbol: "LLYUSDT",
|
||||
label: "LLY",
|
||||
theme: "lly",
|
||||
sort_order: 14,
|
||||
default_enabled: false,
|
||||
source: DataSource::BinanceFutures,
|
||||
},
|
||||
CoinDef {
|
||||
symbol: "AAPLUSDT",
|
||||
label: "AAPL",
|
||||
theme: "aapl",
|
||||
sort_order: 15,
|
||||
default_enabled: false,
|
||||
source: DataSource::BinanceFutures,
|
||||
},
|
||||
CoinDef {
|
||||
symbol: "MSFTUSDT",
|
||||
label: "MSFT",
|
||||
theme: "msft",
|
||||
sort_order: 16,
|
||||
default_enabled: false,
|
||||
source: DataSource::BinanceFutures,
|
||||
},
|
||||
CoinDef {
|
||||
symbol: "COSTUSDT",
|
||||
label: "COST",
|
||||
theme: "cost",
|
||||
sort_order: 17,
|
||||
default_enabled: false,
|
||||
source: DataSource::BinanceFutures,
|
||||
},
|
||||
];
|
||||
|
||||
pub fn coin_menu_id(symbol: &str) -> String {
|
||||
format!("coin:{symbol}")
|
||||
}
|
||||
|
||||
pub fn parse_coin_menu_id(id: &str) -> Option<&str> {
|
||||
id.strip_prefix("coin:")
|
||||
}
|
||||
|
||||
pub fn parse_api_menu_id(id: &str) -> Option<ApiMode> {
|
||||
match id {
|
||||
"api:direct" => Some(ApiMode::Direct),
|
||||
"api:proxy" => Some(ApiMode::Proxy),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sort_index(symbol: &str) -> u8 {
|
||||
ALL_COINS
|
||||
.iter()
|
||||
.find(|c| c.symbol == symbol)
|
||||
.map(|c| c.sort_order)
|
||||
.unwrap_or(255)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SettingsFile {
|
||||
pub enabled: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub proxy_base_url: String,
|
||||
#[serde(default)]
|
||||
pub use_proxy: bool,
|
||||
#[serde(default = "default_refresh_interval_ms")]
|
||||
pub refresh_interval_ms: u64,
|
||||
#[serde(default = "default_background_theme")]
|
||||
pub background_theme: String,
|
||||
}
|
||||
|
||||
fn default_refresh_interval_ms() -> u64 {
|
||||
5000
|
||||
}
|
||||
|
||||
fn default_background_theme() -> String {
|
||||
"dark".to_string()
|
||||
}
|
||||
|
||||
impl Default for SettingsFile {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: ALL_COINS
|
||||
.iter()
|
||||
.filter(|c| c.default_enabled)
|
||||
.map(|c| c.symbol.to_string())
|
||||
.collect(),
|
||||
proxy_base_url: String::new(),
|
||||
use_proxy: false,
|
||||
refresh_interval_ms: default_refresh_interval_ms(),
|
||||
background_theme: default_background_theme(),
|
||||
}
|
||||
}
|
||||
}
|
||||
598
src-tauri/src/lib.rs
Normal file
@@ -0,0 +1,598 @@
|
||||
mod coins;
|
||||
mod settings;
|
||||
|
||||
use coins::{
|
||||
coin_menu_id, parse_api_menu_id, parse_coin_menu_id, sort_index, ApiConfigDto, ApiMode,
|
||||
CoinInfo, DataSource, ALL_COINS,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use settings::{
|
||||
interval_menu_id, parse_interval_menu_id, parse_theme_menu_id, radio_menu_label, theme_menu_id,
|
||||
AppState, BACKGROUND_THEMES, REFRESH_INTERVALS,
|
||||
};
|
||||
use tauri::{
|
||||
menu::{CheckMenuItem, IsMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu},
|
||||
tray::TrayIconBuilder,
|
||||
App, AppHandle, Emitter, Manager, PhysicalPosition, State,
|
||||
};
|
||||
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TickerDto {
|
||||
pub symbol: String,
|
||||
pub label: String,
|
||||
pub last_price: f64,
|
||||
pub change_percent: f64,
|
||||
}
|
||||
|
||||
// ── Binance structs ──────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct BinanceTicker {
|
||||
symbol: String,
|
||||
#[serde(rename = "lastPrice")]
|
||||
last_price: String,
|
||||
#[serde(rename = "priceChangePercent")]
|
||||
price_change_percent: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct BinanceApiError {
|
||||
code: i64,
|
||||
msg: String,
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
fn network_error_message(url: &str, err: &reqwest::Error, settings_path: &std::path::Path) -> String {
|
||||
let hint = if url.contains("api.binance.com") {
|
||||
format!(
|
||||
"本机可能无法直连币安,请在 {} 填写 proxyBaseUrl 并设 useProxy: true",
|
||||
settings_path.display()
|
||||
)
|
||||
} else {
|
||||
"请检查网络或代理".to_string()
|
||||
};
|
||||
format!("network error: {err}\n{hint}")
|
||||
}
|
||||
|
||||
fn format_api_error_body(body: &str) -> String {
|
||||
if let Ok(err) = serde_json::from_str::<BinanceApiError>(body) {
|
||||
return format!("Binance API {}: {}", err.code, err.msg);
|
||||
}
|
||||
body.chars().take(240).collect()
|
||||
}
|
||||
|
||||
fn label_for_symbol(symbol: &str) -> String {
|
||||
ALL_COINS
|
||||
.iter()
|
||||
.find(|c| c.symbol == symbol)
|
||||
.map(|c| c.label.to_string())
|
||||
.unwrap_or_else(|| symbol.replace("USDT", ""))
|
||||
}
|
||||
|
||||
fn get_coin_list(state: &AppState) -> Vec<CoinInfo> {
|
||||
let s = state.settings.lock().unwrap();
|
||||
let mut list: Vec<CoinInfo> = ALL_COINS
|
||||
.iter()
|
||||
.map(|c| CoinInfo {
|
||||
symbol: c.symbol.to_string(),
|
||||
label: c.label.to_string(),
|
||||
theme: c.theme.to_string(),
|
||||
enabled: s.enabled.contains(c.symbol),
|
||||
sort_order: c.sort_order,
|
||||
})
|
||||
.collect();
|
||||
list.sort_by_key(|c| c.sort_order);
|
||||
list
|
||||
}
|
||||
|
||||
// ── Binance fetch ────────────────────────────────────────────────────────────
|
||||
|
||||
async fn do_fetch_batch(state: &AppState, symbols: &[String]) -> Result<Vec<BinanceTicker>, String> {
|
||||
let url = state.build_ticker_url(symbols)?;
|
||||
eprintln!("binance batch: {url}");
|
||||
let response = state
|
||||
.http
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| network_error_message(&url, &e, &state.settings_file_path()))?;
|
||||
|
||||
let status = response.status();
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("read body error: {e}"))?;
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(format!("HTTP {status}: {}", format_api_error_body(&body)));
|
||||
}
|
||||
|
||||
serde_json::from_str::<Vec<BinanceTicker>>(&body)
|
||||
.map_err(|_| format!("parse error: {}", format_api_error_body(&body)))
|
||||
}
|
||||
|
||||
async fn do_fetch_single_binance(state: &AppState, symbol: &str) -> Option<BinanceTicker> {
|
||||
let url = state.build_single_ticker_url(symbol).ok()?;
|
||||
let resp = state.http.get(&url).send().await.ok()?;
|
||||
if !resp.status().is_success() {
|
||||
eprintln!("binance single {symbol}: HTTP {}", resp.status());
|
||||
return None;
|
||||
}
|
||||
let body = resp.text().await.ok()?;
|
||||
serde_json::from_str::<BinanceTicker>(&body).ok()
|
||||
}
|
||||
|
||||
async fn fetch_binance_tickers(state: &AppState, symbols: &[String]) -> Vec<TickerDto> {
|
||||
let raw: Vec<BinanceTicker> = match do_fetch_batch(state, symbols).await {
|
||||
Ok(items) => items,
|
||||
Err(err) if err.contains("-1121") => {
|
||||
eprintln!("binance batch invalid symbol, falling back to per-symbol");
|
||||
let mut results = Vec::new();
|
||||
for sym in symbols {
|
||||
if let Some(t) = do_fetch_single_binance(state, sym).await {
|
||||
results.push(t);
|
||||
}
|
||||
}
|
||||
results
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("binance fetch error: {err}");
|
||||
return vec![];
|
||||
}
|
||||
};
|
||||
|
||||
raw.into_iter()
|
||||
.filter_map(|t| {
|
||||
let last_price = t.last_price.parse::<f64>().ok()?;
|
||||
let change_percent = t.price_change_percent.parse::<f64>().ok()?;
|
||||
Some(TickerDto {
|
||||
label: label_for_symbol(&t.symbol),
|
||||
symbol: t.symbol,
|
||||
last_price,
|
||||
change_percent,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ── Binance Futures fetch ────────────────────────────────────────────────────
|
||||
|
||||
async fn fetch_futures_tickers(state: &AppState, symbols: &[String]) -> Vec<TickerDto> {
|
||||
let url = match state.build_futures_ticker_url(symbols) {
|
||||
Ok(u) => u,
|
||||
Err(e) => { eprintln!("futures url build error: {e}"); return vec![]; }
|
||||
};
|
||||
eprintln!("binance futures batch: {url}");
|
||||
|
||||
let raw: Vec<BinanceTicker> = match state.http.get(&url).send().await {
|
||||
Err(e) => { eprintln!("futures network error: {e}"); return vec![]; }
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
let body = match resp.text().await {
|
||||
Ok(b) => b,
|
||||
Err(e) => { eprintln!("futures read body: {e}"); return vec![]; }
|
||||
};
|
||||
if !status.is_success() {
|
||||
eprintln!("futures HTTP {status}: {}", &body[..body.len().min(200)]);
|
||||
// 降级为逐个请求
|
||||
let mut results = Vec::new();
|
||||
for sym in symbols {
|
||||
let single_url = state.build_futures_single_url(sym);
|
||||
if let Ok(r) = state.http.get(&single_url).send().await {
|
||||
if r.status().is_success() {
|
||||
if let Ok(b) = r.text().await {
|
||||
if let Ok(t) = serde_json::from_str::<BinanceTicker>(&b) {
|
||||
results.push(t);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
eprintln!("futures single {sym}: HTTP {}", r.status());
|
||||
}
|
||||
}
|
||||
}
|
||||
results
|
||||
} else {
|
||||
match serde_json::from_str::<Vec<BinanceTicker>>(&body) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
eprintln!("futures parse error: {e}");
|
||||
return vec![];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
raw.into_iter()
|
||||
.filter_map(|t| {
|
||||
let last_price = t.last_price.parse::<f64>().ok()?;
|
||||
let change_percent = t.price_change_percent.parse::<f64>().ok()?;
|
||||
Some(TickerDto {
|
||||
label: label_for_symbol(&t.symbol),
|
||||
symbol: t.symbol,
|
||||
last_price,
|
||||
change_percent,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ── Tauri commands ───────────────────────────────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
fn list_coins(state: State<AppState>) -> Vec<CoinInfo> {
|
||||
get_coin_list(&state)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_api_config(state: State<AppState>) -> ApiConfigDto {
|
||||
state.api_config()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_refresh_interval(state: State<AppState>) -> u64 {
|
||||
state.refresh_interval_ms()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_background_theme(state: State<AppState>) -> String {
|
||||
state.background_theme()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn set_coin_enabled(
|
||||
symbol: String,
|
||||
enabled: bool,
|
||||
state: State<AppState>,
|
||||
app: AppHandle,
|
||||
) -> Result<Vec<CoinInfo>, String> {
|
||||
state.set_enabled(&symbol, enabled)?;
|
||||
rebuild_tray_menu(&app, &state)?;
|
||||
let coins = get_coin_list(&state);
|
||||
app.emit("coins-changed", &coins)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(coins)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn fetch_tickers(
|
||||
state: State<'_, AppState>,
|
||||
app: AppHandle,
|
||||
) -> Result<Vec<TickerDto>, String> {
|
||||
let all_symbols = state.enabled_api_symbols();
|
||||
if all_symbols.is_empty() {
|
||||
state.clear_last_fetch_error();
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
// split by data source
|
||||
let spot_syms: Vec<String> = ALL_COINS
|
||||
.iter()
|
||||
.filter(|c| c.source == DataSource::BinanceSpot && all_symbols.contains(&c.symbol.to_string()))
|
||||
.map(|c| c.symbol.to_string())
|
||||
.collect();
|
||||
|
||||
let futures_syms: Vec<String> = ALL_COINS
|
||||
.iter()
|
||||
.filter(|c| c.source == DataSource::BinanceFutures && all_symbols.contains(&c.symbol.to_string()))
|
||||
.map(|c| c.symbol.to_string())
|
||||
.collect();
|
||||
|
||||
let mut tickers: Vec<TickerDto> = Vec::new();
|
||||
|
||||
if !spot_syms.is_empty() {
|
||||
let mut t = fetch_binance_tickers(&state, &spot_syms).await;
|
||||
tickers.append(&mut t);
|
||||
}
|
||||
|
||||
if !futures_syms.is_empty() {
|
||||
let mut t = fetch_futures_tickers(&state, &futures_syms).await;
|
||||
tickers.append(&mut t);
|
||||
}
|
||||
|
||||
tickers.sort_by_key(|t| sort_index(&t.symbol));
|
||||
|
||||
if tickers.is_empty() {
|
||||
return Err(fetch_fail(
|
||||
&state,
|
||||
&app,
|
||||
"所有数据源均无响应".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
state.clear_last_fetch_error();
|
||||
|
||||
Ok(tickers)
|
||||
}
|
||||
|
||||
fn fetch_fail(state: &AppState, app: &AppHandle, message: String) -> String {
|
||||
state.set_last_fetch_error(&message);
|
||||
let _ = app.emit("fetch-error", &message);
|
||||
message
|
||||
}
|
||||
|
||||
// ── tray ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
fn make_coin_check(
|
||||
app: &AppHandle,
|
||||
symbol: &str,
|
||||
label: &str,
|
||||
checked: bool,
|
||||
) -> Result<CheckMenuItem<tauri::Wry>, String> {
|
||||
let text = format!("显示 {label}");
|
||||
CheckMenuItem::with_id(
|
||||
app,
|
||||
coin_menu_id(symbol),
|
||||
text,
|
||||
true,
|
||||
checked,
|
||||
None::<&str>,
|
||||
)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn create_tray_menu(app: &AppHandle, state: &AppState) -> Result<Menu<tauri::Wry>, String> {
|
||||
let settings = state.settings.lock().unwrap();
|
||||
|
||||
let show = MenuItem::with_id(app, "show", "显示挂件", true, None::<&str>)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let hide = MenuItem::with_id(app, "hide", "隐藏挂件", true, None::<&str>)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let sep1 = PredefinedMenuItem::separator(app).map_err(|e| e.to_string())?;
|
||||
let sep2 = PredefinedMenuItem::separator(app).map_err(|e| e.to_string())?;
|
||||
let sep3 = PredefinedMenuItem::separator(app).map_err(|e| e.to_string())?;
|
||||
let quit = MenuItem::with_id(app, "quit", "退出", true, None::<&str>)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let uses_proxy = settings.uses_proxy();
|
||||
let api_direct = MenuItem::with_id(
|
||||
app,
|
||||
"api:direct",
|
||||
&radio_menu_label(!uses_proxy, "直连币安 API"),
|
||||
true,
|
||||
None::<&str>,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let api_proxy = MenuItem::with_id(
|
||||
app,
|
||||
"api:proxy",
|
||||
&radio_menu_label(uses_proxy, "CF Worker 代理"),
|
||||
true,
|
||||
None::<&str>,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let network_refs: Vec<&dyn IsMenuItem<tauri::Wry>> =
|
||||
vec![&api_direct as &dyn IsMenuItem<tauri::Wry>, &api_proxy];
|
||||
let network_submenu =
|
||||
Submenu::with_items(app, "行情源", true, &network_refs).map_err(|e| e.to_string())?;
|
||||
|
||||
let coin_checks: Vec<CheckMenuItem<tauri::Wry>> = ALL_COINS
|
||||
.iter()
|
||||
.map(|c| make_coin_check(app, c.symbol, c.label, settings.enabled.contains(c.symbol)))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
let coin_item_refs: Vec<&dyn IsMenuItem<tauri::Wry>> = coin_checks
|
||||
.iter()
|
||||
.map(|item| item as &dyn IsMenuItem<tauri::Wry>)
|
||||
.collect();
|
||||
|
||||
let coins_submenu =
|
||||
Submenu::with_items(app, "显示币种", true, &coin_item_refs).map_err(|e| e.to_string())?;
|
||||
|
||||
let current_interval = settings.refresh_interval_ms;
|
||||
let interval_items: Vec<MenuItem<tauri::Wry>> = REFRESH_INTERVALS
|
||||
.iter()
|
||||
.map(|(ms, label)| {
|
||||
let stored = settings::normalize_refresh_interval_ms(*ms);
|
||||
MenuItem::with_id(
|
||||
app,
|
||||
interval_menu_id(*ms),
|
||||
&radio_menu_label(stored == current_interval, label),
|
||||
true,
|
||||
None::<&str>,
|
||||
)
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
let interval_item_refs: Vec<&dyn IsMenuItem<tauri::Wry>> = interval_items
|
||||
.iter()
|
||||
.map(|item| item as &dyn IsMenuItem<tauri::Wry>)
|
||||
.collect();
|
||||
|
||||
let interval_submenu = Submenu::with_items(app, "刷新间隔", true, &interval_item_refs)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let current_theme = settings.background_theme.as_str();
|
||||
let theme_items: Vec<MenuItem<tauri::Wry>> = BACKGROUND_THEMES
|
||||
.iter()
|
||||
.map(|(id, label)| {
|
||||
MenuItem::with_id(
|
||||
app,
|
||||
theme_menu_id(id),
|
||||
&radio_menu_label(*id == current_theme, label),
|
||||
true,
|
||||
None::<&str>,
|
||||
)
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
let theme_item_refs: Vec<&dyn IsMenuItem<tauri::Wry>> = theme_items
|
||||
.iter()
|
||||
.map(|item| item as &dyn IsMenuItem<tauri::Wry>)
|
||||
.collect();
|
||||
|
||||
let theme_submenu =
|
||||
Submenu::with_items(app, "背景颜色", true, &theme_item_refs).map_err(|e| e.to_string())?;
|
||||
|
||||
let open_settings = MenuItem::with_id(app, "open_settings", "打开设置目录", true, None::<&str>)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Menu::with_items(
|
||||
app,
|
||||
&[
|
||||
&show,
|
||||
&hide,
|
||||
&sep1,
|
||||
&network_submenu,
|
||||
&coins_submenu,
|
||||
&interval_submenu,
|
||||
&theme_submenu,
|
||||
&open_settings,
|
||||
&sep2,
|
||||
&sep3,
|
||||
&quit,
|
||||
],
|
||||
)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn rebuild_tray_menu(app: &AppHandle, state: &AppState) -> Result<(), String> {
|
||||
let menu = create_tray_menu(app, state)?;
|
||||
let tray = app.tray_by_id("main-tray").ok_or("tray not found")?;
|
||||
tray.set_menu(Some(menu)).map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn setup_tray(app: &App, state: &AppState) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let icon = app
|
||||
.default_window_icon()
|
||||
.ok_or("application icon not configured")?
|
||||
.clone();
|
||||
|
||||
let menu = create_tray_menu(app.handle(), state)?;
|
||||
|
||||
let _tray = TrayIconBuilder::with_id("main-tray")
|
||||
.icon(icon)
|
||||
.menu(&menu)
|
||||
.tooltip("CryptoCoin")
|
||||
.on_menu_event(|app, event| {
|
||||
let Some(window) = app.get_webview_window("main") else {
|
||||
return;
|
||||
};
|
||||
|
||||
let state = app.state::<AppState>();
|
||||
|
||||
if let Some(symbol) = parse_coin_menu_id(event.id.as_ref()) {
|
||||
let currently = state.is_enabled(symbol);
|
||||
if let Err(err) = state.set_enabled(symbol, !currently) {
|
||||
eprintln!("toggle coin failed: {err}");
|
||||
} else {
|
||||
let coins = get_coin_list(&state);
|
||||
let _ = app.emit("coins-changed", &coins);
|
||||
}
|
||||
let _ = rebuild_tray_menu(app, &state);
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(mode) = parse_api_menu_id(event.id.as_ref()) {
|
||||
let direct = mode == ApiMode::Direct;
|
||||
if let Err(err) = state.apply_api_menu_choice(direct) {
|
||||
eprintln!("api source switch failed: {err}");
|
||||
let _ = app.emit("fetch-error", err);
|
||||
} else {
|
||||
let _ = app.emit("api-config-changed", state.api_config());
|
||||
}
|
||||
let _ = rebuild_tray_menu(app, &state);
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(ms) = parse_interval_menu_id(event.id.as_ref()) {
|
||||
if let Err(err) = state.set_refresh_interval(ms) {
|
||||
eprintln!("refresh interval switch failed: {err}");
|
||||
} else {
|
||||
let ms = state.refresh_interval_ms();
|
||||
let _ = app.emit("refresh-interval-changed", ms);
|
||||
}
|
||||
let _ = rebuild_tray_menu(app, &state);
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(theme) = parse_theme_menu_id(event.id.as_ref()) {
|
||||
if let Err(err) = state.set_background_theme(&theme) {
|
||||
eprintln!("background theme switch failed: {err}");
|
||||
} else {
|
||||
let _ = app.emit("background-theme-changed", theme);
|
||||
}
|
||||
let _ = rebuild_tray_menu(app, &state);
|
||||
return;
|
||||
}
|
||||
|
||||
match event.id.as_ref() {
|
||||
"open_settings" => {
|
||||
let dir = state.settings_dir();
|
||||
#[cfg(target_os = "windows")]
|
||||
if let Err(err) = std::process::Command::new("explorer")
|
||||
.arg(dir.as_os_str())
|
||||
.spawn()
|
||||
{
|
||||
eprintln!("open settings dir failed: {err}");
|
||||
}
|
||||
}
|
||||
"show" => {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
"hide" => {
|
||||
let _ = window.hide();
|
||||
}
|
||||
"quit" => {
|
||||
app.exit(0);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
})
|
||||
.build(app)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn place_bottom_right(app: &App) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let window = app
|
||||
.get_webview_window("main")
|
||||
.ok_or("main window not found")?;
|
||||
|
||||
let monitor = window.current_monitor()?.ok_or("no monitor found")?;
|
||||
|
||||
let work = monitor.work_area();
|
||||
let size = window.outer_size()?;
|
||||
let margin_x = 16;
|
||||
let margin_y = 48;
|
||||
|
||||
let x = work.position.x + work.size.width as i32 - size.width as i32 - margin_x;
|
||||
let y = work.position.y + work.size.height as i32 - size.height as i32 - margin_y;
|
||||
|
||||
window.set_position(PhysicalPosition::new(x, y))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.setup(|app| {
|
||||
let state = AppState::new(app.handle())?;
|
||||
app.manage(state);
|
||||
let state = app.state::<AppState>();
|
||||
setup_tray(app, &state)?;
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
window.set_title("CryptoCoin")?;
|
||||
}
|
||||
place_bottom_right(app)?;
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
fetch_tickers,
|
||||
list_coins,
|
||||
set_coin_enabled,
|
||||
get_api_config,
|
||||
get_refresh_interval,
|
||||
get_background_theme,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
6
src-tauri/src/main.rs
Normal 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() {
|
||||
cryptocoin_lib::run()
|
||||
}
|
||||
417
src-tauri/src/settings.rs
Normal file
@@ -0,0 +1,417 @@
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
fs,
|
||||
path::PathBuf,
|
||||
sync::Mutex,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use reqwest::Client;
|
||||
use serde::Deserialize;
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
use crate::coins::{sort_index, ApiConfigDto, SettingsFile, ALL_COINS};
|
||||
|
||||
const BINANCE_DIRECT_ORIGIN: &str = "https://api.binance.com";
|
||||
const MIN_REFRESH_INTERVAL_MS: u64 = 3000;
|
||||
|
||||
/// (毫秒, 托盘显示文案)
|
||||
pub const REFRESH_INTERVALS: &[(u64, &str)] = &[
|
||||
(3_000, "3 秒"),
|
||||
(5_000, "5 秒"),
|
||||
(10_000, "10 秒"),
|
||||
(30_000, "30 秒"),
|
||||
(60_000, "1 分钟"),
|
||||
(180_000, "3 分钟"),
|
||||
(300_000, "5 分钟"),
|
||||
(600_000, "10 分钟"),
|
||||
];
|
||||
|
||||
pub fn interval_menu_id(ms: u64) -> String {
|
||||
format!("interval:{ms}")
|
||||
}
|
||||
|
||||
pub fn parse_interval_menu_id(id: &str) -> Option<u64> {
|
||||
let ms: u64 = id.strip_prefix("interval:")?.parse().ok()?;
|
||||
REFRESH_INTERVALS
|
||||
.iter()
|
||||
.any(|(v, _)| *v == ms)
|
||||
.then_some(ms)
|
||||
}
|
||||
|
||||
pub fn normalize_refresh_interval_ms(ms: u64) -> u64 {
|
||||
if ms < MIN_REFRESH_INTERVAL_MS {
|
||||
return 3000;
|
||||
}
|
||||
REFRESH_INTERVALS
|
||||
.iter()
|
||||
.find(|(v, _)| *v == ms)
|
||||
.map(|(v, _)| *v)
|
||||
.unwrap_or(5000)
|
||||
}
|
||||
|
||||
/// (主题 id, 托盘显示文案)
|
||||
pub const BACKGROUND_THEMES: &[(&str, &str)] = &[
|
||||
("dark", "深色"),
|
||||
("slate", "石板灰"),
|
||||
("navy", "深蓝"),
|
||||
("charcoal", "炭黑"),
|
||||
("midnight", "午夜紫"),
|
||||
("forest", "墨绿"),
|
||||
("light", "浅色"),
|
||||
];
|
||||
|
||||
pub fn theme_menu_id(id: &str) -> String {
|
||||
format!("theme:{id}")
|
||||
}
|
||||
|
||||
pub fn parse_theme_menu_id(menu_id: &str) -> Option<String> {
|
||||
let id = menu_id.strip_prefix("theme:")?;
|
||||
if BACKGROUND_THEMES.iter().any(|(tid, _)| *tid == id) {
|
||||
Some(id.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normalize_background_theme(id: &str) -> String {
|
||||
let id = id.trim();
|
||||
if BACKGROUND_THEMES.iter().any(|(tid, _)| *tid == id) {
|
||||
id.to_string()
|
||||
} else {
|
||||
"dark".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn radio_menu_label(selected: bool, label: &str) -> String {
|
||||
if selected {
|
||||
format!("✓ {label}")
|
||||
} else {
|
||||
format!(" {label}")
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AppState {
|
||||
pub settings: Mutex<RuntimeSettings>,
|
||||
settings_path: PathBuf,
|
||||
pub http: Client,
|
||||
pub last_fetch_error: Mutex<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RuntimeSettings {
|
||||
pub enabled: HashSet<String>,
|
||||
pub proxy_base_url: String,
|
||||
pub use_proxy: bool,
|
||||
pub refresh_interval_ms: u64,
|
||||
pub background_theme: String,
|
||||
}
|
||||
|
||||
impl RuntimeSettings {
|
||||
pub fn uses_proxy(&self) -> bool {
|
||||
self.use_proxy && !self.proxy_base_url.trim().is_empty()
|
||||
}
|
||||
|
||||
pub fn api_mode_label(&self) -> &'static str {
|
||||
if self.uses_proxy() {
|
||||
"proxy"
|
||||
} else {
|
||||
"direct"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new(app: &AppHandle) -> Result<Self, String> {
|
||||
let (dir, settings_path, legacy_path) = resolve_settings_paths(app)?;
|
||||
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
migrate_from_app_data_if_needed(app, &settings_path)?;
|
||||
|
||||
let http = Client::builder()
|
||||
.timeout(Duration::from_secs(8))
|
||||
.pool_max_idle_per_host(1)
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let file = load_settings_file(&settings_path, &legacy_path)?;
|
||||
let settings = RuntimeSettings::from_file(file);
|
||||
|
||||
save_settings(&settings_path, &settings)?;
|
||||
eprintln!(
|
||||
"CryptoCoin settings: {} (proxy: {}, interval: {}ms)",
|
||||
settings_path.display(),
|
||||
settings.api_mode_label(),
|
||||
settings.refresh_interval_ms
|
||||
);
|
||||
|
||||
Ok(Self {
|
||||
settings: Mutex::new(settings),
|
||||
settings_path,
|
||||
http,
|
||||
last_fetch_error: Mutex::new(String::new()),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn settings_file_path(&self) -> PathBuf {
|
||||
self.settings_path.clone()
|
||||
}
|
||||
|
||||
pub fn settings_dir(&self) -> PathBuf {
|
||||
self.settings_path
|
||||
.parent()
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
}
|
||||
|
||||
pub fn api_config(&self) -> ApiConfigDto {
|
||||
let s = self.settings.lock().unwrap();
|
||||
ApiConfigDto {
|
||||
mode: s.api_mode_label().to_string(),
|
||||
proxy_base_url: s.proxy_base_url.clone(),
|
||||
use_proxy: s.use_proxy,
|
||||
settings_path: self.settings_path.display().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_last_fetch_error(&self, message: &str) {
|
||||
*self.last_fetch_error.lock().unwrap() = message.to_string();
|
||||
}
|
||||
|
||||
pub fn clear_last_fetch_error(&self) {
|
||||
self.last_fetch_error.lock().unwrap().clear();
|
||||
}
|
||||
|
||||
/// 直连:仅关闭 useProxy,保留 proxyBaseUrl;代理:需已配置 proxyBaseUrl。
|
||||
pub fn apply_api_menu_choice(&self, direct: bool) -> Result<(), String> {
|
||||
let mut s = self.settings.lock().unwrap();
|
||||
if direct {
|
||||
s.use_proxy = false;
|
||||
} else if s.proxy_base_url.trim().is_empty() {
|
||||
return Err(format!(
|
||||
"请先在设置文件中填写 proxyBaseUrl 以使用代理\n路径: {}",
|
||||
self.settings_path.display()
|
||||
));
|
||||
} else {
|
||||
s.use_proxy = true;
|
||||
}
|
||||
save_settings(&self.settings_path, &s)
|
||||
}
|
||||
|
||||
pub fn build_ticker_url(&self, symbols: &[String]) -> Result<String, String> {
|
||||
let s = self.settings.lock().unwrap();
|
||||
let json = serde_json::to_string(symbols).map_err(|e| e.to_string())?;
|
||||
let query = urlencoding::encode(&json);
|
||||
let path = format!("/api/v3/ticker/24hr?symbols={query}");
|
||||
|
||||
let base = if s.uses_proxy() {
|
||||
s.proxy_base_url.trim().trim_end_matches('/').to_string()
|
||||
} else {
|
||||
BINANCE_DIRECT_ORIGIN.to_string()
|
||||
};
|
||||
|
||||
Ok(format!("{base}{path}"))
|
||||
}
|
||||
|
||||
pub fn build_single_ticker_url(&self, symbol: &str) -> Result<String, String> {
|
||||
let s = self.settings.lock().unwrap();
|
||||
let base = if s.uses_proxy() {
|
||||
s.proxy_base_url.trim().trim_end_matches('/').to_string()
|
||||
} else {
|
||||
BINANCE_DIRECT_ORIGIN.to_string()
|
||||
};
|
||||
Ok(format!("{base}/api/v3/ticker/24hr?symbol={symbol}"))
|
||||
}
|
||||
|
||||
pub fn build_futures_ticker_url(&self, symbols: &[String]) -> Result<String, String> {
|
||||
let json = serde_json::to_string(symbols).map_err(|e| e.to_string())?;
|
||||
let query = urlencoding::encode(&json);
|
||||
Ok(format!("https://fapi.binance.com/fapi/v1/ticker/24hr?symbols={query}"))
|
||||
}
|
||||
|
||||
pub fn build_futures_single_url(&self, symbol: &str) -> String {
|
||||
format!("https://fapi.binance.com/fapi/v1/ticker/24hr?symbol={symbol}")
|
||||
}
|
||||
|
||||
pub fn enabled_api_symbols(&self) -> Vec<String> {
|
||||
let s = self.settings.lock().unwrap();
|
||||
let mut symbols: Vec<String> = ALL_COINS
|
||||
.iter()
|
||||
.filter(|c| s.enabled.contains(c.symbol))
|
||||
.map(|c| c.symbol.to_string())
|
||||
.collect();
|
||||
symbols.sort();
|
||||
symbols.dedup();
|
||||
symbols
|
||||
}
|
||||
|
||||
pub fn is_enabled(&self, symbol: &str) -> bool {
|
||||
self.settings.lock().unwrap().enabled.contains(symbol)
|
||||
}
|
||||
|
||||
pub fn refresh_interval_ms(&self) -> u64 {
|
||||
self.settings.lock().unwrap().refresh_interval_ms
|
||||
}
|
||||
|
||||
pub fn background_theme(&self) -> String {
|
||||
self.settings.lock().unwrap().background_theme.clone()
|
||||
}
|
||||
|
||||
pub fn set_background_theme(&self, theme: &str) -> Result<(), String> {
|
||||
let theme = normalize_background_theme(theme);
|
||||
let mut s = self.settings.lock().unwrap();
|
||||
s.background_theme = theme;
|
||||
save_settings(&self.settings_path, &s)
|
||||
}
|
||||
|
||||
pub fn set_refresh_interval(&self, ms: u64) -> Result<(), String> {
|
||||
let ms = normalize_refresh_interval_ms(ms);
|
||||
let mut s = self.settings.lock().unwrap();
|
||||
s.refresh_interval_ms = ms;
|
||||
save_settings(&self.settings_path, &s)
|
||||
}
|
||||
|
||||
pub fn set_enabled(&self, symbol: &str, enabled: bool) -> Result<(), String> {
|
||||
if ALL_COINS.iter().all(|c| c.symbol != symbol) {
|
||||
return Err(format!("unknown symbol: {symbol}"));
|
||||
}
|
||||
|
||||
let mut s = self.settings.lock().unwrap();
|
||||
if enabled {
|
||||
s.enabled.insert(symbol.to_string());
|
||||
} else {
|
||||
if s.enabled.len() <= 1 {
|
||||
return Err("至少保留一个币种".into());
|
||||
}
|
||||
s.enabled.remove(symbol);
|
||||
}
|
||||
|
||||
s.enabled = normalize_enabled(s.enabled.iter().cloned().collect());
|
||||
save_settings(&self.settings_path, &s)
|
||||
}
|
||||
}
|
||||
|
||||
impl RuntimeSettings {
|
||||
fn from_file(file: SettingsFile) -> Self {
|
||||
let proxy_base_url = file.proxy_base_url.trim().to_string();
|
||||
let use_proxy = if file.use_proxy {
|
||||
true
|
||||
} else {
|
||||
!proxy_base_url.is_empty()
|
||||
};
|
||||
|
||||
Self {
|
||||
enabled: normalize_enabled(file.enabled),
|
||||
proxy_base_url,
|
||||
use_proxy,
|
||||
refresh_interval_ms: normalize_refresh_interval_ms(file.refresh_interval_ms),
|
||||
background_theme: normalize_background_theme(&file.background_theme),
|
||||
}
|
||||
}
|
||||
|
||||
fn to_file(&self) -> SettingsFile {
|
||||
let mut list: Vec<String> = self.enabled.iter().cloned().collect();
|
||||
list.sort_by_key(|s| sort_index(s));
|
||||
SettingsFile {
|
||||
enabled: list,
|
||||
proxy_base_url: self.proxy_base_url.clone(),
|
||||
use_proxy: self.use_proxy,
|
||||
refresh_interval_ms: self.refresh_interval_ms,
|
||||
background_theme: self.background_theme.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 配置目录:与 CryptoCoin.exe 同级的 `data` 文件夹(便携安装)。
|
||||
fn resolve_settings_paths(
|
||||
app: &AppHandle,
|
||||
) -> Result<(PathBuf, PathBuf, PathBuf), String> {
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if let Some(exe_dir) = exe.parent() {
|
||||
let dir = exe_dir.join("data");
|
||||
let settings_path = dir.join("settings.json");
|
||||
let legacy_path = dir.join("enabled_coins.json");
|
||||
return Ok((dir, settings_path, legacy_path));
|
||||
}
|
||||
}
|
||||
|
||||
let dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
||||
Ok((
|
||||
dir.clone(),
|
||||
dir.join("settings.json"),
|
||||
dir.join("enabled_coins.json"),
|
||||
))
|
||||
}
|
||||
|
||||
/// 首次使用 `data` 目录时,从旧版 %APPDATA% 迁移 settings.json。
|
||||
fn migrate_from_app_data_if_needed(app: &AppHandle, settings_path: &PathBuf) -> Result<(), String> {
|
||||
if settings_path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let appdata = match app.path().app_data_dir() {
|
||||
Ok(p) => p,
|
||||
Err(_) => return Ok(()),
|
||||
};
|
||||
let old_settings = appdata.join("settings.json");
|
||||
if !old_settings.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(parent) = settings_path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||
}
|
||||
fs::copy(&old_settings, settings_path).map_err(|e| e.to_string())?;
|
||||
eprintln!(
|
||||
"migrated settings from {} to {}",
|
||||
old_settings.display(),
|
||||
settings_path.display()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_settings_file(settings_path: &PathBuf, legacy_path: &PathBuf) -> Result<SettingsFile, String> {
|
||||
if settings_path.exists() {
|
||||
let raw = fs::read_to_string(settings_path).map_err(|e| e.to_string())?;
|
||||
return serde_json::from_str(&raw).map_err(|e| e.to_string());
|
||||
}
|
||||
|
||||
if legacy_path.exists() {
|
||||
let raw = fs::read_to_string(legacy_path).map_err(|e| e.to_string())?;
|
||||
let legacy: LegacySettingsFile = serde_json::from_str(&raw).map_err(|e| e.to_string())?;
|
||||
return Ok(SettingsFile {
|
||||
enabled: legacy.enabled,
|
||||
proxy_base_url: String::new(),
|
||||
use_proxy: false,
|
||||
refresh_interval_ms: 5000,
|
||||
background_theme: "dark".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(SettingsFile::default())
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LegacySettingsFile {
|
||||
pub enabled: Vec<String>,
|
||||
}
|
||||
|
||||
fn normalize_enabled(enabled: Vec<String>) -> HashSet<String> {
|
||||
let valid: HashSet<String> = ALL_COINS
|
||||
.iter()
|
||||
.map(|c| c.symbol.to_string())
|
||||
.collect();
|
||||
|
||||
let list: Vec<String> = enabled.into_iter().filter(|s| valid.contains(s)).collect();
|
||||
let mut set: HashSet<String> = list.into_iter().collect();
|
||||
|
||||
if set.is_empty() {
|
||||
set.insert("BTCUSDT".to_string());
|
||||
}
|
||||
|
||||
set
|
||||
}
|
||||
|
||||
fn save_settings(path: &PathBuf, settings: &RuntimeSettings) -> Result<(), String> {
|
||||
let json = serde_json::to_string_pretty(&settings.to_file()).map_err(|e| e.to_string())?;
|
||||
fs::write(path, json).map_err(|e| e.to_string())
|
||||
}
|
||||
50
src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "CryptoCoin",
|
||||
"mainBinaryName": "CryptoCoin",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.smy.cryptocoin",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"withGlobalTauri": true,
|
||||
"windows": [
|
||||
{
|
||||
"title": "CryptoCoin",
|
||||
"width": 188,
|
||||
"height": 52,
|
||||
"minWidth": 180,
|
||||
"maxWidth": 260,
|
||||
"minHeight": 40,
|
||||
"maxHeight": 240,
|
||||
"resizable": false,
|
||||
"decorations": false,
|
||||
"transparent": true,
|
||||
"alwaysOnTop": true,
|
||||
"skipTaskbar": true,
|
||||
"visible": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"shortDescription": "CryptoCoin BTC/ETH desktop widget",
|
||||
"longDescription": "CryptoCoin - Binance BTC/ETH price desktop widget",
|
||||
"copyright": "Copyright © 2025",
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||