feat: promote linux native audio path

This commit is contained in:
Edison Jwa
2026-05-25 17:42:06 +09:00
parent c19de3a370
commit a2d686d9d0
73 changed files with 26156 additions and 467 deletions
+2
View File
@@ -0,0 +1,2 @@
/sample_identity.ini
/target/
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "tsidentity-import-spike"
version = "0.1.0"
edition = "2021"
publish = false
description = "Chanora PoC: import/export official TeamSpeak identity files and connect using the imported identity."
[workspace]
[dependencies]
anyhow = "1"
base64 = "0.22"
clap = { version = "4", features = ["derive"] }
futures = "0.3"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tsclientlib = { git = "https://github.com/ReSpeak/tsclientlib.git", rev = "04aa2491", default-features = false, features = ["default-tls"] }
+35
View File
@@ -0,0 +1,35 @@
# tsidentity Import Spike
Chanora proof-of-concept. **Not product code.**
## Purpose
Prove TeamSpeak identity import/export compatibility with the official INI-style
format and verify that an imported identity can be used for a live connection.
## What it does
1. Parses an exported TeamSpeak identity file.
2. Computes the imported identity UID and security level.
3. Re-exports the identity in TeamSpeak's `counter + V + obfuscated-key` form.
4. Connects to `kr.teamspeak.app` using the imported identity and nickname.
## Run
```bash
cargo test --offline
cargo run --offline -- \
--identity-file /path/to/exported_identity.ini \
--address kr.teamspeak.app
```
## Important finding
The validation identity used during this spike round-tripped correctly, but its
UID `eaRkG62hRaLs+R9sOuOWK7xbnUY=` did **not** match the live `EdisonJwa` UID
`QcnldW6Qnw/4im/t94j/FYIcVMU=` observed on `kr.teamspeak.app`.
## Verification
See `VERIFICATION.md`.
@@ -0,0 +1,59 @@
# Verification record — `tsidentity-import-spike`
## Result
PASS. The sample TeamSpeak identity file imported successfully, exported back to
the exact same identity string, and was usable for a live connection to
`kr.teamspeak.app`.
## Environment
| Field | Value |
|---|---|
| Date | 2026-05-25 |
| Host OS | Linux (x86_64) |
| Build profile | test + dev |
| Target server | `kr.teamspeak.app` |
## Commands
```bash
cargo test --offline
cargo run --offline -- \
--identity-file /path/to/exported_identity.ini \
--address kr.teamspeak.app
```
## What was observed
- Unit test passed:
- exported identity INI parsed successfully
- exported identity string matched the original exactly
- Imported sample identity properties:
- nickname: `Edison.Jwa`
- UID: `eaRkG62hRaLs+R9sOuOWK7xbnUY=`
- security level: `8`
- counter: `40`
- Live connect using the imported identity succeeded:
- connected nickname: `Edison.Jwa`
- connected UID: `eaRkG62hRaLs+R9sOuOWK7xbnUY=`
- connected DBID: `17225`
- connected CLID: `18506`
- connected CID: `1`
- country: `KR`
- server groups: `{ServerGroupId(8)}`
## Important finding
The provided sample identity is **not** the live `EdisonJwa` identity currently
online on `kr.teamspeak.app`.
- Sample imported identity UID: `eaRkG62hRaLs+R9sOuOWK7xbnUY=`
- Live `EdisonJwa` UID from the query spike: `QcnldW6Qnw/4im/t94j/FYIcVMU=`
That means:
- the import/export implementation is working, and
- the sample INI belongs to a different TeamSpeak identity than the live
`EdisonJwa` account used in the example profile.
+249
View File
@@ -0,0 +1,249 @@
use std::fs;
use std::path::PathBuf;
use anyhow::{anyhow, bail, Context, Result};
use clap::Parser;
use futures::prelude::*;
use tokio::time as tokio_time;
use tracing::info;
use tracing_subscriber::EnvFilter;
use tsclientlib::{Connection, DisconnectOptions, Identity, OutCommandExt, StreamItem};
#[derive(Parser, Debug)]
#[command(
name = "tsidentity-import-spike",
about = "Chanora PoC: parse/export TeamSpeak identity files and connect with the imported identity."
)]
struct Args {
/// Path to an exported TeamSpeak identity INI file.
#[arg(long)]
identity_file: PathBuf,
/// Server address for the live connect verification.
#[arg(short, long, default_value = "kr.teamspeak.app")]
address: String,
/// Override the nickname from the identity file for the live connect verification.
#[arg(long)]
nickname: Option<String>,
/// Server password if required.
#[arg(long)]
password: Option<String>,
/// Skip the live connection step and only validate the import/export round trip.
#[arg(long)]
no_connect: bool,
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct IdentityRecord {
id: String,
identity: String,
nickname: String,
phonetic_nickname: String,
}
#[tokio::main]
async fn main() -> Result<()> {
let args = Args::parse();
let filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("info,tsproto=warn,tsclientlib=warn"));
tracing_subscriber::fmt().with_env_filter(filter).init();
let raw = fs::read_to_string(&args.identity_file)
.with_context(|| format!("reading {}", args.identity_file.display()))?;
let record = IdentityRecord::parse(&raw)?;
let identity = Identity::new_from_str(&record.identity).context("parsing TeamSpeak identity")?;
let computed_uid = identity.key().to_pub().get_uid();
let roundtrip_identity = export_identity_string(&identity);
let roundtrip_record = IdentityRecord {
id: record.id.clone(),
identity: roundtrip_identity.clone(),
nickname: record.nickname.clone(),
phonetic_nickname: record.phonetic_nickname.clone(),
};
println!("=== Identity Import ===");
println!("Identity file : {}", args.identity_file.display());
println!("Profile id : {}", record.id);
println!("Nickname : {}", record.nickname);
println!("UID : {}", computed_uid);
println!("Security level : {}", identity.level());
println!("Counter : {}", identity.counter());
println!("Roundtrip match : {}", if roundtrip_identity == record.identity { "yes" } else { "no" });
println!();
println!("=== Export Preview ===");
println!("{}", roundtrip_record.render());
if args.no_connect {
return Ok(());
}
let connect_nickname = args.nickname.clone().unwrap_or_else(|| record.nickname.clone());
info!(
target: "identity-spike",
address = %args.address,
nickname = %connect_nickname,
uid = %computed_uid,
"connecting with imported identity"
);
let mut builder = Connection::build(args.address.clone())
.name(connect_nickname.clone())
.identity(identity);
if let Some(password) = &args.password {
builder = builder.password(password.clone());
}
let mut con = builder.connect().context("dialling server with imported identity")?;
wait_for_initial_state(&mut con).await?;
if let Ok(state) = con.get_state() {
let _ = state.server.set_subscribed(true).send(&mut con);
}
pump_events(&mut con, std::time::Duration::from_secs(1)).await?;
let state = con.get_state().context("reading post-connect state")?;
let own = state
.clients
.get(&state.own_client)
.ok_or_else(|| anyhow!("own client missing after imported-identity connect"))?;
let own_uid = own
.uid
.as_ref()
.map(|uid| tsclientlib_uid_to_b64(uid.as_ref()))
.unwrap_or_else(|| "<missing>".to_string());
println!();
println!("=== Live Connect Result ===");
println!("Connected nickname : {}", own.name);
println!("Connected UID : {}", own_uid);
println!("Connected DBID : {}", own.database_id.0);
println!("Connected CLID : {}", own.id.0);
println!("Connected CID : {}", own.channel.0);
println!(
"Country : {}",
if own.country_code.is_empty() {
"Unknown".to_string()
} else {
own.country_code.clone()
}
);
println!("Server groups : {:?}", own.server_groups);
println!("Avatar hash : {}", own.avatar_hash);
con.disconnect(DisconnectOptions::new()).ok();
con.events().for_each(|_| futures::future::ready(())).await;
Ok(())
}
impl IdentityRecord {
fn parse(raw: &str) -> Result<Self> {
let mut section = None::<String>;
let mut map = std::collections::HashMap::new();
for line in raw.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if trimmed.starts_with('[') && trimmed.ends_with(']') {
section = Some(trimmed[1..trimmed.len() - 1].to_string());
continue;
}
if section.as_deref() != Some("Identity") {
continue;
}
let (key, value) = trimmed
.split_once('=')
.ok_or_else(|| anyhow!("invalid identity line: {trimmed}"))?;
map.insert(key.to_string(), unquote(value));
}
Ok(Self {
id: required(&map, "id")?,
identity: required(&map, "identity")?,
nickname: required(&map, "nickname")?,
phonetic_nickname: map.get("phonetic_nickname").cloned().unwrap_or_default(),
})
}
fn render(&self) -> String {
format!(
"[Identity]\nid={}\nidentity=\"{}\"\nnickname={}\nphonetic_nickname={}\n",
self.id, self.identity, self.nickname, self.phonetic_nickname
)
}
}
fn required(map: &std::collections::HashMap<String, String>, key: &str) -> Result<String> {
map.get(key)
.cloned()
.ok_or_else(|| anyhow!("missing required identity key `{key}`"))
}
fn unquote(value: &str) -> String {
let trimmed = value.trim();
if trimmed.len() >= 2 && trimmed.starts_with('"') && trimmed.ends_with('"') {
trimmed[1..trimmed.len() - 1].to_string()
} else {
trimmed.to_string()
}
}
fn export_identity_string(identity: &Identity) -> String {
format!("{}V{}", identity.counter(), identity.key().to_ts_obfuscated())
}
async fn wait_for_initial_state(con: &mut Connection) -> Result<()> {
loop {
let event = con
.events()
.next()
.await
.ok_or_else(|| anyhow!("event stream ended before initial state"))??;
if matches!(event, StreamItem::BookEvents(_)) {
return Ok(());
}
}
}
async fn pump_events(con: &mut Connection, budget: std::time::Duration) -> Result<()> {
let deadline = tokio_time::Instant::now() + budget;
loop {
let now = tokio_time::Instant::now();
if now >= deadline {
return Ok(());
}
let remaining = deadline - now;
match tokio_time::timeout(remaining, con.events().next()).await {
Ok(Some(Ok(_))) => {}
Ok(Some(Err(error))) => return Err(error).context("pumping connection events"),
Ok(None) => bail!("event stream ended while pumping events"),
Err(_) => return Ok(()),
}
}
}
fn tsclientlib_uid_to_b64(uid: &tsclientlib::Uid) -> String {
use base64::prelude::*;
BASE64_STANDARD.encode(&uid.0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_and_roundtrips_sample_identity() {
let raw = include_str!("../sample_identity.ini");
let record = IdentityRecord::parse(raw).expect("sample identity must parse");
let identity = Identity::new_from_str(&record.identity).expect("identity must parse");
let uid = identity.key().to_pub().get_uid();
assert_eq!(uid, "eaRkG62hRaLs+R9sOuOWK7xbnUY=");
assert_eq!(export_identity_string(&identity), record.identity);
assert_eq!(record.nickname, "Edison.Jwa");
}
}