mirror of
https://github.com/vrc-get/vrc-get.git
synced 2026-06-21 09:58:08 +00:00
feat: scaffold GPUI migration workspace crates
This commit is contained in:
parent
09c8190634
commit
d2b91647d2
10 changed files with 461 additions and 0 deletions
|
|
@ -5,6 +5,8 @@ members = [
|
|||
"xtask",
|
||||
"vrc-get",
|
||||
"vrc-get-gui",
|
||||
"vrc-get-gui-gpui",
|
||||
"vrc-get-gui-runtime",
|
||||
"vrc-get-gui/windows-installer-wrapper",
|
||||
"vrc-get-vpm",
|
||||
]
|
||||
|
|
@ -31,3 +33,6 @@ incremental = false
|
|||
debug = 1
|
||||
opt-level = 0
|
||||
lto = "off"
|
||||
|
||||
[patch.crates-io]
|
||||
gpui = { git = "https://github.com/zed-industries/zed.git", rev = "69e2130295c2649963eb639fc70b4f2ee8ea1624", package = "gpui" }
|
||||
|
|
|
|||
44
docs/gpui-migration.md
Normal file
44
docs/gpui-migration.md
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
# GPUI staged migration track
|
||||
|
||||
This repository now contains a staged migration track to move GUI rendering from Tauri/WebView to GPUI without deleting the working Tauri app.
|
||||
|
||||
## Scope
|
||||
|
||||
- Keep `vrc-get-gui` (Tauri) as the production frontend until feature parity is reached.
|
||||
- Add `vrc-get-gui-gpui` as an experimental frontend crate in the same workspace.
|
||||
- Keep `vrc-get-vpm` as the shared business/backend library for both frontends.
|
||||
- Introduce `vrc-get-gui-runtime` for a shared Tokio runtime bridge pattern.
|
||||
|
||||
## Stages
|
||||
|
||||
1. Validate with the make-or-break screen first: package management table (`app/_main/projects/manage/-package-list-card.tsx`), including text input and dialog interaction.
|
||||
2. Keep backend command layer in Rust/Tokio and migrate frontend incrementally.
|
||||
3. Port pages in this order:
|
||||
- Setup wizard
|
||||
- Settings
|
||||
- Log viewer
|
||||
- Projects
|
||||
- Packages (last, hardest)
|
||||
|
||||
## i18n migration
|
||||
|
||||
- Script added: `vrc-get-gui/scripts/i18next-to-rust-i18n.mjs`
|
||||
- Converts i18next dotted-key JSON5 format to nested rust-i18n YAML.
|
||||
- Run with:
|
||||
- `npm run i18n:to-rust`
|
||||
- `npm run i18n:to-rust -- locales/ja.json5 locales/ja.yml`
|
||||
|
||||
## Native file dialog policy
|
||||
|
||||
- GPUI migration path uses `rfd` for native file/folder dialogs on Windows/macOS/Linux.
|
||||
|
||||
## GPUI version pinning
|
||||
|
||||
- GPUI is pinned to Zed commit `69e2130295c2649963eb639fc70b4f2ee8ea1624` in workspace patch configuration.
|
||||
- Update only by intentional SHA bumps.
|
||||
|
||||
## Linux GPU note
|
||||
|
||||
- GPUI with Vulkan generally behaves better than WebKit for open-source NVIDIA users.
|
||||
- Nouveau may fall back to llvmpipe (software rendering).
|
||||
- Mesa + AMD/Intel Vulkan is the expected reliable path.
|
||||
22
vrc-get-gui-gpui/Cargo.toml
Normal file
22
vrc-get-gui-gpui/Cargo.toml
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
[package]
|
||||
name = "vrc-get-gui-gpui"
|
||||
version = "0.1.0"
|
||||
description = "Experimental GPUI frontend for vrc-get"
|
||||
|
||||
homepage.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "vrc-get-gui-gpui"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
gpui = { git = "https://github.com/zed-industries/zed.git", rev = "69e2130295c2649963eb639fc70b4f2ee8ea1624", package = "gpui" }
|
||||
gpui-component = "=0.5.1"
|
||||
rfd = "0.15"
|
||||
vrc-get-vpm = { path = "../vrc-get-vpm", features = ["experimental-project-management", "experimental-unity-management"] }
|
||||
vrc-get-gui-runtime = { path = "../vrc-get-gui-runtime" }
|
||||
11
vrc-get-gui-gpui/README.md
Normal file
11
vrc-get-gui-gpui/README.md
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# vrc-get-gui-gpui
|
||||
|
||||
Experimental GPUI frontend crate for staged migration.
|
||||
|
||||
Current focus:
|
||||
|
||||
1. Validate package-management-style table rendering.
|
||||
2. Validate dialog lifecycle and text input behavior.
|
||||
3. Validate native file dialog integration through `rfd`.
|
||||
|
||||
This crate intentionally coexists with the production `vrc-get-gui` (Tauri) crate.
|
||||
162
vrc-get-gui-gpui/src/main.rs
Normal file
162
vrc-get-gui-gpui/src/main.rs
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
use gpui::{
|
||||
App, Application, Context, IntoElement, ParentElement, Render, Styled, Window, WindowOptions,
|
||||
div,
|
||||
};
|
||||
use gpui::prelude::*;
|
||||
use gpui_component::{
|
||||
Root, WindowExt,
|
||||
button::{Button, ButtonVariants as _},
|
||||
h_flex,
|
||||
input::{Input, InputState},
|
||||
table::{Column, Table, TableDelegate, TableState},
|
||||
v_flex,
|
||||
};
|
||||
use vrc_get_gui_runtime::TokioBridge;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct PackageRow {
|
||||
name: String,
|
||||
version: String,
|
||||
source: String,
|
||||
}
|
||||
|
||||
struct PackageTableDelegate {
|
||||
columns: Vec<Column>,
|
||||
rows: Vec<PackageRow>,
|
||||
}
|
||||
|
||||
impl PackageTableDelegate {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
columns: vec![
|
||||
Column::new("name", "Package"),
|
||||
Column::new("version", "Version"),
|
||||
Column::new("source", "Source"),
|
||||
],
|
||||
rows: vec![
|
||||
PackageRow {
|
||||
name: "com.vrchat.base".to_owned(),
|
||||
version: "3.7.5".to_owned(),
|
||||
source: "Official".to_owned(),
|
||||
},
|
||||
PackageRow {
|
||||
name: "com.vrchat.worlds".to_owned(),
|
||||
version: "3.7.5".to_owned(),
|
||||
source: "Official".to_owned(),
|
||||
},
|
||||
PackageRow {
|
||||
name: "com.anatawa12.package-installer".to_owned(),
|
||||
version: "2.0.0".to_owned(),
|
||||
source: "Community".to_owned(),
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TableDelegate for PackageTableDelegate {
|
||||
fn columns_count(&self, _: &App) -> usize {
|
||||
self.columns.len()
|
||||
}
|
||||
|
||||
fn rows_count(&self, _: &App) -> usize {
|
||||
self.rows.len()
|
||||
}
|
||||
|
||||
fn column(&self, col_ix: usize, _: &App) -> &Column {
|
||||
&self.columns[col_ix]
|
||||
}
|
||||
|
||||
fn render_td(
|
||||
&mut self,
|
||||
row_ix: usize,
|
||||
col_ix: usize,
|
||||
_: &mut Window,
|
||||
_: &mut Context<TableState<Self>>,
|
||||
) -> impl IntoElement {
|
||||
let value = match col_ix {
|
||||
0 => self.rows[row_ix].name.clone(),
|
||||
1 => self.rows[row_ix].version.clone(),
|
||||
_ => self.rows[row_ix].source.clone(),
|
||||
};
|
||||
div().child(value)
|
||||
}
|
||||
}
|
||||
|
||||
struct PackageManagementPoc {
|
||||
_bridge: TokioBridge,
|
||||
search_input: gpui::Entity<InputState>,
|
||||
table_state: gpui::Entity<TableState<PackageTableDelegate>>,
|
||||
}
|
||||
|
||||
impl PackageManagementPoc {
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let search_input = cx.new(|cx| InputState::new(window, cx).placeholder("Search package"));
|
||||
let table_state = cx.new(|cx| TableState::new(PackageTableDelegate::new(), window, cx));
|
||||
|
||||
Self {
|
||||
_bridge: TokioBridge::new("vrc-get-gpui-runtime"),
|
||||
search_input,
|
||||
table_state,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for PackageManagementPoc {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
v_flex()
|
||||
.size_full()
|
||||
.p_4()
|
||||
.gap_3()
|
||||
.child(
|
||||
h_flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.child(Input::new(&self.search_input).cleanable())
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
Button::new("open-native-dialog")
|
||||
.label("Open with rfd")
|
||||
.on_click(|_, _, _| {
|
||||
let _ = rfd::FileDialog::new().pick_folder();
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
Button::new("show-dialog")
|
||||
.primary()
|
||||
.label("Show dialog")
|
||||
.on_click(|_, window, cx| {
|
||||
window.open_dialog(cx, |dialog, _, _| {
|
||||
dialog
|
||||
.title("Validation dialog")
|
||||
.confirm()
|
||||
.child("Dialog wiring check for GPUI migration")
|
||||
});
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(Table::new(&self.table_state).stripe(true))
|
||||
.child(
|
||||
div().opacity(0.7).child(
|
||||
"POC target: package table + dialog + text input before full migration",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
Application::new().run(|cx: &mut App| {
|
||||
gpui_component::init(cx);
|
||||
|
||||
cx.open_window(WindowOptions::default(), |window, cx| {
|
||||
let view = cx.new(|cx| PackageManagementPoc::new(window, cx));
|
||||
cx.new(|cx| Root::new(view, window, cx))
|
||||
})
|
||||
.expect("opening gpui window");
|
||||
|
||||
cx.activate(true);
|
||||
});
|
||||
}
|
||||
14
vrc-get-gui-runtime/Cargo.toml
Normal file
14
vrc-get-gui-runtime/Cargo.toml
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
[package]
|
||||
name = "vrc-get-gui-runtime"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
homepage.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "sync"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1", features = ["time"] }
|
||||
127
vrc-get-gui-runtime/src/lib.rs
Normal file
127
vrc-get-gui-runtime/src/lib.rs
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::thread;
|
||||
|
||||
use tokio::runtime::Builder;
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
|
||||
type BoxFuture = Pin<Box<dyn Future<Output = ()> + Send + 'static>>;
|
||||
|
||||
enum Message {
|
||||
Task(BoxFuture),
|
||||
Shutdown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct BridgeClosed;
|
||||
|
||||
impl std::fmt::Display for BridgeClosed {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("tokio bridge is closed")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for BridgeClosed {}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct TokioBridge {
|
||||
sender: mpsc::UnboundedSender<Message>,
|
||||
closed: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl TokioBridge {
|
||||
pub fn new(thread_name: &'static str) -> Self {
|
||||
let (sender, mut receiver) = mpsc::unbounded_channel();
|
||||
let closed = Arc::new(AtomicBool::new(false));
|
||||
let closed_for_thread = closed.clone();
|
||||
|
||||
thread::Builder::new()
|
||||
.name(thread_name.to_owned())
|
||||
.spawn(move || {
|
||||
let runtime = Builder::new_multi_thread()
|
||||
.worker_threads(1)
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("building tokio bridge runtime");
|
||||
|
||||
runtime.block_on(async move {
|
||||
while let Some(message) = receiver.recv().await {
|
||||
match message {
|
||||
Message::Task(task) => {
|
||||
tokio::spawn(task);
|
||||
}
|
||||
Message::Shutdown => break,
|
||||
}
|
||||
}
|
||||
closed_for_thread.store(true, Ordering::Release);
|
||||
});
|
||||
})
|
||||
.expect("spawning tokio bridge thread");
|
||||
|
||||
Self { sender, closed }
|
||||
}
|
||||
|
||||
pub fn is_closed(&self) -> bool {
|
||||
self.closed.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
pub fn spawn<Fut>(&self, task: Fut) -> Result<(), BridgeClosed>
|
||||
where
|
||||
Fut: Future<Output = ()> + Send + 'static,
|
||||
{
|
||||
self.sender
|
||||
.send(Message::Task(Box::pin(task)))
|
||||
.map_err(|_| BridgeClosed)
|
||||
}
|
||||
|
||||
pub fn call<Fut, T>(&self, task: Fut) -> Result<oneshot::Receiver<T>, BridgeClosed>
|
||||
where
|
||||
Fut: Future<Output = T> + Send + 'static,
|
||||
T: Send + 'static,
|
||||
{
|
||||
let (sender, receiver) = oneshot::channel();
|
||||
self.spawn(async move {
|
||||
let _ = sender.send(task.await);
|
||||
})?;
|
||||
Ok(receiver)
|
||||
}
|
||||
|
||||
pub fn shutdown(&self) -> Result<(), BridgeClosed> {
|
||||
self.sender.send(Message::Shutdown).map_err(|_| BridgeClosed)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::TokioBridge;
|
||||
use std::time::Duration;
|
||||
use tokio::time::timeout;
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn call_returns_result() {
|
||||
let bridge = TokioBridge::new("test-runtime");
|
||||
|
||||
let receiver = bridge.call(async { 40 + 2 }).unwrap();
|
||||
assert_eq!(receiver.await.unwrap(), 42);
|
||||
|
||||
bridge.shutdown().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn spawn_runs_in_background() {
|
||||
let bridge = TokioBridge::new("test-runtime-bg");
|
||||
|
||||
let receiver = bridge
|
||||
.call(async {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
"done"
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(timeout(Duration::from_secs(1), receiver).await.unwrap().unwrap(), "done");
|
||||
|
||||
bridge.shutdown().unwrap();
|
||||
}
|
||||
}
|
||||
|
|
@ -24,6 +24,7 @@ serde = { version = "1", features = ["derive"] }
|
|||
serde_with = { version = "3", features = ["base64"] }
|
||||
tauri = { version = "=2.11.2", features = [ "config-toml" ] } # = for sync version between npm and cargo
|
||||
vrc-get-vpm = { path = "../vrc-get-vpm", features = ["experimental-project-management", "experimental-unity-management"] }
|
||||
vrc-get-gui-runtime = { path = "../vrc-get-gui-runtime" }
|
||||
reqwest = { version = "0.13", features = ["gzip", "brotli", "json"] }
|
||||
specta = { version = "2.0.0-rc.24", features = [ "chrono", "url", "indexmap" ] }
|
||||
tauri-specta = { version = "2.0.0-rc.24", features = ["typescript"] }
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
"dev": "vite",
|
||||
"build": "npm install && npm run build:vite",
|
||||
"build:vite": "vite build",
|
||||
"i18n:to-rust": "node scripts/i18next-to-rust-i18n.mjs",
|
||||
"format": "biome format",
|
||||
"check": "biome check",
|
||||
"lint": "tsc && biome lint"
|
||||
|
|
|
|||
74
vrc-get-gui/scripts/i18next-to-rust-i18n.mjs
Normal file
74
vrc-get-gui/scripts/i18next-to-rust-i18n.mjs
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import JSON5 from "json5";
|
||||
|
||||
function setNested(root, dottedKey, value) {
|
||||
const parts = dottedKey.split(":");
|
||||
let current = root;
|
||||
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
const key = parts[i];
|
||||
if (!(key in current)) current[key] = {};
|
||||
if (
|
||||
typeof current[key] !== "object" ||
|
||||
current[key] === null ||
|
||||
Array.isArray(current[key])
|
||||
) {
|
||||
throw new Error(`Key collision at '${parts.slice(0, i + 1).join(":")}'`);
|
||||
}
|
||||
current = current[key];
|
||||
}
|
||||
|
||||
current[parts.at(-1)] = value;
|
||||
}
|
||||
|
||||
function toYaml(value, indent = 0) {
|
||||
const pad = " ".repeat(indent);
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
return `${JSON.stringify(String(value))}`;
|
||||
}
|
||||
|
||||
return Object.entries(value)
|
||||
.map(([key, child]) => {
|
||||
const escapedKey = `'${key.replaceAll("'", "''")}'`;
|
||||
if (typeof child === "object" && child !== null && !Array.isArray(child)) {
|
||||
const nested = toYaml(child, indent + 1);
|
||||
return `${pad}${escapedKey}:\n${nested}`;
|
||||
}
|
||||
return `${pad}${escapedKey}: ${JSON.stringify(String(child))}`;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const inputPath = process.argv[2] ?? "locales/en.json5";
|
||||
const outputPath =
|
||||
process.argv[3] ??
|
||||
path.join(
|
||||
path.dirname(inputPath),
|
||||
`${path.basename(inputPath, path.extname(inputPath))}.yml`,
|
||||
);
|
||||
|
||||
const source = await readFile(inputPath, "utf8");
|
||||
const parsed = JSON5.parse(source);
|
||||
const translationRoot = parsed.translation ?? parsed;
|
||||
if (typeof translationRoot !== "object" || translationRoot === null) {
|
||||
throw new Error("Expected object at root or translation");
|
||||
}
|
||||
|
||||
const rustI18n = {};
|
||||
for (const [key, value] of Object.entries(translationRoot)) {
|
||||
setNested(rustI18n, key, value);
|
||||
}
|
||||
|
||||
const yaml = `${toYaml(rustI18n)}\n`;
|
||||
await writeFile(outputPath, yaml, "utf8");
|
||||
console.log(`Converted ${inputPath} -> ${outputPath}`);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue