Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .cursor/environment.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "OpenLive",
"install": "bash .cursor/install.sh",
"start": "bash .cursor/start-gateway.sh"
}
9 changes: 9 additions & 0 deletions .cursor/install.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# Cloud Agent install: pin Rust 1.98 (lockfile needs edition 2024 / rustc >= 1.85),
# then fetch and compile the workspace including test binaries.
set -euo pipefail

rustup toolchain install 1.98.0 --component rustfmt --component clippy --no-self-update
rustup default 1.98.0
cargo fetch --locked
cargo build --workspace --locked --all-targets
29 changes: 29 additions & 0 deletions .cursor/start-gateway.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# Per-boot: mock OpenLive gateway on :8787. Idempotent; returns after /health is OK.
set -euo pipefail

mkdir -p /tmp/openlive
if curl -sf --max-time 2 http://127.0.0.1:8787/health >/dev/null; then
echo openlive-gateway already healthy
exit 0
fi
if [ ! -x ./target/debug/openlive-gateway ]; then
echo missing ./target/debug/openlive-gateway >&2
exit 1
fi
setsid ./target/debug/openlive-gateway \
--listen 0.0.0.0:8787 \
--provider mock \
--web-dir apps/openlive-gateway/web \
</dev/null >/tmp/openlive/gateway.log 2>&1 &
echo $! >/tmp/openlive/gateway.pid
for _ in $(seq 1 60); do
if curl -sf --max-time 2 http://127.0.0.1:8787/health >/dev/null; then
echo openlive-gateway ready
exit 0
fi
sleep 0.25
done
echo gateway failed to become healthy >&2
cat /tmp/openlive/gateway.log >&2 || true
exit 1
6 changes: 5 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,14 @@ jobs:
key: cargo-tauri-2.11.4-${{ runner.os }}

- name: Install Tauri CLI
run: cargo install tauri-cli --version 2.11.4
# --force keeps cached runners healthy when cargo-tauri already exists.
run: cargo install tauri-cli --version 2.11.4 --force

- name: Lint desktop app
working-directory: apps/openlive-desktop
env:
# Clippy/check must not require a prebuilt gateway binary.
OPENLIVE_SKIP_GATEWAY_BUILD: "1"
run: |
cargo fmt --all -- --check
cargo clippy --all-targets --all-features -- -D warnings
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
/target
apps/openlive-desktop/target/
/data
apps/openlive-gateway/data/
*.zip
.DS_Store

Expand Down
14 changes: 14 additions & 0 deletions THIRD_PARTY_NOTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,20 @@ binaries and update this notice.

---

## Call surface UI

Vendored under `apps/openlive-gateway/web/vendor/`. Keep the license files beside the bundles when redistributing.

