mirror of
https://github.com/vrc-get/vrc-get.git
synced 2026-06-21 09:58:08 +00:00
Compare commits
3 commits
master
...
copilot/mi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
356d0963ae |
||
|
|
04da12d85a |
||
|
|
d2b91647d2 |
12 changed files with 4892 additions and 183 deletions
4209
Cargo.lock
generated
4209
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -5,6 +5,8 @@ members = [
|
||||||
"xtask",
|
"xtask",
|
||||||
"vrc-get",
|
"vrc-get",
|
||||||
"vrc-get-gui",
|
"vrc-get-gui",
|
||||||
|
"vrc-get-gui-gpui",
|
||||||
|
"vrc-get-gui-runtime",
|
||||||
"vrc-get-gui/windows-installer-wrapper",
|
"vrc-get-gui/windows-installer-wrapper",
|
||||||
"vrc-get-vpm",
|
"vrc-get-vpm",
|
||||||
]
|
]
|
||||||
|
|
@ -31,3 +33,6 @@ incremental = false
|
||||||
debug = 1
|
debug = 1
|
||||||
opt-level = 0
|
opt-level = 0
|
||||||
lto = "off"
|
lto = "off"
|
||||||
|
|
||||||
|
[patch.crates-io]
|
||||||
|
gpui = { git = "https://github.com/zed-industries/zed.git", rev = "69e2130295c2649963eb639fc70b4f2ee8ea1624", package = "gpui" }
|
||||||
|
|
|
||||||
77
docs/gpui-migration.md
Normal file
77
docs/gpui-migration.md
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
# 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
|
||||||
|
|
||||||
|
### Stage 1 – Validated ✅
|
||||||
|
|
||||||
|
Package management table POC (`app/_main/projects/manage/-package-list-card.tsx` equivalent):
|
||||||
|
- GPUI table rendering with striped rows and column headers.
|
||||||
|
- Text input with clear button and live search filtering.
|
||||||
|
- Dialog lifecycle (title, confirm button, child content).
|
||||||
|
- Native file dialog integration via `rfd`.
|
||||||
|
- `TokioBridge` async plumbing (`spawn` / `call` / `shutdown`).
|
||||||
|
|
||||||
|
### Stage 2 – In progress
|
||||||
|
|
||||||
|
Wire real `vrc-get-vpm` data into a live Projects list screen:
|
||||||
|
- `backend.rs` — async `load_projects()` using `VccDatabaseConnection`.
|
||||||
|
- `ProjectsView` — loading state → live data, live search filtering via `cx.observe`.
|
||||||
|
- `TokioBridge::call()` dispatches to Tokio; result is awaited in GPUI's async context via `cx.spawn`.
|
||||||
|
|
||||||
|
### Stage 3 – Planned
|
||||||
|
|
||||||
|
Port pages in this order:
|
||||||
|
1. Setup wizard
|
||||||
|
2. Settings
|
||||||
|
3. Log viewer
|
||||||
|
4. Projects (full, with create/add/remove)
|
||||||
|
5. 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.
|
||||||
|
|
||||||
|
## Async bridge pattern
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Dispatch heavy async work to Tokio; await result in GPUI.
|
||||||
|
let rx = self.bridge.call(load_projects()).unwrap();
|
||||||
|
cx.spawn(async move |this: WeakEntity<View>, cx: &mut AsyncApp| {
|
||||||
|
if let Ok(Ok(data)) = rx.await {
|
||||||
|
this.update(cx, |view, cx| {
|
||||||
|
view.data = data;
|
||||||
|
cx.notify();
|
||||||
|
}).ok();
|
||||||
|
}
|
||||||
|
}).detach();
|
||||||
|
```
|
||||||
|
|
||||||
|
The pattern works because `tokio::sync::oneshot::Receiver<T>` implements `Future` and can be awaited from within GPUI's executor.
|
||||||
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.
|
||||||
78
vrc-get-gui-gpui/src/backend.rs
Normal file
78
vrc-get-gui-gpui/src/backend.rs
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use vrc_get_vpm::ProjectType;
|
||||||
|
use vrc_get_vpm::environment::{VccDatabaseConnection, UserProject};
|
||||||
|
use vrc_get_vpm::io::DefaultEnvironmentIo;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct ProjectRow {
|
||||||
|
pub name: String,
|
||||||
|
pub path: String,
|
||||||
|
pub project_type: String,
|
||||||
|
pub unity: String,
|
||||||
|
pub favorite: bool,
|
||||||
|
pub last_modified_ms: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProjectRow {
|
||||||
|
fn from_user_project(p: &UserProject) -> Option<Self> {
|
||||||
|
let path = p.path()?.to_owned();
|
||||||
|
let name = PathBuf::from(&path)
|
||||||
|
.file_name()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.unwrap_or(&path)
|
||||||
|
.to_owned();
|
||||||
|
let project_type = project_type_label(p.project_type());
|
||||||
|
let unity = p
|
||||||
|
.unity_version()
|
||||||
|
.map(|v| v.to_string())
|
||||||
|
.unwrap_or_else(|| "unknown".to_owned());
|
||||||
|
let favorite = p.favorite();
|
||||||
|
let last_modified_ms = p
|
||||||
|
.last_modified()
|
||||||
|
.map(|d| d.as_unix_milliseconds())
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
Some(ProjectRow {
|
||||||
|
name,
|
||||||
|
path,
|
||||||
|
project_type,
|
||||||
|
unity,
|
||||||
|
favorite,
|
||||||
|
last_modified_ms,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn project_type_label(t: ProjectType) -> String {
|
||||||
|
match t {
|
||||||
|
ProjectType::Unknown => "Unknown",
|
||||||
|
ProjectType::LegacySdk2 => "Legacy SDK2",
|
||||||
|
ProjectType::LegacyWorlds => "Legacy Worlds",
|
||||||
|
ProjectType::LegacyAvatars => "Legacy Avatars",
|
||||||
|
ProjectType::UpmWorlds => "UPM Worlds",
|
||||||
|
ProjectType::UpmAvatars => "UPM Avatars",
|
||||||
|
ProjectType::UpmStarter => "UPM Starter",
|
||||||
|
ProjectType::Worlds => "Worlds",
|
||||||
|
ProjectType::Avatars => "Avatars",
|
||||||
|
ProjectType::VpmStarter => "VPM Starter",
|
||||||
|
}
|
||||||
|
.to_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load all projects from the VCC database. Intended to be called from a
|
||||||
|
/// Tokio context (via `TokioBridge::call`).
|
||||||
|
pub async fn load_projects() -> anyhow::Result<Vec<ProjectRow>> {
|
||||||
|
let io = DefaultEnvironmentIo::new_default();
|
||||||
|
let connection = VccDatabaseConnection::connect(&io).await?;
|
||||||
|
|
||||||
|
let mut projects = connection.get_projects();
|
||||||
|
projects.retain(|p| p.path().is_some());
|
||||||
|
|
||||||
|
let rows = projects
|
||||||
|
.iter()
|
||||||
|
.filter_map(ProjectRow::from_user_project)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
267
vrc-get-gui-gpui/src/main.rs
Normal file
267
vrc-get-gui-gpui/src/main.rs
Normal file
|
|
@ -0,0 +1,267 @@
|
||||||
|
mod backend;
|
||||||
|
|
||||||
|
use backend::{ProjectRow, load_projects};
|
||||||
|
|
||||||
|
use gpui::prelude::*;
|
||||||
|
use gpui::{
|
||||||
|
App, Application, Context, IntoElement, ParentElement, Render, SharedString, Styled, Window,
|
||||||
|
WindowOptions, div,
|
||||||
|
};
|
||||||
|
use gpui_component::{
|
||||||
|
Root, StyledExt,
|
||||||
|
button::{Button, ButtonVariants as _},
|
||||||
|
h_flex,
|
||||||
|
input::{Input, InputState},
|
||||||
|
spinner::Spinner,
|
||||||
|
table::{Column, Table, TableDelegate, TableState},
|
||||||
|
v_flex,
|
||||||
|
};
|
||||||
|
use vrc_get_gui_runtime::TokioBridge;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Projects table delegate
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
struct ProjectsDelegate {
|
||||||
|
columns: Vec<Column>,
|
||||||
|
rows: Vec<ProjectRow>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProjectsDelegate {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
columns: vec![
|
||||||
|
Column::new("name", "Name"),
|
||||||
|
Column::new("type", "Type"),
|
||||||
|
Column::new("unity", "Unity"),
|
||||||
|
Column::new("path", "Path"),
|
||||||
|
],
|
||||||
|
rows: vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_rows(&mut self, rows: Vec<ProjectRow>) {
|
||||||
|
self.rows = rows;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TableDelegate for ProjectsDelegate {
|
||||||
|
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 row = &self.rows[row_ix];
|
||||||
|
let value: SharedString = match col_ix {
|
||||||
|
0 => row.name.clone().into(),
|
||||||
|
1 => row.project_type.clone().into(),
|
||||||
|
2 => row.unity.clone().into(),
|
||||||
|
_ => row.path.clone().into(),
|
||||||
|
};
|
||||||
|
div().child(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Loading state
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
enum ProjectsData {
|
||||||
|
Loading,
|
||||||
|
Loaded(Vec<ProjectRow>),
|
||||||
|
Error(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Root view
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
struct ProjectsView {
|
||||||
|
bridge: TokioBridge,
|
||||||
|
search_input: gpui::Entity<InputState>,
|
||||||
|
table_state: gpui::Entity<TableState<ProjectsDelegate>>,
|
||||||
|
data: ProjectsData,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProjectsView {
|
||||||
|
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
|
let bridge = TokioBridge::new("vrc-get-gpui-runtime");
|
||||||
|
let search_input =
|
||||||
|
cx.new(|cx| InputState::new(window, cx).placeholder("Search projects"));
|
||||||
|
let table_state = cx.new(|cx| TableState::new(ProjectsDelegate::new(), window, cx));
|
||||||
|
|
||||||
|
// Re-filter the table whenever the search input changes.
|
||||||
|
cx.observe(&search_input, |view, _, cx| {
|
||||||
|
view.apply_search(cx);
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
|
||||||
|
let mut view = Self {
|
||||||
|
bridge,
|
||||||
|
search_input,
|
||||||
|
table_state,
|
||||||
|
data: ProjectsData::Loading,
|
||||||
|
};
|
||||||
|
view.reload(cx);
|
||||||
|
view
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reload(&mut self, cx: &mut Context<Self>) {
|
||||||
|
self.data = ProjectsData::Loading;
|
||||||
|
cx.notify();
|
||||||
|
|
||||||
|
let rx = self
|
||||||
|
.bridge
|
||||||
|
.call(load_projects())
|
||||||
|
.expect("tokio bridge still alive");
|
||||||
|
|
||||||
|
cx.spawn(async move |this: gpui::WeakEntity<ProjectsView>, cx: &mut gpui::AsyncApp| {
|
||||||
|
match rx.await {
|
||||||
|
Ok(Ok(rows)) => {
|
||||||
|
this.update(cx, |view, cx| {
|
||||||
|
let search = view.search_input.read(cx).value().to_lowercase();
|
||||||
|
let filtered = filter_rows(&rows, &search);
|
||||||
|
view.table_state.update(cx, |table, _| {
|
||||||
|
table.delegate_mut().set_rows(filtered);
|
||||||
|
});
|
||||||
|
view.data = ProjectsData::Loaded(rows);
|
||||||
|
cx.notify();
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
Ok(Err(err)) => {
|
||||||
|
this.update(cx, |view, cx| {
|
||||||
|
view.data = ProjectsData::Error(err.to_string());
|
||||||
|
cx.notify();
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
Err(_) => {}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_search(&mut self, cx: &mut Context<Self>) {
|
||||||
|
let ProjectsData::Loaded(ref all_rows) = self.data else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let search = self.search_input.read(cx).value().to_lowercase();
|
||||||
|
let rows = filter_rows(all_rows, &search);
|
||||||
|
self.table_state.update(cx, |table, _| {
|
||||||
|
table.delegate_mut().set_rows(rows);
|
||||||
|
});
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn filter_rows(rows: &[ProjectRow], search: &str) -> Vec<ProjectRow> {
|
||||||
|
if search.is_empty() {
|
||||||
|
rows.to_vec()
|
||||||
|
} else {
|
||||||
|
rows.iter()
|
||||||
|
.filter(|r| {
|
||||||
|
r.name.to_lowercase().contains(search)
|
||||||
|
|| r.path.to_lowercase().contains(search)
|
||||||
|
})
|
||||||
|
.cloned()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for ProjectsView {
|
||||||
|
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
let is_loading = matches!(self.data, ProjectsData::Loading);
|
||||||
|
let error_msg: Option<SharedString> = if let ProjectsData::Error(ref e) = self.data {
|
||||||
|
Some(e.clone().into())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
// Toolbar
|
||||||
|
let search_el = Input::new(&self.search_input).cleanable(true);
|
||||||
|
|
||||||
|
let reload_btn = Button::new("reload")
|
||||||
|
.label("Reload")
|
||||||
|
.on_click(cx.listener(|view, _event: &gpui::ClickEvent, _window, cx| {
|
||||||
|
view.reload(cx);
|
||||||
|
}));
|
||||||
|
|
||||||
|
let add_btn = Button::new("add-project")
|
||||||
|
.primary()
|
||||||
|
.label("Add Project")
|
||||||
|
.on_click(|_event, _window, _cx| {
|
||||||
|
let _ = rfd::FileDialog::new().pick_folder();
|
||||||
|
});
|
||||||
|
|
||||||
|
let toolbar = h_flex()
|
||||||
|
.items_center()
|
||||||
|
.justify_between()
|
||||||
|
.child(search_el)
|
||||||
|
.child(h_flex().gap_2().child(reload_btn).child(add_btn));
|
||||||
|
|
||||||
|
// Body
|
||||||
|
let body: gpui::AnyElement = if is_loading {
|
||||||
|
h_flex()
|
||||||
|
.size_full()
|
||||||
|
.items_center()
|
||||||
|
.justify_center()
|
||||||
|
.gap_2()
|
||||||
|
.child(Spinner::new())
|
||||||
|
.child(div().child("Loading projects…"))
|
||||||
|
.into_any_element()
|
||||||
|
} else if let Some(msg) = error_msg {
|
||||||
|
div()
|
||||||
|
.p_4()
|
||||||
|
.text_color(gpui::red())
|
||||||
|
.child(format!("Error: {msg}"))
|
||||||
|
.into_any_element()
|
||||||
|
} else {
|
||||||
|
Table::new(&self.table_state)
|
||||||
|
.stripe(true)
|
||||||
|
.into_any_element()
|
||||||
|
};
|
||||||
|
|
||||||
|
v_flex()
|
||||||
|
.size_full()
|
||||||
|
.p_4()
|
||||||
|
.gap_3()
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.items_center()
|
||||||
|
.gap_2()
|
||||||
|
.child(div().font_bold().child("Projects"))
|
||||||
|
.when(is_loading, |el| el.child(Spinner::new())),
|
||||||
|
)
|
||||||
|
.child(toolbar)
|
||||||
|
.child(body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
Application::new().run(|cx: &mut App| {
|
||||||
|
gpui_component::init(cx);
|
||||||
|
|
||||||
|
cx.open_window(WindowOptions::default(), |window, cx| {
|
||||||
|
let view = cx.new(|cx| ProjectsView::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 = ["macros", "rt", "time"] }
|
||||||
135
vrc-get-gui-runtime/src/lib.rs
Normal file
135
vrc-get-gui-runtime/src/lib.rs
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
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"] }
|
serde_with = { version = "3", features = ["base64"] }
|
||||||
tauri = { version = "=2.11.2", features = [ "config-toml" ] } # = for sync version between npm and cargo
|
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-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"] }
|
reqwest = { version = "0.13", features = ["gzip", "brotli", "json"] }
|
||||||
specta = { version = "2.0.0-rc.24", features = [ "chrono", "url", "indexmap" ] }
|
specta = { version = "2.0.0-rc.24", features = [ "chrono", "url", "indexmap" ] }
|
||||||
tauri-specta = { version = "2.0.0-rc.24", features = ["typescript"] }
|
tauri-specta = { version = "2.0.0-rc.24", features = ["typescript"] }
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "npm install && npm run build:vite",
|
"build": "npm install && npm run build:vite",
|
||||||
"build:vite": "vite build",
|
"build:vite": "vite build",
|
||||||
|
"i18n:to-rust": "node scripts/i18next-to-rust-i18n.mjs",
|
||||||
"format": "biome format",
|
"format": "biome format",
|
||||||
"check": "biome check",
|
"check": "biome check",
|
||||||
"lint": "tsc && biome lint"
|
"lint": "tsc && biome lint"
|
||||||
|
|
|
||||||
49
vrc-get-gui/scripts/i18next-to-rust-i18n.mjs
Normal file
49
vrc-get-gui/scripts/i18next-to-rust-i18n.mjs
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { readFile, writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import JSON5 from "json5";
|
||||||
|
|
||||||
|
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 yaml = `${toYaml(translationRoot)}\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