Desktop: Execute editor and node graph natively (#2955)

* Desktop: Execute editor and node graph natively

* Remove decouple execution feature

* Disable feature gate for native communication functions

* Avoid ininite message loop on an infinite canvas

* Add any lint exception

* Build evaluation loop

* Fix texture passing message

* Cleanup

* More cleanup

---------

Co-authored-by: Timon Schelling <me@timon.zip>
This commit is contained in:
Dennis Kobert 2025-07-31 12:26:36 +02:00 committed by GitHub
parent 07802204f2
commit 08ec1d08f6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 207 additions and 104 deletions

6
Cargo.lock generated
View File

@ -1838,11 +1838,16 @@ dependencies = [
"cef",
"dirs",
"futures",
"glam",
"graph-craft",
"graphite-editor",
"include_dir",
"ron",
"thiserror 2.0.12",
"tracing",
"tracing-subscriber",
"wgpu",
"wgpu-executor",
"winit",
]
@ -1904,6 +1909,7 @@ dependencies = [
"js-sys",
"log",
"math-parser",
"ron",
"serde",
"serde-wasm-bindgen",
"wasm-bindgen",

View File

@ -9,17 +9,19 @@ edition = "2024"
rust-version = "1.87"
[features]
# default = ["gpu"]
# gpu = ["graphite-editor/gpu"]
default = ["gpu"]
gpu = ["graphite-editor/gpu"]
[dependencies]
# Local dependencies
# graphite-editor = { path = "../editor", features = [
# "gpu",
# "ron",
# "vello",
# "decouple-execution",
# ] }
# # Local dependencies
graphite-editor = { path = "../editor", features = [
"gpu",
"ron",
"vello",
] }
graph-craft = { workspace = true }
wgpu-executor = { workspace = true }
wgpu = { workspace = true }
winit = { workspace = true, features = ["serde"] }
thiserror = { workspace = true }
@ -29,4 +31,6 @@ include_dir = { workspace = true }
tracing-subscriber = { workspace = true }
tracing = { workspace = true }
dirs = { workspace = true }
ron = { workspace = true}
bytemuck = { workspace = true }
glam = { workspace = true }

View File

@ -2,6 +2,9 @@ use crate::CustomEvent;
use crate::WindowSize;
use crate::render::GraphicsState;
use crate::render::WgpuContext;
use graph_craft::wasm_application_io::WasmApplicationIo;
use graphite_editor::application::Editor;
use graphite_editor::messages::prelude::*;
use std::sync::Arc;
use std::sync::mpsc::Sender;
use std::time::Duration;
@ -21,11 +24,10 @@ pub(crate) struct WinitApp {
pub(crate) cef_context: cef::Context<cef::Initialized>,
pub(crate) window: Option<Arc<Window>>,
cef_schedule: Option<Instant>,
_ui_frame_buffer: Option<wgpu::Texture>,
window_size_sender: Sender<WindowSize>,
_viewport_frame_buffer: Option<wgpu::Texture>,
graphics_state: Option<GraphicsState>,
wgpu_context: WgpuContext,
pub(crate) editor: Editor,
}
impl WinitApp {
@ -34,13 +36,28 @@ impl WinitApp {
cef_context,
window: None,
cef_schedule: Some(Instant::now()),
_viewport_frame_buffer: None,
_ui_frame_buffer: None,
graphics_state: None,
window_size_sender,
wgpu_context,
editor: Editor::new(),
}
}
fn dispatch_message(&mut self, message: Message) {
let responses = self.editor.handle_message(message);
self.send_messages_to_editor(responses);
}
fn send_messages_to_editor(&mut self, responses: Vec<FrontendMessage>) {
if responses.is_empty() {
return;
}
let Ok(message) = ron::to_string(&responses) else {
tracing::error!("Failed to serialize Messages");
return;
};
self.cef_context.send_web_message(message.as_bytes());
}
}
impl ApplicationHandler<CustomEvent> for WinitApp {
@ -49,16 +66,22 @@ impl ApplicationHandler<CustomEvent> for WinitApp {
let timeout = Instant::now() + Duration::from_millis(10);
let wait_until = timeout.min(self.cef_schedule.unwrap_or(timeout));
self.cef_context.work();
event_loop.set_control_flow(ControlFlow::WaitUntil(wait_until));
}
fn new_events(&mut self, _event_loop: &ActiveEventLoop, _cause: StartCause) {
fn new_events(&mut self, _event_loop: &ActiveEventLoop, cause: StartCause) {
if let Some(schedule) = self.cef_schedule
&& schedule < Instant::now()
{
self.cef_schedule = None;
self.cef_context.work();
}
if let StartCause::ResumeTimeReached { .. } = cause {
if let Some(window) = &self.window {
window.request_redraw();
}
}
}
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
@ -77,6 +100,10 @@ impl ApplicationHandler<CustomEvent> for WinitApp {
self.graphics_state = Some(graphics_state);
tracing::info!("Winit window created and ready");
let application_io = WasmApplicationIo::new_with_context(self.wgpu_context.clone());
futures::executor::block_on(graphite_editor::node_graph_executor::replace_application_io(application_io));
}
fn user_event(&mut self, _: &ActiveEventLoop, event: CustomEvent) {
@ -97,6 +124,46 @@ impl ApplicationHandler<CustomEvent> for WinitApp {
self.cef_schedule = Some(instant);
}
}
CustomEvent::MessageReceived { message } => {
if let Message::InputPreprocessor(ipp_message) = &message {
if let Some(window) = &self.window {
window.request_redraw();
}
}
if let Message::InputPreprocessor(InputPreprocessorMessage::BoundsOfViewports { bounds_of_viewports }) = &message {
if let Some(graphic_state) = &mut self.graphics_state {
let window_size = self.window.as_ref().unwrap().inner_size();
let window_size = glam::Vec2::new(window_size.width as f32, window_size.height as f32);
let top_left = bounds_of_viewports[0].top_left.as_vec2() / window_size;
let bottom_right = bounds_of_viewports[0].bottom_right.as_vec2() / window_size;
let offset = top_left.to_array();
let scale = (bottom_right - top_left).recip();
graphic_state.set_viewport_offset(offset);
graphic_state.set_viewport_scale(scale.to_array());
} else {
panic!("graphics state not intialized, viewport offset might be lost");
}
}
self.dispatch_message(message);
}
CustomEvent::NodeGraphRan { texture } => {
if let Some(texture) = texture
&& let Some(graphics_state) = &mut self.graphics_state
{
graphics_state.bind_viewport_texture(&texture);
}
let mut responses = VecDeque::new();
let err = self.editor.poll_node_graph_evaluation(&mut responses);
if let Err(e) = err {
if e != "No active document" {
tracing::error!("Error poling node graph: {}", e);
}
}
for message in responses {
self.dispatch_message(message);
}
}
}
}

View File

@ -120,5 +120,15 @@ impl CefEventHandler for CefHandler {
let _ = self.event_loop_proxy.send_event(CustomEvent::ScheduleBrowserWork(scheduled_time));
}
fn receive_web_message(&self, message: &[u8]) {}
fn receive_web_message(&self, message: &[u8]) {
let str = std::str::from_utf8(message).unwrap();
match ron::from_str(str) {
Ok(message) => {
let _ = self.event_loop_proxy.send_event(CustomEvent::MessageReceived { message });
}
Err(e) => {
tracing::error!("Failed to deserialize message {:?}", e)
}
}
}
}

