diff --git a/.gitignore b/.gitignore index c3cab31..f1e6880 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ build/ dist/ out/ target/ +client/src-tauri/resources/voicy-server.exe # IDE / OS .vs/ diff --git a/README.md b/README.md index a2b9e48..1631ad4 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,18 @@ gives sound only for a whole screen). The stream goes out at 1080p30 with 720p a viewer gets what fits their connection and the size of the stream on their screen, and unwatched layers are not encoded. Shared sound is stereo and leaves out everything voicy itself plays, so nobody hears the room echoed back. +## Local server for testing + +On Windows, start Docker Desktop and choose **Add server → On this PC** in Voicy. The app starts a loopback-only LiveKit container and the bundled `voicy-server.exe`, then creates the owner account. This local server is reachable only from the same computer. Its data is kept in the app config directory and is reused on the next launch; joining a room starts the local services again when needed. The client installer builds and bundles the matching server executable. + +## Room chat + +The server-wide chat sits beside the room list. Its header button opens or closes the panel, and the divider can be dragged to resize it. This panel shows only the server chat. The paper-plane button at the top of the left rail opens a separate **Direct messages** page with a contact list and conversations; clicking a member shows a **Write a direct message** action that opens their conversation on that page. The speech bubble next to a room still opens that room's separate chat. Only the two participants can read a private conversation. Members can send messages without joining voice. Messages are stored by the Voicy server in its SQLite database; remote servers need this server version for chat to work. Enter sends a message, and Shift+Enter adds a line break. + +The server chat, room chats, and direct messages accept one image or other file per message (up to 10 MB), with optional text. PNG, JPEG, WebP, and GIF images appear inline; other files can be downloaded from the message. Direct-message attachments are available only to the two participants. Both client and server must be updated to use attachments. + +Shared screens in your voice room appear as cards. Choose **Watch** to subscribe to a screen and its audio, then **Leave viewing** to unsubscribe without leaving the voice room. + ## Layout ``` diff --git a/client/scripts/stage-local-server.mjs b/client/scripts/stage-local-server.mjs new file mode 100644 index 0000000..03e45b5 --- /dev/null +++ b/client/scripts/stage-local-server.mjs @@ -0,0 +1,16 @@ +import { execFileSync } from "node:child_process"; +import { copyFileSync, mkdirSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const client = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const server = resolve(client, "..", "server"); +const cargo = process.platform === "win32" ? "cargo.exe" : "cargo"; +execFileSync(cargo, ["build", "--release", "--manifest-path", resolve(server, "Cargo.toml")], { + stdio: "inherit", +}); +const source = resolve(server, "target", "release", process.platform === "win32" ? "voicy-server.exe" : "voicy-server"); +const target = resolve(client, "src-tauri", "resources", process.platform === "win32" ? "voicy-server.exe" : "voicy-server"); +mkdirSync(dirname(target), { recursive: true }); +copyFileSync(source, target); +console.log(`Staged local server: ${target}`); diff --git a/client/src-tauri/src/api.rs b/client/src-tauri/src/api.rs index da2cce1..ab48806 100644 --- a/client/src-tauri/src/api.rs +++ b/client/src-tauri/src/api.rs @@ -36,7 +36,14 @@ pub async fn request(host: &str, token: Option<&str>, method: Method, path: &str if !path.starts_with("/api/") || path.contains("..") { return Err(CmdError::new("invalid", "bad api path")); } - let mut req = client().request(method, format!("https://{host}{path}")); + // Plain HTTP is allowed only for the built-in server bound to this PC. + let scheme = if host == "127.0.0.1:8080" { "http" } else { "https" }; + let mut req = client().request(method, format!("{scheme}://{host}{path}")); + if body.as_ref().and_then(|value| value.get("attachment")).is_some_and(|file| !file.is_null()) + || path.starts_with("/api/attachments/") + { + req = req.timeout(Duration::from_secs(90)); + } if let Some(token) = token { req = req.bearer_auth(token); } diff --git a/client/src-tauri/src/lib.rs b/client/src-tauri/src/lib.rs index 00f0c5f..2589453 100644 --- a/client/src-tauri/src/lib.rs +++ b/client/src-tauri/src/lib.rs @@ -2,6 +2,7 @@ mod api; mod deploy; mod error; mod hotkeys; +mod local_server; mod permissions; mod pinterest; mod store; @@ -20,7 +21,7 @@ fn list_servers(app: AppHandle) -> CmdResult> { Ok(store::load(&app)?) } -/// Parses `https:///join/` or `voicy://join//`. +/// Parses HTTPS or voicy invites; local HTTP is allowed only on our loopback server. /// Surrounding text pasted from a chat is fine. fn parse_invite(link: &str) -> CmdResult<(String, String)> { let bad = || CmdError::new("invalid", "это не похоже на ссылку-приглашение Voicy"); @@ -28,7 +29,15 @@ fn parse_invite(link: &str) -> CmdResult<(String, String)> { let (host, code) = if let Some((_, rest)) = text.split_once("voicy://join/") { rest.split_once('/').ok_or_else(bad)? } else { - let rest = text.split_once("https://").ok_or_else(bad)?.1; + let rest = if let Some((_, rest)) = text.split_once("https://") { + rest + } else { + let rest = text.split_once("http://").ok_or_else(bad)?.1; + if !rest.starts_with("127.0.0.1:8080/") { + return Err(bad()); + } + rest + }; let (host, path) = rest.split_once('/').ok_or_else(bad)?; (host, path.strip_prefix("join/").ok_or_else(bad)?) }; @@ -53,7 +62,8 @@ fn invite_from_clipboard(app: AppHandle) -> Option { if saved.iter().any(|s| s.host == host) { return None; } - Some(format!("https://{host}/join/{code}")) + let scheme = if host == local_server::HOST { "http" } else { "https" }; + Some(format!("{scheme}://{host}/join/{code}")) } /// Public info about the server an invite points to. @@ -149,6 +159,12 @@ fn forget_server(app: AppHandle, host: String) -> CmdResult<()> { /// Authenticated call to `https:///api/...` on behalf of the saved member. #[tauri::command] async fn api_request(app: AppHandle, host: String, method: String, path: String, body: Option) -> CmdResult { + if host == local_server::HOST { + let app_for_start = app.clone(); + tauri::async_runtime::spawn_blocking(move || local_server::ensure(&app_for_start, |_| {})) + .await + .map_err(|e| CmdError::new("other", e.to_string()))??; + } let token = store::token(&host)?.ok_or_else(|| CmdError::new("unauthorized", "нет сохранённого входа для этого сервера"))?; let method = Method::from_bytes(method.as_bytes()).map_err(|_| CmdError::new("invalid", "bad method"))?; let result = api::request(&host, Some(&token), method.clone(), &path, body).await?; @@ -180,6 +196,27 @@ async fn api_request(app: AppHandle, host: String, method: String, path: String, Ok(result) } +#[tauri::command] +async fn create_local_server(app: AppHandle, server_name: String, nickname: String, on_log: Channel) -> CmdResult { + let nickname = nickname.trim().to_owned(); + if nickname.is_empty() || nickname.chars().count() > 32 { + return Err(CmdError::new("invalid", "ник должен содержать от 1 до 32 символов")); + } + local_server::prepare(&app, &server_name)?; + let app_for_start = app.clone(); + let config = tauri::async_runtime::spawn_blocking(move || { + local_server::ensure(&app_for_start, |line| { let _ = on_log.send(line.to_owned()); }) + }) + .await + .map_err(|e| CmdError::new("other", e.to_string()))??; + if let Some(saved) = store::load(&app)?.into_iter().find(|s| s.host == local_server::HOST) { + if store::token(local_server::HOST)?.is_some() { + return Ok(saved); + } + } + redeem(&app, local_server::HOST, &config.code, &nickname).await +} + #[derive(Deserialize)] struct DeployReq { ssh: deploy::SshCreds, @@ -313,6 +350,11 @@ pub fn run() { } Ok(()) }) + .on_window_event(|_, event| { + if matches!(event, tauri::WindowEvent::Destroyed) { + local_server::stop(); + } + }) .invoke_handler(tauri::generate_handler![ list_servers, join_server, @@ -327,6 +369,7 @@ pub fn run() { set_hotkeys, pinterest_search, pinterest_image, + create_local_server, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); @@ -346,6 +389,8 @@ mod tests { assert!(parse_invite(&format!("")).is_ok()); assert!(parse_invite("https://evil/join").is_err()); assert!(parse_invite(&format!("https://host.example/other/{CODE}")).is_err()); + assert!(parse_invite(&format!("http://127.0.0.1:8080/join/{CODE}")).is_ok()); + assert!(parse_invite(&format!("http://host.example/join/{CODE}")).is_err()); assert!(parse_invite("voicy://join/host.example/short").is_err()); assert!(parse_invite(&format!("voicy://join/host:99999/{CODE}")).is_err()); assert!(parse_invite(&format!("voicy://join/ho st/{CODE}")).is_err()); diff --git a/client/src-tauri/src/local_server.rs b/client/src-tauri/src/local_server.rs new file mode 100644 index 0000000..96fa2a5 --- /dev/null +++ b/client/src-tauri/src/local_server.rs @@ -0,0 +1,173 @@ +//! A loopback-only Voicy server for trying the app without a VPS. +//! The API runs as a bundled Windows executable; LiveKit runs in Docker Desktop. + +use std::{ + fs, + net::{SocketAddr, TcpStream}, + path::PathBuf, + process::{Child, Command}, + sync::{Mutex, OnceLock}, + thread, + time::Duration, +}; + +use anyhow::{anyhow, bail, Context, Result}; +use rand::RngCore; +use serde::{Deserialize, Serialize}; +use tauri::{path::BaseDirectory, AppHandle, Manager}; + +pub const HOST: &str = "127.0.0.1:8080"; +const LIVEKIT: &str = "voicy-app-livekit"; +const LIVEKIT_IMAGE: &str = "livekit/livekit-server:v1.13"; + +#[derive(Clone, Deserialize, Serialize)] +pub struct LocalConfig { + pub name: String, + pub code: String, +} + +static CHILD: OnceLock>> = OnceLock::new(); + +fn child() -> &'static Mutex> { + CHILD.get_or_init(|| Mutex::new(None)) +} + +fn config_dir(app: &AppHandle) -> Result { + let dir = app.path().app_config_dir()?; + fs::create_dir_all(&dir)?; + Ok(dir) +} + +fn config_file(app: &AppHandle) -> Result { + Ok(config_dir(app)?.join("local-server.json")) +} + +fn load(app: &AppHandle) -> Result { + let path = config_file(app)?; + serde_json::from_slice(&fs::read(&path).with_context(|| format!("не найден локальный сервер: {}", path.display()))?) + .context("повреждены настройки локального сервера") +} + +pub fn prepare(app: &AppHandle, name: &str) -> Result { + if config_file(app)?.exists() { + return load(app); + } + let name = name.trim(); + if name.is_empty() || name.chars().count() > 48 || name.chars().any(char::is_control) { + bail!("название сервера должно содержать от 1 до 48 символов"); + } + let mut bytes = [0u8; 24]; + rand::thread_rng().fill_bytes(&mut bytes); + let code = bytes.iter().map(|b| format!("{b:02x}")).collect(); + let cfg = LocalConfig { name: name.to_owned(), code }; + fs::write(config_file(app)?, serde_json::to_vec_pretty(&cfg)?)?; + Ok(cfg) +} + +fn docker(args: &[&str]) -> Result { + let output = Command::new("docker") + .args(args) + .output() + .context("Docker Desktop не найден. Установи и запусти Docker Desktop")?; + if !output.status.success() { + let detail = String::from_utf8_lossy(&output.stderr).trim().to_owned(); + bail!("Docker: {detail}"); + } + Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned()) +} + +fn port_ready(port: u16) -> bool { + TcpStream::connect_timeout(&SocketAddr::from(([127, 0, 0, 1], port)), Duration::from_millis(250)).is_ok() +} + +fn wait_port(port: u16, label: &str) -> Result<()> { + for _ in 0..60 { + if port_ready(port) { + return Ok(()); + } + thread::sleep(Duration::from_millis(500)); + } + bail!("{label} не запустился на 127.0.0.1:{port}") +} + +fn ensure_livekit(log: &impl Fn(&str)) -> Result<()> { + docker(&["info", "--format", "{{.ServerVersion}}"]) + .context("запусти Docker Desktop и попробуй ещё раз")?; + let inspect = Command::new("docker") + .args(["container", "inspect", "--format", "{{.State.Running}}", LIVEKIT]) + .output()?; + if inspect.status.success() { + if String::from_utf8_lossy(&inspect.stdout).trim() != "true" { + log("Запускаю LiveKit…"); + docker(&["start", LIVEKIT])?; + } + } else { + log("Создаю локальный LiveKit в Docker…"); + docker(&[ + "run", "-d", "--name", LIVEKIT, "--restart", "unless-stopped", + "-p", "127.0.0.1:7880:7880/tcp", + "-p", "127.0.0.1:7881:7881/tcp", + "-p", "127.0.0.1:7882:7882/udp", + LIVEKIT_IMAGE, "--dev", "--bind", "0.0.0.0", "--node-ip", "127.0.0.1", + ])?; + } + wait_port(7880, "LiveKit") +} + +fn server_exe(app: &AppHandle) -> Result { + for path in ["resources/voicy-server.exe", "voicy-server.exe"] { + let resource = app.path().resolve(path, BaseDirectory::Resource)?; + if resource.is_file() { + return Ok(resource); + } + } + let sibling = std::env::current_exe()?.with_file_name("voicy-server.exe"); + if sibling.is_file() { + return Ok(sibling); + } + bail!("voicy-server.exe не входит в эту сборку Voicy") +} + +pub fn ensure(app: &AppHandle, log: impl Fn(&str)) -> Result { + let cfg = load(app)?; + let mut child = child().lock().map_err(|_| anyhow!("не удалось открыть локальный сервер"))?; + if let Some(process) = child.as_mut() { + if process.try_wait()?.is_none() && port_ready(8080) { + return Ok(cfg); + } + *child = None; + } + + ensure_livekit(&log)?; + if port_ready(8080) { + bail!("порт 8080 уже занят другой программой. Освободи его и попробуй снова"); + } + let exe = server_exe(app)?; + let db = config_dir(app)?.join("local-server.db"); + log("Запускаю локальный Voicy…"); + let process = Command::new(exe) + .env("VOICY_BIND", HOST) + .env("VOICY_DB", db) + .env("VOICY_PUBLIC_HOST", HOST) + .env("VOICY_SERVER_NAME", &cfg.name) + .env("VOICY_BOOTSTRAP_CODE", &cfg.code) + .env("LIVEKIT_API_URL", "http://127.0.0.1:7880") + .env("LIVEKIT_API_KEY", "devkey") + .env("LIVEKIT_API_SECRET", "secret") + .spawn() + .context("не удалось запустить voicy-server.exe")?; + *child = Some(process); + wait_port(8080, "Voicy")?; + Ok(cfg) +} + +pub fn stop() { + if let Some(lock) = CHILD.get() { + if let Ok(mut child) = lock.lock() { + if let Some(mut process) = child.take() { + let _ = process.kill(); + let _ = process.wait(); + } + } + } +} diff --git a/client/src-tauri/tauri.conf.json b/client/src-tauri/tauri.conf.json index d45bb78..4c754fb 100644 --- a/client/src-tauri/tauri.conf.json +++ b/client/src-tauri/tauri.conf.json @@ -4,9 +4,9 @@ "version": "0.1.26", "identifier": "com.voicy.app", "build": { - "beforeDevCommand": "npm run dev", + "beforeDevCommand": "node scripts/stage-local-server.mjs && npm run dev", "devUrl": "http://localhost:1420", - "beforeBuildCommand": "npm run build", + "beforeBuildCommand": "node scripts/stage-local-server.mjs && npm run build", "frontendDist": "../dist" }, "app": { @@ -14,8 +14,8 @@ { "label": "main", "title": "Voicy", - "width": 440, - "height": 600, + "width": 960, + "height": 680, "minWidth": 360, "minHeight": 460 } @@ -42,6 +42,7 @@ "active": true, "targets": ["nsis"], "createUpdaterArtifacts": true, + "resources": ["resources/voicy-server.exe"], "icon": [ "icons/32x32.png", "icons/128x128.png", diff --git a/client/src/App.tsx b/client/src/App.tsx index b4d8f42..a34655a 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,10 +1,12 @@ import { getCurrent, onOpenUrl } from "@tauri-apps/plugin-deep-link"; -import { AlertCircle, Check, ChevronRight, Link2, Plus, RefreshCw, Server } from "lucide-react"; +import { AlertCircle, Check, ChevronRight, Laptop, Link2, Plus, RefreshCw, Send, Server } from "lucide-react"; import { MouseEvent, useCallback, useEffect, useRef, useState } from "react"; import { CreateDialog } from "./components/CreateDialog"; +import { DirectMessagesPage, DirectPeer } from "./components/DirectMessagesPage"; import { JoinDialog } from "./components/JoinDialog"; import { DownloadIcon, Logo, PlusIcon } from "./components/icons"; +import { LocalCreateDialog } from "./components/LocalCreateDialog"; import { ServerView } from "./components/ServerView"; import { ConfirmHost, initials, Modal } from "./components/ui"; import { applyHotkeys } from "./lib/hotkeys"; @@ -13,10 +15,10 @@ import { api, errorText, inviteFromClipboard, joinServer, listServers, RoomInfo, import { checkForUpdate, confirmAndInstall, useUpdater } from "./lib/updater"; import { useVoice, voice } from "./lib/voice"; -type Dialog = { kind: "choose" } | { kind: "join"; link?: string; error?: string } | { kind: "create" } | null; +type Dialog = { kind: "choose" } | { kind: "join"; link?: string; error?: string } | { kind: "create" } | { kind: "local" } | null; /** First screen. Almost everyone arrives with an invite, so that comes first. */ -function Welcome({ onInvite, onCreate }: { onInvite: (link: string) => void; onCreate: () => void }) { +function Welcome({ onInvite, onCreate, onLocal }: { onInvite: (link: string) => void; onCreate: () => void; onLocal: () => void }) { const [link, setLink] = useState(""); return (
@@ -38,6 +40,10 @@ function Welcome({ onInvite, onCreate }: { onInvite: (link: string) => void; onC Хочешь свой сервер?{" "}

+

+ Хочешь проверить на этом ПК?{" "} + +

); @@ -63,6 +69,8 @@ export default function App() { const [servers, setServers] = useState([]); const [selected, setSelected] = useState(null); const [dialog, setDialog] = useState(null); + const [page, setPage] = useState<"server" | "inbox">("server"); + const [inboxPeer, setInboxPeer] = useState(null); const v = useVoice(); const reload = useCallback(async () => { @@ -82,6 +90,8 @@ export default function App() { setDialog(null); void reload().then(() => { setSelected(s.host); + setPage("server"); + setInboxPeer(null); if (!connect || voice.getSnapshot().state !== "idle") return; // A friend who just joined wants to be where people already are. void api(s.host, "GET", "/api/rooms") @@ -164,13 +174,13 @@ export default function App() { return (
- {current ? ( - + {page === "inbox" ? ( + setPage("server")} /> + ) : current ? ( + { setInboxPeer(peer); setPage("inbox"); }} onChanged={reload} onRemoved={reload} /> ) : ( - setDialog({ kind: "join", link })} onCreate={() => setDialog({ kind: "create" })} /> + setDialog({ kind: "join", link })} onCreate={() => setDialog({ kind: "create" })} onLocal={() => setDialog({ kind: "local" })} /> )}
@@ -243,6 +255,14 @@ export default function App() { +
)} @@ -256,6 +276,7 @@ export default function App() { /> )} {dialog?.kind === "create" && setDialog(null)} onCreated={(s) => added(s, false)} />} + {dialog?.kind === "local" && setDialog(null)} onCreated={(s) => added(s, false)} />} ); diff --git a/client/src/components/ChannelChat.tsx b/client/src/components/ChannelChat.tsx new file mode 100644 index 0000000..9895718 --- /dev/null +++ b/client/src/components/ChannelChat.tsx @@ -0,0 +1,155 @@ +import { MessageCircle, Paperclip, Send, X } from "lucide-react"; +import { FormEvent, KeyboardEvent, useEffect, useRef, useState } from "react"; + +import { api, ChatMessage, errorText } from "../lib/tauri"; +import { ChatAttachment } from "./ChatAttachment"; +import { Modal } from "./ui"; + +const MAX_FILE_BYTES = 10 * 1024 * 1024; + +function fileBase64(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onerror = () => reject(reader.error ?? new Error("Не удалось прочитать файл")); + reader.onload = () => resolve(String(reader.result).split(",", 2)[1] ?? ""); + reader.readAsDataURL(file); + }); +} + +export function ChatMessages({ host, path, memberId, label, compact = false }: { + host: string; path: string; memberId: string; label: string; compact?: boolean; +}) { + const [messages, setMessages] = useState([]); + const [draft, setDraft] = useState(""); + const [file, setFile] = useState(null); + const [error, setError] = useState(""); + const [sending, setSending] = useState(false); + const latest = useRef(0); + const list = useRef(null); + const fileInput = useRef(null); + + useEffect(() => { + let active = true; + latest.current = 0; + setMessages([]); + setFile(null); + setError(""); + async function load() { + try { + const incoming = await api(host, "GET", `${path}?after=${latest.current}`); + if (!active || incoming.length === 0) return; + latest.current = Math.max(latest.current, incoming[incoming.length - 1].id); + setMessages((current) => { + const seen = new Set(current.map((message) => message.id)); + return [...current, ...incoming.filter((message) => !seen.has(message.id))].slice(-100); + }); + setError(""); + } catch (e) { + if (active) { + const detail = errorText(e); + setError(detail.includes("404") ? "Чат заработает после обновления сервера Voicy." : detail); + } + } + } + void load(); + const timer = window.setInterval(() => void load(), 2000); + return () => { active = false; window.clearInterval(timer); }; + }, [host, path]); + + useEffect(() => { + const element = list.current; + if (element) element.scrollTop = element.scrollHeight; + }, [messages]); + + async function send(event?: FormEvent) { + event?.preventDefault(); + const text = draft.trim(); + if ((!text && !file) || sending) return; + setSending(true); + try { + if (file) { + try { + await api<{ max_bytes: number }>(host, "GET", "/api/attachments/support"); + } catch { + throw new Error("Для отправки файлов обнови сервер Voicy."); + } + } + const attachment = file ? { name: file.name, data: await fileBase64(file) } : undefined; + const message = await api(host, "POST", path, { text, attachment }); + setMessages((current) => current.some((item) => item.id === message.id) ? current : [...current, message].slice(-100)); + setDraft(""); + setFile(null); + setError(""); + } catch (e) { + setError(errorText(e)); + } finally { + setSending(false); + } + } + + function chooseFile(selected: File | undefined) { + if (!selected) return; + if (selected.size === 0 || selected.size > MAX_FILE_BYTES) { + setFile(null); + setError("Файл должен быть размером от 1 байта до 10 МБ."); + return; + } + setFile(selected); + setError(""); + } + + function onKeyDown(event: KeyboardEvent) { + if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) { + event.preventDefault(); + void send(); + } + } + + return ( +
+
+ {messages.length === 0 && !error &&
Пока здесь тихоНапиши первым.
} + {messages.map((message) => ( +
+
+ {message.nickname} + +
+ {message.text &&
{message.text}
} + {message.attachment && } +
+ ))} +
+ {error &&
{error}
} + {file &&
{file.name}{file.size >= 1024 * 1024 ? `${(file.size / (1024 * 1024)).toFixed(1)} МБ` : `${Math.max(1, Math.round(file.size / 1024))} КБ`}
} +
void send(event)}> + { chooseFile(event.target.files?.[0]); event.target.value = ""; }} /> + +