diff --git a/.boxci/pipeline.yml b/.boxci/pipeline.yml index bf8bd94..e687caa 100644 --- a/.boxci/pipeline.yml +++ b/.boxci/pipeline.yml @@ -2,6 +2,9 @@ # # Issue→patch is builtin to boxci (POST /api/webhooks/garden/issue, /api/poll). # This file only defines merge builds (on: merge). +# +# boxci publishes a Radicle Job COB (xyz.radworks.job) for each merge run so +# Desktop/Explorer show CI status + a log link back to https://boxci.boxd.sh. name: sleek on: @@ -12,9 +15,20 @@ env: GIT_TERMINAL_PROMPT: "0" steps: + # Fast heartbeat so Job COB start is visible before the heavy check/APK steps. + - label: ":radar: Job COB probe" + key: job-probe + if: build.env("BOXCI_TRIGGER") == "merge" + command: | + set -euo pipefail + echo "boxci merge run ${BOXCI_RUN_ID:-?} for ${GIT_SHA:-unknown}" + echo "Job COB log URL base: ${BOXCI_PUBLIC_URL:-https://boxci.boxd.sh}" + echo "repo ${BOXCI_REPO_ID:-${RADICLE_RID:-?}} slug=${BOXCI_REPO_SLUG:-?}" + - label: ":rust: Check" key: check if: build.env("BOXCI_TRIGGER") == "merge" + depends_on: job-probe command: | set -euo pipefail cd "${BOXCI_REPO_ROOT:?}" diff --git a/.cursor/environment.json b/.cursor/environment.json new file mode 100644 index 0000000..7d9a146 --- /dev/null +++ b/.cursor/environment.json @@ -0,0 +1,5 @@ +{ + "snapshot": "bld-20260805-abcb8552-a57a-438f-a10e-1f7408849ceb", + "install": "bash scripts/ensure-rad-remote.sh", + "start": "bash scripts/ensure-rad-remote.sh" +} diff --git a/.cursor/skills/boxci-github-patch/SKILL.md b/.cursor/skills/boxci-github-patch/SKILL.md new file mode 100644 index 0000000..e9c1f86 --- /dev/null +++ b/.cursor/skills/boxci-github-patch/SKILL.md @@ -0,0 +1,136 @@ +--- +name: boxci-github-patch +description: >- + Ask boxci to open a Radicle patch from a GitHub commit (cherry-pick + + git push rad HEAD:refs/patches). Use when a Cursor cloud agent (or any + agent) should publish GitHub work onto Radicle via boxci, or when the + user mentions boxci from-github, github-commit → patch, or + /api/patches/from-github. +--- + +# boxci: GitHub commit → Radicle patch + +Cloud agents often finish work on a **GitHub** clone. To land that commit on +**Radicle**, call boxci — do **not** try to `git push rad` from the cloud VM +unless Radicle is already set up there. + +Base URL (default): `https://boxci.boxd.sh` + +## When to use + +- User (or task) has a GitHub commit SHA that should become a Radicle patch +- You know the target Radicle RID (`rad:z…`) or Garden clone URL +- You know the GitHub repo URL for that commit + +## Call + +`POST /api/patches/from-github` with JSON: + +```json +{ + "repo": "rad:z9mjPzpVK472QXaaP1picc5U9xBR", + "github_repo_url": "https://github.com/ORG/REPO.git", + "github_commit": "FULL_OR_PREFIX_SHA", + "branch": "main", + "title": "optional", + "description": "optional body" +} +``` + +| Field | Required | Notes | +|-------|----------|--------| +| `repo` or `repo_url` | yes | `rad:…` / RID, or `https://…radicle.garden/.git` | +| `github_repo_url` | yes | `https://github.com/org/repo.git` | +| `github_commit` | yes | also accepts `sha` / `commit` | +| `branch` | no | Radicle base branch (default `main`) | +| `title` / `description` | no | defaults from the GitHub commit message | +| `dry_run` | no | cherry-pick only; skip patch push | +| `sync` | no | if true, wait for completion (default is async `202`) | + +Auth: if the deployment sets `BOXCI_WEBHOOK_SECRET`, send header +`X-Boxci-Secret: `. Otherwise no secret. + +Equivalent: `POST /api/runs/from-repo` with +`"trigger":"github-commit"` plus the same `github_*` fields and `repo_url`. + +### Example (curl) + +```bash +curl -sS -X POST https://boxci.boxd.sh/api/patches/from-github \ + -H 'Content-Type: application/json' \ + -d "{ + \"repo\": \"rad:z9mjPzpVK472QXaaP1picc5U9xBR\", + \"github_repo_url\": \"https://github.com/ORG/REPO.git\", + \"github_commit\": \"${GITHUB_SHA}\", + \"title\": \"Import from GitHub\", + \"description\": \"Opened by Cursor cloud agent\" + }" +``` + +### Example (python) + +```python +import json, os, urllib.request + +payload = { + "repo": "rad:z9mjPzpVK472QXaaP1picc5U9xBR", + "github_repo_url": "https://github.com/ORG/REPO.git", + "github_commit": os.environ["GITHUB_SHA"], +} +req = urllib.request.Request( + "https://boxci.boxd.sh/api/patches/from-github", + data=json.dumps(payload).encode(), + headers={"Content-Type": "application/json"}, + method="POST", +) +# optional: headers["X-Boxci-Secret"] = os.environ["BOXCI_WEBHOOK_SECRET"] +with urllib.request.urlopen(req, timeout=120) as resp: + run = json.load(resp) +print(run["id"], run["status"]) +``` + +## Poll for `patch_id` + +Default response is **async** (`202`, `status: "running"`). Poll until finished: + +```bash +curl -sS "https://boxci.boxd.sh/api/runs/" +``` + +On success, the `github-patch` step `output_tail` contains a line: + +```text +patch_id=<40-char-id> +``` + +Surface that id to the user, along with: + +- **The boxci run link** — `https://boxci.boxd.sh/runs/` — always live, + always the safe default. Lead with this one. +- **A Garden/explorer link**, if you want one — build it yourself as + `https://app.radicle.xyz/nodes///patches/` + (`` is any seed tracking the repo, e.g. `rosa.radicle.network` or + the repo's own `*.radicle.garden` node). Verify with a HEAD request before + handing it to the user. + + **Do not** just copy the URL `output_tail` prints after `✓ Synced with N + seed(s)` — the boxci host's `rad` has a misconfigured explorer template that + glues the wrong domain onto the `/nodes//...` path (e.g. + `https://nandi.radicle.garden/nodes/rosa.radicle.network/rad:.../patches/...`), + which 404s. Same RID/patch-id, different (working) host. + +## What boxci does + +1. Checks out the Radicle (Garden) repo at `branch` +2. Bootstraps the CI Radicle identity + `rad` remote +3. `git fetch` the GitHub SHA, `cherry-pick` onto `github/` +4. `git push rad HEAD:refs/patches` with title/body + +Conflicts or missing commit parents fail the run — fix on GitHub and retry, or +open the patch manually with the `rad-patch` skill. + +## Do not + +- Do not invent a different boxci URL unless the user gives one +- Do not put GitHub tokens in the JSON body; private fetches use VM `GITHUB_TOKEN` +- Do not confuse this with the issue → cursor-agent flow (`trigger: issue`) diff --git a/.cursor/skills/radicle-patch/SKILL.md b/.cursor/skills/radicle-patch/SKILL.md new file mode 100644 index 0000000..e766194 --- /dev/null +++ b/.cursor/skills/radicle-patch/SKILL.md @@ -0,0 +1,48 @@ +--- +name: radicle-patch +description: > + Open a Radicle patch for sleek. Cloud / GitHub agents must use the + boxci-github-patch skill (POST boxci /api/patches/from-github) — not the + radicle MCP and not local rad auth. Use this skill only as a pointer to + boxci-github-patch, or when a machine already has RADICLE_SECRET_KEY and + a working rad remote for a direct git push rad. +--- + +# Radicle patches (sleek) + +## Cloud / Cursor agents (default) + +**Do not use the radicle MCP** (`create_patch` / `issue_device_key` on +`mcp.boxd.sh`). Hosted MCP cannot see the agent git checkout. + +**Do not** run `rad auth` to mint a device DID in the cloud VM. + +Follow **`.cursor/skills/boxci-github-patch/`** instead: + +1. Push the commit to GitHub (`origin`). +2. `POST https://boxci.boxd.sh/api/patches/from-github` with sleek’s RID, + `https://github.com/codegod100/sleek.git`, and the commit SHA. +3. Poll the boxci run and surface `https://boxci.boxd.sh/runs/` plus + `patch_id` from the step output. + +Sleek defaults: + +| Field | Value | +|-------|--------| +| `repo` | `rad:z9mjPzpVK472QXaaP1picc5U9xBR` | +| `github_repo_url` | `https://github.com/codegod100/sleek.git` | +| `branch` | `main` | + +## Local / CI exception + +Only when `RADICLE_SECRET_KEY` (or an existing `$RAD_HOME`) is already loaded +and `git remote get-url rad` works — e.g. Buildkite issue→agent via +`scripts/buildkite/bootstrap.sh` — you may open a patch with: + +```bash +git push rad HEAD:refs/patches \ + -o patch.message="Title" \ + -o patch.message="Body paragraph" +``` + +Never commit key material under `.radicle/` or elsewhere. diff --git a/.gitignore b/.gitignore index c9d71fb..147e3ef 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,6 @@ result result-* .buildkite/.issue-poll-state +/host/gleam/slash/build/ +/host/target/ +.radicle/ diff --git a/AGENTS.md b/AGENTS.md index 992cb55..9a21572 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,6 +8,18 @@ the `android/` crate is the shared UI/logic library (also built for Android via `cargo-apk`/Waydroid, which is not runnable here — no binder/Waydroid kernel support). +### Radicle git remote (`rad`) +GitHub / Cursor checkouts only have `origin`. Opening Radicle patches requires a +`rad` remote for RID [`rad:z9mjPzpVK472QXaaP1picc5U9xBR`](https://nandi.radicle.garden/rad:z9mjPzpVK472QXaaP1picc5U9xBR): + +```bash +bash scripts/ensure-rad-remote.sh +# equivalent: git remote add rad rad://z9mjPzpVK472QXaaP1picc5U9xBR +``` + +`scripts/codespace-bootstrap.sh` runs this idempotently. Cursor Cloud also runs it +via `.cursor/environment.json` (`install` + `start`) so fresh clones keep `rad`. + ### Toolchain lives in the Nix flake dev shell - Nix (Determinate, multi-user, installed with `--init none`) is preinstalled in the VM image. There is **no systemd**, so the `nix-daemon` is not started @@ -80,6 +92,37 @@ export OPENBAO_TOKEN=… # or use Cursor env printf '%s' "$OPENBAO_TOKEN" | gh secret set OPENBAO_TOKEN -R codegod100/sleek ``` +### Radicle patches (cloud agents → boxci, not MCP) +Cloud / GitHub checkouts should **not** use the radicle MCP +(`create_patch` / `issue_device_key` on `mcp.boxd.sh`) and must **not** run +`rad auth` to mint a device DID. Publish patches via **boxci**: + +Skill: `.cursor/skills/boxci-github-patch/` (upstream: +[`codegod100/cursor`](https://github.com/codegod100/cursor) → +`.cursor/skills/boxci-github-patch`). + +```bash +# After pushing the commit to GitHub: +curl -sS -X POST https://boxci.boxd.sh/api/patches/from-github \ + -H 'Content-Type: application/json' \ + -d "{ + \"repo\": \"rad:z9mjPzpVK472QXaaP1picc5U9xBR\", + \"github_repo_url\": \"https://github.com/codegod100/sleek.git\", + \"github_commit\": \"$(git rev-parse HEAD)\", + \"title\": \"…\", + \"description\": \"…\" + }" +# Poll: GET https://boxci.boxd.sh/api/runs/ +# Lead with https://boxci.boxd.sh/runs/ ; read patch_id= from output_tail +``` + +Helper: `./scripts/boxci/dispatch-github-patch.sh`. Pointer skill for exceptions: +`.cursor/skills/radicle-patch/`. Never commit `.radicle/` key material. + +CI (Buildkite issue→agent) may still `git push rad` after +`scripts/buildkite/bootstrap.sh` loads `RADICLE_SECRET_KEY` from OpenBao +`secret/data/radicle`. + ### Buildkite (baogui reference) Org `nandi`, Default cluster, hosted queue `auto`. Reference pipeline: [baogui-aopjch](https://buildkite.com/nandi/baogui-aopjch). Sleek pipeline: diff --git a/android/Cargo.lock b/android/Cargo.lock index 9cb368c..d6279d7 100644 --- a/android/Cargo.lock +++ b/android/Cargo.lock @@ -2149,6 +2149,18 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fast-interleave" version = "0.1.3" @@ -2855,6 +2867,15 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "hashlink" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" +dependencies = [ + "hashbrown 0.16.1", +] + [[package]] name = "heapless" version = "0.7.17" @@ -3894,6 +3915,17 @@ dependencies = [ "system-deps", ] +[[package]] +name = "libsqlite3-sys" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95b4103cffefa72eb8428cb6b47d6627161e51c2739fc5e3b734584157bc642a" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -6222,6 +6254,16 @@ dependencies = [ "smallvec", ] +[[package]] +name = "rsqlite-vfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" +dependencies = [ + "hashbrown 0.16.1", + "thiserror 2.0.19", +] + [[package]] name = "rubato" version = "0.16.2" @@ -6250,6 +6292,21 @@ dependencies = [ "windowfunctions", ] +[[package]] +name = "rusqlite" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1c93dd1c9683b438c392c492109cb702b8090b2bfc8fed6f6e4eb4523f17af3" +dependencies = [ + "bitflags 2.13.1", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + [[package]] name = "rustc-demangle" version = "0.1.28" @@ -6914,6 +6971,7 @@ dependencies = [ "open", "reqwest 0.12.28", "rfd", + "rusqlite", "rustls", "serde", "serde_json", @@ -7156,6 +7214,18 @@ dependencies = [ "der 0.8.1", ] +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -8045,6 +8115,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "vergen" version = "9.1.0" diff --git a/android/Cargo.toml b/android/Cargo.toml index 30ea865..9dc6b08 100644 --- a/android/Cargo.toml +++ b/android/Cargo.toml @@ -68,6 +68,8 @@ urlencoding = "2" url = "2" base64 = "0.22" dirs = "5" +# Local message index for durable unread counts (independent of the server). +rusqlite = { version = "0.38", features = ["bundled"] } # Decode (file pick) + PNG encode (compose bar uploads). image = { version = "0.25", default-features = false, features = [ "png", diff --git a/android/src/app.rs b/android/src/app.rs index 129ffb1..e4acdb1 100644 --- a/android/src/app.rs +++ b/android/src/app.rs @@ -18,8 +18,9 @@ use crate::state::{ LinkMeta, MediaFetch, Route, Tab, }; use crate::ui::{ - self, image_lightbox_overlay, open_verification_url, policy_gate_overlay, ChatAction, - ChatsAction, ConnectAction, DiscoverAction, PolicyGateAction, SettingsAction, + self, image_lightbox_overlay, open_profile_url, open_verification_url, policy_gate_overlay, + profile_gate_overlay, ChatAction, ChatsAction, ConnectAction, DiscoverAction, PolicyGateAction, + ProfileGateAction, SettingsAction, }; /// Phone-shaped default for local desktop; on Codespaces / noVNC fill the @@ -714,6 +715,29 @@ impl SleekApp { .apply_results(request_id, query, actors); } } + NetEvent::ProfileFetched { + request_id, + result, + } => { + if self.state.profile_gate.fetch_id != request_id { + return; + } + self.state.profile_gate.profile_fetching = false; + self.state.profile_gate.loading = false; + match result { + Ok(profile) => { + if self.state.profile_gate.did.is_none() { + self.state.profile_gate.did = Some(profile.did.clone()); + } + self.state.profile_gate.error = None; + self.state.profile_gate.profile = Some(profile); + } + Err(e) => { + self.state.profile_gate.error = Some(e); + self.state.profile_gate.profile = None; + } + } + } NetEvent::PolicyFetched { request_id, check, @@ -997,7 +1021,7 @@ impl SleekApp { } let msg = ChatMessage { - id: msgid, + id: msgid.clone(), from: from.clone(), text: body, is_system: false, @@ -1015,14 +1039,58 @@ impl SleekApp { let buf = self.state.ensure_buffer(&buffer_name); buf.append(msg); - if !viewing && !is_own { - buf.unread = buf.unread.saturating_add(1); - } + self.state.record_message_for_unread( + &buffer_name, + &msgid, + timestamp.timestamp(), + is_own, + viewing, + ); } Event::MemberDid { nick, did } => { // Fold nick-keyed DM into DID-keyed thread when peer identity // is learned (join / whois / account tag on first message). self.state.adopt_dm_binding(&nick, &did); + if self + .state + .profile_gate + .nick + .as_ref() + .is_some_and(|n| n.eq_ignore_ascii_case(&nick)) + { + self.state.profile_gate.did = Some(did.clone()); + self.maybe_fetch_profile_for_gate(); + } + } + Event::WhoisReply { nick, info } => { + if self + .state + .profile_gate + .nick + .as_ref() + .is_some_and(|n| n.eq_ignore_ascii_case(&nick)) + { + if !self + .state + .profile_gate + .whois_lines + .iter() + .any(|l| l == &info) + { + self.state.profile_gate.whois_lines.push(info); + } + // Offline / unknown nick: stop waiting on IRC identity. A + // handle-shaped nick may still resolve via Bluesky AppView. + if !self.state.profile_gate.profile_fetching + && self.state.profile_gate.profile.is_none() + { + if self.state.profile_gate.bluesky_actor().is_some() { + self.maybe_fetch_profile_for_gate(); + } else { + self.state.profile_gate.loading = false; + } + } + } } Event::ChatHistoryTarget { nick, @@ -1184,7 +1252,29 @@ impl SleekApp { self.handle_delete_tags(&key, &from, &tags); self.handle_av_tags(&key, &tags); } - // batches, history, etc. — not yet rendered + Event::BatchStart { + id, + batch_type, + target, + } => { + if batch_type == "chathistory" && !target.is_empty() { + self.state.history_batches.insert(id, target); + } + } + Event::BatchEnd { id } => { + if let Some(channel) = self.state.history_batches.remove(&id) { + let account = self.state.account_key(); + if let Err(e) = self + .state + .message_store + .baseline_after_history(&account, &channel) + { + log::warn!("message_store history baseline {channel}: {e:#}"); + } + self.state.refresh_unread(&channel); + } + } + // other batches / events — not yet rendered _ => {} } } @@ -1925,6 +2015,79 @@ impl SleekApp { }); } + /// Open peer profile dialog; WHOIS for DID if unknown, then Bluesky getProfile. + fn open_profile_gate(&mut self, nick: &str) { + let nick = nick.trim(); + if nick.is_empty() { + return; + } + self.state.open_profile_gate(nick); + if let Some(n) = self.state.profile_gate.nick.clone() { + if !self.state.profile_gate.whois_sent { + self.state.profile_gate.whois_sent = true; + self.net.send(NetCmd::Raw(format!("WHOIS {n}"))); + } + } + // Fetch immediately when we already have a DID or a handle-shaped nick. + self.maybe_fetch_profile_for_gate(); + } + + fn maybe_fetch_profile_for_gate(&mut self) { + if !self.state.profile_gate.is_open() || self.state.profile_gate.profile_fetching { + return; + } + if self.state.profile_gate.profile.is_some() { + self.state.profile_gate.loading = false; + return; + } + let Some(actor) = self + .state + .profile_gate + .bluesky_actor() + .map(|s| s.to_string()) + else { + // No Bluesky actor yet — UI shows "Looking up…" until WHOIS settles. + return; + }; + let request_id = self.state.profile_gate.fetch_id; + self.state.profile_gate.profile_fetching = true; + self.state.profile_gate.loading = true; + self.state.profile_gate.error = None; + self.net.send(NetCmd::FetchProfile { + request_id, + actor, + }); + } + + fn handle_profile_gate_action(&mut self, ctx: &egui::Context, act: ProfileGateAction) { + match act { + ProfileGateAction::None => {} + ProfileGateAction::Close => { + self.state.close_profile_gate(); + } + ProfileGateAction::Message { nick } => { + self.state.close_profile_gate(); + let key = self.state.dm_buffer_key(&nick); + let need_history = self + .state + .channels + .get(&key) + .map(|b| !b.has_chat_messages()) + .unwrap_or(true); + self.state.open_chat(&key); + if need_history { + self.net.send(NetCmd::HistoryLatest { + target: key, + count: 100, + }); + } + } + ProfileGateAction::OpenBluesky { url } => { + open_profile_url(ctx, &url); + } + } + } + fn refresh_policy_gate(&mut self) { let channel = match self.state.policy_gate.channel.clone() { Some(c) => c, @@ -2564,7 +2727,10 @@ impl eframe::App for SleekApp { }; let mut label = tab.short().to_string(); if tab == Tab::Chats && total_unread > 0 { - label = format!("{label} ({})", total_unread.min(99)); + label = format!( + "{label} ({})", + crate::message_store::unread_label(total_unread) + ); } let text = RichText::new(label) .size(th.type_scale.caption) @@ -2747,6 +2913,9 @@ impl eframe::App for SleekApp { ChatAction::OpenPolicyGate(channel) => { self.open_policy_gate(&channel); } + ChatAction::OpenProfile(nick) => { + self.open_profile_gate(&nick); + } ChatAction::AvStart(channel) => { self.do_av_start(channel); } @@ -2902,6 +3071,9 @@ impl eframe::App for SleekApp { let gate_act = policy_gate_overlay(ctx, &th, &mut self.state); self.handle_policy_gate_action(ctx, gate_act); + let profile_act = profile_gate_overlay(ctx, &th, &mut self.state); + self.handle_profile_gate_action(ctx, profile_act); + // Android back gesture → Escape (egui-winit). At the root shell with // nothing left to dismiss, finish the Activity (leave the app). #[cfg(target_os = "android")] @@ -2910,7 +3082,8 @@ impl eframe::App for SleekApp { || self.state.connection == ConnectionState::Disconnected; let overlay_open = self.state.image_lightbox.is_some() || self.state.react_picker_msg.is_some() - || self.state.policy_gate.channel.is_some(); + || self.state.policy_gate.channel.is_some() + || self.state.profile_gate.is_open(); if at_root && !overlay_open && ctx.input_mut(|i| { diff --git a/android/src/auth.rs b/android/src/auth.rs index 4528339..e9c2db2 100644 --- a/android/src/auth.rs +++ b/android/src/auth.rs @@ -114,7 +114,7 @@ impl Default for SavedPrefs { } /// Writable app config directory (`~/.config/sleek` on desktop; app files on Android). -fn storage_dir() -> PathBuf { +pub(crate) fn storage_dir() -> PathBuf { #[cfg(target_os = "android")] { if let Some(dir) = crate::android_media::app_storage_dir() { diff --git a/android/src/bsky.rs b/android/src/bsky.rs index e6aa716..e70e6c0 100644 --- a/android/src/bsky.rs +++ b/android/src/bsky.rs @@ -52,6 +52,81 @@ pub fn should_typeahead(raw: &str) -> bool { normalize_handle_query(raw).chars().count() >= TYPEAHEAD_MIN_CHARS } +/// Public Bluesky profile fields (from `app.bsky.actor.getProfile`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ActorProfile { + pub did: String, + pub handle: String, + pub display_name: Option, + pub description: Option, + pub avatar: Option, + pub followers_count: Option, + pub follows_count: Option, + pub posts_count: Option, +} + +/// Web URL for a Bluesky profile (`handle` or DID). +pub fn bluesky_profile_url(actor: &str) -> String { + format!("https://bsky.app/profile/{}", actor.trim().trim_start_matches('@')) +} + +/// Whether `did` is a Bluesky-resolvable AT Proto identity (not a guest `did:key`). +pub fn is_atproto_did(did: &str) -> bool { + let d = did.trim(); + (d.starts_with("did:plc:") || d.starts_with("did:web:")) && !d.is_empty() +} + +/// Whether `nick` looks like a Bluesky handle we can pass to `getProfile` +/// (e.g. `alice.bsky.social`, `chadfowler.com`) when WHOIS/DID is unavailable. +pub fn looks_like_bsky_handle(nick: &str) -> bool { + let n = nick.trim().trim_start_matches('@'); + if n.is_empty() || n.len() > 253 { + return false; + } + if n.starts_with("did:") || n.starts_with('#') || n.contains([' ', '/', '\\']) { + return false; + } + // Domain-shaped: at least one dot, no leading/trailing dots, ASCII-ish labels. + let Some((left, right)) = n.rsplit_once('.') else { + return false; + }; + !left.is_empty() + && !right.is_empty() + && right.chars().all(|c| c.is_ascii_alphanumeric()) + && n.chars() + .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-') +} + +/// Fetch a public Bluesky profile by handle or DID (no auth). +pub async fn fetch_actor_profile(actor: &str) -> Result { + let actor = actor.trim().trim_start_matches('@'); + if actor.is_empty() { + bail!("empty actor"); + } + let profile = freeq_sdk::pds::fetch_profile(actor) + .await + .with_context(|| format!("fetch profile {actor}"))?; + Ok(ActorProfile { + did: profile.did, + handle: profile.handle, + display_name: profile + .display_name + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()), + description: profile + .description + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()), + avatar: profile + .avatar + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()), + followers_count: profile.followers_count, + follows_count: profile.follows_count, + posts_count: profile.posts_count, + }) +} + /// Prefix search for Bluesky handles / display names. Does not require auth. pub async fn search_actors_typeahead( query: &str, @@ -288,6 +363,38 @@ mod tests { assert_eq!(normalize_handle_query(""), ""); } + #[test] + fn bluesky_profile_url_strips_at() { + assert_eq!( + bluesky_profile_url("@alice.bsky.social"), + "https://bsky.app/profile/alice.bsky.social" + ); + assert_eq!( + bluesky_profile_url("did:plc:abc"), + "https://bsky.app/profile/did:plc:abc" + ); + } + + #[test] + fn is_atproto_did_rejects_did_key() { + assert!(is_atproto_did("did:plc:abcdef")); + assert!(is_atproto_did("did:web:example.com")); + assert!(!is_atproto_did("did:key:z6Mk")); + assert!(!is_atproto_did("")); + assert!(!is_atproto_did("alice")); + } + + #[test] + fn looks_like_bsky_handle_accepts_domains() { + assert!(looks_like_bsky_handle("chadfowler.com")); + assert!(looks_like_bsky_handle("@alice.bsky.social")); + assert!(looks_like_bsky_handle("a.b")); + assert!(!looks_like_bsky_handle("alice")); + assert!(!looks_like_bsky_handle("#general")); + assert!(!looks_like_bsky_handle("did:plc:x")); + assert!(!looks_like_bsky_handle("foo bar.com")); + } + #[test] fn should_typeahead_requires_min_chars() { assert!(!should_typeahead("")); diff --git a/android/src/lib.rs b/android/src/lib.rs index 8e686a3..b272bea 100644 --- a/android/src/lib.rs +++ b/android/src/lib.rs @@ -6,11 +6,12 @@ mod av; mod av_media; mod bsky; mod clipboard; +mod message_store; mod net; mod policy; mod preview; mod reconnect; -mod slash; +pub mod slash; mod state; mod ui; diff --git a/android/src/message_store.rs b/android/src/message_store.rs new file mode 100644 index 0000000..fdfd487 --- /dev/null +++ b/android/src/message_store.rs @@ -0,0 +1,284 @@ +//! Local SQLite index of messages for durable unread counts. +//! +//! Counts are client-owned: we store msgid + timestamp per account/channel and +//! a last-read watermark. CHATHISTORY establishes a baseline so cold history is +//! not treated as unread; live traffic after that advances the badge. + +use std::path::Path; + +use anyhow::{Context, Result}; +use rusqlite::{params, Connection, OptionalExtension}; + +const SCHEMA: &str = r#" +CREATE TABLE IF NOT EXISTS messages ( + account TEXT NOT NULL, + channel TEXT NOT NULL COLLATE NOCASE, + msgid TEXT NOT NULL, + ts INTEGER NOT NULL, + from_me INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (account, channel, msgid) +); +CREATE INDEX IF NOT EXISTS idx_messages_account_channel_ts + ON messages(account, channel, ts); +CREATE TABLE IF NOT EXISTS read_state ( + account TEXT NOT NULL, + channel TEXT NOT NULL COLLATE NOCASE, + last_read_ts INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (account, channel) +); +"#; + +#[derive(Debug)] +pub struct MessageStore { + conn: Connection, +} + +impl MessageStore { + pub fn open_default() -> Self { + let path = crate::auth::storage_dir().join("messages.db"); + match Self::open(&path) { + Ok(store) => store, + Err(e) => { + log::warn!( + "message_store: open {} failed ({e:#}); using in-memory fallback", + path.display() + ); + Self::open_in_memory().expect("in-memory sqlite") + } + } + } + + pub fn open(path: &Path) -> Result { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("create {}", parent.display()))?; + } + let conn = Connection::open(path) + .with_context(|| format!("open sqlite {}", path.display()))?; + Self::from_conn(conn) + } + + pub fn open_in_memory() -> Result { + Self::from_conn(Connection::open_in_memory()?) + } + + fn from_conn(conn: Connection) -> Result { + conn.execute_batch( + "PRAGMA journal_mode=WAL; + PRAGMA synchronous=NORMAL; + PRAGMA foreign_keys=ON;", + )?; + conn.execute_batch(SCHEMA)?; + Ok(Self { conn }) + } + + /// Idempotent insert. Empty msgid is ignored. + pub fn insert_message( + &self, + account: &str, + channel: &str, + msgid: &str, + ts: i64, + from_me: bool, + ) -> Result<()> { + if msgid.is_empty() || msgid.starts_with("local-") || msgid.starts_with("sys-") { + return Ok(()); + } + self.conn.execute( + "INSERT OR IGNORE INTO messages(account, channel, msgid, ts, from_me) + VALUES (?1, ?2, ?3, ?4, ?5)", + params![account, channel, msgid, ts, from_me as i32], + )?; + Ok(()) + } + + /// Advance last-read to at least `at_ts` (and to the latest stored message). + pub fn mark_read(&self, account: &str, channel: &str, at_ts: i64) -> Result<()> { + let max_msg: i64 = self + .conn + .query_row( + "SELECT COALESCE(MAX(ts), 0) FROM messages WHERE account=?1 AND channel=?2", + params![account, channel], + |r| r.get(0), + ) + .unwrap_or(0); + let ts = at_ts.max(max_msg); + self.conn.execute( + "INSERT INTO read_state(account, channel, last_read_ts) + VALUES (?1, ?2, ?3) + ON CONFLICT(account, channel) DO UPDATE SET + last_read_ts = MAX(read_state.last_read_ts, excluded.last_read_ts)", + params![account, channel, ts], + )?; + Ok(()) + } + + /// After a CHATHISTORY batch: if this channel has no watermark yet, catch + /// up to the latest stored message so cold history is not unread. + pub fn baseline_after_history(&self, account: &str, channel: &str) -> Result<()> { + let exists: bool = self + .conn + .query_row( + "SELECT 1 FROM read_state WHERE account=?1 AND channel=?2", + params![account, channel], + |_| Ok(true), + ) + .optional()? + .unwrap_or(false); + if exists { + return Ok(()); + } + let max_ts: i64 = self.conn.query_row( + "SELECT COALESCE(MAX(ts), 0) FROM messages WHERE account=?1 AND channel=?2", + params![account, channel], + |r| r.get(0), + )?; + self.conn.execute( + "INSERT INTO read_state(account, channel, last_read_ts) VALUES (?1, ?2, ?3)", + params![account, channel, max_ts], + )?; + Ok(()) + } + + /// Before counting a live message: if there is no watermark, set it to the + /// max timestamp of *other* stored messages (history), or 0 if none — so + /// this live message can count as unread without resurrecting history. + pub fn ensure_live_baseline( + &self, + account: &str, + channel: &str, + exclude_msgid: &str, + ) -> Result<()> { + let exists: bool = self + .conn + .query_row( + "SELECT 1 FROM read_state WHERE account=?1 AND channel=?2", + params![account, channel], + |_| Ok(true), + ) + .optional()? + .unwrap_or(false); + if exists { + return Ok(()); + } + let max_other: i64 = self.conn.query_row( + "SELECT COALESCE(MAX(ts), 0) FROM messages + WHERE account=?1 AND channel=?2 AND msgid!=?3", + params![account, channel, exclude_msgid], + |r| r.get(0), + )?; + self.conn.execute( + "INSERT INTO read_state(account, channel, last_read_ts) VALUES (?1, ?2, ?3)", + params![account, channel, max_other], + )?; + Ok(()) + } + + /// Unread = non-own messages strictly after the watermark. + /// No watermark yet → 0 (baseline not established). + pub fn unread_count(&self, account: &str, channel: &str) -> Result { + let last_read: Option = self + .conn + .query_row( + "SELECT last_read_ts FROM read_state WHERE account=?1 AND channel=?2", + params![account, channel], + |r| r.get(0), + ) + .optional()?; + let Some(last_read) = last_read else { + return Ok(0); + }; + let n: i64 = self.conn.query_row( + "SELECT COUNT(*) FROM messages + WHERE account=?1 AND channel=?2 AND from_me=0 AND ts > ?3", + params![account, channel, last_read], + |r| r.get(0), + )?; + Ok(n.max(0) as u32) + } +} + +/// Badge label: show exact count up to 99, then `99+`. +pub fn unread_label(n: u32) -> String { + if n > 99 { + "99+".into() + } else { + n.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn history_baseline_then_live_unread() { + let store = MessageStore::open_in_memory().unwrap(); + let acct = "did:plc:test"; + let ch = "#general"; + + store + .insert_message(acct, ch, "h1", 100, false) + .unwrap(); + store + .insert_message(acct, ch, "h2", 200, false) + .unwrap(); + assert_eq!(store.unread_count(acct, ch).unwrap(), 0); + + store.baseline_after_history(acct, ch).unwrap(); + assert_eq!(store.unread_count(acct, ch).unwrap(), 0); + + store + .insert_message(acct, ch, "live1", 300, false) + .unwrap(); + store.ensure_live_baseline(acct, ch, "live1").unwrap(); + assert_eq!(store.unread_count(acct, ch).unwrap(), 1); + + store.mark_read(acct, ch, 300).unwrap(); + assert_eq!(store.unread_count(acct, ch).unwrap(), 0); + } + + #[test] + fn live_without_history_counts() { + let store = MessageStore::open_in_memory().unwrap(); + let acct = "guest:sleek"; + let ch = "#test"; + + store + .insert_message(acct, ch, "m1", 50, false) + .unwrap(); + store.ensure_live_baseline(acct, ch, "m1").unwrap(); + assert_eq!(store.unread_count(acct, ch).unwrap(), 1); + + store + .insert_message(acct, ch, "m2", 60, true) + .unwrap(); + assert_eq!(store.unread_count(acct, ch).unwrap(), 1); + } + + #[test] + fn reconnect_history_does_not_clobber_watermark() { + let store = MessageStore::open_in_memory().unwrap(); + let acct = "did:plc:a"; + let ch = "#room"; + + store.insert_message(acct, ch, "old", 10, false).unwrap(); + store.baseline_after_history(acct, ch).unwrap(); + store.insert_message(acct, ch, "new", 20, false).unwrap(); + store.ensure_live_baseline(acct, ch, "new").unwrap(); + assert_eq!(store.unread_count(acct, ch).unwrap(), 1); + + // Replay history including the unread message. + store.insert_message(acct, ch, "old", 10, false).unwrap(); + store.insert_message(acct, ch, "new", 20, false).unwrap(); + store.baseline_after_history(acct, ch).unwrap(); + assert_eq!(store.unread_count(acct, ch).unwrap(), 1); + } + + #[test] + fn unread_label_caps() { + assert_eq!(unread_label(0), "0"); + assert_eq!(unread_label(99), "99"); + assert_eq!(unread_label(100), "99+"); + } +} diff --git a/android/src/net.rs b/android/src/net.rs index 47a878a..d022c9f 100644 --- a/android/src/net.rs +++ b/android/src/net.rs @@ -11,7 +11,7 @@ use freeq_sdk::event::Event; use tokio::sync::mpsc; use crate::auth::{self, AuthTokens}; -use crate::bsky::{self, HandleSuggestion}; +use crate::bsky::{self, ActorProfile, HandleSuggestion}; use crate::state::{prefer_websocket, websocket_url_for}; use crate::av_media::{AvMediaConfig, AvMediaSession}; @@ -159,6 +159,12 @@ pub enum NetCmd { api_base: String, did: String, }, + /// Fetch a public Bluesky profile for the peer profile modal. + FetchProfile { + request_id: u64, + /// Handle or DID (`did:plc:` / `did:web:`). + actor: String, + }, Quit, } @@ -243,6 +249,11 @@ pub enum NetEvent { check: Result, rules: Result, }, + /// Bluesky profile for the peer profile modal. + ProfileFetched { + request_id: u64, + result: Result, + }, } /// Payload after a successful image upload + PRIVMSG. @@ -959,6 +970,25 @@ async fn apply_cmd( }); }); } + NetCmd::FetchProfile { + request_id, + actor, + } => { + let tx = event_tx.clone(); + tokio::spawn(async move { + let result = match bsky::fetch_actor_profile(&actor).await { + Ok(p) => Ok(p), + Err(e) => { + log::debug!("fetch profile {actor}: {e}"); + Err(e.to_string()) + } + }; + let _ = tx.send(NetEvent::ProfileFetched { + request_id, + result, + }); + }); + } NetCmd::Quit => { media.stop().await; if let Some(h) = handle.take() { diff --git a/android/src/slash.rs b/android/src/slash.rs index 817c47c..beb4c60 100644 --- a/android/src/slash.rs +++ b/android/src/slash.rs @@ -1,4 +1,9 @@ //! Compose-box slash commands — mirrors freeq-android `SlashCommandParser`. +//! +//! Desktop host may install a Gleam Wasm implementation via [`install_parse_hook`]; +//! Android and unit tests keep the native Rust parser. + +use std::sync::OnceLock; /// A parsed slash-command from the compose box. #[derive(Debug, Clone, PartialEq, Eq)] @@ -15,8 +20,32 @@ pub enum SlashCommand { Empty, } +type ParseHook = Box Result + Send + Sync>; + +static PARSE_HOOK: OnceLock = OnceLock::new(); + +/// Install a Gleam (or other) parser used by [`parse`] when present. +pub fn install_parse_hook(hook: ParseHook) { + let _ = PARSE_HOOK.set(hook); +} + /// Parse a compose-box string that starts with `/`. +/// +/// Prefers the installed hook (desktop Gleam guest); falls back to [`parse_native`]. pub fn parse(input: &str) -> SlashCommand { + if let Some(hook) = PARSE_HOOK.get() { + match hook(input) { + Ok(cmd) => return cmd, + Err(err) => { + log::warn!("slash parse hook failed ({err}); using native parser"); + } + } + } + parse_native(input) +} + +/// Native Rust parser (oracle for tests / Android / hook fallback). +pub fn parse_native(input: &str) -> SlashCommand { let without_slash = input.strip_prefix('/').unwrap_or(input); let (cmd, arg) = match without_slash.split_once(' ') { Some((c, rest)) => (c, Some(rest).filter(|s| !s.is_empty())), @@ -42,9 +71,7 @@ pub fn parse(input: &str) -> SlashCommand { return SlashCommand::Empty; }; match arg.split_once(' ') { - Some((target, text)) - if !target.is_empty() && !text.is_empty() => - { + Some((target, text)) if !target.is_empty() && !text.is_empty() => { SlashCommand::Msg { target: target.to_string(), text: text.to_string(), @@ -66,40 +93,43 @@ mod tests { #[test] fn parses_join_with_channel() { - assert_eq!(parse("/join #freeq"), SlashCommand::Join("#freeq".into())); + assert_eq!( + parse_native("/join #freeq"), + SlashCommand::Join("#freeq".into()) + ); } #[test] fn join_with_no_arg_is_empty() { - assert_eq!(parse("/join"), SlashCommand::Empty); - assert_eq!(parse("/join "), SlashCommand::Empty); + assert_eq!(parse_native("/join"), SlashCommand::Empty); + assert_eq!(parse_native("/join "), SlashCommand::Empty); } #[test] fn part_and_leave_both_resolve_to_part_active() { - assert_eq!(parse("/part"), SlashCommand::PartActive); - assert_eq!(parse("/leave"), SlashCommand::PartActive); + assert_eq!(parse_native("/part"), SlashCommand::PartActive); + assert_eq!(parse_native("/leave"), SlashCommand::PartActive); // Arg is ignored — dispatch uses the active channel. - assert_eq!(parse("/part #ignored"), SlashCommand::PartActive); + assert_eq!(parse_native("/part #ignored"), SlashCommand::PartActive); } #[test] fn parses_nick_change() { assert_eq!( - parse("/nick newhandle"), + parse_native("/nick newhandle"), SlashCommand::Nick("newhandle".into()) ); } #[test] fn nick_with_no_arg_is_empty() { - assert_eq!(parse("/nick"), SlashCommand::Empty); + assert_eq!(parse_native("/nick"), SlashCommand::Empty); } #[test] fn parses_me_action() { assert_eq!( - parse("/me waves hello"), + parse_native("/me waves hello"), SlashCommand::Me("waves hello".into()) ); } @@ -107,7 +137,7 @@ mod tests { #[test] fn parses_msg_with_target_and_text() { assert_eq!( - parse("/msg alice hi there"), + parse_native("/msg alice hi there"), SlashCommand::Msg { target: "alice".into(), text: "hi there".into(), @@ -117,14 +147,14 @@ mod tests { #[test] fn msg_without_text_is_empty() { - assert_eq!(parse("/msg alice"), SlashCommand::Empty); - assert_eq!(parse("/msg"), SlashCommand::Empty); + assert_eq!(parse_native("/msg alice"), SlashCommand::Empty); + assert_eq!(parse_native("/msg"), SlashCommand::Empty); } #[test] fn parses_topic() { assert_eq!( - parse("/topic the new topic"), + parse_native("/topic the new topic"), SlashCommand::Topic("the new topic".into()) ); } @@ -132,30 +162,30 @@ mod tests { #[test] fn unknown_command_falls_through_to_raw() { assert_eq!( - parse("/invite alice #freeq"), + parse_native("/invite alice #freeq"), SlashCommand::Raw("invite alice #freeq".into()) ); assert_eq!( - parse("/whois alice"), + parse_native("/whois alice"), SlashCommand::Raw("whois alice".into()) ); } #[test] fn command_lookup_is_case_insensitive() { - assert_eq!(parse("/JOIN #x"), SlashCommand::Join("#x".into())); - assert_eq!(parse("/Join #x"), SlashCommand::Join("#x".into())); + assert_eq!(parse_native("/JOIN #x"), SlashCommand::Join("#x".into())); + assert_eq!(parse_native("/Join #x"), SlashCommand::Join("#x".into())); } #[test] fn empty_or_lone_slash_input_is_empty() { - assert_eq!(parse("/"), SlashCommand::Empty); + assert_eq!(parse_native("/"), SlashCommand::Empty); } #[test] fn preserves_trailing_arguments_with_spaces() { assert_eq!( - parse("/me does the weird thing"), + parse_native("/me does the weird thing"), SlashCommand::Me("does the weird thing".into()) ); } diff --git a/android/src/state.rs b/android/src/state.rs index 778c288..fe668bb 100644 --- a/android/src/state.rs +++ b/android/src/state.rs @@ -1037,6 +1037,79 @@ pub fn api_base_for_server(server: &str) -> String { } } +/// Peer profile modal (nick tap → Bluesky / WHOIS info). +#[derive(Debug, Default)] +pub struct ProfileGate { + /// Nick the modal is open for (`None` = closed). + pub nick: Option, + /// Peer DID when known (from nick map / WHOIS account). + pub did: Option, + /// True while waiting on WHOIS and/or Bluesky `getProfile`. + pub loading: bool, + pub error: Option, + pub profile: Option, + /// Raw WHOIS reply lines for this nick (guest / extra detail). + pub whois_lines: Vec, + /// Monotonic id so stale HTTP responses are ignored. + pub fetch_id: u64, + /// True after we dispatched WHOIS for this open. + pub whois_sent: bool, + /// True while a Bluesky profile HTTP fetch is in flight. + pub profile_fetching: bool, +} + +impl ProfileGate { + pub fn is_open(&self) -> bool { + self.nick.is_some() + } + + pub fn open(&mut self, nick: String, did: Option) { + self.nick = Some(nick); + self.did = did; + // Only flipped true when a Bluesky HTTP fetch is actually started. + self.loading = false; + self.error = None; + self.profile = None; + self.whois_lines.clear(); + self.whois_sent = false; + self.profile_fetching = false; + self.fetch_id = self.fetch_id.wrapping_add(1); + } + + pub fn close(&mut self) { + *self = Self { + fetch_id: self.fetch_id, + ..Self::default() + }; + } + + /// Actor string to pass to Bluesky `getProfile`, if resolvable. + /// + /// Prefers an AT Proto DID; falls back to a domain-shaped nick (common + /// freeq pattern where the IRC nick *is* the Bluesky handle). + pub fn bluesky_actor(&self) -> Option<&str> { + if let Some(d) = self + .did + .as_deref() + .filter(|d| crate::bsky::is_atproto_did(d)) + { + return Some(d); + } + self.nick + .as_deref() + .filter(|n| crate::bsky::looks_like_bsky_handle(n)) + .map(|n| n.trim().trim_start_matches('@')) + } + + /// WHOIS finished without an AT identity (or nick is offline). + pub fn whois_exhausted(&self) -> bool { + self.whois_lines.iter().any(|l| { + let lower = l.to_ascii_lowercase(); + lower.contains("no such nick") || lower.contains("no such server") + }) + } +} + /// Join-gate modal state (policy acceptance before JOIN). #[derive(Debug, Default)] pub struct PolicyGate { @@ -1104,6 +1177,10 @@ pub struct AppState { pub nick_to_did: HashMap, /// Peer DID → display nick (for rendering DID-keyed buffers). pub did_to_nick: HashMap, + /// Durable unread index (SQLite). Client-owned; independent of the server. + pub message_store: crate::message_store::MessageStore, + /// Open `chathistory` BATCH id → channel (suppress unread until baseline). + pub history_batches: HashMap, pub active_channel: Option, pub route: Route, pub tab: Tab, @@ -1233,6 +1310,8 @@ pub struct AppState { /// Channel policy join-gate modal (authenticated users blocked by ACCEPT rules). pub policy_gate: PolicyGate, + /// Peer profile modal (tap nick in channel / member list). + pub profile_gate: ProfileGate, /// MoQ media-plane reconnect while `local_call` is still active. pub media_reconnect_attempts: u32, pub media_reconnect_at: Option, @@ -1288,6 +1367,8 @@ impl AppState { channel_order: Vec::new(), nick_to_did: HashMap::new(), did_to_nick: HashMap::new(), + message_store: crate::message_store::MessageStore::open_default(), + history_batches: HashMap::new(), active_channel: None, route: Route::Tabs, tab: Tab::Chats, @@ -1356,6 +1437,7 @@ impl AppState { reconnect_attempts: 0, reconnect_at: None, policy_gate: PolicyGate::default(), + profile_gate: ProfileGate::default(), media_reconnect_attempts: 0, media_reconnect_at: None, media_live_since: None, @@ -1794,13 +1876,88 @@ impl AppState { self.channels.values().map(|b| b.unread).sum() } + /// Stable account namespace for the local message index. + pub fn account_key(&self) -> String { + self.did + .clone() + .unwrap_or_else(|| format!("guest:{}", self.nick.to_lowercase())) + } + + /// Whether `channel` currently has an in-flight CHATHISTORY batch. + pub fn channel_in_history_batch(&self, channel: &str) -> bool { + self.history_batches + .values() + .any(|c| c.eq_ignore_ascii_case(channel)) + } + + /// Record a chat message in SQLite and refresh the buffer unread badge. + pub fn record_message_for_unread( + &mut self, + channel: &str, + msgid: &str, + ts: i64, + from_me: bool, + viewing: bool, + ) { + if channel == "*status" || msgid.is_empty() { + return; + } + let account = self.account_key(); + let in_history = self.channel_in_history_batch(channel); + if let Err(e) = + self.message_store + .insert_message(&account, channel, msgid, ts, from_me) + { + log::warn!("message_store insert: {e:#}"); + return; + } + if viewing { + if let Err(e) = self.message_store.mark_read(&account, channel, ts) { + log::warn!("message_store mark_read: {e:#}"); + } + } else if !from_me && !in_history { + if let Err(e) = self + .message_store + .ensure_live_baseline(&account, channel, msgid) + { + log::warn!("message_store baseline: {e:#}"); + } + } + self.refresh_unread(channel); + } + + /// Recompute `Buffer.unread` from SQLite for one channel. + pub fn refresh_unread(&mut self, channel: &str) { + let account = self.account_key(); + let n = match self.message_store.unread_count(&account, channel) { + Ok(n) => n, + Err(e) => { + log::warn!("message_store unread: {e:#}"); + return; + } + }; + if let Some(buf) = self + .find_buffer_key(channel) + .and_then(|k| self.channels.get_mut(&k)) + { + buf.unread = n; + } + } + pub fn ensure_buffer(&mut self, name: &str) -> &mut Buffer { // Prefer an existing case-insensitive match so "Alice" / "alice" share one row. if let Some(existing) = self.find_buffer_key(name) { return self.channels.get_mut(&existing).expect("key from find"); } let key = name.to_string(); - self.channels.insert(key.clone(), Buffer::new(&key)); + let account = self.account_key(); + let unread = self + .message_store + .unread_count(&account, &key) + .unwrap_or(0); + let mut buf = Buffer::new(&key); + buf.unread = unread; + self.channels.insert(key.clone(), buf); self.channel_order.push(key.clone()); self.channels.get_mut(&key).expect("just inserted") } @@ -1964,8 +2121,17 @@ impl AppState { // re-sets these immediately after `open_chat`). self.scroll_to_msgid = None; self.clear_search_highlight(); + let account = self.account_key(); + let at = chrono::Utc::now().timestamp(); + if let Err(e) = self.message_store.mark_read(&account, &key, at) { + log::warn!("message_store mark_read {key}: {e:#}"); + } + let unread = self + .message_store + .unread_count(&account, &key) + .unwrap_or(0); if let Some(buf) = self.channels.get_mut(&key) { - buf.mark_read(); + buf.unread = unread; } } @@ -1995,6 +2161,9 @@ impl AppState { if self.image_lightbox.is_some() { self.close_image_lightbox(); false + } else if self.profile_gate.is_open() { + self.close_profile_gate(); + false } else if self.react_picker_msg.is_some() { self.close_react_picker(); false @@ -2129,6 +2298,22 @@ impl AppState { self.policy_gate.close(); } + /// Open the peer profile modal for `nick` (resolves DID from nick map when known). + pub fn open_profile_gate(&mut self, nick: &str) -> u64 { + let nick = nick.trim(); + if nick.is_empty() { + return self.profile_gate.fetch_id; + } + let did = self.nick_to_did.get(&nick.to_lowercase()).cloned(); + self.close_react_picker(); + self.profile_gate.open(nick.to_string(), did); + self.profile_gate.fetch_id + } + + pub fn close_profile_gate(&mut self) { + self.profile_gate.close(); + } + /// Start editing an existing message in the compose bar. pub fn begin_edit(&mut self, msgid: String, text: String) { self.cancel_reply(); @@ -2205,6 +2390,7 @@ impl AppState { self.show_members = false; self.close_message_search(); self.close_react_picker(); + self.close_profile_gate(); self.scroll_to_msgid = None; self.clear_search_highlight(); self.compose.clear(); @@ -2608,6 +2794,11 @@ mod tests { assert!(!state.chat_back_step()); assert!(state.image_lightbox.is_none()); + state.open_profile_gate("alice"); + assert!(state.profile_gate.is_open()); + assert!(!state.chat_back_step()); + assert!(!state.profile_gate.is_open()); + state.open_react_picker("m1".into()); assert!(!state.chat_back_step()); assert!(state.react_picker_msg.is_none()); @@ -2648,6 +2839,26 @@ mod tests { assert!(state.chat_back_step()); } + #[test] + fn profile_gate_handle_nick_is_bluesky_actor_without_did() { + let mut gate = ProfileGate::default(); + gate.open("chadfowler.com".into(), None); + assert!(!gate.loading); + assert_eq!(gate.bluesky_actor(), Some("chadfowler.com")); + gate.whois_lines + .push("chadfowler.com: No such nick".into()); + assert!(gate.whois_exhausted()); + } + + #[test] + fn profile_gate_plain_nick_needs_did_for_bluesky() { + let mut gate = ProfileGate::default(); + gate.open("alice".into(), None); + assert!(gate.bluesky_actor().is_none()); + gate.did = Some("did:plc:abc".into()); + assert_eq!(gate.bluesky_actor(), Some("did:plc:abc")); + } + #[test] fn apply_edit_rewrites_in_place_and_delete_matches_root() { let mut buf = Buffer::new("#t"); diff --git a/android/src/ui/chat.rs b/android/src/ui/chat.rs index 283a273..dbf774d 100644 --- a/android/src/ui/chat.rs +++ b/android/src/ui/chat.rs @@ -19,7 +19,8 @@ pub enum ChatAction { Back, Send { target: String, text: String }, Part(String), - /// Open a direct message with this nick (from the user list). + /// Open a direct message with this nick (profile dialog / callers). + #[allow(dead_code)] OpenDm(String), /// Start a freeq AV call in this channel. AvStart(String), @@ -59,6 +60,8 @@ pub enum ChatAction { OpenChannel(String), /// Open the channel policy join-gate modal. OpenPolicyGate(String), + /// Open the peer profile modal for this nick. + OpenProfile(String), } fn apply_message_bubble_action( @@ -108,6 +111,9 @@ fn apply_message_bubble_action( MessageBubbleAction::OpenChannel { channel } => { *action = ChatAction::OpenChannel(channel); } + MessageBubbleAction::OpenProfile { nick } => { + *action = ChatAction::OpenProfile(nick); + } } } @@ -123,7 +129,8 @@ pub fn chat_screen(ui: &mut egui::Ui, th: &Theme, state: &mut AppState, channel: // Lightbox / react-picker / policy-gate Esc are handled in their overlays. let overlay_open = state.image_lightbox.is_some() || state.react_picker_msg.is_some() - || state.policy_gate.channel.is_some(); + || state.policy_gate.channel.is_some() + || state.profile_gate.is_open(); if !overlay_open && consume_command(ui, Key::F) { if state.show_message_search { state.focus_message_search = true; @@ -452,21 +459,17 @@ pub fn chat_screen(ui: &mut egui::Ui, th: &Theme, state: &mut AppState, channel: if is_self { dim_label(ui, th, "you"); } else { - dim_label(ui, th, "tap to message"); + dim_label(ui, th, "tap for profile"); } }); }); }); let resp = ui .interact(inner.response.rect, row_id, Sense::click()) - .on_hover_cursor(if is_self { - CursorIcon::Default - } else { - CursorIcon::PointingHand - }); - if resp.clicked() && !is_self { + .on_hover_cursor(CursorIcon::PointingHand); + if resp.clicked() { state.show_members = false; - action = ChatAction::OpenDm(nick.clone()); + action = ChatAction::OpenProfile(nick.clone()); } if i + 1 < n { ui.add_space(sp.xs); diff --git a/android/src/ui/chats.rs b/android/src/ui/chats.rs index f17f599..657cf84 100644 --- a/android/src/ui/chats.rs +++ b/android/src/ui/chats.rs @@ -1,7 +1,7 @@ //! Chats tab — conversation list (freeq-android ChatsTab inspired). -use eframe::egui::{self, Align, Layout}; -use vidya::{button, dim_label, primary_button, text_field_singleline, title, title_2, Theme}; +use eframe::egui; +use vidya::{button, primary_button, title_2, Theme}; use crate::state::AppState; use crate::ui::widgets::{card, conversation_row, empty_state, text_edit_clipboard_menu}; @@ -17,28 +17,6 @@ pub fn chats_tab(ui: &mut egui::Ui, th: &Theme, state: &mut AppState) -> ChatsAc let sp = &th.spacing; let p = &th.palette; - // Header row - ui.horizontal(|ui| { - title(ui, th, "Chats"); - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - dim_label(ui, th, state.connection.label()); - }); - }); - ui.add_space(sp.md); - - // Search - ui.horizontal(|ui| { - ui.set_width(ui.available_width()); - let _ = text_field_singleline(ui, th, &mut state.search); - }); - if state.search.is_empty() { - // Hint sits above; field itself has no placeholder API on helper — - // show dim hint when empty via overlaid caption. - } - ui.add_space(sp.xs); - dim_label(ui, th, "Search chats"); - ui.add_space(sp.md); - // Quick join card(ui, th, |ui| { title_2(ui, th, "Join channel"); diff --git a/android/src/ui/mod.rs b/android/src/ui/mod.rs index e9d0187..1a2e916 100644 --- a/android/src/ui/mod.rs +++ b/android/src/ui/mod.rs @@ -5,6 +5,7 @@ mod chats; mod connect; mod discover; mod policy_gate; +mod profile; mod search; mod settings; mod widgets; @@ -14,6 +15,7 @@ pub use chats::{chats_tab, ChatsAction}; pub use connect::{connect_screen, ConnectAction}; pub use discover::{discover_tab, DiscoverAction}; pub use policy_gate::{open_verification_url, policy_gate_overlay, PolicyGateAction}; +pub use profile::{open_profile_url, profile_gate_overlay, ProfileGateAction}; pub use search::{message_search_panel, SearchAction}; pub use settings::{settings_tab, SettingsAction}; // Widget helpers are used from screens via `widgets::` or direct calls as needed. diff --git a/android/src/ui/profile.rs b/android/src/ui/profile.rs new file mode 100644 index 0000000..843ee63 --- /dev/null +++ b/android/src/ui/profile.rs @@ -0,0 +1,348 @@ +//! Peer profile modal (freeq `UserPopover` parity — nick tap → Bluesky). + +use eframe::egui::{self, Align, Color32, CursorIcon, Layout, RichText, ScrollArea, Sense, Stroke, Vec2}; +use vidya::{button, dim_label, primary_button, Theme}; + +use crate::bsky::{self, ActorProfile}; +use crate::state::{AppState, ImageState}; + +pub enum ProfileGateAction { + None, + Close, + Message { nick: String }, + OpenBluesky { url: String }, +} + +/// Centered modal with peer nick, DID, Bluesky profile, and actions. +pub fn profile_gate_overlay( + ctx: &egui::Context, + th: &Theme, + state: &mut AppState, +) -> ProfileGateAction { + let Some(nick) = state.profile_gate.nick.clone() else { + return ProfileGateAction::None; + }; + + let p = &th.palette; + let sp = &th.spacing; + let frame = egui::Frame::new() + .fill(p.card_bg) + .stroke(Stroke::new(1.0, p.border_soft)) + .corner_radius(sp.radius_md) + .inner_margin(egui::Margin::symmetric(16, 14)) + .shadow(egui::epaint::Shadow { + offset: [0, 4], + blur: 24, + spread: 0, + color: Color32::from_black_alpha(80), + }); + + let mut action = ProfileGateAction::None; + let mut close = false; + let is_self = nick.eq_ignore_ascii_case(&state.nick); + let did = state.profile_gate.did.clone(); + let loading = state.profile_gate.loading; + let error = state.profile_gate.error.clone(); + let profile = state.profile_gate.profile.clone(); + let whois = state.profile_gate.whois_lines.clone(); + let whois_sent = state.profile_gate.whois_sent; + let whois_exhausted = state.profile_gate.whois_exhausted(); + let avatar_url = profile.as_ref().and_then(|pr| pr.avatar.clone()); + + let modal = egui::Modal::new(egui::Id::new("profile_gate_modal")) + .backdrop_color(Color32::from_black_alpha(140)) + .frame(frame) + .show(ctx, |ui| { + let panel_w = (ctx.screen_rect().width() - 32.0).clamp(280.0, 380.0); + ui.set_width(panel_w); + + ui.horizontal(|ui| { + ui.label( + RichText::new("Profile") + .size(th.type_scale.title) + .color(p.text) + .strong(), + ); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + let close_resp = ui + .add( + egui::Button::new( + RichText::new("✕") + .size(th.type_scale.body) + .color(p.text_secondary), + ) + .fill(Color32::TRANSPARENT) + .stroke(Stroke::NONE) + .frame(false), + ) + .on_hover_cursor(CursorIcon::PointingHand); + if close_resp.clicked() { + close = true; + } + }); + }); + + ui.add_space(sp.md); + ui.separator(); + ui.add_space(sp.sm); + + // Sticky identity header (avatar + name/handle); scroll the rest. + ui.horizontal(|ui| { + profile_avatar(ui, th, state, &nick, avatar_url.as_deref(), 56.0); + ui.add_space(sp.md); + ui.vertical(|ui| { + let display = profile + .as_ref() + .and_then(|pr| pr.display_name.as_deref()) + .unwrap_or(nick.as_str()); + ui.label( + RichText::new(display) + .size(th.type_scale.body) + .color(p.text) + .strong(), + ); + if display != nick { + dim_label(ui, th, &nick); + } + if let Some(pr) = profile.as_ref() { + if !pr.handle.is_empty() { + ui.label( + RichText::new(format!("@{}", pr.handle)) + .size(th.type_scale.caption) + .color(p.accent), + ); + } + } + }); + }); + + let body_h = (ctx.screen_rect().height() * 0.5).clamp(180.0, 420.0); + ScrollArea::vertical() + .id_salt("profile_gate_body") + .max_height(body_h) + .show(ui, |ui| { + if let Some(did) = did.as_ref() { + ui.add_space(sp.sm); + let did_resp = ui + .add( + egui::Label::new( + RichText::new(did) + .size(th.type_scale.caption) + .color(p.text_secondary) + .monospace(), + ) + .sense(Sense::click()), + ) + .on_hover_text("Copy DID") + .on_hover_cursor(CursorIcon::PointingHand); + if did_resp.clicked() { + ui.ctx().copy_text(did.clone()); + state.show_toast("DID copied"); + } + } + + if let Some(pr) = profile.as_ref() { + if let Some(desc) = pr.description.as_ref() { + ui.add_space(sp.sm); + ui.label( + RichText::new(truncate_bio(desc, 280)) + .size(th.type_scale.caption) + .color(p.text_secondary), + ); + } + if pr.followers_count.is_some() + || pr.follows_count.is_some() + || pr.posts_count.is_some() + { + ui.add_space(sp.sm); + dim_label(ui, th, &format_stats(pr)); + } + } + + if let Some(err) = error.as_ref() { + ui.add_space(sp.sm); + ui.label( + RichText::new(err) + .size(th.type_scale.caption) + .color(p.destructive), + ); + } + + let whois_done = whois_exhausted || !whois.is_empty(); + let looking_up = !loading + && profile.is_none() + && did.is_none() + && !whois_done + && whois_sent; + let is_guest = !loading + && profile.is_none() + && (did.as_ref().is_some_and(|d| d.starts_with("did:key:")) + || (did.is_none() && whois_done)); + + if loading { + ui.add_space(sp.md); + ui.horizontal(|ui| { + ui.spinner(); + ui.add_space(sp.sm); + dim_label(ui, th, "Loading Bluesky profile…"); + }); + } else if looking_up { + ui.add_space(sp.md); + ui.horizontal(|ui| { + ui.spinner(); + ui.add_space(sp.sm); + dim_label(ui, th, "Looking up identity…"); + }); + } else if is_guest { + ui.add_space(sp.sm); + if whois_exhausted { + dim_label( + ui, + th, + "Not on the server right now — no Bluesky profile found", + ); + } else { + dim_label(ui, th, "Guest — no Bluesky / AT Protocol identity"); + } + } + + // Skip raw "No such nick" noise when we already have a profile + // (common for domain-handle nicks who have quit). + let whois_show: Vec<&String> = whois + .iter() + .filter(|l| { + if profile.is_some() { + !l.to_ascii_lowercase().contains("no such nick") + } else { + true + } + }) + .collect(); + if !whois_show.is_empty() { + ui.add_space(sp.md); + ui.label( + RichText::new("WHOIS") + .size(th.type_scale.caption) + .color(p.text) + .strong(), + ); + ui.add_space(sp.xs); + for line in whois_show { + dim_label(ui, th, line); + } + } + }); + + ui.add_space(sp.sm); + ui.separator(); + ui.add_space(sp.sm); + + ui.horizontal(|ui| { + if !is_self && button(ui, th, "Message").clicked() { + action = ProfileGateAction::Message { nick: nick.clone() }; + } + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if let Some(pr) = profile.as_ref() { + if !pr.handle.is_empty() + && primary_button(ui, th, "Bluesky ↗").clicked() + { + action = ProfileGateAction::OpenBluesky { + url: bsky::bluesky_profile_url(&pr.handle), + }; + } + } + if button(ui, th, "Close").clicked() { + close = true; + } + }); + }); + }); + + if close || modal.should_close() { + action = ProfileGateAction::Close; + } + + action +} + +fn profile_avatar( + ui: &mut egui::Ui, + th: &Theme, + state: &mut AppState, + nick: &str, + avatar_url: Option<&str>, + size: f32, +) { + if let Some(url) = avatar_url { + state.media.touch_image(url); + if let Some(ImageState::Ready(pixels)) = state.media.images.get_mut(url) { + let tex = pixels.texture(ui.ctx(), url).clone(); + let (rect, _) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover()); + egui::Image::new((tex.id(), Vec2::splat(size))) + .fit_to_exact_size(Vec2::splat(size)) + .corner_radius(size * 0.5) + .paint_at(ui, rect); + return; + } + } + crate::ui::widgets::avatar_circle(ui, th, nick, size); +} + +fn truncate_bio(s: &str, max: usize) -> String { + let trimmed = s.trim(); + let first = trimmed.lines().next().unwrap_or(trimmed); + if first.chars().count() <= max { + first.to_string() + } else { + let cut: String = first.chars().take(max.saturating_sub(1)).collect(); + format!("{cut}…") + } +} + +fn format_stats(pr: &ActorProfile) -> String { + let mut parts = Vec::new(); + if let Some(n) = pr.followers_count { + parts.push(format!("{n} followers")); + } + if let Some(n) = pr.follows_count { + parts.push(format!("{n} following")); + } + if let Some(n) = pr.posts_count { + parts.push(format!("{n} posts")); + } + parts.join(" · ") +} + +/// Open a Bluesky (or other) URL in the system browser. +pub fn open_profile_url(ctx: &egui::Context, url: &str) { + #[cfg(target_os = "android")] + { + if crate::android_media::open_url(url).is_err() { + ctx.open_url(egui::OpenUrl::new_tab(url)); + } + } + #[cfg(not(target_os = "android"))] + { + if open::that(url).is_err() { + ctx.open_url(egui::OpenUrl::new_tab(url)); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn truncate_bio_keeps_short() { + assert_eq!(truncate_bio("hello", 10), "hello"); + } + + #[test] + fn truncate_bio_cuts_long() { + let s = "a".repeat(20); + let out = truncate_bio(&s, 10); + assert!(out.ends_with('…')); + assert_eq!(out.chars().count(), 10); + } +} diff --git a/android/src/ui/widgets.rs b/android/src/ui/widgets.rs index 0aa5560..aff626c 100644 --- a/android/src/ui/widgets.rs +++ b/android/src/ui/widgets.rs @@ -36,6 +36,8 @@ pub enum MessageBubbleAction { Delete { msgid: String }, /// Open / join an IRC channel mentioned in the message body. OpenChannel { channel: String }, + /// Open the peer profile modal for this nick. + OpenProfile { nick: String }, } /// Card frame filling parent width. @@ -199,7 +201,7 @@ pub fn conversation_row( ); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { if buf.unread > 0 { - badge(ui, th, &format!("{}", buf.unread.min(99))); + badge(ui, th, &crate::message_store::unread_label(buf.unread)); } else if buf.call.is_some() { ui.label( RichText::new("📞") @@ -1310,12 +1312,28 @@ pub fn message_bubble( ui.set_max_width(inner_w); ui.set_min_width(inner_w); ui.horizontal(|ui| { - ui.label( - RichText::new(&msg.from) - .size(th.type_scale.caption) - .color(p.accent) - .strong(), - ); + // Button (not Label) so the hit target is reliable on software + // GL / remote desktop where Label click sense is easy to miss. + let nick_resp = ui + .add( + egui::Button::new( + RichText::new(&msg.from) + .size(th.type_scale.caption) + .color(p.accent) + .strong(), + ) + .fill(Color32::TRANSPARENT) + .stroke(Stroke::NONE) + .frame(false) + .min_size(Vec2::new(0.0, th.type_scale.caption + 10.0)), + ) + .on_hover_cursor(CursorIcon::PointingHand) + .on_hover_text("View profile"); + if nick_resp.clicked() { + action = MessageBubbleAction::OpenProfile { + nick: msg.from.clone(), + }; + } ui.with_layout(Layout::right_to_left(Align::Center), |ui| { // Meta first in RTL → far right; action chip sits to its left. let mut meta = msg.time_label(); diff --git a/host/Cargo.lock b/host/Cargo.lock index 12cc0fb..4a3e9f6 100644 --- a/host/Cargo.lock +++ b/host/Cargo.lock @@ -24,7 +24,7 @@ version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" dependencies = [ - "gimli", + "gimli 0.32.3", ] [[package]] @@ -235,6 +235,15 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "ar_archive_writer" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73cd58deff2140a0a8eae87e417bd01db68a33e148aa93d1e8cd837e55e312b6" +dependencies = [ + "object 0.39.1", +] + [[package]] name = "arbitrary" version = "1.4.2" @@ -522,7 +531,7 @@ dependencies = [ "cfg-if", "libc", "miniz_oxide", - "object", + "object 0.37.3", "rustc-demangle", "windows-link", ] @@ -560,7 +569,7 @@ dependencies = [ "bitflags 2.13.1", "cexpr", "clang-sys", - "itertools", + "itertools 0.13.0", "log", "prettyplease", "proc-macro2", @@ -581,7 +590,7 @@ dependencies = [ "bitflags 2.13.1", "cexpr", "clang-sys", - "itertools", + "itertools 0.13.0", "proc-macro2", "quote", "regex", @@ -708,6 +717,9 @@ name = "bumpalo" version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +dependencies = [ + "allocator-api2", +] [[package]] name = "bytemuck" @@ -1179,7 +1191,7 @@ dependencies = [ "jni 0.21.1", "js-sys", "libc", - "mach2", + "mach2 0.5.0", "ndk", "ndk-context", "num-derive", @@ -1216,6 +1228,113 @@ dependencies = [ "libc", ] +[[package]] +name = "cranelift-bforest" +version = "0.116.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e15d04a0ce86cb36ead88ad68cf693ffd6cda47052b9e0ac114bc47fd9cd23c4" +dependencies = [ + "cranelift-entity", +] + +[[package]] +name = "cranelift-bitset" +version = "0.116.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c6e3969a7ce267259ce244b7867c5d3bc9e65b0a87e81039588dfdeaede9f34" +dependencies = [ + "serde", + "serde_derive", +] + +[[package]] +name = "cranelift-codegen" +version = "0.116.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c22032c4cb42558371cf516bb47f26cdad1819d3475c133e93c49f50ebf304e" +dependencies = [ + "bumpalo", + "cranelift-bforest", + "cranelift-bitset", + "cranelift-codegen-meta", + "cranelift-codegen-shared", + "cranelift-control", + "cranelift-entity", + "cranelift-isle", + "gimli 0.31.1", + "hashbrown 0.14.5", + "log", + "regalloc2", + "rustc-hash 2.1.3", + "serde", + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cranelift-codegen-meta" +version = "0.116.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c904bc71c61b27fc57827f4a1379f29de64fe95653b620a3db77d59655eee0b8" +dependencies = [ + "cranelift-codegen-shared", +] + +[[package]] +name = "cranelift-codegen-shared" +version = "0.116.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40180f5497572f644ce88c255480981ae2ec1d7bb4d8e0c0136a13b87a2f2ceb" + +[[package]] +name = "cranelift-control" +version = "0.116.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d132c6d0bd8a489563472afc171759da0707804a65ece7ceb15a8c6d7dd5ef" +dependencies = [ + "arbitrary", +] + +[[package]] +name = "cranelift-entity" +version = "0.116.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b2d0d9618275474fbf679dd018ac6e009acbd6ae6850f6a67be33fb3b00b323" +dependencies = [ + "cranelift-bitset", + "serde", + "serde_derive", +] + +[[package]] +name = "cranelift-frontend" +version = "0.116.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fac41e16729107393174b0c9e3730fb072866100e1e64e80a1a963b2e484d57" +dependencies = [ + "cranelift-codegen", + "log", + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cranelift-isle" +version = "0.116.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ca20d576e5070044d0a72a9effc2deacf4d6aa650403189d8ea50126483944d" + +[[package]] +name = "cranelift-native" +version = "0.116.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dee82f3f1f2c4cba9177f1cc5e350fe98764379bcd29340caa7b01f85076c7" +dependencies = [ + "cranelift-codegen", + "libc", + "target-lexicon", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -2149,6 +2268,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + [[package]] name = "fast-interleave" version = "0.1.3" @@ -2599,6 +2724,17 @@ dependencies = [ "weezl", ] +[[package]] +name = "gimli" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" +dependencies = [ + "fallible-iterator", + "indexmap 2.14.0", + "stable_deref_trait", +] + [[package]] name = "gimli" version = "0.32.3" @@ -2829,6 +2965,15 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -2836,6 +2981,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "foldhash 0.1.5", + "serde", ] [[package]] @@ -3201,6 +3347,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "ident_case" version = "1.0.1" @@ -3649,6 +3801,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.13.0" @@ -3832,6 +3993,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "leb128" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c83bff1d572d6b9aeef67ddfc8448e4a3737909cb28e81f97c791b9018703e52" + [[package]] name = "libc" version = "0.2.189" @@ -3967,6 +4134,15 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3d25b0e0b648a86960ac23b7ad4abb9717601dec6f66c165f5b037f3f03065f" +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + [[package]] name = "mach2" version = "0.5.0" @@ -4000,6 +4176,15 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "memfd" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad38eb12aea514a0466ea40a80fd8cc83637065948eb4a426e4aa46261175227" +dependencies = [ + "rustix 1.1.4", +] + [[package]] name = "memmap2" version = "0.9.11" @@ -5117,6 +5302,18 @@ dependencies = [ "objc2-foundation 0.2.2", ] +[[package]] +name = "object" +version = "0.36.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +dependencies = [ + "crc32fast", + "hashbrown 0.15.5", + "indexmap 2.14.0", + "memchr", +] + [[package]] name = "object" version = "0.37.3" @@ -5126,6 +5323,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "object" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b" +dependencies = [ + "memchr", +] + [[package]] name = "oid-registry" version = "0.8.1" @@ -5724,6 +5930,28 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +[[package]] +name = "psm" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dcd034599e63b970727f70d79e02d62390a4a84f7c6b827c27c46d5ac3fa622" +dependencies = [ + "ar_archive_writer", + "cc", +] + +[[package]] +name = "pulley-interpreter" +version = "29.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62d95f8575df49a2708398182f49a888cf9dc30210fb1fd2df87c889edcee75d" +dependencies = [ + "cranelift-bitset", + "log", + "sptr", + "wasmtime-math", +] + [[package]] name = "pxfm" version = "0.1.30" @@ -6034,6 +6262,20 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "regalloc2" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc06e6b318142614e4a48bc725abbf08ff166694835c43c9dae5a9009704639a" +dependencies = [ + "allocator-api2", + "bumpalo", + "hashbrown 0.15.5", + "log", + "rustc-hash 2.1.3", + "smallvec", +] + [[package]] name = "regex" version = "1.13.1" @@ -6932,6 +7174,7 @@ dependencies = [ "cpal", "eframe", "sleek", + "wasmtime", ] [[package]] @@ -6948,6 +7191,9 @@ name = "smallvec" version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +dependencies = [ + "serde", +] [[package]] name = "smithay-client-toolkit" @@ -7165,6 +7411,12 @@ dependencies = [ "der 0.8.0", ] +[[package]] +name = "sptr" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b9b39299b249ad65f3b7e96443bad61c02ca5cd3589f46cb6d610a0fd6c0d6a" + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -8223,6 +8475,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.221.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc8444fe4920de80a4fe5ab564fff2ae58b6b73166b89751f8c6c93509da32e5" +dependencies = [ + "leb128", + "wasmparser", +] + [[package]] name = "wasm-streams" version = "0.5.0" @@ -8236,6 +8498,215 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasmparser" +version = "0.221.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d06bfa36ab3ac2be0dee563380147a5b81ba10dd8885d7fbbc9eb574be67d185" +dependencies = [ + "bitflags 2.13.1", + "hashbrown 0.15.5", + "indexmap 2.14.0", + "semver", + "serde", +] + +[[package]] +name = "wasmprinter" +version = "0.221.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7343c42a97f2926c7819ff81b64012092ae954c5d83ddd30c9fcdefd97d0b283" +dependencies = [ + "anyhow", + "termcolor", + "wasmparser", +] + +[[package]] +name = "wasmtime" +version = "29.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11976a250672556d1c4c04c6d5d7656ac9192ac9edc42a4587d6c21460010e69" +dependencies = [ + "anyhow", + "bitflags 2.13.1", + "bumpalo", + "cc", + "cfg-if", + "hashbrown 0.14.5", + "indexmap 2.14.0", + "libc", + "log", + "mach2 0.4.3", + "memfd", + "object 0.36.7", + "once_cell", + "paste", + "postcard", + "psm", + "pulley-interpreter", + "rustix 0.38.44", + "serde", + "serde_derive", + "smallvec", + "sptr", + "target-lexicon", + "wasmparser", + "wasmtime-asm-macros", + "wasmtime-component-macro", + "wasmtime-cranelift", + "wasmtime-environ", + "wasmtime-fiber", + "wasmtime-jit-icache-coherence", + "wasmtime-math", + "wasmtime-slab", + "wasmtime-versioned-export-macros", + "windows-sys 0.59.0", +] + +[[package]] +name = "wasmtime-asm-macros" +version = "29.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f178b0d125201fbe9f75beaf849bd3e511891f9e45ba216a5b620802ccf64f2" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "wasmtime-component-macro" +version = "29.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d74de6592ed945d0a602f71243982a304d5d02f1e501b638addf57f42d57dfaf" +dependencies = [ + "anyhow", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasmtime-component-util", + "wasmtime-wit-bindgen", + "wit-parser", +] + +[[package]] +name = "wasmtime-component-util" +version = "29.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707dc7b3c112ab5a366b30cfe2fb5b2f8e6a0f682f16df96a5ec582bfe6f056e" + +[[package]] +name = "wasmtime-cranelift" +version = "29.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366be722674d4bf153290fbcbc4d7d16895cc82fb3e869f8d550ff768f9e9e87" +dependencies = [ + "anyhow", + "cfg-if", + "cranelift-codegen", + "cranelift-control", + "cranelift-entity", + "cranelift-frontend", + "cranelift-native", + "gimli 0.31.1", + "itertools 0.12.1", + "log", + "object 0.36.7", + "smallvec", + "target-lexicon", + "thiserror 1.0.69", + "wasmparser", + "wasmtime-environ", + "wasmtime-versioned-export-macros", +] + +[[package]] +name = "wasmtime-environ" +version = "29.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdadc1af7097347aa276a4f008929810f726b5b46946971c660b6d421e9994ad" +dependencies = [ + "anyhow", + "cranelift-bitset", + "cranelift-entity", + "gimli 0.31.1", + "indexmap 2.14.0", + "log", + "object 0.36.7", + "postcard", + "serde", + "serde_derive", + "smallvec", + "target-lexicon", + "wasm-encoder", + "wasmparser", + "wasmprinter", +] + +[[package]] +name = "wasmtime-fiber" +version = "29.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccba90d4119f081bca91190485650730a617be1fff5228f8c4757ce133d21117" +dependencies = [ + "anyhow", + "cc", + "cfg-if", + "rustix 0.38.44", + "wasmtime-asm-macros", + "wasmtime-versioned-export-macros", + "windows-sys 0.59.0", +] + +[[package]] +name = "wasmtime-jit-icache-coherence" +version = "29.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec5e8552e01692e6c2e5293171704fed8abdec79d1a6995a0870ab190e5747d1" +dependencies = [ + "anyhow", + "cfg-if", + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "wasmtime-math" +version = "29.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29210ec2aa25e00f4d54605cedaf080f39ec01a872c5bd520ad04c67af1dde17" +dependencies = [ + "libm", +] + +[[package]] +name = "wasmtime-slab" +version = "29.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcb5821a96fa04ac14bc7b158bb3d5cd7729a053db5a74dad396cd513a5e5ccf" + +[[package]] +name = "wasmtime-versioned-export-macros" +version = "29.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86ff86db216dc0240462de40c8290887a613dddf9685508eb39479037ba97b5b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "wasmtime-wit-bindgen" +version = "29.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8358319c2dd1e4db79e3c1c5d3a5af84956615343f9f89f4e4996a36816e06e6" +dependencies = [ + "anyhow", + "heck", + "indexmap 2.14.0", + "wit-parser", +] + [[package]] name = "wasmtimer" version = "0.4.3" @@ -9229,6 +9700,24 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wit-parser" +version = "0.221.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "896112579ed56b4a538b07a3d16e562d101ff6265c46b515ce0c701eef16b2ac" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.14.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + [[package]] name = "wl-clipboard-rs" version = "0.9.3" diff --git a/host/Cargo.toml b/host/Cargo.toml index f92c263..ae23b21 100644 --- a/host/Cargo.toml +++ b/host/Cargo.toml @@ -5,6 +5,7 @@ edition = "2021" description = "Sleek — desktop window host" license = "MIT" publish = false +build = "build.rs" [workspace] @@ -31,6 +32,11 @@ eframe = { version = "0.31", default-features = false, features = [ "wayland", "x11", ] } +# Gleam slash guest (compose-box parser) via wasmtime. +wasmtime = { version = "29", default-features = false, features = [ + "cranelift", + "runtime", +] } # Same patches as android/ (host is its own workspace root). [patch.crates-io] diff --git a/host/build.rs b/host/build.rs new file mode 100644 index 0000000..a831010 --- /dev/null +++ b/host/build.rs @@ -0,0 +1,125 @@ +//! Compile Gleam Wasm guests into OUT_DIR for `include_bytes!`. + +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn main() { + let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); + let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap()); + let gleam = find_gleam(&manifest_dir); + eprintln!("sleek-host build.rs: gleam → {}", gleam.display()); + println!("cargo:rerun-if-env-changed=GLEAM"); + + build_guest( + &gleam, + &manifest_dir, + &out_dir, + "gleam_slash", + "gleam/slash", + "src/gleam_slash.gleam", + ); +} + +fn build_guest( + gleam: &Path, + manifest_dir: &Path, + out_dir: &Path, + package: &str, + guest_rel: &str, + src_rel: &str, +) { + let guest_dir = manifest_dir.join(guest_rel); + let guest_src = guest_dir.join(src_rel); + + println!("cargo:rerun-if-changed={}", guest_src.display()); + println!( + "cargo:rerun-if-changed={}", + guest_dir.join("gleam.toml").display() + ); + + let status = Command::new(gleam) + .current_dir(&guest_dir) + .arg("build") + .status() + .unwrap_or_else(|err| { + panic!( + "failed to spawn gleam at {}: {err}\n\ + Set GLEAM to a wasm-capable gleam binary \ + (https://tangled.org/nandi.uk/gleam , branch wasm).", + gleam.display() + ) + }); + + if !status.success() { + panic!( + "`gleam build` failed in {} (status {status}).\n\ + Need Gleam with target = \"wasm\" support:\n\ + https://tangled.org/nandi.uk/gleam (branch wasm)\n\ + cargo build -p gleam && export GLEAM=$PWD/target/debug/gleam\n\ + Current candidate: {}", + guest_dir.display(), + gleam.display() + ); + } + + let wasm_src = guest_dir + .join("build/dev/wasm") + .join(package) + .join(format!("{package}.wasm")); + if !wasm_src.is_file() { + panic!( + "gleam build succeeded but {} was not created", + wasm_src.display() + ); + } + + let wasm_dst = out_dir.join(format!("{package}.wasm")); + fs::copy(&wasm_src, &wasm_dst).unwrap_or_else(|err| { + panic!( + "copy {} → {}: {err}", + wasm_src.display(), + wasm_dst.display() + ); + }); + + let inspect = manifest_dir.join("target").join(format!("{package}.wasm")); + if let Some(parent) = inspect.parent() { + let _ = fs::create_dir_all(parent); + } + let _ = fs::copy(&wasm_src, &inspect); + + eprintln!( + "sleek-host build.rs: {package} ready ({} bytes) → {}", + fs::metadata(&wasm_dst).map(|m| m.len()).unwrap_or(0), + wasm_dst.display() + ); +} + +fn find_gleam(manifest_dir: &Path) -> PathBuf { + if let Ok(path) = env::var("GLEAM") { + let p = PathBuf::from(path); + if p.is_file() { + return p; + } + panic!("GLEAM={p:?} is not a file"); + } + + let mut candidates = vec![ + manifest_dir.join("../../gleam/target/debug/gleam"), + manifest_dir.join("../../gleam/target/release/gleam"), + ]; + if let Some(home) = env::var_os("HOME") { + let home = PathBuf::from(home); + candidates.push(home.join("code/gleam/target/debug/gleam")); + candidates.push(home.join("code/gleam/target/release/gleam")); + } + for candidate in candidates { + if candidate.is_file() { + return candidate.canonicalize().unwrap_or(candidate); + } + } + + PathBuf::from("gleam") +} diff --git a/host/gleam/slash/README.md b/host/gleam/slash/README.md new file mode 100644 index 0000000..3d29724 --- /dev/null +++ b/host/gleam/slash/README.md @@ -0,0 +1,18 @@ +# gleam_slash + +Compose-box slash parser as a Gleam → Wasm guest for the sleek desktop host. + +## Layout + +- `parse(input) -> Slash` — custom type decoded by `host/src/gleam_slash.rs` +- String ops (`length`, `byte_at`, `slice`, `lowercase`) are `@external(wasm, "sleek", …)` + provided by the wasmtime linker (no Gleam stdlib on the Wasm target yet) + +## Smoke + +```bash +nix develop --command just gleam-slash +# or: cargo run --manifest-path host/Cargo.toml -- --gleam-slash-only +``` + +Compares Gleam output to `sleek::slash::parse_native` for the same cases. diff --git a/host/gleam/slash/gleam.toml b/host/gleam/slash/gleam.toml new file mode 100644 index 0000000..90cba94 --- /dev/null +++ b/host/gleam/slash/gleam.toml @@ -0,0 +1,4 @@ +name = "gleam_slash" +version = "0.1.0" +description = "Compose-box slash parser (Gleam → Wasm) for sleek desktop host" +target = "wasm" diff --git a/host/gleam/slash/manifest.toml b/host/gleam/slash/manifest.toml new file mode 100644 index 0000000..6e7ebce --- /dev/null +++ b/host/gleam/slash/manifest.toml @@ -0,0 +1,12 @@ +# Do not manually edit this file, it is managed by Gleam. +# +# This file locks the dependency versions used, to make your build +# deterministic and to prevent unexpected versions from being included +# in your application. +# +# You should check this file into your source control repository. + +packages = [ +] + +[requirements] diff --git a/host/gleam/slash/src/gleam_slash.gleam b/host/gleam/slash/src/gleam_slash.gleam new file mode 100644 index 0000000..c731123 --- /dev/null +++ b/host/gleam/slash/src/gleam_slash.gleam @@ -0,0 +1,162 @@ +//// Slash-command parser owned by Gleam; string primitives via `@external(wasm)`. +//// +//// Export: `gleam_slash__parse(String) -> Slash` (i32 struct ptr). +//// Host decodes the custom type from linear memory (tag @0, string fields @4…). +//// +//// Wasm MIR limits we work around: +//// - no `case` on tuples (`RuntimeCheck::Tuple` todo) +//// - no `case` on string values (lowers to indirect call) +//// - no first-class / indirect function calls +//// - nested custom-type patterns can crash wasm codegen (avoid Arg wrappers) + +pub type Slash { + Empty + PartActive + Join(String) + Nick(String) + Me(String) + Topic(String) + Raw(String) + Msg(String, String) +} + +@external(wasm, "sleek", "length") +fn length(s: String) -> Int + +@external(wasm, "sleek", "byte_at") +fn byte_at(s: String, i: Int) -> Int + +@external(wasm, "sleek", "slice") +fn slice(s: String, start: Int, end: Int) -> String + +@external(wasm, "sleek", "lowercase") +fn lowercase(s: String) -> String + +/// Parse a compose-box string that starts with `/` (slash optional). +pub fn parse(input: String) -> Slash { + let stripped = drop_slash(input) + let cmd_raw = split_cmd(stripped) + let arg_text = split_arg(stripped) + let cmd = lowercase(cmd_raw) + case length(cmd) { + 0 -> Empty + _ -> dispatch(cmd, arg_text, stripped) + } +} + +fn dispatch(cmd: String, arg_text: String, stripped: String) -> Slash { + case cmd == "join" { + True -> join_text(arg_text) + False -> + case cmd == "part" { + True -> PartActive + False -> + case cmd == "leave" { + True -> PartActive + False -> + case cmd == "nick" { + True -> nick_text(arg_text) + False -> + case cmd == "me" { + True -> me_text(arg_text) + False -> + case cmd == "topic" { + True -> topic_text(arg_text) + False -> + case cmd == "msg" { + True -> parse_msg(arg_text) + False -> Raw(stripped) + } + } + } + } + } + } + } +} + +fn join_text(text: String) -> Slash { + case length(text) { + 0 -> Empty + _ -> Join(text) + } +} + +fn nick_text(text: String) -> Slash { + case length(text) { + 0 -> Empty + _ -> Nick(text) + } +} + +fn me_text(text: String) -> Slash { + case length(text) { + 0 -> Empty + _ -> Me(text) + } +} + +fn topic_text(text: String) -> Slash { + case length(text) { + 0 -> Empty + _ -> Topic(text) + } +} + +fn parse_msg(text: String) -> Slash { + case length(text) { + 0 -> Empty + _ -> { + let target = split_cmd(text) + let body = split_arg(text) + case length(target) > 0 { + False -> Empty + True -> + case length(body) > 0 { + True -> Msg(target, body) + False -> Empty + } + } + } + } +} + +fn drop_slash(s: String) -> String { + case length(s) { + 0 -> s + _ -> + case byte_at(s, 0) { + // '/' + 47 -> slice(s, 1, length(s)) + _ -> s + } + } +} + +fn split_cmd(s: String) -> String { + let n = length(s) + case find_space(s, 0, n) { + -1 -> s + i -> slice(s, 0, i) + } +} + +fn split_arg(s: String) -> String { + let n = length(s) + case find_space(s, 0, n) { + -1 -> "" + i -> slice(s, i + 1, n) + } +} + +fn find_space(s: String, i: Int, n: Int) -> Int { + case i < n { + False -> -1 + True -> + case byte_at(s, i) { + // ' ' + 32 -> i + _ -> find_space(s, i + 1, n) + } + } +} diff --git a/host/src/gleam_slash.rs b/host/src/gleam_slash.rs new file mode 100644 index 0000000..2f9f48c --- /dev/null +++ b/host/src/gleam_slash.rs @@ -0,0 +1,233 @@ +//! Gleam Wasm slash parser: host string externals + custom-type decode. + +use std::sync::{Mutex, OnceLock}; + +use wasmtime::{Caller, Engine, Linker, Memory, Module, Store, TypedFunc}; + +use crate::gleam_string::{self, HostStringArena}; + +const SLASH_WASM: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/gleam_slash.wasm")); + +/// Mirrors `sleek::slash::SlashCommand` without depending on the android crate +/// layout from build scripts. Converted at the host boundary. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SlashCommand { + Join(String), + PartActive, + Nick(String), + Me(String), + Msg { target: String, text: String }, + Topic(String), + Raw(String), + Empty, +} + +struct HostState { + arena: HostStringArena, +} + +struct SlashSession { + store: Store, + memory: Memory, + parse: TypedFunc, +} + +static SESSION: OnceLock> = OnceLock::new(); + +fn session() -> Result<&'static Mutex, String> { + if let Some(s) = SESSION.get() { + return Ok(s); + } + let loaded = SlashSession::load()?; + let _ = SESSION.set(Mutex::new(loaded)); + SESSION + .get() + .ok_or_else(|| "gleam slash session missing after init".into()) +} + +impl SlashSession { + fn load() -> Result { + let engine = Engine::default(); + let module = Module::new(&engine, SLASH_WASM) + .map_err(|e| format!("parse gleam_slash.wasm: {e}"))?; + + let mut linker = Linker::new(&engine); + register_string_externals(&mut linker)?; + + let mut store = Store::new( + &engine, + HostState { + arena: HostStringArena::new(), + }, + ); + let instance = linker + .instantiate(&mut store, &module) + .map_err(|e| format!("instantiate gleam_slash.wasm: {e}"))?; + + let memory = instance + .get_memory(&mut store, "memory") + .ok_or_else(|| "gleam_slash.wasm missing exported memory".to_string())?; + let parse = instance + .get_typed_func(&mut store, "gleam_slash__parse") + .map_err(|e| format!("export gleam_slash__parse: {e}"))?; + + Ok(Self { + store, + memory, + parse, + }) + } + + fn call_parse(&mut self, in_ptr: i32) -> Result { + self.parse + .call(&mut self.store, in_ptr) + .map_err(|e| format!("call gleam_slash__parse: {e}")) + } +} + +fn register_string_externals(linker: &mut Linker) -> Result<(), String> { + linker + .func_wrap( + "sleek", + "length", + |mut caller: Caller<'_, HostState>, s: i32| -> i64 { + match memory_of(&mut caller).and_then(|m| gleam_string::read(&m, &caller, s)) { + Ok(text) => text.len() as i64, + Err(_) => 0, + } + }, + ) + .map_err(|e| format!("link sleek.length: {e}"))?; + + linker + .func_wrap( + "sleek", + "byte_at", + |mut caller: Caller<'_, HostState>, s: i32, i: i64| -> i64 { + match memory_of(&mut caller).and_then(|m| gleam_string::read(&m, &caller, s)) { + Ok(text) => { + let i = i as usize; + text.as_bytes().get(i).copied().unwrap_or(0) as i64 + } + Err(_) => 0, + } + }, + ) + .map_err(|e| format!("link sleek.byte_at: {e}"))?; + + linker + .func_wrap( + "sleek", + "slice", + |mut caller: Caller<'_, HostState>, s: i32, start: i64, end: i64| -> i32 { + let Ok(memory) = memory_of(&mut caller) else { + return 0; + }; + let Ok(text) = gleam_string::read(&memory, &caller, s) else { + return 0; + }; + let len = text.len() as i64; + let start = start.clamp(0, len) as usize; + let end = end.clamp(start as i64, len) as usize; + let piece = text[start..end].to_string(); + write_host_string(&mut caller, memory, &piece) + }, + ) + .map_err(|e| format!("link sleek.slice: {e}"))?; + + linker + .func_wrap( + "sleek", + "lowercase", + |mut caller: Caller<'_, HostState>, s: i32| -> i32 { + let Ok(memory) = memory_of(&mut caller) else { + return 0; + }; + let Ok(text) = gleam_string::read(&memory, &caller, s) else { + return 0; + }; + let lower = text.to_ascii_lowercase(); + write_host_string(&mut caller, memory, &lower) + }, + ) + .map_err(|e| format!("link sleek.lowercase: {e}"))?; + + Ok(()) +} + +fn write_host_string(caller: &mut Caller<'_, HostState>, memory: Memory, value: &str) -> i32 { + let mut arena = std::mem::take(&mut caller.data_mut().arena); + let ptr = gleam_string::write(&mut arena, &memory, &mut *caller, value).unwrap_or(0); + caller.data_mut().arena = arena; + ptr +} + +fn memory_of(caller: &mut Caller<'_, HostState>) -> Result { + caller + .get_export("memory") + .and_then(|e| e.into_memory()) + .ok_or_else(|| "missing memory export during sleek string external".into()) +} + +/// Parse `input` via the Gleam Wasm guest. +pub fn parse(input: &str) -> Result { + let mutex = session()?; + let mut s = mutex + .lock() + .map_err(|_| "gleam slash session lock poisoned".to_string())?; + + let memory = s.memory; + let mut arena = std::mem::take(&mut s.store.data_mut().arena); + let in_ptr = gleam_string::write(&mut arena, &memory, &mut s.store, input); + s.store.data_mut().arena = arena; + let in_ptr = in_ptr?; + + let out_ptr = s.call_parse(in_ptr)?; + decode_slash(&s.memory, &s.store, out_ptr) +} + +/// Gleam custom type layout: `tag: u32 @0`, fields as `i32` slots at `4 + i*4`. +/// +/// Declaration order in `gleam_slash.gleam`: +/// Empty, PartActive, Join, Nick, Me, Topic, Raw, Msg. +fn decode_slash( + memory: &Memory, + store: &Store, + ptr: i32, +) -> Result { + if ptr <= 0 { + return Err(format!("slash result ptr {ptr} invalid")); + } + let ptr = ptr as usize; + let data = memory.data(store); + if ptr + 4 > data.len() { + return Err("slash result tag out of bounds".into()); + } + let tag = u32::from_le_bytes(data[ptr..ptr + 4].try_into().unwrap()); + let field = |index: usize| -> Result { + let off = ptr + 4 + index * 4; + if off + 4 > data.len() { + return Err(format!("slash field {index} out of bounds")); + } + Ok(i32::from_le_bytes(data[off..off + 4].try_into().unwrap())) + }; + let read_str = |index: usize| -> Result { + let sp = field(index)?; + gleam_string::read(memory, store, sp) + }; + + match tag { + 0 => Ok(SlashCommand::Empty), + 1 => Ok(SlashCommand::PartActive), + 2 => Ok(SlashCommand::Join(read_str(0)?)), + 3 => Ok(SlashCommand::Nick(read_str(0)?)), + 4 => Ok(SlashCommand::Me(read_str(0)?)), + 5 => Ok(SlashCommand::Topic(read_str(0)?)), + 6 => Ok(SlashCommand::Raw(read_str(0)?)), + 7 => Ok(SlashCommand::Msg { + target: read_str(0)?, + text: read_str(1)?, + }), + other => Err(format!("unknown slash tag {other}")), + } +} diff --git a/host/src/gleam_string.rs b/host/src/gleam_string.rs new file mode 100644 index 0000000..d9f6043 --- /dev/null +++ b/host/src/gleam_string.rs @@ -0,0 +1,124 @@ +//! Gleam Wasm string ABI helpers for wasmtime hosts. +//! +//! Layout (see gleam `compiler-core/src/wasm.rs`): +//! +//! ```text +//! String = i32 pointer → { len: u32 LE, data: [u8; len] } in linear `memory` +//! ``` +//! +//! Host→guest writes use a **host arena** in freshly grown high pages so they +//! do not collide with the guest bump allocator (`$gleam_heap_ptr`). + +use wasmtime::{AsContext, AsContextMut, Memory, StoreContext, StoreContextMut}; + +const PAGE: u64 = 65_536; + +/// Next free offset in the host-owned high arena (`0` = not yet placed). +#[derive(Debug, Default, Clone, Copy)] +pub struct HostStringArena { + next: u32, +} + +impl HostStringArena { + pub fn new() -> Self { + Self { next: 0 } + } +} + +/// Read a Gleam `String` from guest linear memory. +pub fn read(memory: &Memory, store: impl AsContext, ptr: i32) -> Result { + read_ctx(memory, store.as_context(), ptr) +} + +pub fn read_ctx( + memory: &Memory, + store: StoreContext<'_, T>, + ptr: i32, +) -> Result { + if ptr < 0 { + return Err(format!("gleam string ptr {ptr} is negative")); + } + let ptr = ptr as usize; + let data = memory.data(store); + if ptr.checked_add(4).is_none_or(|end| end > data.len()) { + return Err(format!( + "gleam string ptr {ptr} out of bounds for len header (mem {})", + data.len() + )); + } + let len = u32::from_le_bytes(data[ptr..ptr + 4].try_into().unwrap()) as usize; + let data_start = ptr + 4; + let data_end = data_start + .checked_add(len) + .ok_or_else(|| format!("gleam string len {len} overflows"))?; + if data_end > data.len() { + return Err(format!( + "gleam string ptr {ptr} len {len} out of bounds (mem {})", + data.len() + )); + } + let bytes = &data[data_start..data_end]; + String::from_utf8(bytes.to_vec()).map_err(|e| format!("gleam string utf-8: {e}")) +} + +/// Write `value` into a host-owned high arena and return the Gleam string pointer. +pub fn write( + arena: &mut HostStringArena, + memory: &Memory, + mut store: impl AsContextMut, + value: &str, +) -> Result { + write_ctx(arena, memory, store.as_context_mut(), value) +} + +pub fn write_ctx( + arena: &mut HostStringArena, + memory: &Memory, + mut store: StoreContextMut<'_, T>, + value: &str, +) -> Result { + let bytes = value.as_bytes(); + let need = 4u64 + .checked_add(bytes.len() as u64) + .ok_or_else(|| "gleam string too large".to_string())?; + let need_aligned = (need + 3) & !3; + + if arena.next == 0 { + let old_size = memory.data_size(&mut store) as u64; + let start = (old_size + 3) & !3; + let end = start + .checked_add(need_aligned) + .ok_or_else(|| "gleam string arena overflow".to_string())?; + ensure_pages(memory, &mut store, end)?; + arena.next = start as u32; + } + + let start = arena.next as u64; + let end = start + .checked_add(need_aligned) + .ok_or_else(|| "gleam string arena overflow".to_string())?; + ensure_pages(memory, &mut store, end)?; + + let start_usize = start as usize; + let data = memory.data_mut(&mut store); + data[start_usize..start_usize + 4].copy_from_slice(&(bytes.len() as u32).to_le_bytes()); + data[start_usize + 4..start_usize + 4 + bytes.len()].copy_from_slice(bytes); + + arena.next = end as u32; + Ok(start as i32) +} + +fn ensure_pages( + memory: &Memory, + store: &mut StoreContextMut<'_, T>, + end_byte: u64, +) -> Result<(), String> { + let need_pages = end_byte.div_ceil(PAGE); + let cur_pages = memory.size(&mut *store); + if need_pages > cur_pages { + memory + .grow(&mut *store, need_pages - cur_pages) + .map_err(|e| format!("memory.grow({}): {e}", need_pages - cur_pages))?; + } + Ok(()) +} diff --git a/host/src/main.rs b/host/src/main.rs index 35c0e6c..11832d8 100644 --- a/host/src/main.rs +++ b/host/src/main.rs @@ -1,5 +1,106 @@ //! Desktop host: `just host` or `cargo run --manifest-path host/Cargo.toml` +//! +//! Builds / loads the Gleam Wasm slash guest (`just gleam-slash` / `--gleam-slash-only`). + +mod gleam_slash; +mod gleam_string; fn main() -> eframe::Result { + let slash_only = std::env::args().any(|a| a == "--gleam-slash-only"); + + match smoke_gleam_slash() { + Ok(summary) => { + println!("gleam slash smoke → {summary}"); + if slash_only { + return Ok(()); + } + } + Err(err) => { + eprintln!("gleam slash smoke failed: {err}"); + if slash_only { + std::process::exit(1); + } + } + } + + install_gleam_slash_hook(); sleek::run_desktop() } + +fn install_gleam_slash_hook() { + sleek::slash::install_parse_hook(Box::new(|input: &str| { + gleam_slash::parse(input).map(into_sleek_slash) + })); +} + +fn into_sleek_slash(cmd: gleam_slash::SlashCommand) -> sleek::slash::SlashCommand { + use gleam_slash::SlashCommand as G; + use sleek::slash::SlashCommand as S; + match cmd { + G::Join(c) => S::Join(c), + G::PartActive => S::PartActive, + G::Nick(n) => S::Nick(n), + G::Me(a) => S::Me(a), + G::Msg { target, text } => S::Msg { target, text }, + G::Topic(t) => S::Topic(t), + G::Raw(r) => S::Raw(r), + G::Empty => S::Empty, + } +} + +fn smoke_gleam_slash() -> Result { + let cases: &[(&str, gleam_slash::SlashCommand)] = &[ + ("/join #freeq", gleam_slash::SlashCommand::Join("#freeq".into())), + ("/join", gleam_slash::SlashCommand::Empty), + ("/part", gleam_slash::SlashCommand::PartActive), + ("/leave", gleam_slash::SlashCommand::PartActive), + ( + "/nick newhandle", + gleam_slash::SlashCommand::Nick("newhandle".into()), + ), + ( + "/me waves hello", + gleam_slash::SlashCommand::Me("waves hello".into()), + ), + ( + "/msg alice hi there", + gleam_slash::SlashCommand::Msg { + target: "alice".into(), + text: "hi there".into(), + }, + ), + ("/msg alice", gleam_slash::SlashCommand::Empty), + ( + "/topic the new topic", + gleam_slash::SlashCommand::Topic("the new topic".into()), + ), + ( + "/invite alice #freeq", + gleam_slash::SlashCommand::Raw("invite alice #freeq".into()), + ), + ("/JOIN #x", gleam_slash::SlashCommand::Join("#x".into())), + ("/", gleam_slash::SlashCommand::Empty), + ( + "/me does the weird thing", + gleam_slash::SlashCommand::Me("does the weird thing".into()), + ), + ]; + + for &(input, ref expected) in cases { + let native = sleek::slash::parse_native(input); + let gleam = gleam_slash::parse(input)?; + let gleam_as_sleek = into_sleek_slash(gleam.clone()); + if gleam_as_sleek != native { + return Err(format!( + "{input:?}: gleam={gleam_as_sleek:?} native={native:?}" + )); + } + if &gleam != expected { + return Err(format!( + "{input:?}: gleam got {gleam:?}, expected {expected:?}" + )); + } + } + + Ok(format!("{} cases match native oracle", cases.len())) +} diff --git a/justfile b/justfile index 5cbdf24..eb941aa 100644 --- a/justfile +++ b/justfile @@ -52,6 +52,18 @@ host *args: fi cargo run --manifest-path host/Cargo.toml {{args}} +# Gleam Wasm slash parser smoke (matches native Rust oracle; no GUI) +gleam-slash: + #!/usr/bin/env bash + set -euo pipefail + if [[ -n "${SLEEK_LD_LIBRARY_PATH:-}" ]]; then + export LD_LIBRARY_PATH="${SLEEK_LD_LIBRARY_PATH}${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" + fi + if [[ -z "${GLEAM:-}" && -x "${HOME}/code/gleam/target/debug/gleam" ]]; then + export GLEAM="${HOME}/code/gleam/target/debug/gleam" + fi + cargo run --manifest-path host/Cargo.toml -- --gleam-slash-only + # Check / build the Android package as a library (desktop target) lib: cargo build --manifest-path android/Cargo.toml --lib diff --git a/scripts/boxci/dispatch-github-patch.sh b/scripts/boxci/dispatch-github-patch.sh new file mode 100755 index 0000000..9a3ff24 --- /dev/null +++ b/scripts/boxci/dispatch-github-patch.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +# Ask boxci to open a Radicle patch from a GitHub commit (sleek). +# +# Usage: +# ./scripts/boxci/dispatch-github-patch.sh +# ./scripts/boxci/dispatch-github-patch.sh --sha abc1234 +# ./scripts/boxci/dispatch-github-patch.sh --sha abc1234 --title "…" --description "…" +# ./scripts/boxci/dispatch-github-patch.sh --dry-run +# +# Env: +# BOXCI_URL — default https://boxci.boxd.sh +# BOXCI_WEBHOOK_SECRET — optional X-Boxci-Secret header +# SLEEK_REPO_ID — default rad:z9mjPzpVK472QXaaP1picc5U9xBR +# SLEEK_GITHUB_REPO_URL — default https://github.com/codegod100/sleek.git +# SLEEK_BRANCH — Radicle base branch (default main) +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +cd "$REPO_ROOT" + +usage() { + sed -n '2,16p' "$0" + exit 2 +} + +SHA="" +TITLE="" +DESCRIPTION="" +DRY_RUN=0 +SYNC=0 +while [[ $# -gt 0 ]]; do + case "$1" in + --sha) + [[ $# -ge 2 ]] || usage + SHA="$2" + shift 2 + ;; + --title) + [[ $# -ge 2 ]] || usage + TITLE="$2" + shift 2 + ;; + --description) + [[ $# -ge 2 ]] || usage + DESCRIPTION="$2" + shift 2 + ;; + --dry-run) DRY_RUN=1; shift ;; + --sync) SYNC=1; shift ;; + -h | --help) usage ;; + *) + if [[ -z "$SHA" && "$1" =~ ^[0-9a-fA-F]{7,40}$ ]]; then + SHA="$1" + shift + else + echo "dispatch-github-patch: unknown arg: $1" >&2 + usage + fi + ;; + esac +done + +BOXCI_URL="${BOXCI_URL:-https://boxci.boxd.sh}" +REPO_ID="${SLEEK_REPO_ID:-rad:z9mjPzpVK472QXaaP1picc5U9xBR}" +GITHUB_REPO_URL="${SLEEK_GITHUB_REPO_URL:-https://github.com/codegod100/sleek.git}" +BRANCH="${SLEEK_BRANCH:-main}" + +command -v curl >/dev/null 2>&1 || { echo "curl required" >&2; exit 1; } +command -v jq >/dev/null 2>&1 || { echo "jq required" >&2; exit 1; } + +if [[ -z "$SHA" ]]; then + command -v git >/dev/null 2>&1 || { echo "git required (or pass --sha)" >&2; exit 1; } + SHA="$(git rev-parse HEAD)" +fi + +if [[ -z "$TITLE" ]] && command -v git >/dev/null 2>&1; then + TITLE="$(git log -1 --format=%s "$SHA" 2>/dev/null || true)" +fi +if [[ -z "$DESCRIPTION" ]] && command -v git >/dev/null 2>&1; then + DESCRIPTION="$(git log -1 --format=%b "$SHA" 2>/dev/null || true)" +fi + +payload="$(jq -n \ + --arg repo "$REPO_ID" \ + --arg github_repo_url "$GITHUB_REPO_URL" \ + --arg github_commit "$SHA" \ + --arg branch "$BRANCH" \ + --arg title "$TITLE" \ + --arg description "$DESCRIPTION" \ + --argjson dry_run "$DRY_RUN" \ + --argjson sync "$SYNC" \ + '{ + repo: $repo, + github_repo_url: $github_repo_url, + github_commit: $github_commit, + branch: $branch, + dry_run: $dry_run, + sync: $sync + } + + (if $title != "" then {title: $title} else {} end) + + (if $description != "" then {description: $description} else {} end)')" + +echo "dispatch-github-patch: repo=$REPO_ID commit=${SHA:0:12} → $BOXCI_URL" >&2 + +hdr=(-H "Content-Type: application/json") +if [[ -n "${BOXCI_WEBHOOK_SECRET:-}" ]]; then + hdr+=(-H "X-Boxci-Secret: ${BOXCI_WEBHOOK_SECRET}") +fi + +resp="$(curl -sS -X POST "${BOXCI_URL%/}/api/patches/from-github" \ + "${hdr[@]}" \ + -d "$payload")" +echo "$resp" | jq . +run_id="$(echo "$resp" | jq -r '.id // empty')" +if [[ -n "$run_id" ]]; then + echo "dispatch-github-patch: run https://${BOXCI_URL#https://}/runs/${run_id}" >&2 + echo "dispatch-github-patch: poll ${BOXCI_URL%/}/api/runs/${run_id}" >&2 +fi diff --git a/scripts/buildkite/bootstrap.sh b/scripts/buildkite/bootstrap.sh index ceba3a4..62fdb19 100755 --- a/scripts/buildkite/bootstrap.sh +++ b/scripts/buildkite/bootstrap.sh @@ -110,9 +110,10 @@ cursor_agent_cmd() { } build_radicle_mcp() { + # Graduated to rad:z4J6Hk21QrvWvQJKpH145GMAcACga / github.com/codegod100/radicle-mcp local mcp_dir="$REPO_ROOT/mcp/radicle" if [[ ! -f "$mcp_dir/package.json" ]]; then - echo "[bootstrap] no mcp/radicle in repo — skipping MCP build" + echo "[bootstrap] no local mcp/radicle — using hosted https://mcp.boxd.sh/radicle/mcp" return 0 fi diff --git a/scripts/buildkite/run-issue-agent.sh b/scripts/buildkite/run-issue-agent.sh index c90bec8..559340c 100755 --- a/scripts/buildkite/run-issue-agent.sh +++ b/scripts/buildkite/run-issue-agent.sh @@ -116,34 +116,38 @@ Requirements: 2. Run relevant verification from AGENTS.md when practical: - \`nix develop . --command cargo clippy --manifest-path host/Cargo.toml -- -D warnings\` - \`nix develop . --command cargo test --manifest-path android/Cargo.toml --lib\` -3. Open a Radicle patch on the \`rad\` remote. The patch MUST have both a title AND a full - description body (not title-only). Prefer \`git push rad HEAD:refs/patches\` with - **repeated** \`-o patch.message=...\` (first option = title; each later option = body - paragraph, joined with a blank line). Do not open a patch with only a title. +3. Open a Radicle patch. The patch MUST have both a title AND a full description body + (not title-only). Do **not** use the radicle MCP (\`create_patch\` / \`issue_device_key\`) + and do **not** run \`rad auth\` to mint a cloud DID. + + Preferred (GitHub → boxci): push the fix commit to GitHub, then follow skill + \`.cursor/skills/boxci-github-patch/\` (\`POST https://boxci.boxd.sh/api/patches/from-github\` + or \`./scripts/boxci/dispatch-github-patch.sh\`) with: + - repo: rad:z9mjPzpVK472QXaaP1picc5U9xBR + - github_repo_url: https://github.com/codegod100/sleek.git + - github_commit: the pushed SHA + - title: ${PATCH_TITLE} + - description: the Required description block below + + On Buildkite agents that already ran \`scripts/buildkite/bootstrap.sh\` (CI + \`RADICLE_SECRET_KEY\` loaded), you may instead \`git push rad HEAD:refs/patches\` with + **repeated** \`-o patch.message=...\` (first = title; later = body paragraphs). Required title: ${PATCH_TITLE} - Required description (set as the second and further \`-o patch.message=\` values; you may - pass the whole block as one \`-o patch.message=...\` with embedded newlines, or split by - paragraph): + Required description: ----- ${PATCH_DESCRIPTION} ----- - Example: + Example (boxci): git checkout -b "${RADICLE_ISSUE_BRANCH}" # … commit the fix … - git push rad HEAD:refs/patches \\ - -o patch.message="${PATCH_TITLE}" \\ - -o patch.message="" - - If using radicle MCP \`create_patch\`, set the same title and the full description body - (issue id, title, issue body, and link) — never omit description. - - branch: "${RADICLE_ISSUE_BRANCH}" - - title: "${PATCH_TITLE}" - - description: the Required description block above (verbatim or equivalent) - - commit: a clear commit message describing the fix + git push -u origin "${RADICLE_ISSUE_BRANCH}" + ./scripts/boxci/dispatch-github-patch.sh --sha "\$(git rev-parse HEAD)" \\ + --title "${PATCH_TITLE}" \\ + --description "" 4. Do not close the issue. Only open the patch. If the issue is not actionable (needs clarification, is a duplicate, etc.), explain why in your response and do not open a patch. diff --git a/scripts/codespace-bootstrap.sh b/scripts/codespace-bootstrap.sh index 4a4e71a..0902fee 100755 --- a/scripts/codespace-bootstrap.sh +++ b/scripts/codespace-bootstrap.sh @@ -24,6 +24,11 @@ log() { ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT" +# Cloud / Codespace clones only get `origin`. Patch publish needs `rad`. +if [[ -x "$ROOT/scripts/ensure-rad-remote.sh" ]]; then + bash "$ROOT/scripts/ensure-rad-remote.sh" || log "ensure-rad-remote failed (non-fatal)" +fi + SOCKET="/nix/var/nix/daemon-socket/socket" DAEMON_LOG="/tmp/nix-daemon.log" BASHRC_NIX_MARKER="# sleek-nix-env" diff --git a/scripts/ensure-rad-remote.sh b/scripts/ensure-rad-remote.sh new file mode 100755 index 0000000..67f5c03 --- /dev/null +++ b/scripts/ensure-rad-remote.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Ensure the working tree has a `rad` git remote for the sleek RID. +# +# Cloud / Codespace checkouts only get `origin` (GitHub). Radicle patch publish +# (`git push rad`, radicle MCP create_patch) needs remote `rad`. +# +# Idempotent: safe to run from install, start, or codespace-bootstrap. +set -euo pipefail + +# RID form is rad:; git-remote-rad / patch publish use rad://. +RADICLE_RID="${RADICLE_RID:-rad:z9mjPzpVK472QXaaP1picc5U9xBR}" +rid_naked="${RADICLE_RID#rad://}" +rid_naked="${rid_naked#rad:}" +RADICLE_REMOTE_URL="${RADICLE_REMOTE_URL:-rad://${rid_naked}}" +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +if ! git -C "$ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "ensure-rad-remote: not a git repo: $ROOT" >&2 + exit 1 +fi + +url="" +if git -C "$ROOT" remote get-url rad >/dev/null 2>&1; then + url="$(git -C "$ROOT" remote get-url rad)" +fi + +if [[ -n "$url" ]]; then + if [[ "$url" == "$RADICLE_REMOTE_URL" ]]; then + echo "ensure-rad-remote: rad → $url" + exit 0 + fi + # Accept rad: as already correct RID, but normalize to rad://. + if [[ "$url" == "rad:${rid_naked}" || "$url" == "$RADICLE_RID" ]]; then + git -C "$ROOT" remote set-url rad "$RADICLE_REMOTE_URL" + echo "ensure-rad-remote: normalized rad → $RADICLE_REMOTE_URL" + exit 0 + fi + echo "ensure-rad-remote: updating rad ($url → $RADICLE_REMOTE_URL)" >&2 + git -C "$ROOT" remote set-url rad "$RADICLE_REMOTE_URL" +else + git -C "$ROOT" remote add rad "$RADICLE_REMOTE_URL" + echo "ensure-rad-remote: added rad → $RADICLE_REMOTE_URL" +fi + +git -C "$ROOT" remote get-url rad >/dev/null