| Project | License | Use in OpenLive |
|---------|---------|-----------------|
| **[Bloub](https://github.com/jeremy-prt/bloub)** (Jérémy Perret) | MIT | Framework-free face/orb engine (`engine.sample(t)`). Design imitates the x.ai avatar. **OpenLive is not affiliated with xAI, x.ai, or OpenAI.** |
| **[Morphicons](https://github.com/guillermolg00/morphicons)** | MIT | Spring morphs for Mute / End / Settings (`createMorph` + lucide path data) |
| **[Lucide](https://lucide.dev/)** | ISC | Icon path data consumed by Morphicons (not `lucide-react`) |

Morphicons are **not** used as the speaking face.

---

## Fonts (web UI)

| Family | Source | License |
Expand Down
11 changes: 8 additions & 3 deletions apps/openlive-desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,12 @@ cargo tauri build

## Notes

- The desktop shell loads the same web UI as the browser version.
- The gateway server must be running locally for the voice surface to work.
- The desktop shell loads a local listening-orb splash immediately, then
navigates to the gateway UI once `http://127.0.0.1:12345/health` is up.
Spawning the gateway must not block first paint.
- `OPENLIVE_SKIP_GATEWAY_BUILD=1` skips copying the real gateway binary
(used by clippy). A placeholder file is written so Tauri's resource
check still passes.
- Replace `icons/icon.ico` and `icons/icon.icns` with branded assets before
publishing.
publishing. Regenerate placeholders with
`python3 scripts/generate-icons.py`.
54 changes: 42 additions & 12 deletions apps/openlive-desktop/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,28 @@
//
// The gateway must already be built (e.g. by beforeBuildCommand or manually
// with `cargo build -p openlive-gateway --release`). Set
// OPENLIVE_SKIP_GATEWAY_BUILD=1 to skip staging entirely.
// OPENLIVE_SKIP_GATEWAY_BUILD=1 to skip staging entirely (clippy/check).
// tauri_build still requires the resource path to exist, so the skip path
// writes a tiny placeholder file.

use std::path::PathBuf;
use std::path::{Path, PathBuf};

fn main() {
if std::env::var("OPENLIVE_SKIP_GATEWAY_BUILD").is_ok() {
eprintln!("[openlive-desktop/build] OPENLIVE_SKIP_GATEWAY_BUILD is set; skipping gateway staging.");
tauri_build::build();
return;
}
println!("cargo:rerun-if-env-changed=OPENLIVE_SKIP_GATEWAY_BUILD");

let manifest_dir = std::env::var("CARGO_MANIFEST_DIR")
.map(PathBuf::from)
.expect("CARGO_MANIFEST_DIR not set");

if std::env::var("OPENLIVE_SKIP_GATEWAY_BUILD").is_ok() {
eprintln!(
"[openlive-desktop/build] OPENLIVE_SKIP_GATEWAY_BUILD is set; skipping gateway staging."
);
write_placeholder_gateway(&manifest_dir);
tauri_build::build();
return;
}

// apps/openlive-desktop -> project root
let project_root = manifest_dir
.parent()
Expand Down Expand Up @@ -50,13 +57,36 @@ fn main() {
// Use a single cross-platform bundled name. Windows requires the .exe
// extension for std::process::Command to find it; on macOS/Linux the
// extension is harmless and keeps the config simple.
let dst_dir = manifest_dir.join("target/release");
let dst = dst_dir.join("openlive-gateway-bundled.exe");

std::fs::create_dir_all(&dst_dir).expect("failed to create target/release");
let dst = gateway_resource_path(&manifest_dir);
if let Some(parent) = dst.parent() {
std::fs::create_dir_all(parent).expect("failed to create target/release");
}
std::fs::copy(&src, &dst).expect("failed to stage gateway binary for bundling");

eprintln!("[openlive-desktop/build] Staged gateway for bundling: {}", dst.display());
eprintln!(
"[openlive-desktop/build] Staged gateway for bundling: {}",
dst.display()
);

tauri_build::build();
}

fn gateway_resource_path(manifest_dir: &Path) -> PathBuf {
manifest_dir
.join("target/release")
.join("openlive-gateway-bundled.exe")
}

/// `tauri_build` validates `bundle.resources` even during clippy. A
/// placeholder keeps lint from requiring a real gateway binary.
fn write_placeholder_gateway(manifest_dir: &Path) {
let dst = gateway_resource_path(manifest_dir);
if dst.exists() {
return;
}
if let Some(parent) = dst.parent() {
std::fs::create_dir_all(parent).expect("failed to create target/release");
}
std::fs::write(&dst, b"OPENLIVE_SKIP_GATEWAY_BUILD placeholder\n")
.expect("failed to write placeholder gateway resource");
}
Binary file added apps/openlive-desktop/icons/128x128.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/openlive-desktop/icons/128x128@2x.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/openlive-desktop/icons/32x32.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified apps/openlive-desktop/icons/icon.icns
Binary file not shown.
Binary file modified apps/openlive-desktop/icons/icon.ico
Binary file not shown.
128 changes: 128 additions & 0 deletions apps/openlive-desktop/scripts/generate-icons.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
#!/usr/bin/env python3
"""Generate placeholder OpenLive desktop icons (PNG, ICO, ICNS)."""

from __future__ import annotations

import math
import struct
import zlib
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1] / "icons"


def chunk(tag: bytes, data: bytes) -> bytes:
return (
struct.pack(">I", len(data))
+ tag
+ data
+ struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF)
)


def write_png(width: int, height: int, rgba_at) -> bytes:
raw = bytearray()
for y in range(height):
raw.append(0)
for x in range(width):
raw.extend(rgba_at(x, y, width, height))
return (
b"\x89PNG\r\n\x1a\n"
+ chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0))
+ chunk(b"IDAT", zlib.compress(bytes(raw), 9))
+ chunk(b"IEND", b"")
)


def orb_pixel(x: int, y: int, w: int, h: int) -> bytes:
cx, cy = (w - 1) / 2, (h - 1) / 2
nx = (x - cx) / max(cx, 1)
ny = (y - cy) / max(cy, 1)
r = math.hypot(nx, ny)
# Transparent outside the orb so the dock icon is circular-ish.
if r > 1.02:
return b"\x00\x00\x00\x00"
# Soft edge
edge = max(0.0, min(1.0, (1.02 - r) / 0.08))
# Light paper orb with two dark elliptical eyes (Bloub-ish).
paper = (244, 244, 242)
ink = (10, 10, 12)
highlight = 1.0 - min(1.0, math.hypot(nx + 0.22, ny + 0.28) * 0.85)
shade = min(1.0, r * 0.35)
pr = int(paper[0] * (0.82 + 0.18 * highlight) * (1 - shade * 0.12))
pg = int(paper[1] * (0.82 + 0.18 * highlight) * (1 - shade * 0.12))
pb = int(paper[2] * (0.84 + 0.16 * highlight) * (1 - shade * 0.08))

