mirror of
https://github.com/michaelshimeles/terminal.git
synced 2026-11-07 17:33:42 -10:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c454c602e | ||
|
|
749b87fe5a | ||
|
|
222b523fd3 | ||
|
|
ab4a8f9020 | ||
|
|
32c676aaa3 | ||
|
|
b0898e6031 | ||
|
|
ea89066fb2 | ||
|
|
156a074988 | ||
|
|
7ecc35954c |
No files matched your search
@@ -0,0 +1,121 @@
|
||||
name: deploy site
|
||||
|
||||
# Builds the browser app with scripts/build-web.sh and deploys the site with it to Vercel:
|
||||
# production on every push to master, a preview for each pull request. Vercel's own Git
|
||||
# deployments are off (site/vercel.json) because Vercel's build doesn't run Rust.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
# Production runs queue instead of cancelling each other, so the newest commit always deploys
|
||||
# last. A new push to a pull request cancels the preview run for the old one.
|
||||
concurrency:
|
||||
group: deploy-site-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
wasm:
|
||||
name: Build the browser app
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# Pull requests build the branch head, like Vercel's Git previews did, not the merge commit.
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Read the wasm-bindgen version from Cargo.lock
|
||||
id: lock
|
||||
run: echo "wasm-bindgen=$(awk '/^name = "wasm-bindgen"$/ { getline; gsub(/version = |"/, ""); print; exit }' Cargo.lock)" >> "$GITHUB_OUTPUT"
|
||||
- uses: taiki-e/install-action@v2
|
||||
with:
|
||||
tool: wasm-bindgen@${{ steps.lock.outputs.wasm-bindgen }}
|
||||
- name: Install wasm-opt (binaryen)
|
||||
env:
|
||||
BINARYEN: version_133
|
||||
SHA256: 2dc9c7813f5375db93d96ead4b78222fcc3e2677bbb832297af4797782a37489
|
||||
run: |
|
||||
curl -sSfLo "$RUNNER_TEMP/binaryen.tar.gz" "https://github.com/WebAssembly/binaryen/releases/download/$BINARYEN/binaryen-$BINARYEN-x86_64-linux.tar.gz"
|
||||
echo "$SHA256 $RUNNER_TEMP/binaryen.tar.gz" | sha256sum -c -
|
||||
tar xzf "$RUNNER_TEMP/binaryen.tar.gz" -C "$RUNNER_TEMP"
|
||||
echo "$RUNNER_TEMP/binaryen-$BINARYEN/bin" >> "$GITHUB_PATH"
|
||||
- run: scripts/build-web.sh
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: web-app
|
||||
path: site/static/app
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
deploy:
|
||||
name: Deploy to Vercel
|
||||
needs: wasm
|
||||
# Pull requests from forks get no secrets, so they stop after the build.
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: ubuntu-24.04
|
||||
environment:
|
||||
name: ${{ github.ref == 'refs/heads/master' && 'production' || 'preview' }}
|
||||
url: ${{ steps.deploy.outputs.url }}
|
||||
env:
|
||||
TARGET: ${{ github.ref == 'refs/heads/master' && 'production' || 'preview' }}
|
||||
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
|
||||
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
|
||||
steps:
|
||||
- name: Check the Vercel secrets
|
||||
env:
|
||||
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
|
||||
run: |
|
||||
missing=0
|
||||
for name in VERCEL_TOKEN VERCEL_ORG_ID VERCEL_PROJECT_ID; do
|
||||
if [ -z "${!name}" ]; then
|
||||
echo "::error::The $name repository secret is not set (see site/README.md)"
|
||||
missing=1
|
||||
fi
|
||||
done
|
||||
exit $missing
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: web-app
|
||||
path: site/static/app
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
cache-dependency-path: site/package-lock.json
|
||||
- run: npm install --global [email protected]
|
||||
# Run from the repository root, because the Vercel project's Root Directory is site/.
|
||||
- name: Pull the project settings and environment variables
|
||||
env:
|
||||
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
|
||||
run: vercel pull --yes --environment="$TARGET" --token="$VERCEL_TOKEN"
|
||||
# The build gets no token, since it runs the site's npm install and build scripts.
|
||||
- name: Build
|
||||
run: |
|
||||
if [ "$TARGET" = production ]; then vercel build --prod; else vercel build; fi
|
||||
test -f .vercel/output/static/app/index.html
|
||||
- name: Deploy
|
||||
id: deploy
|
||||
env:
|
||||
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
|
||||
run: |
|
||||
if [ "$TARGET" = production ]; then
|
||||
url=$(vercel deploy --prebuilt --prod --token="$VERCEL_TOKEN")
|
||||
else
|
||||
url=$(vercel deploy --prebuilt --token="$VERCEL_TOKEN")
|
||||
fi
|
||||
echo "url=$url" >> "$GITHUB_OUTPUT"
|
||||
echo "Deployed $TARGET: $url ($url/app)" >> "$GITHUB_STEP_SUMMARY"
|
||||
Generated
+25
@@ -463,8 +463,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
|
||||
dependencies = [
|
||||
"iana-time-zone",
|
||||
"js-sys",
|
||||
"num-traits",
|
||||
"serde",
|
||||
"wasm-bindgen",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
@@ -3700,6 +3702,15 @@ version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "send_wrapper"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
@@ -4093,7 +4104,10 @@ dependencies = [
|
||||
"chrono",
|
||||
"eframe",
|
||||
"egui",
|
||||
"egui-wgpu",
|
||||
"epaint_default_fonts",
|
||||
"futures",
|
||||
"js-sys",
|
||||
"raw-window-handle",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4107,6 +4121,11 @@ dependencies = [
|
||||
"tr-macos",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"web-time",
|
||||
"wgpu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4476,15 +4495,21 @@ dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
"futures",
|
||||
"js-sys",
|
||||
"reqwest",
|
||||
"rusqlite",
|
||||
"rustls",
|
||||
"send_wrapper",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"tr-core",
|
||||
"tracing",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+10
@@ -36,3 +36,13 @@ debug = 1
|
||||
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 2
|
||||
|
||||
# The browser build (scripts/build-web.sh): one fully optimised unit, no debug info, so the
|
||||
# .wasm is as small as it gets before wasm-opt.
|
||||
[profile.web]
|
||||
inherits = "release"
|
||||
opt-level = "s"
|
||||
lto = "fat"
|
||||
codegen-units = 1
|
||||
debug = false
|
||||
strip = true
|
||||
@@ -198,6 +198,53 @@ for Updates… or `UPDATE` checks at once. `scripts/check-macos-cross.sh` compil
|
||||
and lints the whole workspace for aarch64-apple-darwin from Linux (zig as the C cross-compiler;
|
||||
linking needs the macOS SDK).
|
||||
|
||||
## In the browser
|
||||
|
||||
The same code also builds for WebAssembly and runs in a browser tab on WebGPU:
|
||||
|
||||
```sh
|
||||
rustup target add wasm32-unknown-unknown
|
||||
cargo install wasm-bindgen-cli --version 0.2.129 --locked # must match Cargo.lock
|
||||
brew install binaryen # wasm-opt
|
||||
scripts/build-web.sh # writes site/static/app
|
||||
cd site && npm run dev # then open http://localhost:5173/app
|
||||
```
|
||||
|
||||
`scripts/build-web.sh <dir>` writes somewhere else instead; any static file server works. Add
|
||||
`?sample` to the URL to run offline on sample data, or `?fps` for the frame-time readout.
|
||||
Every push to `master` builds it and deploys it with the site to
|
||||
[terminal.rasmic.xyz/app](https://terminal.rasmic.xyz/app). Pull requests get a preview;
|
||||
[site/README.md](site/README.md#deploys) covers the workflow and the secrets it needs.
|
||||
|
||||

|
||||
|
||||
What the browser build can and can't do yet:
|
||||
|
||||
- Crypto is live, because Binance and Coinbase allow cross-origin requests. History, quotes, order
|
||||
books and websocket bars all come from them. Yahoo, Treasury.gov, Alpaca and CoinDesk don't allow
|
||||
them, so every other market shows sample data and the toolbar says "Crypto live". A same-origin
|
||||
proxy can fix that later.
|
||||
- Congress disclosures, SnapTrade accounts and live order entry need the desktop app. The paper
|
||||
trading demo account works.
|
||||
- Layouts, settings, themes and the demo account are saved in `localStorage` under `terminal-rs/`.
|
||||
- Charts need WebGPU, since they read bars from storage buffers in the vertex shader. Browsers
|
||||
without a WebGPU adapter get a second, larger build on WebGL2 where everything except the chart
|
||||
series works.
|
||||
|
||||
Measured with Playwright driving Chrome 155 on an Apple M4 Pro:
|
||||
|
||||
| | WebGPU build | WebGL2 build |
|
||||
|---|---|---|
|
||||
| `.wasm` | 5.60 MB (2.26 MB gzip, 1.69 MB brotli) | 7.48 MB (2.99 MB gzip, 2.22 MB brotli) |
|
||||
| JS glue | 115 KB | 169 KB |
|
||||
|
||||
- A cold load from localhost draws its first frame 91 to 97 ms after navigation, the medians of
|
||||
three runs of 9 loads, two headless and one in a Retina window. On a 50 Mbit/s link with 40 ms
|
||||
of latency it takes 446 ms, and 1.64 s on 10 Mbit/s with 80 ms.
|
||||
- Panning and zooming a chart costs 1.3 to 1.5 ms of CPU in the UI code and 2.4 to 3.3 ms for the
|
||||
whole frame (medians across runs), and holds the display's 60 Hz.
|
||||
- egui's two emoji fonts, 740 KB together, load after the first frame instead of inside the `.wasm`.
|
||||
|
||||
## Brokerage setup (SnapTrade)
|
||||
|
||||
Everything happens in **Settings › Brokerage**, with no commands needed. Open it from the sidebar
|
||||
@@ -327,10 +374,14 @@ app/ eframe shell: command line, docked panes, chart widget, panels
|
||||
themes.rs theme model, built-in themes, TOML/JSON theme files and the themes folder
|
||||
app/ workspace (drag and drop, pop-out windows, link groups), panels, settings, chrome
|
||||
worker.rs tokio runtime: all network, SQLite, pyramid and indicator work (UI never blocks)
|
||||
storage.rs saved files: the data folder on desktop, localStorage in the browser
|
||||
web.rs browser entry point (eframe web runner), console logging, frame timings
|
||||
web/ the page that hosts the browser build
|
||||
crates/core Bar/Series, timeframes, market keys + security catalog, indicators and their
|
||||
registry (codes, parameters, panes, levels), sample data
|
||||
crates/data MarketData trait; per-class router (Binance/Coinbase, Treasury.gov/FRED, Yahoo,
|
||||
Alpaca), websocket live feeds, sample provider with simulated ticks; bar cache
|
||||
Alpaca), websocket live feeds, sample provider with simulated ticks; bar cache;
|
||||
the browser router (crypto live, the rest sampled) and a tokio/browser task shim
|
||||
crates/chart wgpu renderer, LOD pyramids, viewport math, axes, drawings, egui paint callback
|
||||
crates/congress Senate eFD + House Clerk parsers, rosters, SQLite store, refresh, alerts, performance
|
||||
crates/broker SnapTrade client (request signing, accounts, balances, positions, orders, activity),
|
||||
|
||||
+41
-4
@@ -5,6 +5,11 @@ version.workspace = true
|
||||
edition.workspace = true
|
||||
default-run = "terminal-rs"
|
||||
|
||||
[features]
|
||||
# Browser build only: add wgpu's WebGL2 backend for browsers without WebGPU (about 1 MB more,
|
||||
# and charts stay off there because WebGL2 has no storage buffers).
|
||||
webgl = ["eframe/wgpu"]
|
||||
|
||||
[dependencies]
|
||||
tr-core.workspace = true
|
||||
tr-broker.workspace = true
|
||||
@@ -16,13 +21,45 @@ chrono.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
toml.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
futures = "0.3"
|
||||
egui = "0.36"
|
||||
eframe = { version = "0.36", default-features = false, features = ["wgpu", "default_fonts", "wayland", "x11"] }
|
||||
egui = { version = "0.36", default-features = false }
|
||||
eframe = { version = "0.36", default-features = false, features = ["wgpu_no_default_features", "wayland", "x11"] }
|
||||
tr-macos.workspace = true
|
||||
web-time = "1"
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
eframe = { version = "0.36", default-features = false, features = ["wgpu", "default_fonts"] }
|
||||
tokio.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
raw-window-handle = "0.6"
|
||||
|
||||
# The browser build (see `src/web.rs` and scripts/build-web.sh): WebGPU only, unless the
|
||||
# `webgl` feature adds the WebGL2 fallback.
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
wgpu = { version = "30", default-features = false, features = ["webgpu", "wgsl"] }
|
||||
egui-wgpu = { version = "0.36", default-features = false, features = ["fragile-send-sync-non-atomic-wasm"] }
|
||||
# egui's text fonts only; the page fetches its emoji fonts after start-up (see theme::base_fonts).
|
||||
epaint_default_fonts = "0.36"
|
||||
tokio = { version = "1", default-features = false, features = ["sync", "macros"] }
|
||||
tracing-subscriber = { version = "0.3", default-features = false, features = ["fmt", "registry", "std"] }
|
||||
chrono = { workspace = true, features = ["wasmbind"] }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = [
|
||||
"Blob",
|
||||
"Document",
|
||||
"Element",
|
||||
"HtmlAnchorElement",
|
||||
"HtmlCanvasElement",
|
||||
"Location",
|
||||
"Performance",
|
||||
"Response",
|
||||
"Storage",
|
||||
"Url",
|
||||
"Window",
|
||||
"console",
|
||||
] }
|
||||
+28
-4
@@ -20,7 +20,7 @@ mod workspace;
|
||||
pub use broker::PosSort;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::{Duration, Instant};
|
||||
use std::time::Duration;
|
||||
|
||||
use egui::{Color32, Event, Key, Modifiers, Rect, Ui, UiBuilder, pos2, vec2};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -28,6 +28,7 @@ use tr_chart::drawings::Tool;
|
||||
use tr_congress::Trade;
|
||||
use tr_congress::db::{Alert, PoliticianSummary};
|
||||
use tr_data::{NewsItem, OrderBook, Quote, SearchHit, YieldCurves};
|
||||
use web_time::Instant;
|
||||
|
||||
use crate::anim;
|
||||
use crate::chart_view::ChartState;
|
||||
@@ -568,7 +569,7 @@ impl TerminalApp {
|
||||
};
|
||||
}
|
||||
let settings: Option<Settings> =
|
||||
std::fs::read(settings_path()).ok().and_then(|b| serde_json::from_slice(&b).ok());
|
||||
crate::storage::read(&settings_path()).ok().and_then(|b| serde_json::from_slice(&b).ok());
|
||||
let broker = broker::BrokerState::load(provider.eq_ignore_ascii_case("sample"));
|
||||
let (demo, demo_note) = demo::DemoState::load(demo::demo_path());
|
||||
let mut app = Self {
|
||||
@@ -681,6 +682,17 @@ impl TerminalApp {
|
||||
app
|
||||
}
|
||||
|
||||
/// Replace the status bar message.
|
||||
pub fn set_status(&mut self, status: String) {
|
||||
self.status = status;
|
||||
}
|
||||
|
||||
/// Show the frame-time readout (as F12 does); the browser's `?fps` flag.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn show_perf(&mut self) {
|
||||
self.show_perf = true;
|
||||
}
|
||||
|
||||
fn save_settings(&self) {
|
||||
let s = Settings {
|
||||
watchlist: self.watchlist.clone(),
|
||||
@@ -690,7 +702,7 @@ impl TerminalApp {
|
||||
panes: None,
|
||||
};
|
||||
if let Ok(json) = serde_json::to_vec_pretty(&s)
|
||||
&& let Err(e) = crate::layouts::write_atomic(&settings_path(), &json)
|
||||
&& let Err(e) = crate::storage::write_atomic(&settings_path(), &json)
|
||||
{
|
||||
tracing::warn!("saving settings: {e}");
|
||||
}
|
||||
@@ -1086,6 +1098,9 @@ impl TerminalApp {
|
||||
|
||||
/// Freshness line for the Congress feed: last check, newest filing, next check.
|
||||
fn congress_freshness(&self) -> String {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return "desktop app only for now".into();
|
||||
}
|
||||
let newest = self.latest.iter().map(|t| t.filed_date).max();
|
||||
let minutes = std::env::var("TR_CONGRESS_REFRESH_MIN")
|
||||
.ok()
|
||||
@@ -1384,7 +1399,9 @@ impl TerminalApp {
|
||||
self.last_feed = Some(Instant::now());
|
||||
}
|
||||
self.save_dirty_themes(false);
|
||||
if self.last_save.elapsed() > Duration::from_secs(10) {
|
||||
// A browser tab can close without an exit callback, so save sooner there.
|
||||
let every = Duration::from_secs(if cfg!(target_arch = "wasm32") { 2 } else { 10 });
|
||||
if self.last_save.elapsed() > every {
|
||||
self.autosave();
|
||||
self.last_save = Instant::now();
|
||||
}
|
||||
@@ -1560,6 +1577,13 @@ impl eframe::App for TerminalApp {
|
||||
}
|
||||
let cpu = frame_start.elapsed().as_secs_f64() * 1e3;
|
||||
self.perf_cpu_ms = self.perf_cpu_ms * 0.9 + cpu * 0.1;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
crate::web::record_frame(&crate::web::FrameStats {
|
||||
ui_ms: self.perf_cpu_ms,
|
||||
interval_ms: self.perf_dt_ms,
|
||||
last_cpu_ms: frame.info().cpu_usage.map_or(0.0, |s| f64::from(s) * 1e3),
|
||||
frames: ctx.cumulative_frame_nr(),
|
||||
});
|
||||
if self.show_perf {
|
||||
// Measure continuously while the readout is visible.
|
||||
ctx.request_repaint();
|
||||
|
||||
+23
-5
@@ -3,9 +3,10 @@
|
||||
//! refresh scheduling and the `PORT`/`ACCT`/`POS`/`ORD`/`ACTV` commands. Order entry is in
|
||||
//! `trading`.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
use std::time::Duration;
|
||||
|
||||
use tr_broker::{Credentials, Portfolio, Position, SecretStore};
|
||||
use web_time::Instant;
|
||||
|
||||
use super::{PaneKind, TerminalApp};
|
||||
use crate::command::PortView;
|
||||
@@ -102,7 +103,9 @@ impl BrokerState {
|
||||
pub fn load(offline: bool) -> Self {
|
||||
let store = SecretStore::platform(&data_dir());
|
||||
let mut message = None;
|
||||
let stored = match store.load() {
|
||||
// The browser build can't reach SnapTrade, and keys don't belong in page storage.
|
||||
let stored = if cfg!(target_arch = "wasm32") { Ok(None) } else { store.load() };
|
||||
let stored = match stored {
|
||||
Ok(c) => c.unwrap_or_default(),
|
||||
Err(e) => {
|
||||
tracing::warn!("SnapTrade credentials unavailable: {e:#}");
|
||||
@@ -112,7 +115,9 @@ impl BrokerState {
|
||||
};
|
||||
let mut creds = stored.clone();
|
||||
let from_env = creds.apply_env(|k| std::env::var(k).ok());
|
||||
let simulate = offline || std::env::var("TR_BROKER").is_ok_and(|v| v.eq_ignore_ascii_case("sample"));
|
||||
let simulate = offline
|
||||
|| cfg!(target_arch = "wasm32")
|
||||
|| std::env::var("TR_BROKER").is_ok_and(|v| v.eq_ignore_ascii_case("sample"));
|
||||
Self {
|
||||
store,
|
||||
saved: stored,
|
||||
@@ -157,6 +162,7 @@ impl BrokerState {
|
||||
}
|
||||
|
||||
/// Open a URL or path with the system handler.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn open_external(target: &str) -> std::io::Result<()> {
|
||||
let opener = if cfg!(target_os = "macos") {
|
||||
"open"
|
||||
@@ -168,10 +174,22 @@ pub fn open_external(target: &str) -> std::io::Result<()> {
|
||||
std::process::Command::new(opener).arg(target).spawn().map(|_| ())
|
||||
}
|
||||
|
||||
/// Open a URL in a new browser tab.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn open_external(target: &str) -> std::io::Result<()> {
|
||||
let window = web_sys::window().ok_or_else(|| std::io::Error::other("no browser window"))?;
|
||||
window
|
||||
.open_with_url_and_target_and_features(target, "_blank", "noopener")
|
||||
.map(|_| ())
|
||||
.map_err(|_| std::io::Error::other("the browser blocked the new tab"))
|
||||
}
|
||||
|
||||
/// A stable, non-identifying SnapTrade user id for this installation.
|
||||
pub fn new_user_id() -> String {
|
||||
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_nanos());
|
||||
let mix = nanos ^ (u128::from(std::process::id()) << 64);
|
||||
let nanos = web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map_or(0, |d| d.as_nanos());
|
||||
// Browsers have no process ids.
|
||||
let pid = if cfg!(target_arch = "wasm32") { 0 } else { std::process::id() };
|
||||
let mix = nanos ^ (u128::from(pid) << 64);
|
||||
format!("terminal-rs-{:016x}", (mix as u64) ^ ((mix >> 64) as u64))
|
||||
}
|
||||
|
||||
|
||||
+24
-4
@@ -1,13 +1,14 @@
|
||||
//! Window chrome in the style of a SwiftUI macOS app: a unified toolbar holding the
|
||||
//! command line, a translucent (vibrancy) sidebar, rounded pane cards and a status bar.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
use std::time::Duration;
|
||||
|
||||
use egui::{
|
||||
Align2, Color32, CornerRadius, CursorIcon, Id, Key, Modifiers, Rect, RichText, Sense, Shadow, Stroke, StrokeKind,
|
||||
Ui, UiBuilder, ViewportCommand, pos2, vec2,
|
||||
};
|
||||
use tr_data::SearchHit;
|
||||
use web_time::Instant;
|
||||
|
||||
use super::{PaneKind, TerminalApp};
|
||||
use crate::anim;
|
||||
@@ -101,8 +102,8 @@ impl TerminalApp {
|
||||
resp.context_menu(|ui| self.view_menu(ui));
|
||||
|
||||
// Data source chip (the window title itself is redundant in a compact toolbar).
|
||||
let live = !self.provider.eq_ignore_ascii_case("sample");
|
||||
let chip_text = p.layout_no_wrap(if live { "Live" } else { "Offline" }.into(), ui_font(11.0), muted());
|
||||
let (label, live) = self.source_chip();
|
||||
let chip_text = p.layout_no_wrap(label.into(), ui_font(11.0), muted());
|
||||
let chip = Rect::from_min_size(pos2(x, cy - 9.0), vec2(chip_text.size().x + 20.0, 18.0));
|
||||
p.rect_filled(chip, 9.0, theme::control());
|
||||
p.circle_filled(pos2(chip.left() + 8.0, cy), 3.0, if live { theme::up() } else { amber() });
|
||||
@@ -281,9 +282,28 @@ impl TerminalApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// The data-source chip's label, and whether any of the data is live.
|
||||
fn source_chip(&self) -> (&'static str, bool) {
|
||||
let n = self.provider.to_lowercase();
|
||||
if n == "sample" {
|
||||
("Offline", false)
|
||||
} else if n.contains("sample") {
|
||||
("Crypto live", true)
|
||||
} else {
|
||||
("Live", true)
|
||||
}
|
||||
}
|
||||
|
||||
fn provider_label(&self) -> String {
|
||||
let n = self.provider.to_lowercase();
|
||||
if n.contains("sample") { "Offline · sample data".into() } else { format!("Live · {}", self.provider) }
|
||||
if n == "sample" {
|
||||
"Offline · sample data".into()
|
||||
} else if n.contains("sample") {
|
||||
// The browser build (tr_data::web::WebMarkets).
|
||||
"Live crypto from Binance and Coinbase; every other market is sample data in the browser".into()
|
||||
} else {
|
||||
format!("Live · {}", self.provider)
|
||||
}
|
||||
}
|
||||
|
||||
/// The Bloomberg command line, drawn as a rounded macOS search field.
|
||||
|
||||
+15
-4
@@ -7,11 +7,13 @@
|
||||
//! demo change is saved at once to `demo-account.json` in the app data folder.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::time::{Duration, Instant};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Context;
|
||||
use tr_broker::demo::{DemoBook, Event, STARTING_CASH};
|
||||
use tr_broker::trading::OrderRequest;
|
||||
use tr_broker::{InstrumentKind, Order, Portfolio};
|
||||
use web_time::Instant;
|
||||
|
||||
use super::TerminalApp;
|
||||
use crate::prefs::AccountMode;
|
||||
@@ -49,11 +51,16 @@ impl DemoState {
|
||||
/// account starts; the returned message says so.
|
||||
pub fn load(path: PathBuf) -> (Self, Option<String>) {
|
||||
let now = chrono::Utc::now();
|
||||
let (book, note) = match DemoBook::load(&path, now) {
|
||||
let saved = match crate::storage::read(&path) {
|
||||
Ok(bytes) => DemoBook::from_json(&bytes),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(DemoBook::new(now)),
|
||||
Err(e) => Err(anyhow::Error::new(e).context("reading the demo account")),
|
||||
};
|
||||
let (book, note) = match saved {
|
||||
Ok(b) => (b, None),
|
||||
Err(e) => {
|
||||
let aside = path.with_extension(format!("damaged-{}.json", now.format("%Y%m%d%H%M%S")));
|
||||
let moved = std::fs::rename(&path, &aside).is_ok();
|
||||
let moved = crate::storage::rename(&path, &aside).is_ok();
|
||||
tracing::warn!("demo account: {e:#}");
|
||||
let note = if moved {
|
||||
format!(
|
||||
@@ -91,7 +98,11 @@ impl DemoState {
|
||||
/// Save now. Returns whether the book is on disk.
|
||||
pub fn save(&mut self) -> bool {
|
||||
self.last_save_try = Some(Instant::now());
|
||||
match self.book.save(&self.path) {
|
||||
let saved = self
|
||||
.book
|
||||
.to_json()
|
||||
.and_then(|json| crate::storage::write_atomic(&self.path, &json).context("saving the demo account"));
|
||||
match saved {
|
||||
Ok(()) => {
|
||||
self.save_error = None;
|
||||
true
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Pane content for every panel kind.
|
||||
|
||||
use std::time::Instant;
|
||||
use web_time::Instant;
|
||||
|
||||
use egui::{Align2, Color32, Rect, RichText, Sense, Stroke, Ui, UiBuilder, pos2, vec2};
|
||||
use tr_data::OrderBook;
|
||||
|
||||
@@ -471,7 +471,7 @@ impl TerminalApp {
|
||||
match self.theme_store.save(&self.themes[i]) {
|
||||
Ok(path) => {
|
||||
if let Some(f) = old_file.filter(|f| *f != path) {
|
||||
let _ = std::fs::remove_file(f);
|
||||
let _ = crate::storage::remove(&f);
|
||||
}
|
||||
self.themes[i].source = Some(path);
|
||||
}
|
||||
@@ -519,7 +519,7 @@ impl TerminalApp {
|
||||
expand(given).with_extension(op)
|
||||
};
|
||||
self.status = match themes::ThemeStore::export(&self.themes[idx], &path) {
|
||||
Ok(()) => format!("Exported {name:?} to {}", path.display()),
|
||||
Ok(to) => format!("Exported {name:?} to {to}"),
|
||||
Err(e) => format!("Export failed: {e:#}"),
|
||||
};
|
||||
}
|
||||
@@ -551,7 +551,7 @@ impl TerminalApp {
|
||||
}
|
||||
"import" => {
|
||||
let path = expand(self.theme_io.trim());
|
||||
match themes::load_file(&path) {
|
||||
match themes::import_file(&path) {
|
||||
Ok(mut t) => {
|
||||
t.name = themes::unique_name(&self.themes, &t.name);
|
||||
t.source = None;
|
||||
@@ -566,9 +566,7 @@ impl TerminalApp {
|
||||
}
|
||||
}
|
||||
"folder" => {
|
||||
let _ = std::fs::create_dir_all(&self.theme_store.dir);
|
||||
let opener = if cfg!(target_os = "macos") { "open" } else { "xdg-open" };
|
||||
if let Err(e) = std::process::Command::new(opener).arg(&self.theme_store.dir).spawn() {
|
||||
if let Err(e) = crate::storage::reveal(&self.theme_store.dir) {
|
||||
self.status = format!("Could not open {}: {e}", self.theme_store.dir.display());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
//! configured dark/light pair), cycle, the THEME command, copy-on-write edits of built-ins,
|
||||
//! and debounced saving of edited custom themes.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
use std::time::Duration;
|
||||
|
||||
use super::TerminalApp;
|
||||
use crate::prefs::{Density, UiFont};
|
||||
use crate::themes::{self, SYSTEM, Theme};
|
||||
use web_time::Instant;
|
||||
|
||||
impl TerminalApp {
|
||||
/// Index of the theme currently in effect.
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
//! quietly when SnapTrade rate-limits.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::{Duration, Instant};
|
||||
use std::time::Duration;
|
||||
|
||||
use egui::{Color32, RichText, Ui};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -31,6 +31,7 @@ use tr_broker::trading::{
|
||||
self, Amount, Capability, OrderPreview, OrderRequest, OrderType, PlaceError, Side, TimeInForce,
|
||||
};
|
||||
use tr_broker::{Account, InstrumentKind, Order, demo};
|
||||
use web_time::Instant;
|
||||
|
||||
use super::TerminalApp;
|
||||
use super::broker::{BrokerMode, open_external};
|
||||
@@ -322,7 +323,8 @@ impl TerminalApp {
|
||||
if std::mem::replace(&mut self.trading.caps_loaded, true) {
|
||||
return;
|
||||
}
|
||||
let cached: Option<CapsCache> = std::fs::read(caps_path()).ok().and_then(|b| serde_json::from_slice(&b).ok());
|
||||
let cached: Option<CapsCache> =
|
||||
crate::storage::read(&caps_path()).ok().and_then(|b| serde_json::from_slice(&b).ok());
|
||||
if self.trading.caps.is_none() {
|
||||
self.trading.caps = cached;
|
||||
}
|
||||
@@ -370,7 +372,7 @@ impl TerminalApp {
|
||||
Ok(caps) => {
|
||||
let cache = CapsCache { fetched: Some(chrono::Utc::now()), caps };
|
||||
if let Ok(json) = serde_json::to_vec_pretty(&cache)
|
||||
&& let Err(e) = crate::layouts::write_atomic(&caps_path(), &json)
|
||||
&& let Err(e) = crate::storage::write_atomic(&caps_path(), &json)
|
||||
{
|
||||
tracing::warn!("saving trading access: {e}");
|
||||
}
|
||||
|
||||
@@ -954,8 +954,8 @@ impl TerminalApp {
|
||||
let dir = if downloads.is_dir() { downloads } else { self.store.dir.join("exports") };
|
||||
dir.join(format!("{}.json", crate::layouts::Store::file_stem(&name)))
|
||||
});
|
||||
self.store.export(&name, &path)?;
|
||||
Ok(format!("Exported {name:?} to {}", path.display()))
|
||||
let to = self.store.export(&name, &path)?;
|
||||
Ok(format!("Exported {name:?} to {to}"))
|
||||
}
|
||||
LayoutOp::Import(path) => {
|
||||
let name = self.store.import(&path)?;
|
||||
@@ -964,9 +964,7 @@ impl TerminalApp {
|
||||
Ok(format!("Imported {} as {name:?}", path.display()))
|
||||
}
|
||||
LayoutOp::OpenFolder => {
|
||||
std::fs::create_dir_all(&self.store.dir)?;
|
||||
let opener = if cfg!(target_os = "macos") { "open" } else { "xdg-open" };
|
||||
std::process::Command::new(opener).arg(&self.store.dir).spawn()?;
|
||||
crate::storage::reveal(&self.store.dir)?;
|
||||
Ok(format!("Opened {}", self.store.dir.display()))
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -106,8 +106,8 @@ pub struct ChartState {
|
||||
hover_marker: Option<usize>,
|
||||
/// Resolved security for `symbol`.
|
||||
sec: Security,
|
||||
last_live: Option<std::time::Instant>,
|
||||
last_indicator_req: Option<std::time::Instant>,
|
||||
last_live: Option<web_time::Instant>,
|
||||
last_indicator_req: Option<web_time::Instant>,
|
||||
/// App-level actions requested from this chart (context menu), drained by the app.
|
||||
pub actions: Vec<Action>,
|
||||
/// False while a pane is being dragged over the workspace: no crosshair or hover cards.
|
||||
@@ -404,7 +404,7 @@ impl ChartState {
|
||||
return;
|
||||
}
|
||||
if live {
|
||||
self.last_live = Some(std::time::Instant::now());
|
||||
self.last_live = Some(web_time::Instant::now());
|
||||
}
|
||||
let old_len = self.bars.len();
|
||||
let new_len = bars.len();
|
||||
@@ -443,7 +443,7 @@ impl ChartState {
|
||||
let due = self.last_indicator_req.is_none_or(|t| t.elapsed().as_millis() >= 1500);
|
||||
if !live || due {
|
||||
self.request_indicators(w);
|
||||
self.last_indicator_req = Some(std::time::Instant::now());
|
||||
self.last_indicator_req = Some(web_time::Instant::now());
|
||||
}
|
||||
if !live {
|
||||
self.request_compare(w);
|
||||
|
||||
@@ -9,6 +9,10 @@ use tr_congress::{Chamber, Party, Trade, TxType};
|
||||
use crate::theme::{self, amber, mono, muted, text, ui_font};
|
||||
use crate::worker::Profile;
|
||||
|
||||
/// Shown instead of Congress data in the browser build, which can't fetch disclosures yet.
|
||||
pub const WEB_NOTE: &str =
|
||||
"Congress data needs the desktop app for now: browsers can't reach Senate eFD or the House Clerk.";
|
||||
|
||||
/// What a click inside a panel asks the app to do.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Action {
|
||||
@@ -192,7 +196,12 @@ pub fn feed(ui: &mut Ui, trades: &[Trade], filter: &mut FeedFilter, status: &str
|
||||
header(ui, "FILED TRADED LAG POLITICIAN PTY C TYPE TICKER AMOUNT OWNER ASSET");
|
||||
let rows: Vec<&Trade> = trades.iter().filter(|t| filter.matches(t)).collect();
|
||||
if rows.is_empty() {
|
||||
plain(ui, "No disclosures yet. Ingest runs in the background (REFRESH to force).", muted());
|
||||
let empty = if cfg!(target_arch = "wasm32") {
|
||||
WEB_NOTE
|
||||
} else {
|
||||
"No disclosures yet. Ingest runs in the background (REFRESH to force)."
|
||||
};
|
||||
plain(ui, empty, muted());
|
||||
}
|
||||
let row_h = 16.0;
|
||||
ScrollArea::both().id_salt("feed").auto_shrink([false, false]).show_rows(ui, row_h, rows.len(), |ui, range| {
|
||||
|
||||
+16
-27
@@ -16,6 +16,7 @@ use crate::command::IndicatorSpec;
|
||||
use crate::congress_view::FeedFilter;
|
||||
use crate::dock::{Node, PaneId};
|
||||
use crate::layout::Layout;
|
||||
use crate::storage;
|
||||
|
||||
pub const FORMAT_VERSION: u32 = 1;
|
||||
|
||||
@@ -276,16 +277,6 @@ pub fn chart_settings(symbol: &str, d: &crate::prefs::PanelDefaults) -> PaneSett
|
||||
}
|
||||
}
|
||||
|
||||
/// Write via a temp file and rename, so a crash never leaves a half-written file.
|
||||
pub fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
let tmp = path.with_extension("tmp");
|
||||
std::fs::write(&tmp, bytes)?;
|
||||
std::fs::rename(tmp, path)
|
||||
}
|
||||
|
||||
/// The layouts folder and its files.
|
||||
pub struct Store {
|
||||
pub dir: PathBuf,
|
||||
@@ -314,19 +305,17 @@ impl Store {
|
||||
/// Saved layout names, sorted case-insensitively. Names come from the documents, so a
|
||||
/// file copied in under any file name keeps its display name.
|
||||
pub fn list(&self) -> Vec<String> {
|
||||
let mut names: Vec<String> = std::fs::read_dir(&self.dir)
|
||||
let mut names: Vec<String> = storage::list(&self.dir)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.filter(|e| e.path().extension().is_some_and(|x| x == "json"))
|
||||
.filter_map(|e| {
|
||||
let bytes = std::fs::read(e.path()).ok()?;
|
||||
.filter(|p| p.extension().is_some_and(|x| x == "json"))
|
||||
.filter_map(|p| {
|
||||
let bytes = storage::read(&p).ok()?;
|
||||
let doc: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
|
||||
Some(
|
||||
doc["name"]
|
||||
.as_str()
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| e.path().file_stem().unwrap_or_default().to_string_lossy().into_owned()),
|
||||
.unwrap_or_else(|| p.file_stem().unwrap_or_default().to_string_lossy().into_owned()),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
@@ -343,12 +332,12 @@ impl Store {
|
||||
/// that name (e.g. dropped into the folder by hand).
|
||||
fn find_file(&self, name: &str) -> Option<PathBuf> {
|
||||
let p = self.path(name);
|
||||
if p.exists() {
|
||||
if storage::exists(&p) {
|
||||
return Some(p);
|
||||
}
|
||||
std::fs::read_dir(&self.dir).ok()?.flatten().map(|e| e.path()).find(|p| {
|
||||
storage::list(&self.dir).into_iter().find(|p| {
|
||||
p.extension().is_some_and(|x| x == "json")
|
||||
&& std::fs::read(p)
|
||||
&& storage::read(p)
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok())
|
||||
.is_some_and(|v| v["name"].as_str().is_some_and(|n| n.eq_ignore_ascii_case(name)))
|
||||
@@ -357,7 +346,7 @@ impl Store {
|
||||
|
||||
pub fn load(&self, name: &str) -> Result<LayoutDoc> {
|
||||
let path = self.find_file(name).with_context(|| format!("no saved layout named {name:?}"))?;
|
||||
let mut doc = LayoutDoc::from_json(&std::fs::read(&path)?).with_context(|| format!("{}", path.display()))?;
|
||||
let mut doc = LayoutDoc::from_json(&storage::read(&path)?).with_context(|| format!("{}", path.display()))?;
|
||||
if doc.name.trim().is_empty() {
|
||||
doc.name = name.to_string();
|
||||
}
|
||||
@@ -366,13 +355,13 @@ impl Store {
|
||||
|
||||
pub fn save(&self, doc: &LayoutDoc) -> Result<PathBuf> {
|
||||
let path = self.path(&doc.name);
|
||||
write_atomic(&path, &doc.to_json()).with_context(|| format!("writing {}", path.display()))?;
|
||||
storage::write_atomic(&path, &doc.to_json()).with_context(|| format!("writing {}", path.display()))?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
pub fn delete(&self, name: &str) -> Result<()> {
|
||||
let path = self.find_file(name).with_context(|| format!("no saved layout named {name:?}"))?;
|
||||
std::fs::remove_file(path)?;
|
||||
storage::remove(&path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -411,16 +400,16 @@ impl Store {
|
||||
(2..).map(|i| format!("{base} {i}")).find(|n| !self.exists(n)).expect("unbounded")
|
||||
}
|
||||
|
||||
/// Write a layout to an arbitrary path.
|
||||
pub fn export(&self, name: &str, to: &Path) -> Result<()> {
|
||||
/// Write a layout to an arbitrary path (a download in the browser). Returns where it went.
|
||||
pub fn export(&self, name: &str, to: &Path) -> Result<String> {
|
||||
let doc = self.load(name)?;
|
||||
write_atomic(to, &doc.to_json()).with_context(|| format!("writing {}", to.display()))
|
||||
storage::export(to, &doc.to_json()).with_context(|| format!("writing {}", to.display()))
|
||||
}
|
||||
|
||||
/// Copy a layout file into the store (renaming on conflict). Returns its name.
|
||||
pub fn import(&self, from: &Path) -> Result<String> {
|
||||
let mut doc =
|
||||
LayoutDoc::from_json(&std::fs::read(from).with_context(|| format!("reading {}", from.display()))?)?;
|
||||
LayoutDoc::from_json(&storage::import(from).with_context(|| format!("reading {}", from.display()))?)?;
|
||||
if doc.name.trim().is_empty() {
|
||||
doc.name = from.file_stem().unwrap_or_default().to_string_lossy().into_owned();
|
||||
}
|
||||
|
||||
+47
-14
@@ -12,13 +12,58 @@ mod layouts;
|
||||
mod markets_view;
|
||||
mod palette;
|
||||
mod prefs;
|
||||
mod storage;
|
||||
mod theme;
|
||||
mod themes;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web;
|
||||
mod widgets;
|
||||
mod worker;
|
||||
|
||||
use std::time::Instant;
|
||||
use app::TerminalApp;
|
||||
use web_time::Instant;
|
||||
|
||||
/// Build the terminal once eframe has a GPU: register the chart renderer, start the worker
|
||||
/// and restore the saved workspace. Shared by the desktop and browser entry points.
|
||||
fn create_app(
|
||||
cc: &eframe::CreationContext<'_>,
|
||||
provider: tr_data::Provider,
|
||||
msaa: u32,
|
||||
t0: Instant,
|
||||
) -> Result<TerminalApp, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let rs = cc.wgpu_render_state.as_ref().ok_or("terminal-rs requires the wgpu renderer")?;
|
||||
// Charts read their series from storage buffers in the vertex shader. WebGL2 has none,
|
||||
// so there chart panes keep their axes and labels but draw no series.
|
||||
let charts = rs.adapter.get_downlevel_capabilities().flags.contains(eframe::wgpu::DownlevelFlags::VERTEX_STORAGE);
|
||||
if charts {
|
||||
rs.renderer.write().callback_resources.insert(tr_chart::gpu::ChartRenderer::new(
|
||||
&rs.device,
|
||||
rs.target_format,
|
||||
msaa,
|
||||
));
|
||||
}
|
||||
let name = provider.name().to_string();
|
||||
let worker = worker::Worker::spawn(cc.egui_ctx.clone(), provider)?;
|
||||
let info = rs.adapter.get_info();
|
||||
tracing::info!("window + GPU ready in {:?} (adapter: {}, backend: {:?})", t0.elapsed(), info.name, info.backend);
|
||||
let mut app = TerminalApp::new(&cc.egui_ctx, worker, name);
|
||||
if !charts {
|
||||
tracing::warn!("this GPU backend has no vertex storage buffers; charts are off");
|
||||
app.set_status(if cfg!(target_arch = "wasm32") {
|
||||
"Charts need WebGPU. This browser fell back to WebGL2, so they are off.".into()
|
||||
} else {
|
||||
"This GPU backend has no vertex storage buffers, so charts are off.".into()
|
||||
});
|
||||
}
|
||||
Ok(app)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn main() {
|
||||
web::start();
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn main() -> eframe::Result {
|
||||
let t0 = Instant::now();
|
||||
tracing_subscriber::fmt()
|
||||
@@ -58,18 +103,6 @@ fn main() -> eframe::Result {
|
||||
eframe::run_native(
|
||||
"terminal-rs",
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
let rs = cc.wgpu_render_state.as_ref().ok_or("terminal-rs requires the wgpu renderer")?;
|
||||
rs.renderer.write().callback_resources.insert(tr_chart::gpu::ChartRenderer::new(
|
||||
&rs.device,
|
||||
rs.target_format,
|
||||
u32::from(msaa),
|
||||
));
|
||||
let provider = tr_data::from_env();
|
||||
let name = provider.name().to_string();
|
||||
let worker = worker::Worker::spawn(cc.egui_ctx.clone(), provider)?;
|
||||
tracing::info!("window + GPU ready in {:?} (adapter: {})", t0.elapsed(), rs.adapter.get_info().name);
|
||||
Ok(Box::new(app::TerminalApp::new(&cc.egui_ctx, worker, name)))
|
||||
}),
|
||||
Box::new(move |cc| Ok(Box::new(create_app(cc, tr_data::from_env(), u32::from(msaa), t0)?))),
|
||||
)
|
||||
}
|
||||
+2
-2
@@ -183,7 +183,7 @@ pub struct Palette {
|
||||
impl Palette {
|
||||
/// Load recent picks from `path` (missing or unreadable files start empty).
|
||||
pub fn load(path: &Path) -> Self {
|
||||
let recents = std::fs::read(path).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default();
|
||||
let recents = crate::storage::read(path).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default();
|
||||
Self { recents, path: Some(path.to_path_buf()), ..Default::default() }
|
||||
}
|
||||
|
||||
@@ -230,7 +230,7 @@ impl Palette {
|
||||
self.recents.truncate(MAX_RECENTS);
|
||||
if let Some(path) = &self.path
|
||||
&& let Ok(json) = serde_json::to_vec_pretty(&self.recents)
|
||||
&& let Err(e) = crate::layouts::write_atomic(path, &json)
|
||||
&& let Err(e) = crate::storage::write_atomic(path, &json)
|
||||
{
|
||||
tracing::warn!("saving palette recents: {e}");
|
||||
}
|
||||
|
||||
+3
-2
@@ -684,7 +684,8 @@ impl Default for Prefs {
|
||||
|
||||
impl Prefs {
|
||||
pub fn load(path: &std::path::Path) -> Prefs {
|
||||
let mut p: Prefs = std::fs::read(path).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default();
|
||||
let mut p: Prefs =
|
||||
crate::storage::read(path).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default();
|
||||
p.migrate();
|
||||
p.hotkeys = p.hotkeys.with_defaults();
|
||||
p.appearance.font_scale = p.appearance.font_scale.clamp(0.7, 1.6);
|
||||
@@ -756,7 +757,7 @@ impl Prefs {
|
||||
}
|
||||
|
||||
pub fn save(&self, path: &std::path::Path) -> std::io::Result<()> {
|
||||
crate::layouts::write_atomic(path, &serde_json::to_vec_pretty(self).map_err(std::io::Error::other)?)
|
||||
crate::storage::write_atomic(path, &serde_json::to_vec_pretty(self).map_err(std::io::Error::other)?)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
//! Where preferences, settings, layouts, themes, palette history and the demo account are
|
||||
//! kept: files under the data directory on the desktop, `localStorage` entries keyed by path
|
||||
//! in the browser. Every saved document is UTF-8 text (JSON or TOML).
|
||||
//!
|
||||
//! [`export`], [`import`] and [`reveal`] are for files the user names: on the desktop they are
|
||||
//! ordinary files and folders; in the browser an export is a download, and there is no import
|
||||
//! or folder yet.
|
||||
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub use imp::*;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod imp {
|
||||
use super::*;
|
||||
|
||||
pub fn read(path: &Path) -> io::Result<Vec<u8>> {
|
||||
std::fs::read(path)
|
||||
}
|
||||
|
||||
/// Write via a temp file and rename, so a crash never leaves a half-written file.
|
||||
pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
let tmp = path.with_extension("tmp");
|
||||
std::fs::write(&tmp, bytes)?;
|
||||
std::fs::rename(tmp, path)
|
||||
}
|
||||
|
||||
/// Entries directly inside `dir` (none if it doesn't exist).
|
||||
pub fn list(dir: &Path) -> Vec<PathBuf> {
|
||||
std::fs::read_dir(dir).into_iter().flatten().flatten().map(|e| e.path()).collect()
|
||||
}
|
||||
|
||||
pub fn exists(path: &Path) -> bool {
|
||||
path.exists()
|
||||
}
|
||||
|
||||
pub fn remove(path: &Path) -> io::Result<()> {
|
||||
std::fs::remove_file(path)
|
||||
}
|
||||
|
||||
pub fn rename(from: &Path, to: &Path) -> io::Result<()> {
|
||||
std::fs::rename(from, to)
|
||||
}
|
||||
|
||||
/// Write a file the user exports. Returns where it went, for the status line.
|
||||
pub fn export(path: &Path, bytes: &[u8]) -> io::Result<String> {
|
||||
write_atomic(path, bytes)?;
|
||||
Ok(path.display().to_string())
|
||||
}
|
||||
|
||||
/// Read a file the user imports.
|
||||
pub fn import(path: &Path) -> io::Result<Vec<u8>> {
|
||||
std::fs::read(path)
|
||||
}
|
||||
|
||||
/// Open a folder in the system file manager, creating it first.
|
||||
pub fn reveal(dir: &Path) -> io::Result<()> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let opener = if cfg!(target_os = "macos") { "open" } else { "xdg-open" };
|
||||
std::process::Command::new(opener).arg(dir).spawn().map(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod imp {
|
||||
use super::*;
|
||||
|
||||
fn store() -> io::Result<web_sys::Storage> {
|
||||
web_sys::window()
|
||||
.and_then(|w| w.local_storage().ok().flatten())
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::Unsupported, "browser storage is unavailable"))
|
||||
}
|
||||
|
||||
fn key(path: &Path) -> String {
|
||||
path.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
pub fn read(path: &Path) -> io::Result<Vec<u8>> {
|
||||
match store()?.get_item(&key(path)) {
|
||||
Ok(Some(text)) => Ok(text.into_bytes()),
|
||||
Ok(None) => Err(io::ErrorKind::NotFound.into()),
|
||||
Err(_) => Err(io::Error::other("browser storage refused the read")),
|
||||
}
|
||||
}
|
||||
|
||||
/// A single `setItem`, which is already all-or-nothing.
|
||||
pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
|
||||
let text = std::str::from_utf8(bytes).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
||||
store()?.set_item(&key(path), text).map_err(|_| io::Error::other("browser storage is full or blocked"))
|
||||
}
|
||||
|
||||
/// Keys directly under `dir/`.
|
||||
pub fn list(dir: &Path) -> Vec<PathBuf> {
|
||||
let Ok(s) = store() else { return Vec::new() };
|
||||
let prefix = format!("{}/", key(dir).trim_end_matches('/'));
|
||||
(0..s.length().unwrap_or(0))
|
||||
.filter_map(|i| s.key(i).ok().flatten())
|
||||
.filter(|k| k.strip_prefix(&prefix).is_some_and(|rest| !rest.is_empty() && !rest.contains('/')))
|
||||
.map(PathBuf::from)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn exists(path: &Path) -> bool {
|
||||
store().ok().and_then(|s| s.get_item(&key(path)).ok().flatten()).is_some()
|
||||
}
|
||||
|
||||
pub fn remove(path: &Path) -> io::Result<()> {
|
||||
if !exists(path) {
|
||||
return Err(io::ErrorKind::NotFound.into());
|
||||
}
|
||||
store()?.remove_item(&key(path)).map_err(|_| io::Error::other("browser storage refused the delete"))
|
||||
}
|
||||
|
||||
pub fn rename(from: &Path, to: &Path) -> io::Result<()> {
|
||||
write_atomic(to, &read(from)?)?;
|
||||
remove(from)
|
||||
}
|
||||
|
||||
/// Download the exported file under its file name. Returns where it went, for the status line.
|
||||
pub fn export(path: &Path, bytes: &[u8]) -> io::Result<String> {
|
||||
use wasm_bindgen::JsCast;
|
||||
let fail = |what: &str| io::Error::other(format!("the browser refused the download ({what})"));
|
||||
let name = path.file_name().map_or_else(|| "export".into(), |n| n.to_string_lossy().into_owned());
|
||||
let doc = web_sys::window().and_then(|w| w.document()).ok_or_else(|| fail("no document"))?;
|
||||
let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes));
|
||||
let blob = web_sys::Blob::new_with_u8_array_sequence(&parts).map_err(|_| fail("blob"))?;
|
||||
let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(|_| fail("object URL"))?;
|
||||
let link: web_sys::HtmlAnchorElement =
|
||||
doc.create_element("a").map_err(|_| fail("link"))?.dyn_into().map_err(|_| fail("link"))?;
|
||||
link.set_href(&url);
|
||||
link.set_download(&name);
|
||||
link.click();
|
||||
let _ = web_sys::Url::revoke_object_url(&url);
|
||||
Ok(format!("your downloads as {name}"))
|
||||
}
|
||||
|
||||
// TODO(web): read imports through a file picker instead of a typed path.
|
||||
pub fn import(_path: &Path) -> io::Result<Vec<u8>> {
|
||||
Err(io::Error::new(io::ErrorKind::Unsupported, "importing files needs the desktop app for now"))
|
||||
}
|
||||
|
||||
pub fn reveal(_dir: &Path) -> io::Result<()> {
|
||||
Err(io::Error::new(io::ErrorKind::Unsupported, "the browser keeps these in its local storage, not a folder"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn round_trip() {
|
||||
let dir = std::env::temp_dir().join(format!("tr-storage-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let a = dir.join("sub/a.json");
|
||||
assert!(list(&dir.join("sub")).is_empty());
|
||||
assert_eq!(read(&a).unwrap_err().kind(), io::ErrorKind::NotFound);
|
||||
write_atomic(&a, b"{}").unwrap();
|
||||
assert!(exists(&a));
|
||||
assert_eq!(list(&dir.join("sub")), vec![a.clone()]);
|
||||
let b = dir.join("sub/b.json");
|
||||
rename(&a, &b).unwrap();
|
||||
assert!(!exists(&a));
|
||||
assert_eq!(read(&b).unwrap(), b"{}");
|
||||
remove(&b).unwrap();
|
||||
assert!(list(&dir.join("sub")).is_empty());
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
+40
-1
@@ -53,6 +53,9 @@ thread_local! {
|
||||
static DENSITY: Cell<f32> = const { Cell::new(1.0) };
|
||||
static UI_MONO: Cell<bool> = const { Cell::new(false) };
|
||||
static FONTS: Cell<Option<MonoFont>> = const { Cell::new(None) };
|
||||
/// Fonts fetched after start-up (the browser build's emoji fallbacks).
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
static LATE_FONTS: std::cell::RefCell<Vec<(String, std::sync::Arc<FontData>)>> = const { std::cell::RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
/// Spacing multiplier from the density preference (compact < 1 < comfortable).
|
||||
@@ -176,13 +179,49 @@ pub fn ui_font(size: f32) -> FontId {
|
||||
pub const CARD_RADIUS: u8 = 10;
|
||||
pub const CONTROL_RADIUS: u8 = 6;
|
||||
|
||||
/// egui's bundled fonts. The browser build starts with only the two text fonts, which keeps
|
||||
/// the emoji fonts out of the download; they arrive later through [`add_late_font`].
|
||||
pub fn base_fonts() -> FontDefinitions {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let fonts = FontDefinitions::default();
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let fonts = {
|
||||
use std::sync::Arc;
|
||||
let mut f = FontDefinitions::empty();
|
||||
f.font_data.insert("Hack".into(), Arc::new(FontData::from_static(epaint_default_fonts::HACK_REGULAR)));
|
||||
f.font_data.insert("Ubuntu-Light".into(), Arc::new(FontData::from_static(epaint_default_fonts::UBUNTU_LIGHT)));
|
||||
f.families.insert(FontFamily::Monospace, vec!["Hack".into(), "Ubuntu-Light".into()]);
|
||||
f.families.insert(FontFamily::Proportional, vec!["Ubuntu-Light".into()]);
|
||||
LATE_FONTS.with_borrow(|late| {
|
||||
for (name, data) in late {
|
||||
f.font_data.insert(name.clone(), data.clone());
|
||||
for family in [FontFamily::Proportional, FontFamily::Monospace] {
|
||||
f.families.entry(family).or_default().push(name.clone());
|
||||
}
|
||||
}
|
||||
});
|
||||
f
|
||||
};
|
||||
fonts
|
||||
}
|
||||
|
||||
/// Add a font fetched after start-up as the last fallback of every family.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn add_late_font(ctx: &egui::Context, name: &str, data: FontData) {
|
||||
LATE_FONTS.with_borrow_mut(|late| late.push((name.to_owned(), std::sync::Arc::new(data))));
|
||||
// Reinstall with the current monospace choice (or let the first configure pick it up).
|
||||
if let Some(mono) = FONTS.take() {
|
||||
install_fonts(ctx, mono);
|
||||
}
|
||||
}
|
||||
|
||||
/// Use the system UI fonts when present (read from the OS at runtime, not bundled).
|
||||
fn install_fonts(ctx: &egui::Context, mono: MonoFont) {
|
||||
if FONTS.with(Cell::get) == Some(mono) {
|
||||
return;
|
||||
}
|
||||
FONTS.with(|f| f.set(Some(mono)));
|
||||
let mut fonts = FontDefinitions::default();
|
||||
let mut fonts = base_fonts();
|
||||
let ui_paths: &[&str] = &[
|
||||
"/System/Library/Fonts/SFNS.ttf",
|
||||
"/System/Library/Fonts/SFNSText.ttf",
|
||||
|
||||
+20
-12
@@ -10,6 +10,7 @@ use egui::Color32;
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
|
||||
use crate::prefs::{Density, MonoFont, UiFont};
|
||||
use crate::storage;
|
||||
use crate::theme::Palette;
|
||||
|
||||
/// Selection that follows the OS appearance using the configured dark/light pair.
|
||||
@@ -561,11 +562,8 @@ impl ThemeStore {
|
||||
/// built-in or earlier name is listed with a suffix rather than shadowing it.
|
||||
pub fn load_all(&self) -> Vec<Theme> {
|
||||
let mut out = builtins();
|
||||
let mut files: Vec<PathBuf> = std::fs::read_dir(&self.dir)
|
||||
let mut files: Vec<PathBuf> = storage::list(&self.dir)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.extension().is_some_and(|x| x == "toml" || x == "json"))
|
||||
.collect();
|
||||
files.sort();
|
||||
@@ -606,7 +604,7 @@ impl ThemeStore {
|
||||
.clone()
|
||||
.filter(|p| p.extension().is_some_and(|x| x == "toml"))
|
||||
.unwrap_or_else(|| self.path(&theme.name));
|
||||
crate::layouts::write_atomic(&path, theme.to_toml().as_bytes())
|
||||
storage::write_atomic(&path, theme.to_toml().as_bytes())
|
||||
.with_context(|| format!("writing {}", path.display()))?;
|
||||
Ok(path)
|
||||
}
|
||||
@@ -617,23 +615,33 @@ impl ThemeStore {
|
||||
bail!("built-in themes cannot be deleted");
|
||||
}
|
||||
let path = theme.source.clone().unwrap_or_else(|| self.path(&theme.name));
|
||||
if !path.exists() {
|
||||
if !storage::exists(&path) {
|
||||
bail!("no file for theme {:?}", theme.name);
|
||||
}
|
||||
Ok(std::fs::remove_file(path)?)
|
||||
Ok(storage::remove(&path)?)
|
||||
}
|
||||
|
||||
/// Write a theme to `to` as JSON (`.json`) or TOML (anything else).
|
||||
pub fn export(theme: &Theme, to: &Path) -> Result<()> {
|
||||
/// Write a theme to `to` as JSON (`.json`) or TOML (anything else); a download in the
|
||||
/// browser. Returns where it went.
|
||||
pub fn export(theme: &Theme, to: &Path) -> Result<String> {
|
||||
let json = to.extension().is_some_and(|x| x.eq_ignore_ascii_case("json"));
|
||||
let text = if json { theme.to_json() } else { theme.to_toml() };
|
||||
crate::layouts::write_atomic(to, text.as_bytes()).with_context(|| format!("writing {}", to.display()))
|
||||
storage::export(to, text.as_bytes()).with_context(|| format!("writing {}", to.display()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a TOML or JSON theme file.
|
||||
/// Read a TOML or JSON theme file from the themes folder.
|
||||
pub fn load_file(path: &Path) -> Result<Theme> {
|
||||
let text = std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
|
||||
parse_file(path, storage::read(path).with_context(|| format!("reading {}", path.display()))?)
|
||||
}
|
||||
|
||||
/// Read a theme file the user imports.
|
||||
pub fn import_file(path: &Path) -> Result<Theme> {
|
||||
parse_file(path, storage::import(path).with_context(|| format!("reading {}", path.display()))?)
|
||||
}
|
||||
|
||||
fn parse_file(path: &Path, bytes: Vec<u8>) -> Result<Theme> {
|
||||
let text = String::from_utf8(bytes).with_context(|| format!("{} is not UTF-8 text", path.display()))?;
|
||||
let json = path.extension().is_some_and(|x| x.eq_ignore_ascii_case("json"));
|
||||
let mut t = Theme::parse(&text, json)?;
|
||||
t.source = Some(path.to_path_buf());
|
||||
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
//! Browser entry point and page glue: start eframe's web runner on the page's canvas, log to
|
||||
//! the console, and publish frame timings on `window.terminalPerf` for measurement.
|
||||
//!
|
||||
//! Query flags: `?sample` serves offline sample data only (like `TR_PROVIDER=sample`), and
|
||||
//! `?fps` shows the frame-time readout and repaints continuously (like `TR_FPS`).
|
||||
|
||||
use std::cell::Cell;
|
||||
|
||||
use tracing::{Level, Metadata};
|
||||
use tracing_subscriber::fmt::MakeWriter;
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
use tracing_subscriber::util::SubscriberInitExt;
|
||||
use wasm_bindgen::{JsCast, JsValue};
|
||||
use web_time::Instant;
|
||||
|
||||
pub fn start() {
|
||||
init_tracing();
|
||||
let t0 = Instant::now();
|
||||
let query = web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default();
|
||||
let offline = has_flag(&query, "sample");
|
||||
let perf = has_flag(&query, "fps");
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let Some(canvas) = canvas() else { return fail("the page has no #terminal canvas") };
|
||||
let started = eframe::WebRunner::new()
|
||||
.start(
|
||||
canvas,
|
||||
eframe::WebOptions::default(),
|
||||
Box::new(move |cc| {
|
||||
// The first frame needs fonts; set them before it (set_fonts applies from the next pass).
|
||||
cc.egui_ctx.set_fonts(crate::theme::base_fonts());
|
||||
wasm_bindgen_futures::spawn_local(load_emoji_fonts(cc.egui_ctx.clone()));
|
||||
let mut app = crate::create_app(cc, tr_data::for_browser(offline), 1, t0)?;
|
||||
if perf {
|
||||
app.show_perf();
|
||||
}
|
||||
Ok(Box::new(app))
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
if let Err(e) = started {
|
||||
fail(&format!("{e:?}"));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Fetch egui's emoji fonts, which scripts/build-web.sh puts in `fonts/` next to the page, and
|
||||
/// add them as fallbacks. Until they arrive, emoji and some symbols draw as boxes.
|
||||
async fn load_emoji_fonts(ctx: egui::Context) {
|
||||
let fonts = [("NotoEmoji-Regular", 0.81), ("emoji-icon-font", 0.90)];
|
||||
let fetched = futures::future::join_all(fonts.iter().map(|(name, _)| fetch(format!("fonts/{name}.ttf")))).await;
|
||||
for ((name, scale), bytes) in fonts.into_iter().zip(fetched) {
|
||||
match bytes {
|
||||
Ok(bytes) => {
|
||||
let tweak = egui::FontTweak { scale, ..Default::default() };
|
||||
crate::theme::add_late_font(&ctx, name, egui::FontData::from_owned(bytes).tweak(tweak));
|
||||
}
|
||||
Err(e) => tracing::warn!("font {name}: {e}"),
|
||||
}
|
||||
}
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
async fn fetch(url: String) -> Result<Vec<u8>, String> {
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
let window = web_sys::window().ok_or("no window")?;
|
||||
let resp: web_sys::Response = JsFuture::from(window.fetch_with_str(&url))
|
||||
.await
|
||||
.map_err(|e| format!("{e:?}"))?
|
||||
.dyn_into()
|
||||
.map_err(|_| "not a response")?;
|
||||
if !resp.ok() {
|
||||
return Err(format!("HTTP {}", resp.status()));
|
||||
}
|
||||
let buf = JsFuture::from(resp.array_buffer().map_err(|e| format!("{e:?}"))?).await.map_err(|e| format!("{e:?}"))?;
|
||||
Ok(js_sys::Uint8Array::new(&buf).to_vec())
|
||||
}
|
||||
|
||||
fn has_flag(query: &str, flag: &str) -> bool {
|
||||
query.trim_start_matches('?').split('&').any(|p| p.split('=').next() == Some(flag))
|
||||
}
|
||||
|
||||
fn canvas() -> Option<web_sys::HtmlCanvasElement> {
|
||||
web_sys::window()?.document()?.get_element_by_id("terminal")?.dyn_into().ok()
|
||||
}
|
||||
|
||||
/// Show why the app couldn't start in place of the loading message.
|
||||
fn fail(why: &str) {
|
||||
tracing::error!("terminal failed to start: {why}");
|
||||
if let Some(el) = web_sys::window().and_then(|w| w.document()).and_then(|d| d.get_element_by_id("status")) {
|
||||
el.set_text_content(Some(&format!("Terminal couldn't start: {why}")));
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-frame numbers from `TerminalApp::ui` (milliseconds).
|
||||
pub struct FrameStats {
|
||||
/// Smoothed CPU time inside `ui()`.
|
||||
pub ui_ms: f64,
|
||||
/// Smoothed time between frames.
|
||||
pub interval_ms: f64,
|
||||
/// eframe's CPU time for the whole previous frame (UI, tessellation and painting).
|
||||
pub last_cpu_ms: f64,
|
||||
pub frames: u64,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static FIRST_FRAME: Cell<bool> = const { Cell::new(true) };
|
||||
}
|
||||
|
||||
pub fn record_frame(s: &FrameStats) {
|
||||
let Some(w) = web_sys::window() else { return };
|
||||
let perf = match js_sys::Reflect::get(&w, &"terminalPerf".into()) {
|
||||
Ok(v) if v.is_object() => v,
|
||||
_ => {
|
||||
let v: JsValue = js_sys::Object::new().into();
|
||||
let _ = js_sys::Reflect::set(&w, &"terminalPerf".into(), &v);
|
||||
v
|
||||
}
|
||||
};
|
||||
let set = |k: &str, v: f64| {
|
||||
let _ = js_sys::Reflect::set(&perf, &k.into(), &v.into());
|
||||
};
|
||||
if FIRST_FRAME.replace(false) {
|
||||
let at = w.performance().map_or(0.0, |p| p.now());
|
||||
tracing::info!("first frame built {at:.0} ms after navigation start");
|
||||
set("firstFrameMs", at);
|
||||
if let Some(el) = w.document().and_then(|d| d.get_element_by_id("status")) {
|
||||
el.remove();
|
||||
}
|
||||
}
|
||||
set("uiMs", s.ui_ms);
|
||||
set("intervalMs", s.interval_ms);
|
||||
set("cpuMs", s.last_cpu_ms);
|
||||
set("frames", s.frames as f64);
|
||||
}
|
||||
|
||||
fn init_tracing() {
|
||||
let filter = tracing_subscriber::filter::Targets::new()
|
||||
.with_default(Level::INFO)
|
||||
.with_target("wgpu_core", Level::WARN)
|
||||
.with_target("wgpu_hal", Level::WARN)
|
||||
.with_target("naga", Level::WARN);
|
||||
let fmt = tracing_subscriber::fmt::layer().with_writer(Console).without_time().with_ansi(false);
|
||||
let _ = tracing_subscriber::registry().with(fmt).with(filter).try_init();
|
||||
}
|
||||
|
||||
/// Sends each formatted log line to the browser console at its level.
|
||||
struct Console;
|
||||
|
||||
struct Line {
|
||||
level: Level,
|
||||
buf: Vec<u8>,
|
||||
}
|
||||
|
||||
impl std::io::Write for Line {
|
||||
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
|
||||
self.buf.extend_from_slice(bytes);
|
||||
Ok(bytes.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Line {
|
||||
fn drop(&mut self) {
|
||||
let text = JsValue::from_str(String::from_utf8_lossy(&self.buf).trim_end());
|
||||
match self.level {
|
||||
Level::ERROR => web_sys::console::error_1(&text),
|
||||
Level::WARN => web_sys::console::warn_1(&text),
|
||||
_ => web_sys::console::log_1(&text),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> MakeWriter<'a> for Console {
|
||||
type Writer = Line;
|
||||
|
||||
fn make_writer(&'a self) -> Line {
|
||||
Line { level: Level::INFO, buf: Vec::new() }
|
||||
}
|
||||
|
||||
fn make_writer_for(&'a self, meta: &Metadata<'_>) -> Line {
|
||||
Line { level: *meta.level(), buf: Vec::new() }
|
||||
}
|
||||
}
|
||||
+75
-17
@@ -1,6 +1,10 @@
|
||||
//! Background worker. All network, SQLite, indicator and LOD-pyramid work runs on a
|
||||
//! tokio runtime; the UI thread only sends [`Request`]s and drains [`Response`]s, so it
|
||||
//! never blocks.
|
||||
//!
|
||||
//! In the browser (`wasm32`) the same requests run as tasks on the page's event loop
|
||||
//! ([`tr_data::rt`]). There is no bar cache or Congress ingest there, and CPU work such as
|
||||
//! indicators runs inline.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
@@ -11,12 +15,16 @@ use tokio::sync::Mutex;
|
||||
use tr_chart::gpu::{LineKind, LineRef};
|
||||
use tr_chart::scene::{Overlay, SeriesGpu, line_ref, marker_ref};
|
||||
use tr_congress::db::{Alert, PoliticianSummary};
|
||||
use tr_congress::model::NewTradeAlert;
|
||||
use tr_congress::performance::{self, Performance, TradeReturn};
|
||||
use tr_congress::refresh::{self, HttpSource, NewTradeAlert, RefreshConfig};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use tr_congress::refresh::{self, HttpSource, RefreshConfig};
|
||||
use tr_congress::{Db, Politician, Trade};
|
||||
use tr_core::indicators::registry::{Input, Plot, Tone};
|
||||
use tr_core::{Bar, Security, Series, Timeframe};
|
||||
use tr_data::{BarCache, Provider, Quote, SearchHit, YieldCurves};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use tr_data::BarCache;
|
||||
use tr_data::{Provider, Quote, SearchHit, YieldCurves, rt};
|
||||
|
||||
use crate::command::IndicatorSpec;
|
||||
use crate::theme;
|
||||
@@ -268,8 +276,11 @@ pub enum Response {
|
||||
},
|
||||
Politicians(Vec<PoliticianSummary>),
|
||||
Alerts(Vec<Alert>),
|
||||
// Only the desktop's Congress ingest raises these.
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
NewAlerts(Vec<NewTradeAlert>),
|
||||
Status(String),
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
CongressRefreshed(String),
|
||||
Curves(Box<YieldCurves>),
|
||||
SearchHits {
|
||||
@@ -282,6 +293,7 @@ pub enum Response {
|
||||
pub struct Worker {
|
||||
tx: tokio::sync::mpsc::UnboundedSender<Request>,
|
||||
pub rx: mpsc::Receiver<Response>,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
_rt: tokio::runtime::Runtime,
|
||||
}
|
||||
|
||||
@@ -293,6 +305,7 @@ struct Shared {
|
||||
/// Authoritative bars per chart pane (history, paging and live ticks all merge here),
|
||||
/// tagged with the pane's load generation.
|
||||
series: std::sync::Mutex<SeriesMap>,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
cache: std::sync::Mutex<Option<BarCache>>,
|
||||
db: Mutex<Db>,
|
||||
out: mpsc::Sender<Response>,
|
||||
@@ -306,7 +319,12 @@ impl Shared {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where saved files live. In the browser this is the key prefix in `localStorage` (see
|
||||
/// [`crate::storage`]).
|
||||
pub fn data_dir() -> PathBuf {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return PathBuf::from("terminal-rs");
|
||||
}
|
||||
if let Some(d) = std::env::var_os("TR_DATA_DIR") {
|
||||
return PathBuf::from(d);
|
||||
}
|
||||
@@ -322,6 +340,7 @@ pub fn data_dir() -> PathBuf {
|
||||
}
|
||||
|
||||
impl Worker {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn spawn(ctx: egui::Context, provider: Provider) -> anyhow::Result<Self> {
|
||||
let dir = data_dir();
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
@@ -376,11 +395,28 @@ impl Worker {
|
||||
Ok(Self { tx, rx, _rt: rt })
|
||||
}
|
||||
|
||||
/// The browser worker: tasks on the page's event loop, no disk cache, an empty Congress store.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn spawn(ctx: egui::Context, provider: Provider) -> anyhow::Result<Self> {
|
||||
let (tx, mut req_rx) = tokio::sync::mpsc::unbounded_channel::<Request>();
|
||||
let (out, rx) = mpsc::channel();
|
||||
let shared =
|
||||
Arc::new(Shared { provider, series: Default::default(), db: Mutex::new(Db::open_in_memory()?), out, ctx });
|
||||
rt::spawn(async move {
|
||||
while let Some(req) = req_rx.recv().await {
|
||||
let s = shared.clone();
|
||||
rt::spawn(async move { handle(&s, req).await });
|
||||
}
|
||||
});
|
||||
Ok(Self { tx, rx })
|
||||
}
|
||||
|
||||
pub fn send(&self, req: Request) {
|
||||
let _ = self.tx.send(req);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
async fn congress_refresh(s: &Shared) {
|
||||
s.send(Response::Status("Refreshing congressional disclosures (Senate eFD + House Clerk)…".into()));
|
||||
let source = match HttpSource::new() {
|
||||
@@ -399,6 +435,35 @@ async fn congress_refresh(s: &Shared) {
|
||||
}
|
||||
}
|
||||
|
||||
// TODO(web proxy): Senate eFD and the House Clerk don't allow cross-origin requests.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
async fn congress_refresh(s: &Shared) {
|
||||
s.send(Response::Status(crate::congress_view::WEB_NOTE.into()));
|
||||
}
|
||||
|
||||
/// Bars saved by an earlier session, to paint before the network answers.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn cached_bars(s: &Shared, symbol: &str, tf: Timeframe, limit: usize) -> Option<Vec<Bar>> {
|
||||
s.cache.lock().ok().and_then(|c| c.as_ref().and_then(|c| c.load(symbol, tf, limit).ok()))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn cache_bars(s: &Shared, symbol: &str, tf: Timeframe, bars: &[Bar]) {
|
||||
if let Ok(mut c) = s.cache.lock()
|
||||
&& let Some(c) = c.as_mut()
|
||||
{
|
||||
let _ = c.store(symbol, tf, bars);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn cached_bars(_: &Shared, _: &str, _: Timeframe, _: usize) -> Option<Vec<Bar>> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn cache_bars(_: &Shared, _: &str, _: Timeframe, _: &[Bar]) {}
|
||||
|
||||
fn overlay(values: &[f64], color: egui::Color32, kind: LineKind) -> Overlay {
|
||||
let line = if kind == LineKind::Dots { marker_ref(values) } else { line_ref(values) };
|
||||
Overlay { line, color: theme::rgba(color), kind, scale: 1.0 }
|
||||
@@ -486,8 +551,7 @@ async fn handle(s: &Arc<Shared>, req: Request) {
|
||||
return; // a newer load for this pane is already running
|
||||
}
|
||||
// Paint from the disk cache immediately, then refresh from the network.
|
||||
let cached = s.cache.lock().ok().and_then(|c| c.as_ref().and_then(|c| c.load(&symbol, tf, limit).ok()));
|
||||
if let Some(bars) = cached.filter(|b| !b.is_empty()) {
|
||||
if let Some(bars) = cached_bars(s, &symbol, tf, limit).filter(|b| !b.is_empty()) {
|
||||
let bars = Arc::new(bars);
|
||||
let gpu = build_gpu(bars.clone()).await;
|
||||
s.send(Response::History {
|
||||
@@ -505,11 +569,7 @@ async fn handle(s: &Arc<Shared>, req: Request) {
|
||||
}
|
||||
match s.provider.history(&symbol, tf, None, limit).await {
|
||||
Ok(bars) if !bars.is_empty() => {
|
||||
if let Ok(mut c) = s.cache.lock()
|
||||
&& let Some(c) = c.as_mut()
|
||||
{
|
||||
let _ = c.store(&symbol, tf, &bars);
|
||||
}
|
||||
cache_bars(s, &symbol, tf, &bars);
|
||||
let exhausted = bars.len() < limit / 2;
|
||||
let bars = Arc::new(bars);
|
||||
if !store_series(s, pane, generation, bars.clone()) {
|
||||
@@ -569,17 +629,15 @@ async fn handle(s: &Arc<Shared>, req: Request) {
|
||||
}
|
||||
}
|
||||
Request::Indicators { pane, generation, bars, tf, specs, palette } => {
|
||||
let built = tokio::task::spawn_blocking(move || build_indicators(&bars, tf, &specs, &palette))
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let built = rt::blocking(move || build_indicators(&bars, tf, &specs, &palette)).await.unwrap_or_default();
|
||||
s.send(Response::Indicators { pane, generation, built });
|
||||
}
|
||||
Request::Compare { pane, generation, symbol, tf, base } => {
|
||||
let limit = base.len().max(100);
|
||||
match s.provider.history(&symbol, tf, None, limit).await {
|
||||
Ok(other) => {
|
||||
let line = tokio::task::spawn_blocking(move || line_ref(&align_compare(&base, &other))).await;
|
||||
if let Ok(line) = line {
|
||||
let line = rt::blocking(move || line_ref(&align_compare(&base, &other))).await;
|
||||
if let Some(line) = line {
|
||||
s.send(Response::Compare { pane, generation, symbol, line });
|
||||
}
|
||||
}
|
||||
@@ -771,8 +829,8 @@ fn current_series(s: &Shared, pane: PaneId, generation: u64) -> Option<Arc<Vec<B
|
||||
/// four updates a second. Stops when the pane reloads, closes or the feed ends.
|
||||
fn start_live(s: Arc<Shared>, pane: PaneId, generation: u64, symbol: String, tf: Timeframe) {
|
||||
let Some(mut feed) = s.provider.live(&symbol, tf) else { return };
|
||||
tokio::spawn(async move {
|
||||
let mut tick = tokio::time::interval(Duration::from_millis(250));
|
||||
rt::spawn(async move {
|
||||
let mut tick = rt::interval(Duration::from_millis(250));
|
||||
let mut dirty = false;
|
||||
loop {
|
||||
tokio::select! {
|
||||
@@ -814,7 +872,7 @@ fn start_live(s: Arc<Shared>, pane: PaneId, generation: u64, symbol: String, tf:
|
||||
}
|
||||
|
||||
async fn build_gpu(bars: Arc<Vec<Bar>>) -> SeriesGpu {
|
||||
tokio::task::spawn_blocking(move || SeriesGpu::build(&bars)).await.expect("pyramid build task")
|
||||
rt::blocking(move || SeriesGpu::build(&bars)).await.expect("pyramid build task")
|
||||
}
|
||||
|
||||
fn resolve_politician(db: &Db, query: &str) -> Option<Politician> {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="dark light" />
|
||||
<title>Terminal</title>
|
||||
<link rel="icon" href="data:," />
|
||||
<script>
|
||||
// The WebGPU build when the browser has WebGPU, else the larger WebGL2 one (no charts).
|
||||
// Start downloading now; the module below cancels this if there is no WebGPU adapter.
|
||||
window.terminalLoad = { build: 'gpu' in navigator ? 'terminal' : 'terminal-webgl', abort: new AbortController() };
|
||||
terminalLoad.wasm = fetch(`./${terminalLoad.build}_bg.wasm`, { signal: terminalLoad.abort.signal });
|
||||
document.head.append(Object.assign(document.createElement('link'), { rel: 'modulepreload', href: `./${terminalLoad.build}.js` }));
|
||||
</script>
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: #1e1e20;
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
html,
|
||||
body {
|
||||
background: #f5f5f7;
|
||||
}
|
||||
}
|
||||
canvas {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
outline: none;
|
||||
}
|
||||
#status {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin: 0;
|
||||
font: 13px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
color: #8e8e93;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="terminal"></canvas>
|
||||
<p id="status">Loading Terminal…</p>
|
||||
<script type="module">
|
||||
const load = window.terminalLoad;
|
||||
if (load.build === 'terminal' && !(await navigator.gpu.requestAdapter().catch(() => null))) {
|
||||
load.wasm.catch(() => {}); // the abort below rejects it
|
||||
load.abort.abort();
|
||||
load.build = 'terminal-webgl';
|
||||
load.wasm = fetch(`./${load.build}_bg.wasm`);
|
||||
}
|
||||
window.terminalBuild = load.build;
|
||||
try {
|
||||
const { default: init } = await import(`./${load.build}.js`);
|
||||
await init({ module_or_path: load.wasm }); // runs the app, which removes #status after its first frame
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
document.getElementById('status').textContent = `Terminal couldn't start: ${e}`;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -12,7 +12,6 @@ serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
reqwest.workspace = true
|
||||
tracing.workspace = true
|
||||
tokio.workspace = true
|
||||
futures = "0.3"
|
||||
hmac = "0.12"
|
||||
sha2 = "0.10"
|
||||
@@ -21,6 +20,13 @@ base64 = "0.23"
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
security-framework = "3"
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
tokio.workspace = true
|
||||
|
||||
# The browser build never calls SnapTrade (see `SnapTrade::with_base`); the client only compiles.
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
tokio = { version = "1", default-features = false, features = ["sync", "time"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["io-util", "net"] }
|
||||
reqwest.workspace = true
|
||||
@@ -156,26 +156,33 @@ impl DemoBook {
|
||||
/// the caller can keep it aside instead of overwriting the user's history.
|
||||
pub fn load(path: &Path, now: DateTime<Utc>) -> Result<Self> {
|
||||
match std::fs::read(path) {
|
||||
Ok(bytes) => {
|
||||
let mut b: DemoBook = serde_json::from_slice(&bytes).context("the demo account file is damaged")?;
|
||||
b.version = FILE_VERSION;
|
||||
if !b.cash.is_finite() || !b.starting_cash.is_finite() {
|
||||
anyhow::bail!("the demo account file has invalid balances");
|
||||
}
|
||||
Ok(b)
|
||||
}
|
||||
Ok(bytes) => Self::from_json(&bytes),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::new(now)),
|
||||
Err(e) => Err(e).context("reading the demo account"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a saved account, rejecting damaged files.
|
||||
pub fn from_json(bytes: &[u8]) -> Result<Self> {
|
||||
let mut b: DemoBook = serde_json::from_slice(bytes).context("the demo account file is damaged")?;
|
||||
b.version = FILE_VERSION;
|
||||
if !b.cash.is_finite() || !b.starting_cash.is_finite() {
|
||||
anyhow::bail!("the demo account file has invalid balances");
|
||||
}
|
||||
Ok(b)
|
||||
}
|
||||
|
||||
pub fn to_json(&self) -> Result<Vec<u8>> {
|
||||
Ok(serde_json::to_vec_pretty(self)?)
|
||||
}
|
||||
|
||||
/// Write atomically (temporary file, then rename).
|
||||
pub fn save(&self, path: &Path) -> Result<()> {
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
let tmp = path.with_extension("tmp");
|
||||
std::fs::write(&tmp, serde_json::to_vec_pretty(self)?)?;
|
||||
std::fs::write(&tmp, self.to_json()?)?;
|
||||
std::fs::rename(&tmp, path).context("saving the demo account")
|
||||
}
|
||||
|
||||
|
||||
@@ -256,6 +256,7 @@ impl SnapTrade {
|
||||
Self::with_base(creds, &base)
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn with_base(creds: Credentials, base: &str) -> Result<Self> {
|
||||
if !creds.has_keys() {
|
||||
bail!("SnapTrade client ID and consumer key are not set");
|
||||
@@ -268,6 +269,14 @@ impl SnapTrade {
|
||||
Ok(Self { http, base, creds })
|
||||
}
|
||||
|
||||
/// SnapTrade doesn't answer cross-origin requests, so the browser build can't make a client
|
||||
/// (and so never sends an order).
|
||||
/// TODO(web proxy): call SnapTrade through a same-origin proxy that keeps the keys server-side.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn with_base(_creds: Credentials, _base: &str) -> Result<Self> {
|
||||
bail!("SnapTrade isn't available in the browser yet; use the desktop app for brokerage accounts")
|
||||
}
|
||||
|
||||
pub fn credentials(&self) -> &Credentials {
|
||||
&self.creds
|
||||
}
|
||||
|
||||
@@ -6,8 +6,9 @@ edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
tr-core.workspace = true
|
||||
egui = "0.36"
|
||||
egui-wgpu = "0.36"
|
||||
egui = { version = "0.36", default-features = false }
|
||||
# GPU backends come from the app (eframe), so the browser build can leave out WebGL.
|
||||
egui-wgpu = { version = "0.36", default-features = false }
|
||||
bytemuck = { version = "1", features = ["derive"] }
|
||||
chrono.workspace = true
|
||||
serde.workspace = true
|
||||
@@ -15,6 +16,8 @@ pollster = "0.4"
|
||||
|
||||
[dev-dependencies]
|
||||
criterion.workspace = true
|
||||
# Tests, benches and examples render offscreen on a real backend.
|
||||
egui-wgpu = "0.36"
|
||||
|
||||
[[bench]]
|
||||
name = "frame"
|
||||
|
||||
@@ -6,11 +6,14 @@ edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
thiserror.workspace = true
|
||||
chrono.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
regex.workspace = true
|
||||
|
||||
# Ingest and storage: desktop only (the browser build has no SQLite and can't reach the sources).
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
thiserror.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
reqwest.workspace = true
|
||||
rusqlite.workspace = true
|
||||
|
||||
@@ -7,6 +7,7 @@ use anyhow::Result;
|
||||
use chrono::{NaiveDate, Utc};
|
||||
use rusqlite::{Connection, OptionalExtension, Row, params};
|
||||
|
||||
pub use crate::model::{Alert, PoliticianSummary};
|
||||
use crate::model::{AmountRange, Chamber, ParsedTrade, Party, Politician, Trade, TxType};
|
||||
|
||||
const SCHEMA: &str = r#"
|
||||
@@ -111,17 +112,6 @@ pub struct FilingRecord {
|
||||
pub is_amendment: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Alert {
|
||||
pub id: i64,
|
||||
pub politician_id: i64,
|
||||
pub politician: String,
|
||||
pub filing_id: String,
|
||||
pub created_at: i64,
|
||||
pub message: String,
|
||||
pub seen: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct Stats {
|
||||
pub politicians: i64,
|
||||
@@ -130,15 +120,6 @@ pub struct Stats {
|
||||
pub scanned: i64,
|
||||
}
|
||||
|
||||
/// A politician with aggregate trade counts, for list views.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct PoliticianSummary {
|
||||
pub politician: Politician,
|
||||
pub trades: i64,
|
||||
pub last_filed: Option<NaiveDate>,
|
||||
pub tracked: bool,
|
||||
}
|
||||
|
||||
pub struct Db {
|
||||
conn: Connection,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
//! The browser build's stand-in for the SQLite store: always empty, because the browser
|
||||
//! can't fetch disclosures yet. It keeps the desktop store's method signatures so callers
|
||||
//! compile unchanged.
|
||||
//!
|
||||
//! TODO(web proxy): fill this from a same-origin proxy for Senate eFD and the House Clerk.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
|
||||
pub use crate::model::{Alert, PoliticianSummary};
|
||||
use crate::model::{Politician, Trade};
|
||||
|
||||
pub struct Db;
|
||||
|
||||
impl Db {
|
||||
pub fn open(_path: impl AsRef<Path>) -> Result<Self> {
|
||||
Ok(Self)
|
||||
}
|
||||
|
||||
pub fn open_in_memory() -> Result<Self> {
|
||||
Ok(Self)
|
||||
}
|
||||
|
||||
pub fn latest_trades(&self, _limit: usize) -> Result<Vec<Trade>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
pub fn trades_for_ticker(&self, _ticker: &str, _limit: usize) -> Result<Vec<Trade>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
pub fn trades_for_politician(&self, _politician_id: i64) -> Result<Vec<Trade>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
pub fn politician(&self, _id: i64) -> Result<Option<Politician>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub fn search_politicians(&self, _query: &str, _limit: usize) -> Result<Vec<PoliticianSummary>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
pub fn set_tracked(&self, _politician_id: i64, _tracked: bool) -> Result<()> {
|
||||
bail!("tracking needs congressional data, which the browser build can't load yet")
|
||||
}
|
||||
|
||||
pub fn is_tracked(&self, _politician_id: i64) -> Result<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
pub fn alerts(&self, _unseen_only: bool, _limit: usize) -> Result<Vec<Alert>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
pub fn mark_alerts_seen(&self) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,23 @@
|
||||
//! STOCK Act congressional trade disclosures, ingested directly from the
|
||||
//! official sources: the Senate eFD system and the House Clerk's PTR filings.
|
||||
//!
|
||||
//! The browser build (`wasm32`) has no SQLite and can't reach either site (they don't allow
|
||||
//! cross-origin requests), so there [`db`] is an empty stand-in and there is no ingest.
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod db;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
#[path = "db_web.rs"]
|
||||
pub mod db;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod house;
|
||||
pub mod model;
|
||||
pub mod performance;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod refresh;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod roster;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod senate;
|
||||
|
||||
pub use db::Db;
|
||||
|
||||
@@ -243,6 +243,36 @@ pub fn normalize_ticker(raw: &str) -> Option<String> {
|
||||
RE.is_match(&t).then_some(t)
|
||||
}
|
||||
|
||||
/// An alert raised when a tracked politician files new trades.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Alert {
|
||||
pub id: i64,
|
||||
pub politician_id: i64,
|
||||
pub politician: String,
|
||||
pub filing_id: String,
|
||||
pub created_at: i64,
|
||||
pub message: String,
|
||||
pub seen: bool,
|
||||
}
|
||||
|
||||
/// A politician with aggregate trade counts, for list views.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct PoliticianSummary {
|
||||
pub politician: Politician,
|
||||
pub trades: i64,
|
||||
pub last_filed: Option<NaiveDate>,
|
||||
pub tracked: bool,
|
||||
}
|
||||
|
||||
/// A new filing by a tracked politician, found during a refresh.
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct NewTradeAlert {
|
||||
pub politician_id: i64,
|
||||
pub politician: String,
|
||||
pub filing_id: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -12,6 +12,7 @@ use tracing::{debug, info, warn};
|
||||
|
||||
use crate::db::{Db, FilingRecord, FilingStatus, NewPolitician};
|
||||
use crate::house::{self, IndexEntry, PdfError};
|
||||
pub use crate::model::NewTradeAlert;
|
||||
use crate::model::{Chamber, Party};
|
||||
use crate::roster::{self, Roster};
|
||||
use crate::senate::{self, ReportKind, ReportRef};
|
||||
@@ -58,14 +59,6 @@ impl RefreshConfig {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct NewTradeAlert {
|
||||
pub politician_id: i64,
|
||||
pub politician: String,
|
||||
pub filing_id: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct RefreshReport {
|
||||
pub new_filings: usize,
|
||||
|
||||
+16
-2
@@ -10,15 +10,29 @@ anyhow.workspace = true
|
||||
chrono.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
reqwest.workspace = true
|
||||
rusqlite.workspace = true
|
||||
tracing.workspace = true
|
||||
async-trait = "0.1"
|
||||
futures = "0.3"
|
||||
web-time = "1"
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
tokio.workspace = true
|
||||
rusqlite.workspace = true
|
||||
tokio-tungstenite = { version = "0.30", default-features = false, features = ["connect", "rustls-tls-webpki-roots"] }
|
||||
rustls = { version = "0.23", default-features = false, features = ["aws_lc_rs", "std"] }
|
||||
|
||||
# The browser build: no threads, SQLite or raw sockets. Fetch and WebSocket come from the
|
||||
# browser, and chrono reads the clock through JavaScript.
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
tokio = { version = "1", default-features = false, features = ["sync", "macros"] }
|
||||
chrono = { workspace = true, features = ["wasmbind"] }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = ["Window", "WebSocket", "MessageEvent", "CloseEvent", "Event", "BinaryType"] }
|
||||
send_wrapper = { version = "0.6", features = ["futures"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["test-util"] }
|
||||
rustls = { version = "0.23", default-features = false, features = ["aws_lc_rs", "std"] }
|
||||
+36
-32
@@ -6,13 +6,12 @@ use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use chrono::DateTime;
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use serde_json::Value;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use tr_core::{Bar, Security, Timeframe, catalog};
|
||||
|
||||
use crate::{LiveFeed, Quote};
|
||||
use crate::ws::Socket;
|
||||
use crate::{LiveFeed, Quote, rt};
|
||||
|
||||
const BINANCE_REST: [&str; 2] = ["https://data-api.binance.vision", "https://api.binance.com"];
|
||||
const BINANCE_WS: [&str; 2] = ["wss://data-stream.binance.vision/ws", "wss://stream.binance.com:9443/ws"];
|
||||
@@ -186,25 +185,37 @@ pub fn parse_coinbase_book(json: &str, symbol: &str, depth: usize) -> Result<cra
|
||||
Ok(b)
|
||||
}
|
||||
|
||||
const TIMEOUT: Duration = Duration::from_secs(20);
|
||||
|
||||
pub struct CryptoProvider {
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl CryptoProvider {
|
||||
pub fn new() -> Result<Self> {
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent(concat!("terminal-rs/", env!("CARGO_PKG_VERSION")))
|
||||
.timeout(Duration::from_secs(20))
|
||||
.build()?;
|
||||
Ok(Self { client })
|
||||
let client = reqwest::Client::builder();
|
||||
// Browsers set their own User-Agent, and fetch takes its timeout per request.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let client = client.user_agent(concat!("terminal-rs/", env!("CARGO_PKG_VERSION"))).timeout(TIMEOUT);
|
||||
Ok(Self { client: client.build()? })
|
||||
}
|
||||
|
||||
async fn get(&self, url: &str, query: &[(&str, String)]) -> Result<String> {
|
||||
let resp = self.client.get(url).query(query).send().await?;
|
||||
if !resp.status().is_success() {
|
||||
bail!("GET {url}: HTTP {}", resp.status());
|
||||
}
|
||||
Ok(resp.text().await?)
|
||||
let req = self.client.get(url).query(query);
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let req = req.timeout(TIMEOUT);
|
||||
let fetch = async move {
|
||||
let resp = req.send().await?;
|
||||
if !resp.status().is_success() {
|
||||
bail!("GET {url}: HTTP {}", resp.status());
|
||||
}
|
||||
Ok(resp.text().await?)
|
||||
};
|
||||
// A browser fetch holds JavaScript values, which are not `Send`. The web build has
|
||||
// one thread, so asserting it is sound and keeps the provider traits shared.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let fetch = send_wrapper::SendWrapper::new(fetch);
|
||||
fetch.await
|
||||
}
|
||||
|
||||
async fn binance_history(
|
||||
@@ -363,7 +374,7 @@ impl CryptoProvider {
|
||||
let (tx, rx) = mpsc::channel(256);
|
||||
let stream = format!("{}@kline_{}", binance_symbol(sec).to_ascii_lowercase(), binance_interval(tf));
|
||||
let product = coinbase_product(sec);
|
||||
tokio::spawn(async move {
|
||||
rt::spawn(async move {
|
||||
let mut backoff = 1;
|
||||
let mut attempt = 0usize;
|
||||
while !tx.is_closed() {
|
||||
@@ -378,7 +389,7 @@ impl CryptoProvider {
|
||||
tracing::debug!("live feed {stream}: {e:#}");
|
||||
}
|
||||
attempt += 1;
|
||||
tokio::time::sleep(Duration::from_secs(backoff)).await;
|
||||
rt::sleep(Duration::from_secs(backoff)).await;
|
||||
backoff = (backoff * 2).min(30);
|
||||
}
|
||||
});
|
||||
@@ -387,32 +398,25 @@ impl CryptoProvider {
|
||||
}
|
||||
|
||||
async fn binance_ws(url: &str, tx: &mpsc::Sender<Bar>) -> Result<()> {
|
||||
let (mut ws, _) = tokio_tungstenite::connect_async(url).await.context("binance websocket")?;
|
||||
while let Some(msg) = ws.next().await {
|
||||
match msg? {
|
||||
Message::Text(t) => {
|
||||
if let Some(bar) = parse_binance_ws(t.as_str())
|
||||
&& tx.send(bar).await.is_err()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Message::Close(_) => break,
|
||||
_ => {}
|
||||
let mut ws = Socket::connect(url).await.context("binance websocket")?;
|
||||
while let Some(text) = ws.next().await {
|
||||
if let Some(bar) = parse_binance_ws(&text?)
|
||||
&& tx.send(bar).await.is_err()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
bail!("binance websocket closed")
|
||||
}
|
||||
|
||||
async fn coinbase_ws(product: &str, tf: Timeframe, tx: &mpsc::Sender<Bar>) -> Result<()> {
|
||||
let (mut ws, _) = tokio_tungstenite::connect_async(COINBASE_WS).await.context("coinbase websocket")?;
|
||||
let mut ws = Socket::connect(COINBASE_WS).await.context("coinbase websocket")?;
|
||||
let sub = serde_json::json!({"type": "subscribe", "product_ids": [product], "channels": ["ticker"]});
|
||||
ws.send(Message::Text(sub.to_string().into())).await?;
|
||||
ws.send(sub.to_string()).await?;
|
||||
// Aggregate ticks into the current bar; the chart merges partial bars into history.
|
||||
let mut bar: Option<Bar> = None;
|
||||
while let Some(msg) = ws.next().await {
|
||||
let Message::Text(t) = msg? else { continue };
|
||||
let Some((ts, px, size)) = parse_coinbase_ws(t.as_str()) else { continue };
|
||||
while let Some(text) = ws.next().await {
|
||||
let Some((ts, px, size)) = parse_coinbase_ws(&text?) else { continue };
|
||||
let bucket = tf.bucket_start(ts);
|
||||
let b = match bar.as_mut() {
|
||||
Some(b) if b.ts == bucket => {
|
||||
|
||||
@@ -11,14 +11,26 @@
|
||||
//!
|
||||
//! The router is wrapped in [`Fallback`], which serves sample data if a source is
|
||||
//! unreachable, so the terminal always has something to draw.
|
||||
//!
|
||||
//! The browser build (`wasm32`) can only reach sources that allow cross-origin requests:
|
||||
//! [`for_browser`] serves live crypto through [`web::WebMarkets`] and sample data for the
|
||||
//! rest. It has no SQLite bar cache.
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod alpaca;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod cache;
|
||||
pub mod crypto;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod markets;
|
||||
pub mod rss;
|
||||
pub mod rt;
|
||||
pub mod sample;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod treasury;
|
||||
pub mod web;
|
||||
mod ws;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod yahoo;
|
||||
|
||||
use std::sync::Arc;
|
||||
@@ -29,6 +41,7 @@ use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::mpsc;
|
||||
use tr_core::{AssetClass, Bar, Security, Timeframe, catalog};
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use cache::BarCache;
|
||||
|
||||
/// Latest quote for a symbol.
|
||||
@@ -384,6 +397,7 @@ pub fn security(symbol: &str) -> Security {
|
||||
}
|
||||
|
||||
/// Build the provider described in the crate docs from environment variables.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn from_env() -> Provider {
|
||||
// reqwest and the websocket client both use rustls; pin one crypto provider.
|
||||
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
||||
@@ -404,6 +418,18 @@ pub fn from_env() -> Provider {
|
||||
}
|
||||
}
|
||||
|
||||
/// The browser build's provider: sample data only when `offline`, otherwise live crypto
|
||||
/// with sample data for everything else.
|
||||
pub fn for_browser(offline: bool) -> Provider {
|
||||
if !offline {
|
||||
match web::WebMarkets::new() {
|
||||
Ok(m) => return Arc::new(Fallback::new(m)),
|
||||
Err(e) => tracing::warn!("crypto sources unavailable, using sample data: {e:#}"),
|
||||
}
|
||||
}
|
||||
Arc::new(sample::SampleProvider::default().with_live_ticks())
|
||||
}
|
||||
|
||||
/// Keep at most `limit` bars ending at or before `end`.
|
||||
pub(crate) fn clip(mut bars: Vec<Bar>, end: Option<i64>, limit: usize) -> Vec<Bar> {
|
||||
if let Some(end) = end {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
//! Background tasks and timers on either target: tokio on the desktop, the browser's event
|
||||
//! loop on the web (`wasm32`), where there is one thread and no tokio runtime.
|
||||
|
||||
use std::future::Future;
|
||||
use std::time::Duration;
|
||||
|
||||
use web_time::Instant;
|
||||
|
||||
/// Run `fut` in the background.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn spawn<F: Future<Output = ()> + Send + 'static>(fut: F) {
|
||||
tokio::spawn(fut);
|
||||
}
|
||||
|
||||
/// Run `fut` in the background.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn spawn<F: Future<Output = ()> + 'static>(fut: F) {
|
||||
wasm_bindgen_futures::spawn_local(fut);
|
||||
}
|
||||
|
||||
/// Run CPU-bound work off the async workers; `None` if it panicked. The web has one thread,
|
||||
/// so there it runs inline.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub async fn blocking<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> Option<T> {
|
||||
tokio::task::spawn_blocking(f).await.ok()
|
||||
}
|
||||
|
||||
/// Run CPU-bound work off the async workers; `None` if it panicked. The web has one thread,
|
||||
/// so there it runs inline.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub async fn blocking<T>(f: impl FnOnce() -> T) -> Option<T> {
|
||||
Some(f())
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub async fn sleep(d: Duration) {
|
||||
tokio::time::sleep(d).await;
|
||||
}
|
||||
|
||||
/// Resolves after `d`, through `setTimeout`.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub async fn sleep(d: Duration) {
|
||||
let ms = d.as_millis().min(i32::MAX as u128) as i32;
|
||||
let promise = js_sys::Promise::new(&mut |resolve, _| {
|
||||
if let Some(w) = web_sys::window() {
|
||||
let _ = w.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, ms);
|
||||
}
|
||||
});
|
||||
let _ = wasm_bindgen_futures::JsFuture::from(promise).await;
|
||||
}
|
||||
|
||||
/// A periodic timer: the first tick completes at once, later ones every `period`. A late
|
||||
/// tick fires immediately and the next one is a full period after it.
|
||||
pub struct Interval {
|
||||
period: Duration,
|
||||
next: Instant,
|
||||
}
|
||||
|
||||
pub fn interval(period: Duration) -> Interval {
|
||||
Interval { period, next: Instant::now() }
|
||||
}
|
||||
|
||||
impl Interval {
|
||||
/// Wait for the next tick. Dropping the future part-way (in `select!`) loses nothing.
|
||||
pub async fn tick(&mut self) {
|
||||
let now = Instant::now();
|
||||
if self.next > now {
|
||||
sleep(self.next - now).await;
|
||||
}
|
||||
self.next = self.next.max(Instant::now()) + self.period;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn interval_spaces_ticks() {
|
||||
let mut iv = interval(Duration::from_millis(20));
|
||||
let t0 = Instant::now();
|
||||
iv.tick().await;
|
||||
assert!(t0.elapsed() < Duration::from_millis(15), "first tick is immediate");
|
||||
iv.tick().await;
|
||||
iv.tick().await;
|
||||
assert!(t0.elapsed() >= Duration::from_millis(40));
|
||||
assert_eq!(blocking(|| 6 * 7).await, Some(42));
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ use async_trait::async_trait;
|
||||
use tokio::sync::mpsc;
|
||||
use tr_core::{Bar, Timeframe, sample};
|
||||
|
||||
use crate::{LiveFeed, MarketData, OptionChain, OrderBook, security};
|
||||
use crate::{LiveFeed, MarketData, OptionChain, OrderBook, rt, security};
|
||||
|
||||
/// Deterministic synthetic data anchored at a fixed "now" per process, so paging is
|
||||
/// consistent: page N+1 is exactly the bars preceding page N. Levels and volatility
|
||||
@@ -74,8 +74,8 @@ impl MarketData for SampleProvider {
|
||||
let (tx, rx) = mpsc::channel(64);
|
||||
let tick_vol = sec.class.sample_vol() * (0.4 / (365.0 * 86_400.0f64)).sqrt() * 8.0;
|
||||
let mut rng = sample::Rng::new(sample::seed_for(symbol) ^ 0x5eed);
|
||||
tokio::spawn(async move {
|
||||
let mut iv = tokio::time::interval(Duration::from_millis(400));
|
||||
rt::spawn(async move {
|
||||
let mut iv = rt::interval(Duration::from_millis(400));
|
||||
loop {
|
||||
iv.tick().await;
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
//! Market data for the browser build.
|
||||
//!
|
||||
//! A page can only read sources that allow cross-origin requests. Binance and Coinbase do,
|
||||
//! so crypto is live: REST history, quotes and order books, and websocket bars. Yahoo,
|
||||
//! Treasury.gov, Alpaca and CoinDesk do not, so every other asset class comes from the
|
||||
//! sample generator and there are no headlines or network search.
|
||||
//!
|
||||
//! TODO(web proxy): route equities, FX, indices, futures, Treasuries and news through a
|
||||
//! same-origin proxy to those sources, then use [`crate::markets::Markets`] here instead.
|
||||
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use tr_core::{AssetClass, Bar, Timeframe};
|
||||
|
||||
use crate::crypto::CryptoProvider;
|
||||
use crate::sample::SampleProvider;
|
||||
use crate::{LiveFeed, MarketData, OrderBook, Quote, YieldCurves, security};
|
||||
|
||||
pub struct WebMarkets {
|
||||
crypto: CryptoProvider,
|
||||
sample: SampleProvider,
|
||||
}
|
||||
|
||||
impl WebMarkets {
|
||||
pub fn new() -> Result<Self> {
|
||||
Ok(Self { crypto: CryptoProvider::new()?, sample: SampleProvider::default() })
|
||||
}
|
||||
}
|
||||
|
||||
fn is_crypto(symbol: &str) -> bool {
|
||||
security(symbol).class == AssetClass::Crypto
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl MarketData for WebMarkets {
|
||||
fn name(&self) -> &str {
|
||||
"binance+sample"
|
||||
}
|
||||
|
||||
async fn history(&self, symbol: &str, tf: Timeframe, end: Option<i64>, limit: usize) -> Result<Vec<Bar>> {
|
||||
if is_crypto(symbol) {
|
||||
self.crypto.history(&security(symbol), tf, end, limit).await
|
||||
} else {
|
||||
self.sample.history(symbol, tf, end, limit).await
|
||||
}
|
||||
}
|
||||
|
||||
async fn quotes(&self, symbols: &[String]) -> Result<Vec<Quote>> {
|
||||
let (crypto, other): (Vec<String>, Vec<String>) = symbols.iter().cloned().partition(|s| is_crypto(s));
|
||||
let mut out = self.sample.quotes(&other).await?;
|
||||
if !crypto.is_empty() {
|
||||
let secs: Vec<_> = crypto.iter().map(|k| (k.clone(), security(k))).collect();
|
||||
match self.crypto.quotes(&secs).await {
|
||||
Ok(q) => out.extend(q),
|
||||
Err(e) if out.is_empty() => return Err(e),
|
||||
Err(e) => tracing::warn!("crypto quotes: {e:#}"),
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn live(&self, symbol: &str, tf: Timeframe) -> Option<LiveFeed> {
|
||||
is_crypto(symbol).then(|| CryptoProvider::live(&security(symbol), tf))
|
||||
}
|
||||
|
||||
async fn order_book(&self, symbol: &str, depth: usize) -> Result<OrderBook> {
|
||||
let sec = security(symbol);
|
||||
if sec.class == AssetClass::Crypto {
|
||||
self.crypto.order_book(symbol, &sec, depth).await
|
||||
} else {
|
||||
anyhow::bail!("no public order book for {} in the browser", sec.key())
|
||||
}
|
||||
}
|
||||
|
||||
async fn yield_curves(&self) -> Result<YieldCurves> {
|
||||
self.sample.yield_curves().await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn non_crypto_comes_from_sample_data() {
|
||||
let m = WebMarkets::new().unwrap();
|
||||
let bars = m.history("AAPL US Equity", Timeframe::D1, None, 50).await.unwrap();
|
||||
assert_eq!(bars.len(), 50);
|
||||
let q = m.quotes(&["AAPL US Equity".into(), "EURUSD Curncy".into()]).await.unwrap();
|
||||
assert_eq!(q.len(), 2);
|
||||
assert!(m.live("AAPL US Equity", Timeframe::M1).is_none());
|
||||
assert!(m.order_book("AAPL US Equity", 10).await.is_err());
|
||||
assert_eq!(m.yield_curves().await.unwrap().current.points.len(), 11);
|
||||
assert!(is_crypto("BTCUSD Curncy") && !is_crypto("US10Y Govt"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
//! A text-message websocket client: tokio-tungstenite on the desktop, the browser's
|
||||
//! `WebSocket` on the web.
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use native::Socket;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub use web::Socket;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod native {
|
||||
use anyhow::{Context, Result};
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream};
|
||||
|
||||
pub struct Socket(WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>);
|
||||
|
||||
impl Socket {
|
||||
pub async fn connect(url: &str) -> Result<Self> {
|
||||
let (ws, _) = tokio_tungstenite::connect_async(url).await.with_context(|| format!("websocket {url}"))?;
|
||||
Ok(Self(ws))
|
||||
}
|
||||
|
||||
pub async fn send(&mut self, text: String) -> Result<()> {
|
||||
Ok(self.0.send(Message::Text(text.into())).await?)
|
||||
}
|
||||
|
||||
/// The next text message; `None` once the server closes the connection.
|
||||
pub async fn next(&mut self) -> Option<Result<String>> {
|
||||
while let Some(msg) = self.0.next().await {
|
||||
match msg {
|
||||
Ok(Message::Text(t)) => return Some(Ok(t.as_str().to_owned())),
|
||||
Ok(Message::Close(_)) => return None,
|
||||
Ok(_) => {}
|
||||
Err(e) => return Some(Err(e.into())),
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web {
|
||||
use anyhow::{Result, anyhow, bail};
|
||||
use tokio::sync::mpsc;
|
||||
use wasm_bindgen::JsCast;
|
||||
use wasm_bindgen::prelude::*;
|
||||
use web_sys::{CloseEvent, MessageEvent, WebSocket};
|
||||
|
||||
enum Event {
|
||||
Open,
|
||||
Text(String),
|
||||
Error,
|
||||
Closed,
|
||||
}
|
||||
|
||||
/// Browser websocket; its callbacks feed a channel. Closed when dropped.
|
||||
pub struct Socket {
|
||||
ws: WebSocket,
|
||||
rx: mpsc::UnboundedReceiver<Event>,
|
||||
_on_open: Closure<dyn FnMut()>,
|
||||
_on_message: Closure<dyn FnMut(MessageEvent)>,
|
||||
_on_error: Closure<dyn FnMut()>,
|
||||
_on_close: Closure<dyn FnMut(CloseEvent)>,
|
||||
}
|
||||
|
||||
impl Socket {
|
||||
pub async fn connect(url: &str) -> Result<Self> {
|
||||
let ws = WebSocket::new(url).map_err(|e| anyhow!("websocket {url}: {e:?}"))?;
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
let t = tx.clone();
|
||||
let on_open = Closure::<dyn FnMut()>::new(move || {
|
||||
let _ = t.send(Event::Open);
|
||||
});
|
||||
let t = tx.clone();
|
||||
let on_message = Closure::<dyn FnMut(MessageEvent)>::new(move |e: MessageEvent| {
|
||||
if let Some(text) = e.data().as_string() {
|
||||
let _ = t.send(Event::Text(text));
|
||||
}
|
||||
});
|
||||
let t = tx.clone();
|
||||
let on_error = Closure::<dyn FnMut()>::new(move || {
|
||||
let _ = t.send(Event::Error);
|
||||
});
|
||||
let on_close = Closure::<dyn FnMut(CloseEvent)>::new(move |_| {
|
||||
let _ = tx.send(Event::Closed);
|
||||
});
|
||||
ws.set_onopen(Some(on_open.as_ref().unchecked_ref()));
|
||||
ws.set_onmessage(Some(on_message.as_ref().unchecked_ref()));
|
||||
ws.set_onerror(Some(on_error.as_ref().unchecked_ref()));
|
||||
ws.set_onclose(Some(on_close.as_ref().unchecked_ref()));
|
||||
let mut socket =
|
||||
Self { ws, rx, _on_open: on_open, _on_message: on_message, _on_error: on_error, _on_close: on_close };
|
||||
match socket.rx.recv().await {
|
||||
Some(Event::Open) => Ok(socket),
|
||||
_ => bail!("websocket {url}: connection failed"),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn send(&mut self, text: String) -> Result<()> {
|
||||
self.ws.send_with_str(&text).map_err(|e| anyhow!("websocket send: {e:?}"))
|
||||
}
|
||||
|
||||
/// The next text message; `None` once the server closes the connection.
|
||||
pub async fn next(&mut self) -> Option<Result<String>> {
|
||||
match self.rx.recv().await? {
|
||||
Event::Text(t) => Some(Ok(t)),
|
||||
Event::Error => Some(Err(anyhow!("websocket error"))),
|
||||
Event::Open | Event::Closed => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Socket {
|
||||
fn drop(&mut self) {
|
||||
// Detach the callbacks before their closures are freed.
|
||||
self.ws.set_onopen(None);
|
||||
self.ws.set_onmessage(None);
|
||||
self.ws.set_onerror(None);
|
||||
self.ws.set_onclose(None);
|
||||
let _ = self.ws.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 196 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 126 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 121 KiB |
Executable
+62
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build the browser version of Terminal (WebAssembly) into a static folder.
|
||||
#
|
||||
# scripts/build-web.sh [out-dir] # default: site/static/app, served by the site at /app/
|
||||
#
|
||||
# It writes two builds: terminal_bg.wasm (WebGPU) and terminal-webgl_bg.wasm (adds the
|
||||
# WebGL2 fallback, which is larger and draws no charts). The page loads the second only when
|
||||
# the browser has no WebGPU adapter.
|
||||
#
|
||||
# Needs the wasm32-unknown-unknown target (rustup target add wasm32-unknown-unknown),
|
||||
# wasm-bindgen-cli at the version pinned in Cargo.lock, and wasm-opt from binaryen.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
out="${1:-site/static/app}"
|
||||
target=wasm32-unknown-unknown
|
||||
wasm="target/$target/web/terminal-rs.wasm"
|
||||
|
||||
want=$(awk '/^name = "wasm-bindgen"$/ { getline; gsub(/version = |"/, ""); print; exit }' Cargo.lock)
|
||||
have=$(wasm-bindgen --version 2>/dev/null | awk '{ print $2 }' || true)
|
||||
if [ "$want" != "$have" ]; then
|
||||
echo "wasm-bindgen-cli ${have:-is missing}; Cargo.lock wants $want:" >&2
|
||||
echo " cargo install wasm-bindgen-cli --version $want --locked" >&2
|
||||
exit 1
|
||||
fi
|
||||
command -v wasm-opt >/dev/null || { echo "wasm-opt is missing: install binaryen" >&2; exit 1; }
|
||||
|
||||
# The wasm features rustc enables for this target (rustc --print cfg --target wasm32-unknown-unknown).
|
||||
features=(--enable-bulk-memory --enable-bulk-memory-opt --enable-call-indirect-overlong --enable-multivalue
|
||||
--enable-mutable-globals --enable-nontrapping-float-to-int --enable-reference-types --enable-sign-ext)
|
||||
|
||||
mkdir -p "$out"
|
||||
rm -rf "$out"/terminal* "$out/fonts"
|
||||
|
||||
# build <name> [cargo args...]
|
||||
build() {
|
||||
local name=$1
|
||||
shift
|
||||
cargo build --profile web --target "$target" -p terminal-rs "$@"
|
||||
wasm-bindgen --target web --no-typescript --out-dir "$out" --out-name "$name" "$wasm"
|
||||
wasm-opt -Os "${features[@]}" --strip-debug --strip-producers "$out/${name}_bg.wasm" -o "$out/${name}_bg.wasm"
|
||||
}
|
||||
build terminal
|
||||
build terminal-webgl --features webgl
|
||||
cp app/web/index.html "$out/index.html"
|
||||
|
||||
# egui's emoji fonts, which the page fetches after start-up (see app/src/web.rs), with their licenses.
|
||||
fonts=$(cargo metadata --format-version 1 --locked --filter-platform "$target" | python3 -c '
|
||||
import json, os, sys
|
||||
pkgs = json.load(sys.stdin)["packages"]
|
||||
print(next(os.path.dirname(p["manifest_path"]) for p in pkgs if p["name"] == "epaint_default_fonts"))')/fonts
|
||||
mkdir -p "$out/fonts"
|
||||
cp "$fonts"/{NotoEmoji-Regular.ttf,OFL.txt,emoji-icon-font.ttf,emoji-icon-font-mit-license.txt} "$out/fonts/"
|
||||
|
||||
size() { wc -c <"$1" | tr -d ' '; }
|
||||
echo "wrote $out"
|
||||
for name in terminal terminal-webgl; do
|
||||
f="$out/${name}_bg.wasm"
|
||||
gz=$(gzip -9c "$f" | wc -c | tr -d ' ')
|
||||
br=$(command -v brotli >/dev/null && brotli -c -q 11 "$f" | wc -c | tr -d ' ' || echo "n/a")
|
||||
echo "${name}_bg.wasm: $(size "$f") bytes raw, $gz gzip -9, $br brotli -q 11; ${name}.js: $(size "$out/$name.js") bytes"
|
||||
done
|
||||
@@ -24,3 +24,6 @@ vite.config.ts.timestamp-*
|
||||
|
||||
# Page screenshots for review
|
||||
/review
|
||||
|
||||
# Browser build output (scripts/build-web.sh)
|
||||
/static/app
|
||||
@@ -45,6 +45,24 @@ npm run check
|
||||
the download starts anyway.
|
||||
Without JavaScript the buttons link straight to `/download`.
|
||||
|
||||
## Deploys
|
||||
|
||||
GitHub Actions deploys the site, not Vercel's Git integration, because the browser app at `/app`
|
||||
is built from Rust and Vercel's build doesn't run Rust. `.github/workflows/deploy-site.yml` runs
|
||||
`scripts/build-web.sh`, then `vercel build` and `vercel deploy --prebuilt` from the repository
|
||||
root. Pushes to `master` deploy to production. Pull requests from branches in this repository
|
||||
get a preview, linked from the pull request; pull requests from forks only build the app.
|
||||
`vercel.json` turns off Vercel's own Git deployments, so each commit deploys once.
|
||||
|
||||
The workflow reads three repository secrets:
|
||||
|
||||
- `VERCEL_TOKEN`: a Vercel access token (Account Settings › Tokens) scoped to the goshen-labs team.
|
||||
- `VERCEL_ORG_ID` and `VERCEL_PROJECT_ID`: `orgId` and `projectId` from `.vercel/project.json`,
|
||||
which `vercel link --scope goshen-labs --project terminal` writes.
|
||||
|
||||
To redeploy without a new commit, run the workflow from the Actions tab. Run on `master`, it
|
||||
deploys to production.
|
||||
|
||||
## Emails
|
||||
|
||||
`POST /download` adds each email to the `emails` sorted set in Upstash Redis, scored by when the
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
/**
|
||||
* The browser build lives in `static/app` (written by `scripts/build-web.sh`, not committed). Static
|
||||
* folders have no index page here, so send `/app` to the page itself.
|
||||
*/
|
||||
export const GET: RequestHandler = ({ url }) => {
|
||||
// Keep the query (`?sample`, `?fps`).
|
||||
redirect(308, `/app/index.html${url.search}`);
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "https://openapi.vercel.sh/vercel.json",
|
||||
"git": {
|
||||
"deploymentEnabled": false
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user