init: CryptoCoin desktop ticker

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-23 21:57:39 +08:00
commit 3b979089d9
69 changed files with 8974 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

4883
src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

25
src-tauri/Cargo.toml Normal file
View 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
View File

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

View 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

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

View File

@@ -0,0 +1,4 @@
[[permission]]
identifier = "allow-api-settings"
description = "Enables API config commands"
commands.allow = ["get_api_config"]

View 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"]

View 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
View 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
View 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
View 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
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() {
cryptocoin_lib::run()
}

417
src-tauri/src/settings.rs Normal file
View 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
View 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"
]
}
}