View File

@ -1,9 +1,6 @@
use cef::rc::{ConvertReturnValue, Rc, RcImpl};
use cef::sys::{_cef_render_process_handler_t, cef_base_ref_counted_t, cef_render_process_handler_t, cef_v8_propertyattribute_t, cef_v8_value_create_array_buffer_with_copy};
use cef::{
CefString, ImplFrame, ImplRenderProcessHandler, ImplV8Context, ImplV8Value, V8Handler, V8Propertyattribute, V8Value, WrapRenderProcessHandler, v8_context_get_entered_context,
v8_value_create_function,
};
use cef::{CefString, ImplFrame, ImplRenderProcessHandler, ImplV8Context, ImplV8Value, V8Handler, V8Propertyattribute, V8Value, WrapRenderProcessHandler, v8_value_create_function};
use crate::cef::ipc::{MessageType, UnpackMessage, UnpackedMessage};
@ -61,7 +58,9 @@ impl ImplRenderProcessHandler for RenderProcessHandlerImpl {
cef_v8_propertyattribute_t::V8_PROPERTY_ATTRIBUTE_READONLY.wrap_result(),
);
if global.value_bykey(Some(&CefString::from(function_name))).is_some() {
frame.execute_java_script(Some(&CefString::from(function_call.as_str())), None, 0);
}
if context.exit() == 0 {
tracing::error!("Failed to exit V8 context");

View File

@ -1,4 +1,4 @@
use cef::{CefString, Frame, ImplBinaryValue, ImplBrowser, ImplFrame, ImplListValue, ImplProcessMessage, ImplV8Context, ProcessId, V8Context, rc::ConvertParam, sys::cef_process_id_t};
use cef::{CefString, Frame, ImplBinaryValue, ImplBrowser, ImplFrame, ImplListValue, ImplProcessMessage, ImplV8Context, ProcessId, V8Context, sys::cef_process_id_t};
use super::{Context, Initialized};

View File

@ -1,7 +1,8 @@
use std::fmt::Debug;
use std::process::exit;
use std::time::Instant;
use std::{fmt::Debug, time::Duration};
use graphite_editor::messages::prelude::Message;
use tracing_subscriber::EnvFilter;
use winit::event_loop::EventLoop;
@ -20,6 +21,8 @@ mod dirs;
pub(crate) enum CustomEvent {
UiUpdate(wgpu::Texture),
ScheduleBrowserWork(Instant),
MessageReceived { message: Message },
NodeGraphRan { texture: Option<wgpu::Texture> },
}
fn main() {
@ -38,7 +41,7 @@ fn main() {
let (window_size_sender, window_size_receiver) = std::sync::mpsc::channel();
let wgpu_context = futures::executor::block_on(WgpuContext::new());
let wgpu_context = futures::executor::block_on(WgpuContext::new()).unwrap();
let cef_context = match cef_context.init(cef::CefHandler::new(window_size_receiver, event_loop.create_proxy(), wgpu_context.clone())) {
Ok(c) => c,
Err(cef::InitError::AlreadyRunning) => {
@ -51,6 +54,25 @@ fn main() {
}
};
tracing::info!("Cef initialized successfully");
let rendering_loop_proxy = event_loop.create_proxy();
let target_fps = 60;
std::thread::spawn(move || {
loop {
let last_render = Instant::now();
let (has_run, texture) = futures::executor::block_on(graphite_editor::node_graph_executor::run_node_graph());
if has_run {
let _ = rendering_loop_proxy.send_event(CustomEvent::NodeGraphRan {
texture: texture.map(|t| (*t.texture).clone()),
});
}
let frame_time = Duration::from_secs_f32((target_fps as f32).recip());
let sleep = last_render + frame_time - Instant::now();
std::thread::sleep(sleep);
}
});
let mut winit_app = WinitApp::new(cef_context, window_size_sender, wgpu_context);
event_loop.run_app(&mut winit_app).unwrap();

View File

@ -56,46 +56,7 @@ pub(crate) enum FrameBufferError {
InvalidSize { buffer_size: usize, expected_size: usize, width: usize, height: usize },
}
#[derive(Debug, Clone)]
pub(crate) struct WgpuContext {
pub(crate) device: wgpu::Device,
pub(crate) queue: wgpu::Queue,
adapter: wgpu::Adapter,
instance: wgpu::Instance,
}
impl WgpuContext {
pub(crate) async fn new() -> Self {
let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor {
backends: wgpu::Backends::PRIMARY,
..Default::default()
});
let adapter = instance
.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::default(),
compatible_surface: None,
force_fallback_adapter: false,
})
.await
.unwrap();
let required_limits = adapter.limits();
let (device, queue) = adapter
.request_device(&wgpu::DeviceDescriptor {
label: None,
required_features: wgpu::Features::PUSH_CONSTANTS,
required_limits,
memory_hints: Default::default(),
trace: wgpu::Trace::Off,
})
.await
.unwrap();
Self { device, queue, adapter, instance }
}
}
pub use wgpu_executor::Context as WgpuContext;
#[derive(Debug)]
pub(crate) struct GraphicsState {

View File

@ -14,7 +14,6 @@ license = "Apache-2.0"
default = ["wasm"]
wasm = ["wasm-bindgen", "graphene-std/wasm", "wasm-bindgen-futures"]
gpu = ["interpreted-executor/gpu", "wgpu-executor"]
decouple-execution = []
resvg = ["graphene-std/resvg"]
vello = ["graphene-std/vello", "resvg"]
ron = ["dep:ron"]

View File

@ -206,11 +206,14 @@ impl Dispatcher {
self.message_handlers.preferences_message_handler.process_message(message, &mut queue, ());
}
Message::Tool(message) => {
let document_id = self.message_handlers.portfolio_message_handler.active_document_id().unwrap();
let Some(document) = self.message_handlers.portfolio_message_handler.documents.get_mut(&document_id) else {
let Some(document_id) = self.message_handlers.portfolio_message_handler.active_document_id() else {
warn!("Called ToolMessage without an active document.\nGot {message:?}");
return;
};
let Some(document) = self.message_handlers.portfolio_message_handler.documents.get_mut(&document_id) else {
warn!("Called ToolMessage with an invalid active document.\nGot {message:?}");
return;
};
let context = ToolMessageContext {
document_id,

View File

@ -34,13 +34,12 @@ impl MessageHandler<NewDocumentDialogMessage, ()> for NewDocumentDialogMessageHa
.into(),
],
});
}
responses.add(DeferMessage::AfterNavigationReady {
messages: vec![DocumentMessage::ZoomCanvasToFitAll.into(), DocumentMessage::DeselectAllLayers.into()],
});
}
}
}
self.send_dialog_to_frontend(responses);
}

