init: CryptoCoin desktop ticker
Co-authored-by: Cursor <cursoragent@cursor.com>
31
.gitignore
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Tauri / Rust
|
||||
src-tauri/target/
|
||||
src-tauri/gen/schemas/
|
||||
|
||||
# Local / editor
|
||||
.claude/
|
||||
3
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"]
|
||||
}
|
||||
56
README.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# CryptoCoin
|
||||
|
||||
Windows 桌面迷你行情挂件(Tauri 2 + 币安 24hr API)。
|
||||
|
||||
## 设置文件(重要)
|
||||
|
||||
配置保存在 **与 `CryptoCoin.exe` 同级** 的 `data` 文件夹:
|
||||
|
||||
```
|
||||
D:\WindowsApp\CryptoCoin\data\settings.json
|
||||
```
|
||||
|
||||
(便携安装;开发时则在 `target\debug\data\` 等目录。)
|
||||
|
||||
首次从旧版 `%APPDATA%\com.smy.cryptocoin\` 升级时会自动迁移 `settings.json` 到 `data`。
|
||||
|
||||
可将项目内 `src-tauri/settings.json` 复制为 `data\settings.json` 模板。托盘 **打开设置目录** 会打开 `data` 文件夹。
|
||||
|
||||
示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"enabled": ["BTCUSDT", "ETHUSDT"],
|
||||
"proxyBaseUrl": "https://bn-api-proxy.smyhub.com",
|
||||
"useProxy": true,
|
||||
"refreshIntervalMs": 5000,
|
||||
"backgroundTheme": "dark"
|
||||
}
|
||||
```
|
||||
|
||||
`backgroundTheme` 可选:`dark`、`slate`、`navy`、`charcoal`、`midnight`、`forest`、`light`;也可在托盘 **背景颜色** 中切换。
|
||||
|
||||
- `useProxy: true` 且 `proxyBaseUrl` 非空 → 走代理
|
||||
- `useProxy: false` → 直连(`proxyBaseUrl` 仍保留,便于下次切回代理)
|
||||
- `refreshIntervalMs` 最小 **3000**(托盘从 3 秒起,避免 API 限流)
|
||||
|
||||
## 开发
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run tauri dev
|
||||
```
|
||||
|
||||
## 发布安装包
|
||||
|
||||
修改代码后需重新打包,旧 exe 不会包含新逻辑:
|
||||
|
||||
```bash
|
||||
npm run tauri build
|
||||
```
|
||||
|
||||
安装包:`src-tauri/target/release/bundle/nsis/CryptoCoin_*_x64-setup.exe`
|
||||
|
||||
## 代理 Worker
|
||||
|
||||
见 [cryptocoin-binance-proxy/README.md](cryptocoin-binance-proxy/README.md)。
|
||||
158
cryptocoin-binance-proxy/README.md
Normal file
@@ -0,0 +1,158 @@
|
||||
# CryptoCoin 币安 API 代理(Cloudflare Worker)
|
||||
|
||||
|
||||
|
||||
当本机无法直连 `api.binance.com` 时,可通过此 Worker 转发行情请求。
|
||||
|
||||
|
||||
|
||||
## 部署
|
||||
|
||||
|
||||
|
||||
1. 安装 [Node.js](https://nodejs.org/) 与 Cloudflare 账号
|
||||
|
||||
2. 在本目录执行:
|
||||
|
||||
|
||||
|
||||
```bash
|
||||
|
||||
npm install
|
||||
|
||||
npx wrangler login
|
||||
|
||||
npm run deploy
|
||||
|
||||
```
|
||||
|
||||
|
||||
|
||||
3. 记下部署后的地址,例如:
|
||||
|
||||
|
||||
|
||||
```
|
||||
|
||||
https://cryptocoin-binance-proxy.你的用户名.workers.dev
|
||||
|
||||
```
|
||||
|
||||
|
||||
|
||||
4. 在 CryptoCoin 中配置(二选一):
|
||||
|
||||
|
||||
|
||||
**方式 A:托盘菜单**
|
||||
|
||||
|
||||
|
||||
- 托盘右键 → **行情源** → 带 **✓** 的项为当前模式
|
||||
|
||||
- **CF Worker 代理** 需在设置文件中已填写 `proxyBaseUrl`
|
||||
|
||||
- 选 **直连** 不会删除已保存的代理地址
|
||||
|
||||
|
||||
|
||||
**方式 B:编辑设置文件**
|
||||
|
||||
|
||||
|
||||
路径(与 **CryptoCoin.exe** 同级的 `data` 目录):
|
||||
|
||||
|
||||
|
||||
```
|
||||
|
||||
D:\WindowsApp\CryptoCoin\data\settings.json
|
||||
|
||||
```
|
||||
|
||||
|
||||
|
||||
代理模式:
|
||||
|
||||
|
||||
|
||||
```json
|
||||
|
||||
{
|
||||
|
||||
"enabled": ["BTCUSDT", "ETHUSDT"],
|
||||
|
||||
"proxyBaseUrl": "https://cryptocoin-binance-proxy.你的用户名.workers.dev",
|
||||
|
||||
"useProxy": true,
|
||||
|
||||
"refreshIntervalMs": 5000
|
||||
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
|
||||
|
||||
直连模式:
|
||||
|
||||
|
||||
|
||||
```json
|
||||
|
||||
{
|
||||
|
||||
"enabled": ["BTCUSDT", "ETHUSDT"],
|
||||
|
||||
"proxyBaseUrl": "https://cryptocoin-binance-proxy.你的用户名.workers.dev",
|
||||
|
||||
"useProxy": false,
|
||||
|
||||
"refreshIntervalMs": 5000
|
||||
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
|
||||
|
||||
`refreshIntervalMs` 可选值(毫秒):3000、5000、10000、30000、60000、180000、300000、600000;也可在托盘 **刷新间隔** 中切换。
|
||||
|
||||
|
||||
|
||||
改完后重启应用,或在托盘切换一次「直连 / 代理」。
|
||||
|
||||
|
||||
|
||||
**Release 用户**:修改代码后需执行 `npm run tauri build` 并重新安装,否则仍为旧版行为。
|
||||
|
||||
|
||||
|
||||
## 接口说明
|
||||
|
||||
|
||||
|
||||
- `GET /health` — 健康检查
|
||||
|
||||
- `GET /api/v3/ticker/24hr?symbols=...` — 转发至币安同名接口
|
||||
|
||||
|
||||
|
||||
仅允许 `/api/` 路径,其它路径返回 404。
|
||||
|
||||
|
||||
|
||||
## 本地调试
|
||||
|
||||
|
||||
|
||||
```bash
|
||||
|
||||
npm run dev
|
||||
|
||||
```
|
||||
|
||||
|
||||
|
||||
默认 `http://127.0.0.1:8787`,可浏览器访问 `/health` 验证。
|
||||
|
||||
12
cryptocoin-binance-proxy/package.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "cryptocoin-binance-proxy",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"deploy": "wrangler deploy",
|
||||
"dev": "wrangler dev"
|
||||
},
|
||||
"devDependencies": {
|
||||
"wrangler": "^4.0.0"
|
||||
}
|
||||
}
|
||||
69
cryptocoin-binance-proxy/src/index.js
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* CryptoCoin Binance API 反向代理
|
||||
* 将 /api/v3/... 转发到 https://api.binance.com/api/v3/...
|
||||
*/
|
||||
const BINANCE_ORIGIN = "https://api.binance.com";
|
||||
const ALLOWED_PREFIX = "/api/";
|
||||
|
||||
export default {
|
||||
async fetch(request) {
|
||||
if (request.method === "OPTIONS") {
|
||||
return cors(new Response(null, { status: 204 }));
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (url.pathname === "/" || url.pathname === "/health") {
|
||||
return cors(
|
||||
new Response(JSON.stringify({ ok: true, service: "cryptocoin-binance-proxy" }), {
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (!url.pathname.startsWith(ALLOWED_PREFIX)) {
|
||||
return cors(new Response("Not Found", { status: 404 }));
|
||||
}
|
||||
|
||||
const target = `${BINANCE_ORIGIN}${url.pathname}${url.search}`;
|
||||
|
||||
try {
|
||||
const upstream = await fetch(target, {
|
||||
method: request.method,
|
||||
headers: {
|
||||
"User-Agent": "CryptoCoin-Widget/1.0",
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
const body = await upstream.arrayBuffer();
|
||||
const headers = new Headers(upstream.headers);
|
||||
headers.set("access-control-allow-origin", "*");
|
||||
|
||||
return new Response(body, {
|
||||
status: upstream.status,
|
||||
statusText: upstream.statusText,
|
||||
headers,
|
||||
});
|
||||
} catch (err) {
|
||||
return cors(
|
||||
new Response(JSON.stringify({ error: String(err) }), {
|
||||
status: 502,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
function cors(response) {
|
||||
const headers = new Headers(response.headers);
|
||||
headers.set("access-control-allow-origin", "*");
|
||||
headers.set("access-control-allow-methods", "GET, OPTIONS");
|
||||
headers.set("access-control-allow-headers", "Content-Type");
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
6
cryptocoin-binance-proxy/wrangler.toml
Normal file
@@ -0,0 +1,6 @@
|
||||
name = "cryptocoin-binance-proxy"
|
||||
main = "src/index.js"
|
||||
compatibility_date = "2024-08-01"
|
||||
|
||||
# 部署后得到地址类似: https://cryptocoin-binance-proxy.<你的子域>.workers.dev
|
||||
# 填入应用设置 proxyBaseUrl(不要末尾斜杠)
|
||||
16
index.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="stylesheet" href="/src/styles.css" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>CryptoCoin</title>
|
||||
<script type="module" src="/src/main.ts" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="panel" class="panel" data-tauri-drag-region>
|
||||
<div id="widget" class="widget"></div>
|
||||
<div id="fetch-status" class="fetch-status" aria-live="polite"></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
1414
package-lock.json
generated
Normal file
21
package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "cryptocoin",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||
"@tauri-apps/api": "^2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "^6.0.3"
|
||||
}
|
||||
}
|
||||
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"
|
||||
]
|
||||
}
|
||||
}
|
||||
5
src/assets/icons/aapl.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 13 13">
|
||||
<!-- Apple silhouette -->
|
||||
<path d="M6.5 2.5 C6.5 2.5 7.5 1 8.5 1.5" stroke="#d1d5db" stroke-width="1" fill="none" stroke-linecap="round"/>
|
||||
<path d="M9.5 4 C9.5 4 11.5 4.5 11.5 7 C11.5 10 9.5 12 7.5 12 C6.5 12 6 11.5 5.5 11.5 C5 11.5 4.5 12 3.5 12 C1.5 12 1.5 9 1.5 7 C1.5 4.5 3.5 3.5 5 3.5 C5.8 3.5 6.2 4 6.5 4 C6.8 4 7.2 3.5 8 3.5 C8.8 3.5 9.5 4 9.5 4Z" fill="#d1d5db"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 446 B |
4
src/assets/icons/bnb.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="12" fill="#f3ba2f"/>
|
||||
<path fill="#14161a" d="m12 4.8 2.2 2.2-2.2 2.2-2.2-2.2L12 4.8zm-4.4 4.4 2.2 2.2-2.2 2.2-2.2-2.2 2.2-2.2zm8.8 0 2.2 2.2-2.2 2.2-2.2-2.2 2.2-2.2zM12 13.6l2.2 2.2-2.2 2.2-2.2-2.2 2.2-2.2z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 309 B |
4
src/assets/icons/btc.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="12" fill="#f0b90b"/>
|
||||
<path fill="#14161a" d="M16.5 10.2c.2-1.5-1-2.3-2.6-2.8l.5-2-1.3-.3-.5 1.9c-.3-.1-.7-.1-1-.2l.5-2-1.3-.3-.5 1.9c-.3 0-.6-.1-.9-.1l-1.8-.5-.4 1.6s1 .3 1 .3c.6.1.7.5.6.8l-.7 2.6c0 .1.1.1.2.1h-.2l-.9 3.5c-.1.2-.2.4-.7.3 0 0-1-.3-1-.3l-.8 1.7 1.7.4c.3.1.7.2 1 .2l-.6 2.3 1.3.3.6-2.3c.4.1.8.2 1.1.3l-.6 2.3 1.3.3.7-2.6c1.7.3 3 .1 3.6-1.4.5-1.4 0-2.2-1.1-2.7.9-.2 1.5-.8 1.6-1.9zm-2.8 2.9c-.3 1.3-2.3.6-3 .5l.5-2.1c.7.2 2.7.5 2.5 1.6zm.3-3c-.3 1.2-2.1.6-2.7.4l.5-1.9c.6.2 2.4.4 2.2 1.5z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 604 B |
6
src/assets/icons/cost.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 13 13">
|
||||
<!-- Shopping cart -->
|
||||
<path d="M1 2 L2.5 2 L4.5 8.5 L10 8.5 L11.5 4.5 L3.5 4.5" stroke="#60a5fa" stroke-width="1.2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="5" cy="11" r="1.2" fill="#60a5fa"/>
|
||||
<circle cx="9" cy="11" r="1.2" fill="#60a5fa"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 350 B |
5
src/assets/icons/crcl.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 13 13">
|
||||
<!-- Circle ring (Circle company) -->
|
||||
<circle cx="6.5" cy="6.5" r="5" fill="none" stroke="#38bdf8" stroke-width="2.5"/>
|
||||
<circle cx="6.5" cy="6.5" r="1.5" fill="#38bdf8"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 245 B |
4
src/assets/icons/doge.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="12" fill="#c2a633"/>
|
||||
<path fill="#fff" d="M9.2 7.2h6.9c1.9 0 3.1 1 3.1 2.6 0 1.1-.6 2-1.6 2.4 1.2.4 2 1.4 2 2.8 0 1.9-1.4 3-3.8 3H7.4V7.2h1.8zm5.1 3.9c1.1 0 1.7-.4 1.7-1.1 0-.7-.6-1.1-1.7-1.1h-3.3v2.2h3.3zm.3 4.8c1.2 0 1.9-.5 1.9-1.3 0-.8-.7-1.3-1.9-1.3H9.2v2.6h5.4z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 369 B |
6
src/assets/icons/eth.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="12" fill="#627eea"/>
|
||||
<path fill="#fff" d="M12 4 6 12.2 12 15.8l6-3.6L12 4z"/>
|
||||
<path fill="#fff" opacity="0.6" d="M6 12.2 12 20v-4.2L6 12.2z"/>
|
||||
<path fill="#fff" opacity="0.85" d="M12 15.8 18 12.2 12 20V15.8z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 314 B |
5
src/assets/icons/jnj.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 13 13">
|
||||
<!-- Medical cross -->
|
||||
<rect x="5" y="1" width="3" height="11" rx="0.8" fill="#fca5a5"/>
|
||||
<rect x="1" y="5" width="11" height="3" rx="0.8" fill="#fca5a5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 229 B |
8
src/assets/icons/jpm.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 13 13">
|
||||
<!-- Bank columns -->
|
||||
<rect x="1" y="1.5" width="11" height="1.5" rx="0.3" fill="#93c5fd"/>
|
||||
<rect x="1" y="10" width="11" height="1.5" rx="0.3" fill="#93c5fd"/>
|
||||
<rect x="1.5" y="3" width="2" height="7" fill="#93c5fd"/>
|
||||
<rect x="5.5" y="3" width="2" height="7" fill="#93c5fd"/>
|
||||
<rect x="9.5" y="3" width="2" height="7" fill="#93c5fd"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 415 B |
5
src/assets/icons/lly.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 13 13">
|
||||
<!-- Pharmaceutical cross -->
|
||||
<rect x="5" y="1" width="3" height="11" rx="1" fill="#fb7185"/>
|
||||
<rect x="1" y="5" width="11" height="3" rx="1" fill="#fb7185"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 232 B |
7
src/assets/icons/msft.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 13 13">
|
||||
<!-- Windows 4-square logo -->
|
||||
<rect x="1" y="1" width="5" height="5" rx="0.5" fill="#7dd3fc"/>
|
||||
<rect x="7" y="1" width="5" height="5" rx="0.5" fill="#7dd3fc"/>
|
||||
<rect x="1" y="7" width="5" height="5" rx="0.5" fill="#7dd3fc"/>
|
||||
<rect x="7" y="7" width="5" height="5" rx="0.5" fill="#7dd3fc"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 369 B |
6
src/assets/icons/mu.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 13 13">
|
||||
<!-- Memory chip: 3 horizontal bars -->
|
||||
<rect x="1" y="1.5" width="11" height="2.5" rx="0.6" fill="#22d3ee"/>
|
||||
<rect x="1" y="5.25" width="11" height="2.5" rx="0.6" fill="#22d3ee"/>
|
||||
<rect x="1" y="9" width="11" height="2.5" rx="0.6" fill="#22d3ee"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 325 B |
18
src/assets/icons/nvda.svg
Normal file
@@ -0,0 +1,18 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 13 13">
|
||||
<!-- GPU chip body -->
|
||||
<rect x="3" y="3" width="7" height="7" rx="1" fill="#4ade80"/>
|
||||
<!-- pins left -->
|
||||
<rect x="0.5" y="4.8" width="2.5" height="1.1" fill="#4ade80"/>
|
||||
<rect x="0.5" y="7.1" width="2.5" height="1.1" fill="#4ade80"/>
|
||||
<!-- pins right -->
|
||||
<rect x="10" y="4.8" width="2.5" height="1.1" fill="#4ade80"/>
|
||||
<rect x="10" y="7.1" width="2.5" height="1.1" fill="#4ade80"/>
|
||||
<!-- pins top -->
|
||||
<rect x="4.8" y="0.5" width="1.1" height="2.5" fill="#4ade80"/>
|
||||
<rect x="7.1" y="0.5" width="1.1" height="2.5" fill="#4ade80"/>
|
||||
<!-- pins bottom -->
|
||||
<rect x="4.8" y="10" width="1.1" height="2.5" fill="#4ade80"/>
|
||||
<rect x="7.1" y="10" width="1.1" height="2.5" fill="#4ade80"/>
|
||||
<!-- inner core -->
|
||||
<rect x="5" y="5" width="3" height="3" rx="0.5" fill="#16a34a"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 857 B |
6
src/assets/icons/sndk.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 13 13">
|
||||
<!-- Flash storage chip -->
|
||||
<rect x="1.5" y="2" width="10" height="9" rx="1.5" fill="#fbbf24"/>
|
||||
<rect x="3.5" y="5" width="6" height="1.2" rx="0.4" fill="#b45309"/>
|
||||
<rect x="3.5" y="7.5" width="6" height="1.2" rx="0.4" fill="#b45309"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 312 B |
10
src/assets/icons/sol.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<defs>
|
||||
<linearGradient id="sol" x1="4" y1="20" x2="20" y2="4">
|
||||
<stop offset="0" stop-color="#9945ff"/>
|
||||
<stop offset="1" stop-color="#14f195"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="12" cy="12" r="12" fill="#14161a"/>
|
||||
<path fill="url(#sol)" d="M6.5 15.5h11l-1.8 1.8H4.7l1.8-1.8zm0-3.6h11l-1.8 1.8H4.7l1.8-1.8zm0-3.6h11L14.7 10H4.7l1.8-1.7z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 438 B |
9
src/assets/icons/spcx.svg
Normal file
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 13 13">
|
||||
<!-- Rocket (SpaceX) -->
|
||||
<path d="M6.5,1 C6.5,1 4,4 4,7.5 L5.5,7.5 L5.5,10 L7.5,10 L7.5,7.5 L9,7.5 C9,4 6.5,1 6.5,1 Z" fill="#818cf8"/>
|
||||
<!-- fins -->
|
||||
<polygon points="4,7.5 2.5,11 5.5,10" fill="#818cf8"/>
|
||||
<polygon points="9,7.5 10.5,11 7.5,10" fill="#818cf8"/>
|
||||
<!-- window -->
|
||||
<circle cx="6.5" cy="5.5" r="1.2" fill="#6366f1"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 410 B |
4
src/assets/icons/tsla.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 13 13">
|
||||
<!-- Lightning bolt -->
|
||||
<polygon points="8.5,0.5 3.5,7.5 6.5,7.5 4.5,12.5 9.5,5.5 6.5,5.5" fill="#f87171"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 180 B |
4
src/assets/icons/usdc.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="12" fill="#2775ca"/>
|
||||
<path fill="#fff" d="M13 6.5H9.8v2.4H7.5v2h2.3v.9c0 2.6-1.3 3.9-3.9 4.1v2.4c3.7-.2 5.6-2 5.8-5.8v-1.6H16V9h-3zm-3.2 6.2V9H13c-.1 2.2-.9 3.2-3.2 3.7z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 272 B |
4
src/assets/icons/usdt.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="12" fill="#26a17b"/>
|
||||
<path fill="#fff" d="M13.2 6.5h-2.4v2.2H8.2v2h2.6v1.1c0 2.5-1.2 3.7-3.6 3.9v2.3c3.4-.2 5.2-1.8 5.4-5.5V10.7h2.1V8.7h-2.1V6.5zm-2.4 5.8V8.7h2.4c-.1 2.1-.8 3.1-2.4 3.6z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 290 B |
5
src/assets/icons/usstk.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 13 13">
|
||||
<rect x="1" y="9" width="2.5" height="3.5" fill="currentColor"/>
|
||||
<rect x="5.25" y="6" width="2.5" height="6.5" fill="currentColor"/>
|
||||
<rect x="9.5" y="2.5" width="2.5" height="10" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 275 B |
4
src/assets/icons/xrp.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="12" fill="#23292f"/>
|
||||
<path fill="#00aae4" d="M4.2 6.5h3.1l4.7 5.2 4.7-5.2h3.1L12.2 13.1v4.4h-2.4v-4.4L4.2 6.5zm7.8 11h2.4V24h-2.4v-6.5z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 238 B |
6
src/assets/tauri.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
|
||||
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
25
src/assets/typescript.svg
Normal file
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||
width="512.000000pt" height="512.000000pt" viewBox="0 0 512.000000 512.000000"
|
||||
preserveAspectRatio="xMidYMid meet">
|
||||
|
||||
<g transform="translate(0.000000,512.000000) scale(0.100000,-0.100000)"
|
||||
fill="#2D79C7" stroke="none">
|
||||
<path d="M430 5109 c-130 -19 -248 -88 -325 -191 -53 -71 -83 -147 -96 -247
|
||||
-6 -49 -9 -813 -7 -2166 l3 -2090 22 -65 c54 -159 170 -273 328 -323 l70 -22
|
||||
2140 0 2140 0 66 23 c160 55 272 169 322 327 l22 70 0 2135 0 2135 -22 70
|
||||
c-49 157 -155 265 -319 327 l-59 23 -2115 1 c-1163 1 -2140 -2 -2170 -7z
|
||||
m3931 -2383 c48 -9 120 -26 160 -39 l74 -23 3 -237 c1 -130 0 -237 -2 -237 -3
|
||||
0 -26 14 -53 30 -61 38 -197 84 -310 106 -110 20 -293 15 -368 -12 -111 -39
|
||||
-175 -110 -175 -193 0 -110 97 -197 335 -300 140 -61 309 -146 375 -189 30
|
||||
-20 87 -68 126 -107 119 -117 164 -234 164 -426 0 -310 -145 -518 -430 -613
|
||||
-131 -43 -248 -59 -445 -60 -243 -1 -405 24 -577 90 l-68 26 0 242 c0 175 -3
|
||||
245 -12 254 -9 9 -9 12 0 12 7 0 12 -4 12 -9 0 -17 139 -102 223 -138 136 -57
|
||||
233 -77 382 -76 145 0 224 19 295 68 75 52 100 156 59 242 -41 84 -135 148
|
||||
-374 253 -367 161 -522 300 -581 520 -23 86 -23 253 -1 337 73 275 312 448
|
||||
682 492 109 13 401 6 506 -13z m-1391 -241 l0 -205 -320 0 -320 0 0 -915 0
|
||||
-915 -255 0 -255 0 0 915 0 915 -320 0 -320 0 0 205 0 205 895 0 895 0 0 -205z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
1
src/assets/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
323
src/main.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
import "@fontsource/jetbrains-mono/latin-500.css";
|
||||
import "@fontsource/jetbrains-mono/latin-600.css";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { LogicalSize } from "@tauri-apps/api/dpi";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
|
||||
import iconBtc from "./assets/icons/btc.svg";
|
||||
import iconBnb from "./assets/icons/bnb.svg";
|
||||
import iconDoge from "./assets/icons/doge.svg";
|
||||
import iconEth from "./assets/icons/eth.svg";
|
||||
import iconSol from "./assets/icons/sol.svg";
|
||||
import iconUsdc from "./assets/icons/usdc.svg";
|
||||
import iconUsdt from "./assets/icons/usdt.svg";
|
||||
import iconXrp from "./assets/icons/xrp.svg";
|
||||
import iconNvda from "./assets/icons/nvda.svg";
|
||||
import iconMu from "./assets/icons/mu.svg";
|
||||
import iconLly from "./assets/icons/lly.svg";
|
||||
import iconTsla from "./assets/icons/tsla.svg";
|
||||
import iconCrcl from "./assets/icons/crcl.svg";
|
||||
import iconSndk from "./assets/icons/sndk.svg";
|
||||
import iconSpcx from "./assets/icons/spcx.svg";
|
||||
import iconAapl from "./assets/icons/aapl.svg";
|
||||
import iconMsft from "./assets/icons/msft.svg";
|
||||
import iconJpm from "./assets/icons/jpm.svg";
|
||||
import iconCost from "./assets/icons/cost.svg";
|
||||
import iconJnj from "./assets/icons/jnj.svg";
|
||||
|
||||
interface CoinInfo {
|
||||
symbol: string;
|
||||
label: string;
|
||||
theme: string;
|
||||
enabled: boolean;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
interface TickerDto {
|
||||
symbol: string;
|
||||
label: string;
|
||||
lastPrice: number;
|
||||
changePercent: number;
|
||||
}
|
||||
|
||||
let pollMs = 5000;
|
||||
let statusEl: HTMLElement | null = null;
|
||||
|
||||
const BACKGROUND_THEMES = [
|
||||
"dark",
|
||||
"slate",
|
||||
"navy",
|
||||
"charcoal",
|
||||
"midnight",
|
||||
"forest",
|
||||
"light",
|
||||
] as const;
|
||||
|
||||
const COIN_ICONS: Record<string, string> = {
|
||||
btc: iconBtc,
|
||||
eth: iconEth,
|
||||
usdt: iconUsdt,
|
||||
usdc: iconUsdc,
|
||||
bnb: iconBnb,
|
||||
xrp: iconXrp,
|
||||
sol: iconSol,
|
||||
doge: iconDoge,
|
||||
nvda: iconNvda,
|
||||
mu: iconMu,
|
||||
lly: iconLly,
|
||||
tsla: iconTsla,
|
||||
crcl: iconCrcl,
|
||||
sndk: iconSndk,
|
||||
spcx: iconSpcx,
|
||||
aapl: iconAapl,
|
||||
msft: iconMsft,
|
||||
jpm: iconJpm,
|
||||
cost: iconCost,
|
||||
jnj: iconJnj,
|
||||
};
|
||||
|
||||
const panelEl = document.getElementById("panel") as HTMLDivElement;
|
||||
const widgetEl = document.getElementById("widget") as HTMLDivElement;
|
||||
const rowKey = (coin: CoinInfo) => `${coin.symbol}:${coin.label}`;
|
||||
const rowEls = new Map<string, HTMLElement>();
|
||||
|
||||
let enabledCoins: CoinInfo[] = [];
|
||||
let pollTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let pollGeneration = 0;
|
||||
let refreshInFlight = false;
|
||||
|
||||
function formatPrice(value: number): string {
|
||||
return value.toLocaleString("en-US", {
|
||||
minimumFractionDigits: 3,
|
||||
maximumFractionDigits: 3,
|
||||
});
|
||||
}
|
||||
|
||||
function formatPercent(value: number): string {
|
||||
const sign = value > 0 ? "+" : value < 0 ? "" : "";
|
||||
return `${sign}${value.toFixed(2)}%`;
|
||||
}
|
||||
|
||||
function coinIconHtml(theme: string): string {
|
||||
const src = COIN_ICONS[theme] ?? iconBtc;
|
||||
return `<img class="coin-icon" src="${src}" width="13" height="13" alt="" draggable="false" />`;
|
||||
}
|
||||
|
||||
function buildRow(coin: CoinInfo): HTMLElement {
|
||||
const row = document.createElement("div");
|
||||
row.className = "row";
|
||||
row.dataset.symbol = coin.symbol;
|
||||
row.dataset.label = coin.label;
|
||||
|
||||
row.innerHTML = `
|
||||
<div class="symbol-wrap ${coin.theme}">
|
||||
${coinIconHtml(coin.theme)}
|
||||
<span class="symbol">${coin.label}</span>
|
||||
</div>
|
||||
<span class="price">--</span>
|
||||
<span class="change">--</span>
|
||||
`;
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
function setupDragging() {
|
||||
const startDrag = (event: MouseEvent) => {
|
||||
if (event.button !== 0) return;
|
||||
event.preventDefault();
|
||||
void getCurrentWindow().startDragging();
|
||||
};
|
||||
|
||||
panelEl.addEventListener("mousedown", startDrag);
|
||||
widgetEl.addEventListener("mousedown", startDrag);
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
pollGeneration += 1;
|
||||
if (pollTimer !== null) {
|
||||
clearTimeout(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function schedulePoll() {
|
||||
if (enabledCoins.length === 0) return;
|
||||
const gen = pollGeneration;
|
||||
pollTimer = setTimeout(() => {
|
||||
if (gen !== pollGeneration) return;
|
||||
void refresh().finally(() => {
|
||||
if (gen === pollGeneration) schedulePoll();
|
||||
});
|
||||
}, pollMs);
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
stopPolling();
|
||||
schedulePoll();
|
||||
}
|
||||
|
||||
function ensureStatusEl() {
|
||||
if (statusEl?.isConnected) return statusEl;
|
||||
statusEl = document.getElementById("fetch-status");
|
||||
return statusEl;
|
||||
}
|
||||
|
||||
function applyBackgroundTheme(theme: string) {
|
||||
const id = BACKGROUND_THEMES.includes(theme as (typeof BACKGROUND_THEMES)[number])
|
||||
? theme
|
||||
: "dark";
|
||||
for (const t of BACKGROUND_THEMES) {
|
||||
panelEl.classList.remove(`theme-${t}`);
|
||||
}
|
||||
panelEl.classList.add(`theme-${id}`);
|
||||
}
|
||||
|
||||
function setFetchStatus(message: string | null) {
|
||||
const el = ensureStatusEl();
|
||||
if (!el) return;
|
||||
if (message) {
|
||||
el.textContent = message;
|
||||
el.classList.add("visible");
|
||||
} else {
|
||||
el.textContent = "";
|
||||
el.classList.remove("visible");
|
||||
}
|
||||
}
|
||||
|
||||
function invokeErrorMessage(err: unknown): string {
|
||||
if (typeof err === "string") return err;
|
||||
if (typeof err === "object" && err !== null && "message" in err) {
|
||||
return String((err as { message: unknown }).message);
|
||||
}
|
||||
return String(err);
|
||||
}
|
||||
|
||||
async function rebuildWidget(coins: CoinInfo[]) {
|
||||
enabledCoins = coins.filter((c) => c.enabled).sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
widgetEl.replaceChildren();
|
||||
rowEls.clear();
|
||||
stopPolling();
|
||||
|
||||
if (enabledCoins.length === 0) {
|
||||
widgetEl.innerHTML = `<div class="empty">请在托盘勾选要显示的币种</div>`;
|
||||
await fitWindowToWidget();
|
||||
return;
|
||||
}
|
||||
|
||||
for (const coin of enabledCoins) {
|
||||
const row = buildRow(coin);
|
||||
widgetEl.appendChild(row);
|
||||
rowEls.set(rowKey(coin), row);
|
||||
}
|
||||
|
||||
await fitWindowToWidget();
|
||||
await refresh();
|
||||
startPolling();
|
||||
}
|
||||
|
||||
function renderTicker(coin: CoinInfo, ticker: TickerDto) {
|
||||
const row = rowEls.get(rowKey(coin));
|
||||
if (!row) return;
|
||||
|
||||
const priceEl = row.querySelector(".price")!;
|
||||
const changeEl = row.querySelector(".change")!;
|
||||
|
||||
priceEl.textContent = formatPrice(ticker.lastPrice);
|
||||
changeEl.textContent = formatPercent(ticker.changePercent);
|
||||
changeEl.classList.remove("up", "down", "flat");
|
||||
|
||||
if (ticker.changePercent > 0) {
|
||||
changeEl.classList.add("up");
|
||||
} else if (ticker.changePercent < 0) {
|
||||
changeEl.classList.add("down");
|
||||
} else {
|
||||
changeEl.classList.add("flat");
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
if (enabledCoins.length === 0 || refreshInFlight) return;
|
||||
|
||||
refreshInFlight = true;
|
||||
try {
|
||||
const tickers = await invoke<TickerDto[]>("fetch_tickers");
|
||||
const bySymbol = new Map(tickers.map((t) => [t.symbol, t]));
|
||||
|
||||
for (const coin of enabledCoins) {
|
||||
const ticker = bySymbol.get(coin.symbol);
|
||||
if (ticker) renderTicker(coin, ticker);
|
||||
}
|
||||
|
||||
widgetEl.classList.remove("error");
|
||||
setFetchStatus(null);
|
||||
} catch (err) {
|
||||
const msg = invokeErrorMessage(err);
|
||||
console.error("fetch_tickers failed:", msg);
|
||||
setFetchStatus(msg.length > 48 ? `${msg.slice(0, 48)}…` : msg);
|
||||
widgetEl.classList.add("error");
|
||||
rowEls.forEach((row) => {
|
||||
const changeEl = row.querySelector(".change")!;
|
||||
if (changeEl.textContent === "--") {
|
||||
changeEl.textContent = "!";
|
||||
changeEl.classList.add("down");
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
refreshInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fitWindowToWidget() {
|
||||
const w = Math.ceil(panelEl.getBoundingClientRect().width);
|
||||
const h = Math.ceil(panelEl.getBoundingClientRect().height);
|
||||
if (w > 0 && h > 0) {
|
||||
await getCurrentWindow().setSize(new LogicalSize(w, h));
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCoins() {
|
||||
const coins = await invoke<CoinInfo[]>("list_coins");
|
||||
await rebuildWidget(coins);
|
||||
}
|
||||
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
setupDragging();
|
||||
panelEl.classList.add("theme-dark");
|
||||
|
||||
void Promise.all([
|
||||
invoke<number>("get_refresh_interval").catch(() => 5000),
|
||||
invoke<string>("get_background_theme").catch(() => "dark"),
|
||||
]).then(([ms, theme]) => {
|
||||
pollMs = ms;
|
||||
applyBackgroundTheme(theme);
|
||||
void loadCoins();
|
||||
});
|
||||
document.fonts.ready.then(() => void fitWindowToWidget());
|
||||
|
||||
void listen<CoinInfo[]>("coins-changed", (event) => {
|
||||
void rebuildWidget(event.payload);
|
||||
});
|
||||
|
||||
void listen("api-config-changed", () => {
|
||||
void refresh();
|
||||
});
|
||||
|
||||
void listen<number>("refresh-interval-changed", (event) => {
|
||||
pollMs = event.payload;
|
||||
startPolling();
|
||||
});
|
||||
|
||||
void listen<string>("background-theme-changed", (event) => {
|
||||
applyBackgroundTheme(event.payload);
|
||||
void fitWindowToWidget();
|
||||
});
|
||||
|
||||
void listen<string>("fetch-error", (event) => {
|
||||
const msg = event.payload;
|
||||
console.error("fetch-error:", msg);
|
||||
setFetchStatus(msg.length > 48 ? `${msg.slice(0, 48)}…` : msg);
|
||||
widgetEl.classList.add("error");
|
||||
});
|
||||
});
|
||||
365
src/styles.css
Normal file
@@ -0,0 +1,365 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
width: max-content;
|
||||
height: max-content;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
font-family: "JetBrains Mono", "Cascadia Mono", Consolas, monospace;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
font-feature-settings: "tnum" 1;
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 2px;
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
.widget {
|
||||
display: grid;
|
||||
grid-template-columns: 14px 28px 8ch 5.5ch;
|
||||
column-gap: 5px;
|
||||
row-gap: 4px;
|
||||
align-items: center;
|
||||
padding: 6px 8px;
|
||||
border-radius: 5px;
|
||||
cursor: move;
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
.widget.error {
|
||||
border-color: rgba(255, 80, 80, 0.45);
|
||||
}
|
||||
|
||||
.fetch-status {
|
||||
display: none;
|
||||
max-width: 320px;
|
||||
padding: 3px 8px 5px;
|
||||
font-size: 8.5px;
|
||||
line-height: 1.35;
|
||||
color: rgba(255, 120, 120, 0.95);
|
||||
border-radius: 5px;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
.fetch-status.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.widget .empty {
|
||||
grid-column: 1 / -1;
|
||||
font-size: 10px;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
padding: 4px 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.row,
|
||||
.symbol-wrap {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.coin-icon {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
object-fit: contain;
|
||||
pointer-events: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
|
||||
.symbol-wrap.btc {
|
||||
color: #f0b90b;
|
||||
}
|
||||
|
||||
.symbol-wrap.btc .symbol {
|
||||
color: #f5c842;
|
||||
}
|
||||
|
||||
.symbol-wrap.eth {
|
||||
color: #4f8ff7;
|
||||
}
|
||||
|
||||
.symbol-wrap.eth .symbol {
|
||||
color: #6ba4ff;
|
||||
}
|
||||
|
||||
.symbol-wrap.usdt {
|
||||
color: #26a17b;
|
||||
}
|
||||
|
||||
.symbol-wrap.usdt .symbol {
|
||||
color: #3dd68c;
|
||||
}
|
||||
|
||||
.symbol-wrap.usdc {
|
||||
color: #2775ca;
|
||||
}
|
||||
|
||||
.symbol-wrap.usdc .symbol {
|
||||
color: #4a9eed;
|
||||
}
|
||||
|
||||
.symbol-wrap.bnb {
|
||||
color: #f0b90b;
|
||||
}
|
||||
|
||||
.symbol-wrap.bnb .symbol {
|
||||
color: #f5c842;
|
||||
}
|
||||
|
||||
.symbol-wrap.xrp {
|
||||
color: #23292f;
|
||||
}
|
||||
|
||||
.symbol-wrap.xrp .symbol {
|
||||
color: #9ecfff;
|
||||
}
|
||||
|
||||
.symbol-wrap.sol {
|
||||
color: #9945ff;
|
||||
}
|
||||
|
||||
.symbol-wrap.sol .symbol {
|
||||
color: #c9a0ff;
|
||||
}
|
||||
|
||||
.symbol-wrap.doge {
|
||||
color: #c2a633;
|
||||
}
|
||||
|
||||
.symbol-wrap.doge .symbol {
|
||||
color: #e8d06b;
|
||||
}
|
||||
|
||||
.symbol-wrap.spcx {
|
||||
color: #6366f1;
|
||||
}
|
||||
|
||||
.symbol-wrap.spcx .symbol {
|
||||
color: #818cf8;
|
||||
}
|
||||
|
||||
.symbol-wrap.nvda {
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.symbol-wrap.nvda .symbol {
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.symbol-wrap.mu {
|
||||
color: #0891b2;
|
||||
}
|
||||
|
||||
.symbol-wrap.mu .symbol {
|
||||
color: #22d3ee;
|
||||
}
|
||||
|
||||
.symbol-wrap.lly {
|
||||
color: #e11d48;
|
||||
}
|
||||
|
||||
.symbol-wrap.lly .symbol {
|
||||
color: #fb7185;
|
||||
}
|
||||
|
||||
.symbol-wrap.tsla {
|
||||
color: #cc2222;
|
||||
}
|
||||
|
||||
.symbol-wrap.tsla .symbol {
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.symbol-wrap.crcl {
|
||||
color: #0369a1;
|
||||
}
|
||||
|
||||
.symbol-wrap.crcl .symbol {
|
||||
color: #38bdf8;
|
||||
}
|
||||
|
||||
.symbol-wrap.sndk {
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.symbol-wrap.sndk .symbol {
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.symbol-wrap.aapl {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.symbol-wrap.aapl .symbol {
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
.symbol-wrap.msft {
|
||||
color: #0369a1;
|
||||
}
|
||||
|
||||
.symbol-wrap.msft .symbol {
|
||||
color: #7dd3fc;
|
||||
}
|
||||
|
||||
.symbol-wrap.jpm {
|
||||
color: #1e3a5f;
|
||||
}
|
||||
|
||||
.symbol-wrap.jpm .symbol {
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
.symbol-wrap.cost {
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.symbol-wrap.cost .symbol {
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.symbol-wrap.jnj {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.symbol-wrap.jnj .symbol {
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.symbol {
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
line-height: 13px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.price,
|
||||
.change {
|
||||
font-size: 10.5px;
|
||||
line-height: 13px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.price {
|
||||
color: #e8ecf1;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.02em;
|
||||
text-align: right;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.change {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.change.up {
|
||||
color: #3dd68c;
|
||||
}
|
||||
|
||||
.change.down {
|
||||
color: #f2555a;
|
||||
}
|
||||
|
||||
.change.flat {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
/* 背景主题:托盘「背景颜色」切换,class 在 #panel 上 */
|
||||
.panel.theme-dark .widget {
|
||||
background: rgba(10, 12, 16, 0.92);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.panel.theme-dark .fetch-status {
|
||||
background: rgba(10, 12, 16, 0.92);
|
||||
border: 1px solid rgba(255, 80, 80, 0.35);
|
||||
}
|
||||
|
||||
.panel.theme-slate .widget {
|
||||
background: rgba(22, 28, 36, 0.94);
|
||||
border: 1px solid rgba(140, 160, 190, 0.18);
|
||||
}
|
||||
|
||||
.panel.theme-slate .fetch-status {
|
||||
background: rgba(22, 28, 36, 0.94);
|
||||
border: 1px solid rgba(255, 80, 80, 0.35);
|
||||
}
|
||||
|
||||
.panel.theme-navy .widget {
|
||||
background: rgba(12, 20, 42, 0.94);
|
||||
border: 1px solid rgba(80, 120, 200, 0.22);
|
||||
}
|
||||
|
||||
.panel.theme-navy .fetch-status {
|
||||
background: rgba(12, 20, 42, 0.94);
|
||||
border: 1px solid rgba(255, 80, 80, 0.35);
|
||||
}
|
||||
|
||||
.panel.theme-charcoal .widget {
|
||||
background: rgba(18, 18, 20, 0.95);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.panel.theme-charcoal .fetch-status {
|
||||
background: rgba(18, 18, 20, 0.95);
|
||||
border: 1px solid rgba(255, 80, 80, 0.35);
|
||||
}
|
||||
|
||||
.panel.theme-midnight .widget {
|
||||
background: rgba(16, 12, 32, 0.94);
|
||||
border: 1px solid rgba(140, 100, 220, 0.22);
|
||||
}
|
||||
|
||||
.panel.theme-midnight .fetch-status {
|
||||
background: rgba(16, 12, 32, 0.94);
|
||||
border: 1px solid rgba(255, 80, 80, 0.35);
|
||||
}
|
||||
|
||||
.panel.theme-forest .widget {
|
||||
background: rgba(10, 22, 16, 0.94);
|
||||
border: 1px solid rgba(80, 160, 110, 0.22);
|
||||
}
|
||||
|
||||
.panel.theme-forest .fetch-status {
|
||||
background: rgba(10, 22, 16, 0.94);
|
||||
border: 1px solid rgba(255, 80, 80, 0.35);
|
||||
}
|
||||
|
||||
.panel.theme-light .widget {
|
||||
background: rgba(245, 247, 250, 0.96);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.panel.theme-light .fetch-status {
|
||||
background: rgba(245, 247, 250, 0.96);
|
||||
border: 1px solid rgba(200, 60, 60, 0.4);
|
||||
color: rgba(180, 40, 40, 0.95);
|
||||
}
|
||||
|
||||
.panel.theme-light .price {
|
||||
color: #1a1d24;
|
||||
}
|
||||
|
||||
.panel.theme-light .widget .empty {
|
||||
color: rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.panel.theme-light .change.flat {
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
6
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module "*.svg" {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
23
tsconfig.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
30
vite.config.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
// @ts-expect-error process is a nodejs global
|
||||
const host = process.env.TAURI_DEV_HOST;
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig(async () => ({
|
||||
|
||||
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
||||
//
|
||||
// 1. prevent Vite from obscuring rust errors
|
||||
clearScreen: false,
|
||||
// 2. tauri expects a fixed port, fail if that port is not available
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
host: host || false,
|
||||
hmr: host
|
||||
? {
|
||||
protocol: "ws",
|
||||
host,
|
||||
port: 1421,
|
||||
}
|
||||
: undefined,
|
||||
watch: {
|
||||
// 3. tell Vite to ignore watching `src-tauri`
|
||||
ignored: ["**/src-tauri/**"],
|
||||
},
|
||||
},
|
||||
}));
|
||||