def in_eye(ex: float, rot: float) -> bool:
dx, dy = nx - ex, ny + 0.04
cr, sr = math.cos(rot), math.sin(rot)
rx = dx * cr + dy * sr
ry = -dx * sr + dy * cr
return (rx / 0.16) ** 2 + (ry / 0.30) ** 2 <= 1.0

if in_eye(-0.22, math.radians(-18)) or in_eye(0.22, math.radians(18)):
pr, pg, pb = ink
alpha = int(255 * edge)
return bytes((max(0, min(255, pr)), max(0, min(255, pg)), max(0, min(255, pb)), alpha))


def write_ico(path: Path, sizes: list[int]) -> None:
images = [(size, write_png(size, size, orb_pixel)) for size in sizes]
count = len(images)
offset = 6 + 16 * count
directory = struct.pack("<HHH", 0, 1, count)
payload = b""
for size, png in images:
directory += struct.pack(
"<BBBBHHII",
size if size < 256 else 0,
size if size < 256 else 0,
0,
0,
1,
32,
len(png),
offset,
)
payload += png
offset += len(png)
path.write_bytes(directory + payload)


def write_icns(path: Path, entries: dict[bytes, bytes]) -> None:
body = b""
for ostype, data in entries.items():
body += ostype + struct.pack(">I", len(data) + 8) + data
path.write_bytes(b"icns" + struct.pack(">I", len(body) + 8) + body)


def main() -> None:
ROOT.mkdir(parents=True, exist_ok=True)
pngs = {}
for size in (16, 32, 64, 128, 256, 512, 1024):
pngs[size] = write_png(size, size, orb_pixel)
if size in {32, 128, 256}:
name = "128x128@2x.png" if size == 256 else f"{size}x{size}.png"
(ROOT / name).write_bytes(pngs[size])
write_ico(ROOT / "icon.ico", [16, 32, 48, 256])
write_icns(
ROOT / "icon.icns",
{
b"icp4": pngs[16],
b"icp5": pngs[32],
b"icp6": pngs[64],
b"ic07": pngs[128],
b"ic08": pngs[256],
b"ic09": pngs[512],
b"ic10": pngs[1024],
b"ic11": pngs[32],
b"ic12": pngs[64],
b"ic13": pngs[256],
b"ic14": pngs[512],
},
)
print(f"wrote icons in {ROOT}")


if __name__ == "__main__":
main()
82 changes: 82 additions & 0 deletions apps/openlive-desktop/splash/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>OpenLive</title>
<style>
html,
body {
margin: 0;
height: 100%;
background: #0a0a0c;
color: #f4f4f2;
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
display: grid;
place-items: center;
}
.stage {
display: flex;
flex-direction: column;
align-items: center;
gap: 28px;
}
.orb {
width: min(42vmin, 280px);
height: min(42vmin, 280px);
}
.copy {
text-align: center;
}
.label {
margin: 0;
letter-spacing: 0.28em;
text-transform: uppercase;
font-size: 11px;
opacity: 0.72;
}
.detail {
margin: 8px 0 0;
font-size: 15px;
opacity: 0.88;
}
</style>
</head>
<body>
<div class="stage">
<svg class="orb" viewBox="-158 -158 316 316" role="img" aria-label="OpenLive listening">
<circle cx="0" cy="0" r="118" fill="#f4f4f2" />
<ellipse cx="-28" cy="-6" rx="18" ry="34" fill="#0a0a0c" transform="rotate(-18 -28 -6)" />
<ellipse cx="28" cy="-6" rx="18" ry="34" fill="#0a0a0c" transform="rotate(18 28 -6)" />
</svg>
<div class="copy">
<p class="label" id="status">Listening</p>
<p class="detail">Speak freely.</p>
</div>
</div>
<script>
const GATEWAY = "http://127.0.0.1:12345";
const status = document.getElementById("status");
async function ready() {
try {
const response = await fetch(GATEWAY + "/health", { cache: "no-store" });
return response.ok;
} catch {
return false;
}
}
async function waitThenOpen() {
for (let i = 0; i < 80; i++) {
if (await ready()) {
window.location.replace(GATEWAY + "/");
return;
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
status.textContent = "Reconnecting";
window.location.replace(GATEWAY + "/");
}
waitThenOpen();
</script>
</body>
</html>
Loading
Loading