Compare commits

...
Author SHA1 Message Date
Michael ShimelesandClaude Opus 5.5 5c454c602e ci(web): deploy the site with the browser build from GitHub Actions
Vercel's build doesn't run Rust, so a deploy from Vercel's Git integration
has no /app. The new deploy-site workflow runs scripts/build-web.sh, then
vercel build and vercel deploy --prebuilt. Pushes to master deploy to
production, and pull requests from this repository get a preview.
site/vercel.json turns off Vercel's Git deployments so each commit deploys
once.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 05:50:38 -04:00
Michael ShimelesandClaude Opus 5.5 749b87fe5a fix(web): real downloads for exports, one wasm download, keep /app flags
- Layout and theme exports download a file in the browser instead of
  writing a hidden localStorage key. Imports and "open folder" say they
  need the desktop app for now.
- The page starts the WebGPU wasm with an abortable fetch and cancels it
  when there is no WebGPU adapter, so only the WebGL2 build downloads.
- /app keeps its query string when it redirects to /app/index.html.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 02:55:25 -04:00
Michael ShimelesandClaude Opus 5.5 222b523fd3 docs(readme): rewrap browser timings
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 02:33:57 -04:00
Michael ShimelesandClaude Opus 5.5 ab4a8f9020 docs(readme): final-build throttled timing
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 02:33:45 -04:00
Michael ShimelesandClaude Opus 5.5 32c676aaa3 docs: browser build screenshots
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 02:32:48 -04:00
Michael ShimelesandClaude Opus 5.5 b0898e6031 docs(readme): use the Retina window run for browser timings
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 02:32:04 -04:00
Michael ShimelesandClaude Opus 5.5 ea89066fb2 docs(readme): state browser timings as ranges across runs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 02:30:27 -04:00
Michael ShimelesandClaude Opus 5.5 156a074988 perf(web): WebGPU-only main build, emoji fonts after first frame
The default browser build drops wgpu's WebGL2 path (naga, wgpu-core,
wgpu-hal), and the page loads a second build with it only when there is
no WebGPU adapter. egui's emoji fonts now load after the first frame
instead of shipping inside the .wasm. Together that takes the WebGPU
bundle from 2.64 MB to 1.69 MB brotli.

Also: label the browser's mixed data source as "Crypto live", tell the
Congress panes the data needs the desktop app, autosave every 2 s in the
browser, and redirect /app to the build on the site.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 02:28:05 -04:00
Michael ShimelesandClaude Opus 5.5 7ecc35954c feat(web): build the terminal for the browser with WebAssembly
Compile the app for wasm32-unknown-unknown and run it with eframe's web
runner on WebGPU, with a separate WebGL2 build for browsers without it.
Desktop-only pieces sit behind cfg(target_arch = "wasm32") seams: a
tokio/browser task shim, a websocket wrapper, localStorage in place of
files, and stand-ins for SQLite, Congress ingest and SnapTrade.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 02:15:17 -04:00
51 changed files with 1637 additions and 197 deletions

No files matched your search

+121
View File
@@ -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
View File
@@ -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
View File
@@ -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
+52 -1
View File
@@ -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.
![The browser build in Chrome: a live BTCUSD chart from Binance beside a demo-account position](docs/img/web-live-crypto.png)
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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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;
+4 -6
View File
@@ -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 -1
View File
@@ -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.
+5 -3
View File
@@ -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}");
}
+3 -5
View File
@@ -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()))
}
})();
+4 -4
View File
@@ -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);
+10 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)?)
}
}
+173
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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> {
+71
View File
@@ -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>
+7 -1
View File
@@ -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
+16 -9
View File
@@ -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")
}
+9
View File
@@ -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
}
+5 -2
View File
@@ -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"
+5 -2
View File
@@ -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
+1 -20
View File
@@ -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,
}
+60
View File
@@ -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(())
}
}
+11
View File
@@ -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;
+30
View File
@@ -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::*;
+1 -8
View File
@@ -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
View File
@@ -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
View File
@@ -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 => {
+26
View File
@@ -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 {
+89
View File
@@ -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));
}
}
+3 -3
View File
@@ -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();
+96
View File
@@ -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"));
}
}
+125
View File
@@ -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

+62
View File
@@ -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
+3
View File
@@ -24,3 +24,6 @@ vite.config.ts.timestamp-*
# Page screenshots for review
/review
# Browser build output (scripts/build-web.sh)
/static/app
+18
View File
@@ -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
+11
View File
@@ -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}`);
};
+6
View File
@@ -0,0 +1,6 @@
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"git": {
"deploymentEnabled": false
}
}