Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions guest_server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
Expand Down Expand Up @@ -156,6 +157,37 @@ func getRdpConnectedStatus(w http.ResponseWriter, r *http.Request) {
w.Write(jsonResponse)
}

type ProcessStatusResponse struct {
Running bool `json:"running"`
}

func getProcessStatus(w http.ResponseWriter, r *http.Request) {
path := r.URL.Query().Get("path")
if path == "" {
http.Error(w, "path query param required", http.StatusBadRequest)
return
}

// Use PowerShell to check if process with matching path is running
// - Expand environment variables in the input path
// - Use case-insensitive comparison (-ieq)
escapedPath := strings.ReplaceAll(path, "'", "''")
psCommand := fmt.Sprintf(
"$p=[Environment]::ExpandEnvironmentVariables('%s') -replace '/','\\'; if(Get-Process | Where-Object {$_.Path -ieq $p} | Select-Object -First 1){'1'}else{'0'}",
escapedPath,
)
cmd := exec.Command("powershell", "-NoProfile", "-NoLogo", "-Command", psCommand)
output, _ := cmd.Output()

response := ProcessStatusResponse{
Running: strings.TrimSpace(string(output)) == "1",
}

w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(response)
}

func applyUpdate(w http.ResponseWriter, r *http.Request) {
// Verify password
expectedHash, err := getSecureRegKey(AUTHKEY_HASH_REG)
Expand Down Expand Up @@ -334,6 +366,7 @@ func main() {
r.HandleFunc("/version", getVersion).Methods("GET")
r.HandleFunc("/metrics", getMetrics).Methods("GET")
r.HandleFunc("/rdp/status", getRdpConnectedStatus).Methods("GET")
r.HandleFunc("/process/status", getProcessStatus).Methods("GET")
r.HandleFunc("/update", applyUpdate).Methods("POST")
r.HandleFunc("/get-icon", getIcon).Methods("POST")
r.HandleFunc("/auth/set-hash", setAuthHash).Methods("POST")
Expand Down
19 changes: 19 additions & 0 deletions src/renderer/lib/winboat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,25 @@ export class Winboat {
return status.rdpConnected;
}

/**
* Checks if a process with the given executable path is running in the Windows guest
* @param path The executable path to check
* @returns true if the process is running, false otherwise
*/
async isProcessRunning(path: string): Promise<boolean> {
try {
const res = await nodeFetch(`${this.apiUrl}/process/status?path=${encodeURIComponent(path)}`, {
signal: AbortSignal.timeout(FETCH_TIMEOUT),
});
const data = (await res.json()) as { running: boolean };
logger.info(`Process ${path} is running: ${data.running}`);
return data.running;
} catch (err) {
logger.warn(`isProcessRunning failed for ${path}: ${err}`);
return false;
}
}

static readCompose(composePath: string): ComposeConfig {
const composeFile = fs.readFileSync(composePath, "utf-8");
const composeContents = YAML.parse(composeFile) as ComposeConfig;
Expand Down
74 changes: 71 additions & 3 deletions src/renderer/views/Apps.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,23 @@
<template>
<div>
<!-- Loading Overlay -->
<Transition name="fade">
<div
v-if="launchingApp"
class="fixed inset-0 z-50 flex flex-col items-center justify-center bg-black/60 backdrop-blur-sm"
>
<div class="relative mb-4">
<img
class="size-20 rounded-xl"
:src="`data:image/png;charset=utf-8;base64,${launchingApp.Icon}`"
alt="App Icon"
/>
<x-throbber class="absolute inset-0 m-auto size-10"></x-throbber>
</div>
<p class="mt-4 text-lg font-semibold text-white">Opening {{ launchingApp.Name }}...</p>
</div>
</Transition>

<dialog ref="addCustomAppDialog">
<h3 class="mb-2">{{ currentAppForm.Source === "custom" ? "Edit App" : "Add App" }}</h3>
<div class="flex flex-row gap-5 mt-4 w-[35vw]">
Expand Down Expand Up @@ -184,8 +202,11 @@
v-for="app of computedApps"
:key="app.id"
class="flex relative flex-row gap-2 justify-between items-center p-2 my-0 backdrop-blur-xl backdrop-brightness-150 cursor-pointer generic-hover bg-neutral-800/20"
:class="{ 'bg-gradient-to-r from-yellow-600/20 bg-neutral-800/20': app.Source === 'custom' }"
@click="winboat.launchApp(app)"
:class="{
'bg-gradient-to-r from-yellow-600/20 bg-neutral-800/20': app.Source === 'custom',
'pointer-events-none opacity-50': launchingApp,
}"
@click="handleAppLaunch(app)"
@contextmenu="openContextMenu($event, app)"
>
<div class="flex flex-row items-center gap-2 w-[85%]">
Expand Down Expand Up @@ -257,6 +278,7 @@ const FormData: typeof import("form-data") = require("form-data");

const winboat = Winboat.getInstance();
const apps = ref<WinApp[]>([]);
const launchingApp = ref<WinApp | null>(null);
const searchInput = ref("");
const sortBy = ref("");
const filterBy = ref("all");
Expand Down Expand Up @@ -462,10 +484,56 @@ function onContextMenuHide() {

function launchApp() {
if (contextMenuTarget.value) {
winboat.launchApp(contextMenuTarget.value);
handleAppLaunch(contextMenuTarget.value);
}
}

/**
* Handles app launch with loading state management and spam-click prevention.
* Polls the guest server to detect when the process is actually running.
*/
async function handleAppLaunch(app: WinApp) {
console.log("Handle app launch: ", app.Name);
if (launchingApp.value) return; // Prevent double-clicks
launchingApp.value = app;

// Fire and forget - don't await since launchApp blocks until app closes
winboat.launchApp(app).catch(err => {
console.error("Error launching app:", err);
});

// For UWP apps (launched via explorer.exe), we can't detect the actual process
// For non-.exe files (.msc, etc.), they're opened by other host processes
// In both cases, fall back to a timeout
const isUwpApp = app.Source === "uwp";
const isExecutable = app.Path.toLowerCase().endsWith(".exe");

if (isUwpApp || !isExecutable) {
// Can't track these by process path, use timeout
setTimeout(() => {
launchingApp.value = null;
}, 2000);
return;
}

// Poll for process status (max 10 seconds, check every 500ms)
const maxAttempts = 20;
for (let i = 0; i < maxAttempts; i++) {
await new Promise(r => setTimeout(r, 500));
try {
const running = await winboat.isProcessRunning(app.Path);
console.log("Process running: ", running);
if (running) {
break;
}
} catch {
// Ignore errors and continue polling
}
}

launchingApp.value = null;
}

/**
* Triggers the file picker for the custom app icon, then processes the image selected
*/
Expand Down