Skip to content

Commit c0d000c

Browse files
committed
0092 devtools を GPUI ベースのネイティブ GUI アプリとして実装する
- OBS WebSocket プロトコル層 (obsdc / p2p) を Rust に移植する - shiguredo_webrtc で HTTP Bootstrap + DataChannel の P2P クライアントを実装する - GPUI で接続・映像表示・ソース管理 UI を実装する
1 parent 83c4d74 commit c0d000c

18 files changed

Lines changed: 11722 additions & 2229 deletions

File tree

Cargo.lock

Lines changed: 7691 additions & 2229 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ license = "Apache-2.0"
1111

1212
[workspace]
1313
members = [
14+
"devtools/gui",
1415
"examples/camera_record",
1516
"examples/camera_sora_grid",
1617
"examples/hls_s3",

devtools/gui/Cargo.toml

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
[package]
2+
name = "hisui-devtools-gui"
3+
version = "0.0.0"
4+
edition = "2024"
5+
description = "Hisui DevTools native GUI (GPUI)"
6+
publish = false
7+
8+
[dependencies]
9+
# NOTE: 通常の依存は突然挙動が変わることが内容に、バージョンは厳密一致で指定している
10+
# NOTE: examples と共有する依存は [workspace.dependencies] で定義している
11+
12+
# OBS WebSocket 認証 (SHA-256)
13+
aws-lc-rs = "=1.17.3"
14+
# base64
15+
base64ct = { version = "=1.8.3", features = ["alloc"] }
16+
# GUI フレームワーク (Zed の GPU アクセラレーション UI)
17+
# runtime_shaders: Metal Toolchain 無しでもビルドできるようにする (実行時にシェーダーをコンパイルする)
18+
gpui = { version = "=0.2.2", features = ["runtime_shaders"] }
19+
# GPUI の RenderImage::new に渡す画像データ (image::Frame) 用
20+
image = "=0.25.1"
21+
# JSON のパース・生成
22+
nojson = { workspace = true }
23+
# I420 から RGBA への色変換
24+
shiguredo_libyuv = "=2026.2.0-canary.1"
25+
# WebRTC
26+
shiguredo_webrtc = { workspace = true }
27+
# 非同期処理
28+
tokio = { workspace = true }
29+
# ログ
30+
tracing = { workspace = true }
31+
tracing-subscriber = { workspace = true }

devtools/gui/src/error.rs

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
//! エラー型。
2+
//!
3+
//! hisui 本体の `src/error.rs` と同じ設計。任意のエラー型から変換可能にするために
4+
//! 意図的に [`std::error::Error`] を実装していない。
5+
6+
use std::backtrace::{Backtrace, BacktraceStatus};
7+
use std::panic::Location;
8+
9+
/// エラー型
10+
pub struct Error {
11+
/// エラーが発生した理由
12+
pub reason: String,
13+
14+
/// エラーが作成されたソースコードの場所
15+
pub location: &'static Location<'static>,
16+
17+
/// エラー発生箇所を示すバックトレース
18+
///
19+
/// バックトレースは `RUST_BACKTRACE` 環境変数が設定されていない場合には取得されない
20+
pub backtrace: Backtrace,
21+
}
22+
23+
impl Error {
24+
/// [`Error`] インスタンスを生成する
25+
#[track_caller]
26+
pub fn new<T: Into<String>>(reason: T) -> Self {
27+
Self {
28+
reason: reason.into(),
29+
location: Location::caller(),
30+
backtrace: Backtrace::capture(),
31+
}
32+
}
33+
34+
/// エラー理由のみの文字列表現を返す
35+
///
36+
/// `Display` を実装していないため、互換用途で明示的に提供する。
37+
pub fn display(&self) -> String {
38+
self.reason.clone()
39+
}
40+
}
41+
42+
impl std::fmt::Debug for Error {
43+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44+
write!(f, "{}", self.reason)?;
45+
write!(f, " (at {}:{})", self.location.file(), self.location.line())?;
46+
47+
if self.backtrace.status() == BacktraceStatus::Disabled {
48+
write!(f, " [RUST_BACKTRACE=1 for backtrace]")?;
49+
}
50+
if self.backtrace.status() == BacktraceStatus::Captured {
51+
write!(f, "\n\nBacktrace:\n{}", self.backtrace)?;
52+
}
53+
54+
Ok(())
55+
}
56+
}
57+
58+
/// このクレートのエラー結果型
59+
pub type Result<T> = std::result::Result<T, Error>;
60+
61+
impl From<std::io::Error> for Error {
62+
#[track_caller]
63+
fn from(e: std::io::Error) -> Self {
64+
Self::new(e.to_string())
65+
}
66+
}
67+
68+
impl From<tokio::time::error::Elapsed> for Error {
69+
#[track_caller]
70+
fn from(e: tokio::time::error::Elapsed) -> Self {
71+
Self::new(e.to_string())
72+
}
73+
}
74+
75+
#[cfg(test)]
76+
mod tests {
77+
use super::*;
78+
79+
#[test]
80+
fn display_returns_reason() {
81+
let err = Error::new("reason");
82+
assert_eq!(err.display(), "reason");
83+
}
84+
}

