feat: scaffold GPUI migration workspace crates

This commit is contained in:
copilot-swe-agent[bot] 2026-05-31 15:04:02 +00:00 committed by GitHub
commit d2b91647d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 461 additions and 0 deletions

View file

@ -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
View 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.

View 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" }

View 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.

View 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);
});
}

View 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"] }

View 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();
}
}

View file

@ -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"] }

View file

@ -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"

View 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;
});