View File

@ -702,12 +702,12 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
if create_document {
// Wait for the document to be rendered so the click targets can be calculated in order to determine the artboard size that will encompass the pasted image
responses.add(DeferMessage::AfterNavigationReady {
messages: vec![DocumentMessage::ZoomCanvasToFitAll.into()],
});
responses.add(DeferMessage::AfterGraphRun {
messages: vec![DocumentMessage::WrapContentInArtboard { place_artboard_at_origin: true }.into()],
});
responses.add(DeferMessage::AfterNavigationReady {
messages: vec![DocumentMessage::ZoomCanvasToFitAll.into()],
});
}
}
PortfolioMessage::PasteSvg {
@ -883,6 +883,7 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
responses.add(FrontendMessage::UpdateOpenDocumentsList { open_documents });
}
PortfolioMessage::UpdateVelloPreference => {
responses.add(FrontendMessage::UpdateViewportHolePunch { active: preferences.use_vello });
responses.add(NodeGraphMessage::RunDocumentGraph);
self.persistent_data.use_vello = preferences.use_vello;
}

View File

@ -29,7 +29,6 @@ pub struct ExecutionRequest {
render_config: RenderConfig,
}
#[cfg_attr(feature = "decouple-execution", derive(serde::Serialize, serde::Deserialize))]
pub struct ExecutionResponse {
execution_id: u64,
result: Result<TaggedValue, String>,
@ -46,7 +45,6 @@ pub struct CompilationResponse {
node_graph_errors: GraphErrors,
}
#[cfg_attr(feature = "decouple-execution", derive(serde::Serialize, serde::Deserialize))]
pub enum NodeGraphUpdate {
ExecutionResponse(ExecutionResponse),
CompilationResponse(CompilationResponse),

View File

@ -427,22 +427,14 @@ struct InspectState {
}
/// The resulting value from the temporary inspected during execution
#[derive(Clone, Debug, Default)]
#[cfg_attr(feature = "decouple-execution", derive(serde::Serialize, serde::Deserialize))]
pub struct InspectResult {
#[cfg(not(feature = "decouple-execution"))]
introspected_data: Option<Arc<dyn std::any::Any + Send + Sync + 'static>>,
#[cfg(feature = "decouple-execution")]
introspected_data: Option<TaggedValue>,
pub inspect_node: NodeId,
}
impl InspectResult {
pub fn take_data(&mut self) -> Option<Arc<dyn std::any::Any + Send + Sync + 'static>> {
#[cfg(not(feature = "decouple-execution"))]
return self.introspected_data.clone();
#[cfg(feature = "decouple-execution")]
return self.introspected_data.take().map(|value| value.to_any());
}
}
@ -487,8 +479,6 @@ impl InspectState {
fn access(&self, executor: &DynamicExecutor) -> Option<InspectResult> {
let introspected_data = executor.introspect(&[self.monitor_node]).inspect_err(|e| warn!("Failed to introspect monitor node {e}")).ok();
// TODO: Consider displaying the error instead of ignoring it
#[cfg(feature = "decouple-execution")]
let introspected_data = introspected_data.as_ref().and_then(|data| TaggedValue::try_from_std_any_ref(data).ok());
Some(InspectResult {
inspect_node: self.inspect_node,

View File

@ -12,6 +12,7 @@
"production": "npm run setup && npm run wasm:build-production && concurrently -k -n \"VITE,RUST\" \"vite\" \"npm run wasm:watch-production\"",
"---------- BUILDS ----------": "",
"build-dev": "npm run wasm:build-dev && vite build",
"build-native": "npm run native:build-dev && vite build",
"build-profiling": "npm run wasm:build-profiling && vite build",
"build": "npm run wasm:build-production && vite build",
"---------- UTILITIES ----------": "",
@ -19,6 +20,7 @@
"lint-fix": "eslint . --fix && tsc --noEmit",
"---------- INTERNAL ----------": "",
"setup": "node package-installer.js",
"native:build-dev": "wasm-pack build ./wasm --dev --target=web --features native",
"wasm:build-dev": "wasm-pack build ./wasm --dev --target=web",
"wasm:build-profiling": "wasm-pack build ./wasm --profiling --target=web",
"wasm:build-production": "wasm-pack build ./wasm --release --target=web",

View File

@ -1,7 +1,7 @@
// import { panicProxy } from "@graphite/utility-functions/panic-proxy";
import { type JsMessageType } from "@graphite/messages";
import { createSubscriptionRouter, type SubscriptionRouter } from "@graphite/subscription-router";
import init, { setRandomSeed, wasmMemory, EditorHandle } from "@graphite-frontend/wasm/pkg/graphite_wasm.js";
import init, { setRandomSeed, wasmMemory, EditorHandle, receiveNativeMessage } from "@graphite-frontend/wasm/pkg/graphite_wasm.js";
export type Editor = {
raw: WebAssembly.Memory;
@ -27,6 +27,8 @@ export async function initWasm() {
wasmImport = await wasmMemory();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any).imageCanvases = {};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any).receiveNativeMessage = receiveNativeMessage;
// Provide a random starter seed which must occur after initializing the WASM module, since WASM can't generate its own random numbers
const randomSeedFloat = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER);

View File

@ -13,6 +13,7 @@ license = "Apache-2.0"
[features]
default = ["gpu"]
gpu = ["editor/gpu"]
native = []
[lib]
crate-type = ["cdylib", "rlib"]
@ -37,6 +38,7 @@ wasm-bindgen-futures = { workspace = true }
math-parser = { workspace = true }
wgpu = { workspace = true }
web-sys = { workspace = true }
ron = { workspace = true }
[package.metadata.wasm-pack.profile.dev]
wasm-opt = false

View File

@ -154,6 +154,7 @@ impl EditorHandle {
}
// Sends a message to the dispatcher in the Editor Backend
#[cfg(not(feature = "native"))]
fn dispatch<T: Into<Message>>(&self, message: T) {
// Process no further messages after a crash to avoid spamming the console
if EDITOR_HAS_CRASHED.load(Ordering::SeqCst) {
@ -169,6 +170,16 @@ impl EditorHandle {
}
}
#[cfg(feature = "native")]
fn dispatch<T: Into<Message>>(&self, message: T) {
let message: Message = message.into();
let Ok(serialized_message) = ron::to_string(&message) else {
log::error!("Failed to serialize message");
return;
};
crate::native_communcation::send_message_to_cef(serialized_message)
}
// Sends a FrontendMessage to JavaScript
fn send_frontend_message_to_js(&self, mut message: FrontendMessage) {
if let FrontendMessage::UpdateImageData { ref image_data } = message {
@ -228,22 +239,15 @@ impl EditorHandle {
wasm_bindgen_futures::spawn_local(poll_node_graph_evaluation());
if !EDITOR_HAS_CRASHED.load(Ordering::SeqCst) {
editor_and_handle(|editor, handle| {
for message in editor.handle_message(InputPreprocessorMessage::CurrentTime {
editor_and_handle(|_, handle| {
handle.dispatch(InputPreprocessorMessage::CurrentTime {
timestamp: js_sys::Date::now() as u64,
}) {
handle.send_frontend_message_to_js(message);
}
for message in editor.handle_message(AnimationMessage::IncrementFrameCounter) {
handle.send_frontend_message_to_js(message);
}
});
handle.dispatch(AnimationMessage::IncrementFrameCounter);
// Used by auto-panning, but this could possibly be refactored in the future, see:
// <https://github.com/GraphiteEditor/Graphite/pull/2562#discussion_r2041102786>
for message in editor.handle_message(BroadcastMessage::TriggerEvent(BroadcastEvent::AnimationFrame)) {
handle.send_frontend_message_to_js(message);
}
handle.dispatch(BroadcastMessage::TriggerEvent(BroadcastEvent::AnimationFrame));
});
}
@ -910,7 +914,7 @@ fn editor<T: Default>(callback: impl FnOnce(&mut editor::application::Editor) ->
}
/// Provides access to the `Editor` and its `EditorHandle` by calling the given closure with them as arguments.
pub(crate) fn editor_and_handle(mut callback: impl FnMut(&mut Editor, &mut EditorHandle)) {
pub(crate) fn editor_and_handle(callback: impl FnOnce(&mut Editor, &mut EditorHandle)) {
EDITOR_HANDLE.with(|editor_handle| {
editor(|editor| {
let mut guard = editor_handle.try_lock();
@ -964,9 +968,7 @@ fn auto_save_all_documents() {
return;
}
editor_and_handle(|editor, handle| {
for message in editor.handle_message(PortfolioMessage::AutoSaveAllDocuments) {
handle.send_frontend_message_to_js(message);
}
editor_and_handle(|_, handle| {
handle.dispatch(PortfolioMessage::AutoSaveAllDocuments);
});
}

View File

@ -6,6 +6,7 @@ extern crate log;
pub mod editor_api;
pub mod helpers;
pub mod native_communcation;
use editor::messages::prelude::*;
use std::panic;

View File

@ -0,0 +1,35 @@
use editor::{application::Editor, messages::prelude::FrontendMessage};
use js_sys::{ArrayBuffer, Uint8Array};
use wasm_bindgen::prelude::*;
use crate::editor_api::{self, EditorHandle};
#[wasm_bindgen(js_name = "receiveNativeMessage")]
pub fn receive_native_message(buffer: ArrayBuffer) {
let buffer = Uint8Array::new(buffer.as_ref()).to_vec();
match ron::from_str::<Vec<FrontendMessage>>(str::from_utf8(buffer.as_slice()).unwrap()) {
Ok(messages) => {
let callback = move |_: &mut Editor, handle: &mut EditorHandle| {
for message in messages {
handle.send_frontend_message_to_js_rust_proxy(message);
}
};
editor_api::editor_and_handle(callback);
}
Err(e) => log::error!("Failed to deserialize frontend messages: {e:?}"),
}
}
pub fn send_message_to_cef(message: String) {
let global = js_sys::global();
// Get the function by name
let func = js_sys::Reflect::get(&global, &JsValue::from_str("sendNativeMessage")).expect("Function not found");
let func = func.dyn_into::<js_sys::Function>().expect("Not a function");
let array = Uint8Array::from(message.as_bytes());
let buffer = array.buffer();
// Call it with argument
func.call1(&JsValue::NULL, &JsValue::from(buffer)).expect("Function call failed");
}

View File

@ -4,7 +4,7 @@
"scripts": {
"---------- DEV SERVER ----------": "",
"start": "cd frontend && npm start",
"start-desktop": "cd frontend && npm run build-dev && cargo run -p graphite-desktop",
"start-desktop": "cd frontend && npm run build-native && cargo run -p graphite-desktop",
"profiling": "cd frontend && npm run profiling",
"production": "cd frontend && npm run production",
"---------- BUILDS ----------": "",