devtools/gui/src/lib.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
//! Hisui DevTools のネイティブ GUI アプリ。
2+
//!
3+
//! ブラウザ向け devtools (`devtools/`) の機能を GPUI で実装し直したもので、
4+
//! P2P 接続・映像表示・OBS WebSocket 操作を提供する。
5+
6+
pub mod error;
7+
pub mod obsdc;
8+
pub mod p2p;
9+
pub mod webrtc;
10+
11+
pub use error::{Error, Result};

devtools/gui/src/main.rs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
//! Hisui DevTools のネイティブ GUI アプリのエントリポイント。
2+
3+
mod ui;
4+
5+
use gpui::{App, Application, WindowOptions, prelude::*};
6+
7+
use crate::ui::DevToolsApp;
8+
9+
fn main() {
10+
tracing_subscriber::fmt().init();
11+
12+
Application::new().run(|cx: &mut App| {
13+
cx.open_window(
14+
WindowOptions {
15+
titlebar: Some(gpui::TitlebarOptions {
16+
title: Some("Hisui DevTools".into()),
17+
..Default::default()
18+
}),
19+
window_bounds: Some(gpui::WindowBounds::Windowed(gpui::Bounds::centered(
20+
None,
21+
gpui::size(gpui::px(1280.), gpui::px(800.)),
22+
cx,
23+
))),
24+
focus: true,
25+
..Default::default()
26+
},
27+
|_, cx| cx.new(DevToolsApp::new),
28+
)
29+
.expect("ウィンドウの作成に失敗しました");
30+
31+
// 最後のウィンドウが閉じられたらアプリを終了する
32+
cx.on_window_closed(|cx| {
33+
if cx.windows().is_empty() {
34+
cx.quit();
35+
}
36+
})
37+
.detach();
38+
39+
cx.activate(true);
40+
});
41+
}

devtools/gui/src/obsdc.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
//! OBS WebSocket 5.x プロトコル (DataChannel 経由) の定義。
2+
3+
mod auth;
4+
mod protocol;
5+
6+
pub use auth::generate_authentication_string;
7+
pub use protocol::{
8+
AuthenticationChallenge, ClientMessage, EventData, EventSubscription, HelloData,
9+
IdentifiedData, IdentifyData, OpCode, ProtocolError, RequestData, RequestResponseData,
10+
RequestStatus, ServerMessage, parse_server_message, serialize_client_message,
11+
};

devtools/gui/src/obsdc/auth.rs

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
//! OBS WebSocket 5.x SHA-256 認証。
2+
//!
3+
//! `devtools/src/obsdc/auth.ts` の Rust 移植。
4+
//! アルゴリズムは hisui 本体の `src/obsws/auth.rs` の `build_authentication_response` と同一。
5+
6+
use base64ct::{Base64, Encoding as _};
7+
8+
/// OBS WebSocket 5.x の認証文字列を生成する。
9+
///
10+
/// 1. `base64_secret = base64(sha256(password + salt))`
11+
/// 2. `authentication = base64(sha256(base64_secret + challenge))`
12+
pub fn generate_authentication_string(password: &str, salt: &str, challenge: &str) -> String {
13+
let secret_hash = aws_lc_rs::digest::digest(
14+
&aws_lc_rs::digest::SHA256,
15+
format!("{password}{salt}").as_bytes(),
16+
);
17+
let base64_secret = Base64::encode_string(secret_hash.as_ref());
18+
let secret_challenge_hash = aws_lc_rs::digest::digest(
19+
&aws_lc_rs::digest::SHA256,
20+
format!("{base64_secret}{challenge}").as_bytes(),
21+
);
22+
Base64::encode_string(secret_challenge_hash.as_ref())
23+
}
24+
25+
#[cfg(test)]
26+
mod tests {
27+
use super::*;
28+
29+
// ブラウザ版 auth.test.ts のテストを移植したもの
30+
31+
#[test]
32+
fn generate_authentication_string_matches_spec() {
33+
// プロトコル仕様のサンプル値
34+
let password = "supersecretpassword";
35+
let salt = "lM1GncleQOaCu9lT1yeUZhFYnqhsLLP1G5lAGo3ixaI=";
36+
let challenge = "+IxH4CnCiqpX1rM9scsNynZzbOe4KhDeYcTNS3PDaeY=";
37+
38+
let result = generate_authentication_string(password, salt, challenge);
39+
40+
// 結果は Base64 文字列であること
41+
assert!(
42+
result
43+
.chars()
44+
.all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '/' || c == '='),
45+
"Base64 文字列であること: {}",
46+
result
47+
);
48+
// 長さは SHA-256 の Base64 エンコード (44 文字)
49+
assert_eq!(result.len(), 44);
50+
}
51+
52+
#[test]
53+
fn generate_authentication_string_is_deterministic() {
54+
let password = "testpassword";
55+
let salt = "testsalt";
56+
let challenge = "testchallenge";
57+
58+
let result1 = generate_authentication_string(password, salt, challenge);
59+
let result2 = generate_authentication_string(password, salt, challenge);
60+
61+
assert_eq!(result1, result2);
62+
}
63+
64+
#[test]
65+
fn generate_authentication_string_differs_by_password() {
66+
let salt = "testsalt";
67+
let challenge = "testchallenge";
68+
69+
let result1 = generate_authentication_string("password1", salt, challenge);
70+
let result2 = generate_authentication_string("password2", salt, challenge);
71+
72+
assert_ne!(result1, result2);
73+
}
74+
}

0 commit comments

Comments
 (0)