fix: implemented file server for custom UI commands on Windows (#24)

This commit is contained in:
Huakun Shen 2024-11-12 12:25:24 -05:00 committed by GitHub
parent 7865d18580
commit c7003326db
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 215 additions and 44 deletions

View File

@ -113,8 +113,12 @@ pub fn run() {
Some(ext) => { Some(ext) => {
// let app_state = app_handle.state::<tauri_plugin_jarvis::model::app_state::AppState>(); // let app_state = app_handle.state::<tauri_plugin_jarvis::model::app_state::AppState>();
// let extension_path = app_state.extension_path.lock().unwrap().clone(); // let extension_path = app_state.extension_path.lock().unwrap().clone();
// tauri_file_server(app_handle, request, extension_path) tauri_file_server(
tauri_file_server(app_handle, request, ext.path.clone(), ext.dist.clone()) app_handle,
request,
ext.info.path.clone(),
ext.info.dist.clone(),
)
} }
None => tauri::http::Response::builder() None => tauri::http::Response::builder()
.status(tauri::http::StatusCode::NOT_FOUND) .status(tauri::http::StatusCode::NOT_FOUND)

View File

@ -2,10 +2,12 @@ import { appState } from "@/stores"
import { winExtMap } from "@/stores/winExtMap" import { winExtMap } from "@/stores/winExtMap"
import { trimSlash } from "@/utils/url" import { trimSlash } from "@/utils/url"
import { constructExtensionSupportDir } from "@kksh/api" import { constructExtensionSupportDir } from "@kksh/api"
import { spawnExtensionFileServer } from "@kksh/api/commands"
import { CustomUiCmd, ExtPackageJsonExtra, TemplateUiCmd } from "@kksh/api/models" import { CustomUiCmd, ExtPackageJsonExtra, TemplateUiCmd } from "@kksh/api/models"
import { launchNewExtWindow } from "@kksh/extension" import { launchNewExtWindow } from "@kksh/extension"
import { convertFileSrc } from "@tauri-apps/api/core" import { convertFileSrc } from "@tauri-apps/api/core"
import * as fs from "@tauri-apps/plugin-fs" import * as fs from "@tauri-apps/plugin-fs"
import { platform } from "@tauri-apps/plugin-os"
import { goto } from "$app/navigation" import { goto } from "$app/navigation"
export async function createExtSupportDir(extPath: string) { export async function createExtSupportDir(extPath: string) {
@ -44,28 +46,43 @@ export async function onCustomUiCmdSelect(
// console.log("onCustomUiCmdSelect", ext, cmd, isDev, hmr) // console.log("onCustomUiCmdSelect", ext, cmd, isDev, hmr)
await createExtSupportDir(ext.extPath) await createExtSupportDir(ext.extPath)
let url = cmd.main let url = cmd.main
const useDevMain = hmr && isDev && cmd.devMain
if (hmr && isDev && cmd.devMain) { if (useDevMain) {
url = cmd.devMain url = cmd.devMain
} else { } else {
url = decodeURIComponent(convertFileSrc(`${trimSlash(cmd.main)}`, "ext")) url = decodeURIComponent(convertFileSrc(`${trimSlash(cmd.main)}`, "ext"))
} }
const url2 = `/extension/ui-iframe?url=${encodeURIComponent(url)}&extPath=${encodeURIComponent(ext.extPath)}` let url2 = `/extension/ui-iframe?url=${encodeURIComponent(url)}&extPath=${encodeURIComponent(ext.extPath)}`
if (cmd.window) { if (cmd.window) {
const winLabel = await winExtMap.registerExtensionWithWindow({ const winLabel = await winExtMap.registerExtensionWithWindow({
extPath: ext.extPath, extPath: ext.extPath,
dist: cmd.dist dist: cmd.dist
}) })
console.log("Launch new window, ", winLabel) if (platform() === "windows" && !useDevMain) {
const addr = await spawnExtensionFileServer(winLabel)
const newUrl = `http://${addr}`
url2 = `/extension/ui-iframe?url=${encodeURIComponent(newUrl)}&extPath=${encodeURIComponent(ext.extPath)}`
}
console.log("URL 2", url2)
const window = launchNewExtWindow(winLabel, url2, cmd.window) const window = launchNewExtWindow(winLabel, url2, cmd.window)
window.onCloseRequested(async (event) => { window.onCloseRequested(async (event) => {
await winExtMap.unregisterExtensionFromWindow(winLabel) await winExtMap.unregisterExtensionFromWindow(winLabel)
}) })
} else { } else {
console.log("Launch main window") console.log("Launch main window")
return winExtMap const winLabel = await winExtMap.registerExtensionWithWindow({
.registerExtensionWithWindow({ windowLabel: "main", extPath: ext.extPath, dist: cmd.dist }) windowLabel: "main",
.then(() => goto(url2)) extPath: ext.extPath,
dist: cmd.dist
})
if (platform() === "windows" && !useDevMain) {
const addr = await spawnExtensionFileServer(winLabel) // addr has format "127.0.0.1:<port>"
console.log("Extension file server address: ", addr)
const newUrl = `http://${addr}`
url2 = `/extension/ui-iframe?url=${encodeURIComponent(newUrl)}&extPath=${encodeURIComponent(ext.extPath)}`
}
console.log("URL 2", url2)
goto(url2)
} }
appState.clearSearchTerm() appState.clearSearchTerm()
} }

View File

@ -24,7 +24,7 @@
import { cn, commandScore } from "@kksh/ui/utils" import { cn, commandScore } from "@kksh/ui/utils"
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow" import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"
import { exit } from "@tauri-apps/plugin-process" import { exit } from "@tauri-apps/plugin-process"
import { CircleXIcon, EllipsisVerticalIcon, RefreshCcwIcon } from "lucide-svelte" import { ArrowBigUpIcon, CircleXIcon, EllipsisVerticalIcon, RefreshCcwIcon } from "lucide-svelte"
let inputEle: HTMLInputElement | null = null let inputEle: HTMLInputElement | null = null
function onKeyDown(event: KeyboardEvent) { function onKeyDown(event: KeyboardEvent) {
@ -100,7 +100,6 @@
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
<DropdownMenu.Content class="w-80"> <DropdownMenu.Content class="w-80">
<DropdownMenu.Group> <DropdownMenu.Group>
<DropdownMenu.Separator />
<DropdownMenu.Item onclick={() => exit()}> <DropdownMenu.Item onclick={() => exit()}>
<CircleXIcon class="h-4 w-4 text-red-500" /> <CircleXIcon class="h-4 w-4 text-red-500" />
Quit Quit
@ -116,12 +115,12 @@
<DropdownMenu.Item onclick={toggleDevTools}> <DropdownMenu.Item onclick={toggleDevTools}>
<Icon icon="mingcute:code-fill" class="mr-2 h-5 w-5 text-green-500" /> <Icon icon="mingcute:code-fill" class="mr-2 h-5 w-5 text-green-500" />
Toggle Devtools Toggle Devtools
<DropdownMenu.Shortcut>⌃+Shift+I</DropdownMenu.Shortcut> <DropdownMenu.Shortcut><span class="flex items-center">⌃+<ArrowBigUpIcon class="w-4 h-4" />+I</span></DropdownMenu.Shortcut>
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item onclick={() => location.reload()}> <DropdownMenu.Item onclick={() => location.reload()}>
<RefreshCcwIcon class="mr-2 h-4 w-4 text-green-500" /> <RefreshCcwIcon class="mr-2 h-4 w-4 text-green-500" />
Reload Window Reload Window
<DropdownMenu.Shortcut>⌃+Shift+R</DropdownMenu.Shortcut> <DropdownMenu.Shortcut><span class="flex items-center">⌃+<ArrowBigUpIcon class="w-4 h-4" />+R</span></DropdownMenu.Shortcut>
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item <DropdownMenu.Item
onclick={() => { onclick={() => {

View File

@ -68,6 +68,7 @@
// navigateTo(localePath("/")) // navigateTo(localePath("/"))
// }, // },
goBack: async () => { goBack: async () => {
console.log("goBack iframe ui API called")
if (isInMainWindow()) { if (isInMainWindow()) {
goto("/") goto("/")
} else { } else {
@ -132,6 +133,7 @@
} satisfies IApp } satisfies IApp
function onBackBtnClicked() { function onBackBtnClicked() {
console.log("onBackBtnClicked")
if (isInMainWindow()) { if (isInMainWindow()) {
goHome() goHome()
} else { } else {
@ -173,13 +175,12 @@
class={cn("absolute", positionToTailwindClasses(uiControl.backBtnPosition))} class={cn("absolute", positionToTailwindClasses(uiControl.backBtnPosition))}
size="icon" size="icon"
variant="outline" variant="outline"
data-tauri-drag-region
onclick={onBackBtnClicked} onclick={onBackBtnClicked}
> >
{#if appWin.label === "main"} {#if appWin.label === "main"}
<ArrowLeftIcon class="w-4" data-tauri-drag-region /> <ArrowLeftIcon class="w-4" />
{:else} {:else}
<XIcon class="w-4" data-tauri-drag-region /> <XIcon class="w-4" />
{/if} {/if}
</Button> </Button>
{/if} {/if}
@ -198,7 +199,6 @@
class={cn("absolute", positionToTailwindClasses(uiControl.refreshBtnPosition))} class={cn("absolute", positionToTailwindClasses(uiControl.refreshBtnPosition))}
size="icon" size="icon"
variant="outline" variant="outline"
data-tauri-drag-region
onclick={iframeUiAPI.reloadPage} onclick={iframeUiAPI.reloadPage}
> >
<RefreshCcwIcon class="w-4" /> <RefreshCcwIcon class="w-4" />

View File

@ -47,9 +47,9 @@
<Switch bind:checked={$appConfig} /> <Switch bind:checked={$appConfig} />
</li> --> </li> -->
</ul> </ul>
{#if dev} <!-- {#if dev}
<Shiki class="w-full overflow-x-auto" lang="json" code={JSON.stringify($appConfig, null, 2)} /> <Shiki class="w-full overflow-x-auto" lang="json" code={JSON.stringify($appConfig, null, 2)} />
{/if} {/if} -->
</main> </main>
<style scoped> <style scoped>

View File

@ -31,6 +31,12 @@ export function unregisterExtensionWindow(label: string): Promise<void> {
}) })
} }
export function spawnExtensionFileServer(windowLabel: string): Promise<string> {
return invoke<string>(generateJarvisPluginCommand("spawn_extension_file_server"), {
windowLabel
})
}
export function registerExtensionSpawnedProcess(windowLabel: string, pid: number): Promise<void> { export function registerExtensionSpawnedProcess(windowLabel: string, pid: number): Promise<void> {
return invoke(generateJarvisPluginCommand("register_extension_spawned_process"), { return invoke(generateJarvisPluginCommand("register_extension_spawned_process"), {
windowLabel, windowLabel,

View File

@ -62,6 +62,7 @@ const COMMANDS: &[&str] = &[
"register_extension_spawned_process", "register_extension_spawned_process",
"unregister_extension_spawned_process", "unregister_extension_spawned_process",
"unregister_extension_window", "unregister_extension_window",
"spawn_extension_file_server",
"get_ext_label_map", "get_ext_label_map",
// "ext_store_wrapper_set", // "ext_store_wrapper_set",
// "ext_store_wrapper_get", // "ext_store_wrapper_get",

View File

@ -58,6 +58,7 @@ commands.allow = [
"is_window_label_registered", "is_window_label_registered",
"register_extension_window", "register_extension_window",
"unregister_extension_window", "unregister_extension_window",
"spawn_extension_file_server",
"register_extension_spawned_process", "register_extension_spawned_process",
"unregister_extension_spawned_process", "unregister_extension_spawned_process",
"get_ext_label_map", "get_ext_label_map",

View File

@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-spawn-extension-file-server"
description = "Enables the spawn_extension_file_server command without any pre-configured scope."
commands.allow = ["spawn_extension_file_server"]
[[permission]]
identifier = "deny-spawn-extension-file-server"
description = "Denies the spawn_extension_file_server command without any pre-configured scope."
commands.deny = ["spawn_extension_file_server"]

View File

@ -1610,6 +1610,32 @@ Denies the sleep_displays command without any pre-configured scope.
<tr> <tr>
<td> <td>
`jarvis:allow-spawn-extension-file-server`
</td>
<td>
Enables the spawn_extension_file_server command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`jarvis:deny-spawn-extension-file-server`
</td>
<td>
Denies the spawn_extension_file_server command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`jarvis:allow-start-server` `jarvis:allow-start-server`
</td> </td>

View File

@ -909,6 +909,16 @@
"type": "string", "type": "string",
"const": "deny-sleep-displays" "const": "deny-sleep-displays"
}, },
{
"description": "Enables the spawn_extension_file_server command without any pre-configured scope.",
"type": "string",
"const": "allow-spawn-extension-file-server"
},
{
"description": "Denies the spawn_extension_file_server command without any pre-configured scope.",
"type": "string",
"const": "deny-spawn-extension-file-server"
},
{ {
"description": "Enables the start_server command without any pre-configured scope.", "description": "Enables the start_server command without any pre-configured scope.",
"type": "string", "type": "string",

View File

@ -1,14 +1,20 @@
use crate::JarvisState; use crate::JarvisState;
use crate::{ use crate::{
model::{ model::{
extension::Extension, extension::{Extension, ExtensionInfo},
manifest::{ExtPackageJsonExtra, MANIFEST_FILE_NAME}, manifest::{ExtPackageJsonExtra, MANIFEST_FILE_NAME},
}, },
utils::manifest::load_jarvis_ext_manifest, utils::manifest::load_jarvis_ext_manifest,
}; };
use std::collections::HashMap; use std::collections::HashMap;
use std::{fmt::format, path::PathBuf}; use std::net::{SocketAddr, TcpListener};
use tauri::{command, AppHandle, Runtime, State, Window}; use std::{
path::PathBuf,
sync::{Arc, Mutex},
};
use tauri::{AppHandle, Runtime, State, Window};
use tower_http::cors::CorsLayer;
use tower_http::services::ServeDir;
/// manifest_path can be folder of package.json /// manifest_path can be folder of package.json
/// If it's a folder, join it with package.json /// If it's a folder, join it with package.json
@ -61,6 +67,9 @@ pub async fn is_window_label_registered<R: Runtime>(
.contains_key(label.as_str())) .contains_key(label.as_str()))
} }
/// When ui iframe ext loaded, register the window containing the iframe with the extension info
/// Extension info contains path to extension command and dist path, so Kunkun's custom file server knows where to load
/// static files from when a request is received from a window.
#[tauri::command] #[tauri::command]
pub async fn register_extension_window<R: Runtime>( pub async fn register_extension_window<R: Runtime>(
_app: AppHandle<R>, _app: AppHandle<R>,
@ -74,17 +83,14 @@ pub async fn register_extension_window<R: Runtime>(
None => format!("main:ext:{}", uuid::Uuid::new_v4()), None => format!("main:ext:{}", uuid::Uuid::new_v4()),
}; };
let mut label_ext_map = state.window_label_ext_map.lock().unwrap(); let mut label_ext_map = state.window_label_ext_map.lock().unwrap();
// if label_ext_map.contains_key(window_label_2.as_str()) {
// return Err(format!(
// "Window with label {} is already registered",
// &window_label_2
// ));
// }
let ext = Extension { let ext = Extension {
path: extension_path, info: ExtensionInfo {
processes: vec![], path: extension_path,
dist: dist, processes: vec![],
// identifier: manifest.kunkun.identifier, dist: dist,
},
shutdown_handle: Arc::new(Mutex::new(None)),
server_handle: Arc::new(Mutex::new(None)),
}; };
label_ext_map.insert(window_label_2.clone(), ext); label_ext_map.insert(window_label_2.clone(), ext);
Ok(window_label_2) Ok(window_label_2)
@ -106,7 +112,7 @@ pub async fn register_extension_spawned_process<R: Runtime>(
)); ));
} }
let ext = label_ext_map.get_mut(window_label.as_str()).unwrap(); let ext = label_ext_map.get_mut(window_label.as_str()).unwrap();
ext.processes.push(pid); ext.info.processes.push(pid);
Ok(()) Ok(())
} }
@ -121,6 +127,7 @@ pub async fn unregister_extension_spawned_process<R: Runtime>(
label_ext_map label_ext_map
.get_mut(window_label.as_str()) .get_mut(window_label.as_str())
.unwrap() .unwrap()
.info
.processes .processes
.retain(|p| *p != pid); .retain(|p| *p != pid);
Ok(()) Ok(())
@ -131,20 +138,95 @@ pub async fn get_ext_label_map<R: Runtime>(
_app: AppHandle<R>, _app: AppHandle<R>,
_window: Window<R>, _window: Window<R>,
state: State<'_, JarvisState>, state: State<'_, JarvisState>,
) -> Result<HashMap<String, Extension>, String> { ) -> Result<HashMap<String, ExtensionInfo>, String> {
Ok(state.window_label_ext_map.lock().unwrap().clone()) let label_ext_map = state.window_label_ext_map.lock().unwrap();
// turn label_ext_map from HashMap<String, Extension> to HashMap<String, ExtensionInfo>
let label_ext_map_info: HashMap<String, ExtensionInfo> = label_ext_map
.iter()
.map(|(label, ext)| (label.clone(), ext.info.clone()))
.collect();
Ok(label_ext_map_info)
} }
/// unregister extension window
#[tauri::command] #[tauri::command]
pub async fn unregister_extension_window<R: Runtime>( pub async fn unregister_extension_window<R: Runtime>(
_app: AppHandle<R>, _app: AppHandle<R>,
state: State<'_, JarvisState>, state: State<'_, JarvisState>,
label: String, label: String,
) -> Result<bool, String> { ) -> Result<(), String> {
Ok(state // find extension, if there is shutdown handle, shutdown it
.window_label_ext_map let mut label_ext_map = state.window_label_ext_map.lock().unwrap();
.lock() // find extension info with window_label
.unwrap() let ext = label_ext_map.get(label.as_str()).cloned();
.remove(label.as_str()) // drop(label_ext_map);
.is_some()) match ext {
Some(ext) => {
let shutdown_handle = ext.shutdown_handle.lock().unwrap();
if let Some(shutdown_handle) = shutdown_handle.as_ref() {
shutdown_handle.shutdown();
log::info!("Shutdown extension file server with label {}", label);
}
let server_handle = ext.server_handle.lock().unwrap();
if let Some(server_handle) = server_handle.as_ref() {
server_handle.abort();
log::info!("Abort extension file server with label {}", label);
}
label_ext_map.remove(label.as_str());
log::info!("Unregistered extension window with label {}", label);
}
None => return Err(format!("Extension with label {} not found", label)),
}
// state
// .window_label_ext_map
// .lock()
// .unwrap()
// .remove(label.as_str())
// .is_some()
Ok(())
}
/// spawn extension file server, only for Windows
#[tauri::command]
pub async fn spawn_extension_file_server<R: Runtime>(
_app: AppHandle<R>,
state: State<'_, JarvisState>,
window_label: String,
) -> Result<SocketAddr, String> {
let mut label_ext_map = state.window_label_ext_map.lock().unwrap();
// find extension info with window_label
let ext = label_ext_map.get(window_label.as_str());
if ext.is_none() {
return Err(format!("Extension with label {} not found", window_label));
}
let ext = ext.unwrap();
let mut ext_path = ext.info.path.clone();
if let Some(dist) = ext.info.dist.clone() {
ext_path = ext_path.join(dist);
}
// TODO: spawn file server
let shutdown_handle = axum_server::Handle::new();
let shutdown_handle_clone = shutdown_handle.clone();
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let rest_router = axum::Router::new()
.layer(CorsLayer::permissive())
.nest_service("/", ServeDir::new(ext_path));
let server_handle = tauri::async_runtime::spawn(async move {
// axum_server::bind(addr)
axum_server::from_tcp(listener)
.handle(shutdown_handle_clone)
.serve(rest_router.into_make_service())
// .serve(combined_router.into_make_service())
.await
.unwrap();
});
// add server handle and shutdown handle to extension
let mut ext = label_ext_map.get_mut(window_label.as_str()).unwrap();
ext.server_handle.lock().unwrap().replace(server_handle);
ext.shutdown_handle.lock().unwrap().replace(shutdown_handle);
// TODO: add server handle and shutdown handle
Ok(addr)
} }

View File

@ -125,6 +125,7 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
commands::extension::register_extension_spawned_process, commands::extension::register_extension_spawned_process,
commands::extension::unregister_extension_spawned_process, commands::extension::unregister_extension_spawned_process,
commands::extension::get_ext_label_map, commands::extension::get_ext_label_map,
commands::extension::spawn_extension_file_server,
/* ---------------------- extension storage API wrapper --------------------- */ /* ---------------------- extension storage API wrapper --------------------- */
// commands::storage::ext_store_wrapper_set, // commands::storage::ext_store_wrapper_set,
// commands::storage::ext_store_wrapper_get, // commands::storage::ext_store_wrapper_get,

View File

@ -1,11 +1,22 @@
use super::manifest::Permissions; use super::manifest::Permissions;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::path::PathBuf; use std::{
net::SocketAddr,
path::PathBuf,
sync::{Arc, Mutex},
};
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Extension { pub struct ExtensionInfo {
pub path: PathBuf, pub path: PathBuf,
pub processes: Vec<u32>, pub processes: Vec<u32>,
pub dist: Option<String>, pub dist: Option<String>,
// pub identifier: String, // pub identifier: String,
} }
#[derive(Debug, Clone)]
pub struct Extension {
pub info: ExtensionInfo,
pub shutdown_handle: Arc<Mutex<Option<axum_server::Handle>>>,
pub server_handle: Arc<std::sync::Mutex<Option<tauri::async_runtime::JoinHandle<()>>>>,
}