1#![allow(clippy::disallowed_methods, reason = "tooling is exempt")]
2
3use std::fs;
4use std::path::{Path, PathBuf};
5use std::process::Command;
6
7use anyhow::{Context as _, Result, bail};
8use cargo_toml::Manifest;
9use clap::Parser;
10use regex::Regex;
11use toml_edit::{DocumentMut, Item, Table, value};
12
13use crate::workspace::load_workspace;
14
15const GITIGNORE_ENTRY: &str = ".webrtc-sys/";
16const LOCAL_DIR_NAME: &str = ".webrtc-sys";
17const ENV_VAR: &str = "LK_CUSTOM_WEBRTC";
18
19#[derive(Parser)]
20pub struct SetupWebrtcArgs {
21 /// Re-download even if the target directory already exists.
22 #[arg(long)]
23 force: bool,
24
25 /// Override the host triple component used for the release artifact
26 /// (e.g. `mac-arm64-release`). Defaults to the current host.
27 #[arg(long)]
28 triple: Option<String>,
29
30 /// Skip writing to `~/.cargo/config.toml`. Useful when you only want the
31 /// archive on disk and intend to set `LK_CUSTOM_WEBRTC` yourself.
32 #[arg(long)]
33 no_cargo_config: bool,
34}
35
36pub fn run_setup_webrtc(args: SetupWebrtcArgs) -> Result<()> {
37 let metadata = load_workspace()?;
38 let workspace_root = metadata.workspace_root.as_std_path().to_path_buf();
39
40 let rev = read_webrtc_sys_rev(&workspace_root)?;
41 eprintln!("Pinned livekit-rust-sdks rev: {rev}");
42
43 let tag = fetch_webrtc_tag(&rev)?;
44 eprintln!("WEBRTC_TAG for that rev: {tag}");
45
46 let triple = match args.triple {
47 Some(triple) => triple,
48 None => host_webrtc_triple()?,
49 };
50 eprintln!("Target triple: {triple}");
51
52 let local_root = workspace_root.join(LOCAL_DIR_NAME);
53 let tag_dir = local_root.join(&tag);
54 let extracted_dir = tag_dir.join(&triple);
55
56 if extracted_dir.exists() && !args.force {
57 eprintln!(
58 "Already present at {}, skipping download.",
59 extracted_dir.display()
60 );
61 } else {
62 if extracted_dir.exists() {
63 fs::remove_dir_all(&extracted_dir)
64 .with_context(|| format!("removing stale {}", extracted_dir.display()))?;
65 }
66 fs::create_dir_all(&tag_dir).with_context(|| format!("creating {}", tag_dir.display()))?;
67 download_and_extract(&tag, &triple, &tag_dir)?;
68 }
69
70 let absolute = extracted_dir
71 .canonicalize()
72 .with_context(|| format!("canonicalizing {}", extracted_dir.display()))?;
73
74 ensure_gitignore_entry(&workspace_root)?;
75
76 if args.no_cargo_config {
77 eprintln!(
78 "Skipping ~/.cargo/config.toml update. Set {ENV_VAR}={} yourself.",
79 absolute.display()
80 );
81 } else {
82 update_cargo_config(&absolute)?;
83 }
84
85 eprintln!();
86 eprintln!("Done. {ENV_VAR} -> {}", absolute.display());
87 Ok(())
88}
89
90fn read_webrtc_sys_rev(workspace_root: &Path) -> Result<String> {
91 let manifest_path = workspace_root.join("Cargo.toml");
92 let manifest = Manifest::from_path(&manifest_path)
93 .with_context(|| format!("parsing {}", manifest_path.display()))?;
94
95 let patch = manifest
96 .patch
97 .get("crates-io")
98 .context("workspace Cargo.toml has no [patch.crates-io] section")?;
99 let dep = patch
100 .get("webrtc-sys")
101 .context("[patch.crates-io] is missing webrtc-sys")?;
102 let detail = dep
103 .detail()
104 .context("webrtc-sys patch entry is not a table")?;
105 detail
106 .git
107 .as_ref()
108 .context("webrtc-sys patch is missing a git source")?;
109 detail
110 .rev
111 .clone()
112 .context("webrtc-sys patch is missing a `rev`")
113}
114
115fn fetch_webrtc_tag(rev: &str) -> Result<String> {
116 let url = format!(
117 "https://raw.githubusercontent.com/zed-industries/livekit-rust-sdks/{rev}/webrtc-sys/build/src/lib.rs"
118 );
119 let body = curl_text(&url).with_context(|| format!("fetching {url}"))?;
120
121 let re =
122 Regex::new(r#"pub\s+const\s+WEBRTC_TAG\s*:\s*&str\s*=\s*"([^"]+)""#).expect("static regex");
123 let captures = re
124 .captures(&body)
125 .with_context(|| format!("could not find WEBRTC_TAG in {url}"))?;
126 Ok(captures[1].to_string())
127}
128
129fn host_webrtc_triple() -> Result<String> {
130 let os = match std::env::consts::OS {
131 "macos" => "mac",
132 "linux" => "linux",
133 "windows" => "win",
134 other => bail!("unsupported host OS: {other}"),
135 };
136 let arch = match std::env::consts::ARCH {
137 "aarch64" => "arm64",
138 "x86_64" => "x64",
139 other => bail!("unsupported host arch: {other}"),
140 };
141 Ok(format!("{os}-{arch}-release"))
142}
143
144fn download_and_extract(tag: &str, triple: &str, into: &Path) -> Result<()> {
145 let url = format!(
146 "https://github.com/zed-industries/livekit-rust-sdks/releases/download/{tag}/webrtc-{triple}.zip"
147 );
148 let zip_path = into.join(format!("webrtc-{triple}.zip"));
149
150 eprintln!("Downloading {url}");
151 let status = Command::new("curl")
152 .args(["-fL", "--retry", "3", "--progress-bar", "-o"])
153 .arg(&zip_path)
154 .arg(&url)
155 .status()
156 .context("running curl")?;
157 if !status.success() {
158 bail!("curl exited with {status} while downloading {url}");
159 }
160
161 eprintln!("Extracting into {}", into.display());
162 let status = Command::new("unzip")
163 .arg("-q")
164 .arg("-o")
165 .arg(&zip_path)
166 .arg("-d")
167 .arg(into)
168 .status()
169 .context("running unzip")?;
170 if !status.success() {
171 bail!(
172 "unzip exited with {status} while extracting {}",
173 zip_path.display()
174 );
175 }
176
177 fs::remove_file(&zip_path).ok();
178 Ok(())
179}
180
181fn curl_text(url: &str) -> Result<String> {
182 let output = Command::new("curl")
183 .args(["-fsSL", url])
184 .output()
185 .context("running curl")?;
186 if !output.status.success() {
187 bail!(
188 "curl failed for {url} (exit {}): {}",
189 output.status,
190 String::from_utf8_lossy(&output.stderr).trim(),
191 );
192 }
193 String::from_utf8(output.stdout).context("curl returned non-UTF-8 body")
194}
195
196fn ensure_gitignore_entry(workspace_root: &Path) -> Result<()> {
197 let path = workspace_root.join(".gitignore");
198 let existing =
199 fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
200 if existing
201 .lines()
202 .any(|line| line.trim() == GITIGNORE_ENTRY || line.trim() == LOCAL_DIR_NAME)
203 {
204 return Ok(());
205 }
206 let mut updated = existing;
207 if !updated.ends_with('\n') {
208 updated.push('\n');
209 }
210 updated.push_str(GITIGNORE_ENTRY);
211 updated.push('\n');
212 fs::write(&path, updated).with_context(|| format!("writing {}", path.display()))?;
213 eprintln!("Added {GITIGNORE_ENTRY} to .gitignore");
214 Ok(())
215}
216
217fn update_cargo_config(webrtc_path: &Path) -> Result<()> {
218 let home = std::env::var_os("HOME")
219 .or_else(|| std::env::var_os("USERPROFILE"))
220 .context("could not determine home directory")?;
221 let config_path = PathBuf::from(home).join(".cargo").join("config.toml");
222 if config_path.exists() {
223 bail!(
224 "{} already exists; refusing to modify it. \
225 Add `[env]\\n{ENV_VAR} = \"{}\"` yourself, \
226 or re-run with --no-cargo-config.",
227 config_path.display(),
228 webrtc_path.display(),
229 );
230 }
231
232 if let Some(parent) = config_path.parent() {
233 fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
234 }
235
236 let mut doc = DocumentMut::new();
237 let mut env_table = Table::new();
238 env_table.set_implicit(false);
239 let path_str = webrtc_path
240 .to_str()
241 .context("webrtc path is not valid UTF-8")?;
242 env_table.insert(ENV_VAR, value(path_str));
243 doc.insert("env", Item::Table(env_table));
244
245 fs::write(&config_path, doc.to_string())
246 .with_context(|| format!("writing {}", config_path.display()))?;
247 eprintln!("Wrote {} with {ENV_VAR}={path_str}", config_path.display());
248 Ok(())
249}