Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ build/
dist/
out/
target/
client/src-tauri/resources/voicy-server.exe

# IDE / OS
.vs/
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

```
Expand Down
16 changes: 16 additions & 0 deletions client/scripts/stage-local-server.mjs
Original file line number Diff line number Diff line change
@@ -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}`);
9 changes: 8 additions & 1 deletion client/src-tauri/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
51 changes: 48 additions & 3 deletions client/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ mod api;
mod deploy;
mod error;
mod hotkeys;
mod local_server;
mod permissions;
mod pinterest;
mod store;
Expand All @@ -20,15 +21,23 @@ fn list_servers(app: AppHandle) -> CmdResult<Vec<SavedServer>> {
Ok(store::load(&app)?)
}

/// Parses `https://<host>/join/<code>` or `voicy://join/<host>/<code>`.
/// 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");
let text = link.trim();
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)?)
};
Expand All @@ -53,7 +62,8 @@ fn invite_from_clipboard(app: AppHandle) -> Option<String> {
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.
Expand Down Expand Up @@ -149,6 +159,12 @@ fn forget_server(app: AppHandle, host: String) -> CmdResult<()> {
/// Authenticated call to `https://<host>/api/...` on behalf of the saved member.
#[tauri::command]
async fn api_request(app: AppHandle, host: String, method: String, path: String, body: Option<Value>) -> CmdResult<Value> {
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?;
Expand Down Expand Up @@ -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<String>) -> CmdResult<SavedServer> {
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,
Expand Down Expand Up @@ -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,
Expand All @@ -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");
Expand All @@ -346,6 +389,8 @@ mod tests {
assert!(parse_invite(&format!("<voicy://join/host.example/{CODE}/>")).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());
Expand Down
173 changes: 173 additions & 0 deletions client/src-tauri/src/local_server.rs
Original file line number Diff line number Diff line change
@@ -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<Mutex<Option<Child>>> = OnceLock::new();

fn child() -> &'static Mutex<Option<Child>> {
CHILD.get_or_init(|| Mutex::new(None))
}

fn config_dir(app: &AppHandle) -> Result<PathBuf> {
let dir = app.path().app_config_dir()?;
fs::create_dir_all(&dir)?;
Ok(dir)
}

fn config_file(app: &AppHandle) -> Result<PathBuf> {
Ok(config_dir(app)?.join("local-server.json"))
}

fn load(app: &AppHandle) -> Result<LocalConfig> {
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<LocalConfig> {
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<String> {
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<PathBuf> {
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<LocalConfig> {
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();
}
}
}
}
9 changes: 5 additions & 4 deletions client/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,18 @@
"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": {
"windows": [
{
"label": "main",
"title": "Voicy",
"width": 440,
"height": 600,
"width": 960,
"height": 680,
"minWidth": 360,
"minHeight": 460
}
Expand All @@ -42,6 +42,7 @@
"active": true,
"targets": ["nsis"],
"createUpdaterArtifacts": true,
"resources": ["resources/voicy-server.exe"],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
Expand Down
Loading
Loading