Compare commits

..

7 Commits

Author SHA1 Message Date
Vantz Stockwell
6015f8669b fix: WebviewUrl::App hash fragment bug — tool windows loading empty page
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 3m45s
ROOT CAUSE FOUND: WebviewUrl::App takes a PathBuf, not a URL.
Passing "index.html#/tool/ping?sessionId=abc" treated the ENTIRE
string including # and ? as a file path. Tauri looked for a file
literally named "index.html#/tool/ping?sessionId=abc" which doesn't
exist. The webview loaded an empty/404 page and WKWebView killed
the content process, closing the window instantly.

Fix:
- Rust: split URL at '#' — pass only "index.html" to WebviewUrl::App,
  then set the hash fragment via window.eval() after build()
- Vue: App.vue now listens for 'hashchange' event in addition to
  checking hash on mount, so the eval-injected hash triggers the
  correct tool/detached mode

This was NEVER a CSP issue, focus issue, crossorigin issue, or
async chunk loading issue. It was always a bad file path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 15:04:30 -04:00
Vantz Stockwell
703ebdd557 fix: Rust-side window creation + RDP tab switch layout delay
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 3m51s
Tool/help/editor/detach windows:
- Moved ALL child window creation from JS-side WebviewWindow to
  Rust-side WebviewWindowBuilder via new open_child_window command.
  JS WebviewWindow on macOS WKWebView was creating windows that
  never fully initialized — the webview content process failed
  silently. Rust-side creation uses the proper main thread context.
- All four call sites (tool, help, editor, detach) now use invoke()
- Errors surface as alert() instead of silent failure

RDP tab switch:
- Immediate force_refresh on tab activation for instant visual feedback
- 300ms delayed dimension check (was double-rAF which was too fast)
- If dimensions changed, resize + 500ms delayed refresh for clean repaint
- Fixes 3/4 resolution rendering after copilot panel toggle

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 14:29:14 -04:00
Vantz Stockwell
d462381cce fix: create child windows hidden, show after tauri://created
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 3m50s
Per Gemini's analysis: WKWebView may reap windows that fail to
establish focus during creation. All WebviewWindow instances now
created with visible: false + focus: true, then wv.show() is
called only after tauri://created confirms the webview is ready.

This prevents the OS window manager from treating the window as
an orphaned popup during the brief initialization period.

Applied to: tool windows, help windows, editor windows, detached
session windows.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 13:40:30 -04:00
Vantz Stockwell
10dc3f9cbe fix: synchronous ToolWindow import + bars to 48px/16px
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 3m47s
Tool windows (still closing instantly after every prior fix):
- Changed ToolWindow from defineAsyncComponent to direct synchronous
  import. All 14 tool components now bundled into the main JS chunk.
  Eliminates async chunk loading as a failure point — if the main
  bundle loads (which it does, since the main window works), the
  tool window code is guaranteed to be available.
- ToolWindow chunk no longer exists as a separate file

Status bar + Monitor bar:
- Both set to h-[48px] text-base px-6 (48px height, 16px text)
- Matching sizes for visual consistency

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 13:35:39 -04:00
Vantz Stockwell
cf1c10495b fix: MonitorBar squeezed by terminal container height: 100%
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 3m58s
.terminal-container had both height: 100% (CSS) and flex-1 (Tailwind).
In the flex-col parent, height: 100% forced the terminal to claim the
full parent height, squeezing MonitorBar below its h-6 minimum.

Fix: replaced height: 100% with min-height: 0. flex-1 handles sizing,
min-height: 0 allows proper flex shrinking so MonitorBar gets its full
24px allocation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 13:07:43 -04:00
Vantz Stockwell
0b923051c6 fix: revert StatusBar and MonitorBar to matching h-6 text-[10px]
Some checks failed
Build & Sign Wraith / Build Windows + Sign (push) Has been cancelled
Both bars now identical: 24px height, 10px font. MonitorBar had been
changed during debugging — reverted to match StatusBar exactly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 13:06:35 -04:00
Vantz Stockwell
04c140f608 fix: RDP canvas re-measures container on tab switch
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 3m44s
When switching from SSH back to RDP, the canvas retained the resolution
from when the copilot panel was open — even after closing the panel.
The ResizeObserver doesn't fire while the tab is hidden (v-show/display),
so the container size change goes unnoticed.

Fix: On tab activation, double-rAF waits for layout, measures the
container via getBoundingClientRect, compares with canvas.width/height,
and sends rdp_resize if they differ. This ensures the RDP session
always matches the current available space.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 12:58:57 -04:00
10 changed files with 122 additions and 66 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -14,3 +14,4 @@ pub mod updater;
pub mod tools_commands_r2; pub mod tools_commands_r2;
pub mod workspace_commands; pub mod workspace_commands;
pub mod docker_commands; pub mod docker_commands;
pub mod window_commands;

View File

@ -0,0 +1,40 @@
use tauri::AppHandle;
use tauri::WebviewWindowBuilder;
/// Open a child window from the Rust side using WebviewWindowBuilder.
///
/// The `url` parameter supports hash fragments (e.g. "index.html#/tool/ping?sessionId=abc").
/// WebviewUrl::App takes a PathBuf and cannot handle hash/query — so we load plain
/// index.html and set the hash via JS after the window is created.
#[tauri::command]
pub fn open_child_window(
app_handle: AppHandle,
label: String,
title: String,
url: String,
width: f64,
height: f64,
) -> Result<(), String> {
// Split "index.html#/tool/ping?sessionId=abc" into path and fragment
let (path, hash) = match url.split_once('#') {
Some((p, h)) => (p.to_string(), Some(format!("#{}", h))),
None => (url.clone(), None),
};
let webview_url = tauri::WebviewUrl::App(path.into());
let window = WebviewWindowBuilder::new(&app_handle, &label, webview_url)
.title(&title)
.inner_size(width, height)
.resizable(true)
.center()
.build()
.map_err(|e| format!("Failed to create window '{}': {}", label, e))?;
// Set the hash fragment after the window loads — this triggers App.vue's
// onMounted hash detection to render the correct tool/detached component.
if let Some(hash) = hash {
let _ = window.eval(&format!("window.location.hash = '{}';", hash));
}
Ok(())
}

View File

@ -234,6 +234,7 @@ pub fn run() {
commands::updater::check_for_updates, commands::updater::check_for_updates,
commands::workspace_commands::save_workspace, commands::workspace_commands::load_workspace, commands::workspace_commands::save_workspace, commands::workspace_commands::load_workspace,
commands::docker_commands::docker_list_containers, commands::docker_commands::docker_list_images, commands::docker_commands::docker_list_volumes, commands::docker_commands::docker_action, commands::docker_commands::docker_list_containers, commands::docker_commands::docker_list_images, commands::docker_commands::docker_list_volumes, commands::docker_commands::docker_action,
commands::window_commands::open_child_window,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");

View File

@ -2,15 +2,12 @@
import { ref, onMounted, onErrorCaptured, defineAsyncComponent } from "vue"; import { ref, onMounted, onErrorCaptured, defineAsyncComponent } from "vue";
import { useAppStore } from "@/stores/app.store"; import { useAppStore } from "@/stores/app.store";
import UnlockLayout from "@/layouts/UnlockLayout.vue"; import UnlockLayout from "@/layouts/UnlockLayout.vue";
import ToolWindow from "@/components/tools/ToolWindow.vue";
const MainLayout = defineAsyncComponent({ const MainLayout = defineAsyncComponent({
loader: () => import("@/layouts/MainLayout.vue"), loader: () => import("@/layouts/MainLayout.vue"),
onError(error) { console.error("[App] MainLayout load failed:", error); }, onError(error) { console.error("[App] MainLayout load failed:", error); },
}); });
const ToolWindow = defineAsyncComponent({
loader: () => import("@/components/tools/ToolWindow.vue"),
onError(error) { console.error("[App] ToolWindow load failed:", error); },
});
const DetachedSession = defineAsyncComponent({ const DetachedSession = defineAsyncComponent({
loader: () => import("@/components/session/DetachedSession.vue"), loader: () => import("@/components/session/DetachedSession.vue"),
onError(error) { console.error("[App] DetachedSession load failed:", error); }, onError(error) { console.error("[App] DetachedSession load failed:", error); },
@ -19,7 +16,6 @@ const DetachedSession = defineAsyncComponent({
const app = useAppStore(); const app = useAppStore();
const appError = ref<string | null>(null); const appError = ref<string | null>(null);
// Tool window mode detected from URL hash: #/tool/network-scanner?sessionId=abc
const isToolMode = ref(false); const isToolMode = ref(false);
const isDetachedMode = ref(false); const isDetachedMode = ref(false);
const toolName = ref(""); const toolName = ref("");
@ -31,32 +27,39 @@ onErrorCaptured((err) => {
return false; return false;
}); });
onMounted(async () => { /** Parse hash and set mode flags. Called on mount and on hashchange. */
const hash = window.location.hash; function applyHash(hash: string): void {
if (hash.startsWith("#/tool/")) { if (hash.startsWith("#/tool/")) {
isToolMode.value = true; isToolMode.value = true;
const rest = hash.substring(7); // after "#/tool/" const rest = hash.substring(7);
const [name, query] = rest.split("?"); const [name, query] = rest.split("?");
toolName.value = name; toolName.value = name;
toolSessionId.value = new URLSearchParams(query || "").get("sessionId") || ""; toolSessionId.value = new URLSearchParams(query || "").get("sessionId") || "";
} else if (hash.startsWith("#/detached-session")) { } else if (hash.startsWith("#/detached-session")) {
isDetachedMode.value = true; isDetachedMode.value = true;
} else { }
}
onMounted(async () => {
// Check hash at load time (present if JS-side WebviewWindow set it in the URL)
applyHash(window.location.hash);
// Also listen for hash changes (Rust-side window sets hash via eval after load)
window.addEventListener("hashchange", () => applyHash(window.location.hash));
// Only init vault for the main app window (no hash)
if (!isToolMode.value && !isDetachedMode.value) {
await app.checkVaultState(); await app.checkVaultState();
} }
}); });
</script> </script>
<template> <template>
<!-- Error display for debugging -->
<div v-if="appError" class="fixed inset-0 z-50 flex items-center justify-center bg-[#0d1117] text-red-400 p-8 text-sm font-mono whitespace-pre-wrap"> <div v-if="appError" class="fixed inset-0 z-50 flex items-center justify-center bg-[#0d1117] text-red-400 p-8 text-sm font-mono whitespace-pre-wrap">
{{ appError }} {{ appError }}
</div> </div>
<!-- Detached session window mode -->
<DetachedSession v-else-if="isDetachedMode" /> <DetachedSession v-else-if="isDetachedMode" />
<!-- Tool popup window mode -->
<ToolWindow v-else-if="isToolMode" :tool="toolName" :session-id="toolSessionId" /> <ToolWindow v-else-if="isToolMode" :tool="toolName" :session-id="toolSessionId" />
<!-- Normal app mode -->
<div v-else class="app-root"> <div v-else class="app-root">
<UnlockLayout v-if="!app.isUnlocked" /> <UnlockLayout v-if="!app.isUnlocked" />
<MainLayout v-else /> <MainLayout v-else />

View File

@ -2,7 +2,7 @@
.terminal-container { .terminal-container {
width: 100%; width: 100%;
height: 100%; min-height: 0;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
background: var(--wraith-bg-primary); background: var(--wraith-bg-primary);

View File

@ -204,17 +204,45 @@ onBeforeUnmount(() => {
if (resizeTimeout) { clearTimeout(resizeTimeout); resizeTimeout = null; } if (resizeTimeout) { clearTimeout(resizeTimeout); resizeTimeout = null; }
}); });
// Focus canvas and force full frame refresh when switching to this tab // Focus canvas, re-check dimensions, and force full frame on tab switch.
// Uses 300ms delay to let the flex layout fully settle (copilot panel toggle, etc.)
watch( watch(
() => props.isActive, () => props.isActive,
(active) => { (active) => {
if (active && canvasRef.value) { if (!active || !canvasRef.value) return;
// Force full frame fetch to repaint the canvas immediately
invoke("rdp_force_refresh", { sessionId: props.sessionId }).catch(() => {}); // Immediate focus so keyboard works right away
if (keyboardGrabbed.value) { if (keyboardGrabbed.value) canvasRef.value.focus();
setTimeout(() => canvasRef.value?.focus(), 0);
// Immediate force refresh to show SOMETHING while we check dimensions
invoke("rdp_force_refresh", { sessionId: props.sessionId }).catch(() => {});
// Delayed dimension check layout needs time to settle
setTimeout(() => {
const wrapper = canvasWrapper.value;
const canvas = canvasRef.value;
if (!wrapper || !canvas) return;
const { width: cw, height: ch } = wrapper.getBoundingClientRect();
const newW = Math.round(cw) & ~1;
const newH = Math.round(ch);
if (newW >= 200 && newH >= 200 && (newW !== canvas.width || newH !== canvas.height)) {
invoke("rdp_resize", {
sessionId: props.sessionId,
width: newW,
height: newH,
}).then(() => {
if (canvas) {
canvas.width = newW;
canvas.height = newH;
}
setTimeout(() => {
invoke("rdp_force_refresh", { sessionId: props.sessionId }).catch(() => {});
}, 500);
}).catch(() => {});
} }
} }, 300);
}, },
); );
</script> </script>

View File

@ -133,17 +133,14 @@ async function detachTab(): Promise<void> {
session.active = false; session.active = false;
// Open a new Tauri window for this session // Open a new Tauri window for this session
const { WebviewWindow } = await import("@tauri-apps/api/webviewWindow"); try {
const label = `detached-${session.id.substring(0, 8)}-${Date.now()}`; await invoke("open_child_window", {
const wv = new WebviewWindow(label, { label: `detached-${session.id.substring(0, 8)}-${Date.now()}`,
title: `${session.name} — Wraith`, title: `${session.name} — Wraith`,
width: 900, url: `index.html#/detached-session?sessionId=${session.id}&name=${encodeURIComponent(session.name)}&protocol=${session.protocol}`,
height: 600, width: 900, height: 600,
resizable: true, });
center: true, } catch (err) { console.error("Detach window error:", err); }
url: `index.html#/detached-session?sessionId=${session.id}&name=${encodeURIComponent(session.name)}&protocol=${session.protocol}`,
});
wv.once("tauri://error", (e) => { console.error("Detach window error:", e); });
} }
function closeMenuTab(): void { function closeMenuTab(): void {

View File

@ -1,7 +1,7 @@
<template> <template>
<div <div
v-if="stats" v-if="stats"
class="flex items-center gap-4 px-3 h-6 bg-[var(--wraith-bg-tertiary)] border-t border-[var(--wraith-border)] text-[10px] font-mono shrink-0 select-none" class="flex items-center gap-4 px-6 h-[48px] bg-[var(--wraith-bg-tertiary)] border-t border-[var(--wraith-border)] text-base font-mono shrink-0 select-none"
> >
<!-- CPU --> <!-- CPU -->
<span class="flex items-center gap-1"> <span class="flex items-center gap-1">

View File

@ -367,17 +367,14 @@ function closeHelpMenuDeferred(): void {
async function handleHelpAction(page: string): Promise<void> { async function handleHelpAction(page: string): Promise<void> {
showHelpMenu.value = false; showHelpMenu.value = false;
const { WebviewWindow } = await import("@tauri-apps/api/webviewWindow"); try {
const label = `help-${page}-${Date.now()}`; await invoke("open_child_window", {
const wv = new WebviewWindow(label, { label: `help-${page}-${Date.now()}`,
title: `Wraith — Help`, title: "Wraith — Help",
width: 750, url: `index.html#/tool/help?page=${page}`,
height: 600, width: 750, height: 600,
resizable: true, });
center: true, } catch (err) { console.error("Help window error:", err); alert("Window error: " + String(err)); }
url: `index.html#/tool/help?page=${page}`,
});
wv.once("tauri://error", (e) => { console.error("Help window error:", e); alert("Window error: " + JSON.stringify(e.payload)); });
} }
async function handleToolAction(tool: string): Promise<void> { async function handleToolAction(tool: string): Promise<void> {
@ -391,8 +388,6 @@ async function handleToolAction(tool: string): Promise<void> {
return; return;
} }
const { WebviewWindow } = await import("@tauri-apps/api/webviewWindow");
const toolConfig: Record<string, { title: string; width: number; height: number }> = { const toolConfig: Record<string, { title: string; width: number; height: number }> = {
"network-scanner": { title: "Network Scanner", width: 800, height: 600 }, "network-scanner": { title: "Network Scanner", width: 800, height: 600 },
"port-scanner": { title: "Port Scanner", width: 700, height: 500 }, "port-scanner": { title: "Port Scanner", width: 700, height: 500 },
@ -413,17 +408,14 @@ async function handleToolAction(tool: string): Promise<void> {
const sessionId = activeSessionId.value || ""; const sessionId = activeSessionId.value || "";
// Open tool in a new Tauri window try {
const label = `tool-${tool}-${Date.now()}`; await invoke("open_child_window", {
const wv = new WebviewWindow(label, { label: `tool-${tool}-${Date.now()}`,
title: `Wraith — ${config.title}`, title: `Wraith — ${config.title}`,
width: config.width, url: `index.html#/tool/${tool}?sessionId=${sessionId}`,
height: config.height, width: config.width, height: config.height,
resizable: true, });
center: true, } catch (err) { console.error("Tool window error:", err); alert("Tool window error: " + String(err)); }
url: `index.html#/tool/${tool}?sessionId=${sessionId}`,
});
wv.once("tauri://error", (e) => { console.error("Tool window error:", e); alert("Window error: " + JSON.stringify(e.payload)); });
} }
async function handleFileMenuAction(action: string): Promise<void> { async function handleFileMenuAction(action: string): Promise<void> {
@ -443,20 +435,14 @@ function handleThemeSelect(theme: ThemeDefinition): void {
async function handleOpenFile(entry: FileEntry): Promise<void> { async function handleOpenFile(entry: FileEntry): Promise<void> {
if (!activeSessionId.value) return; if (!activeSessionId.value) return;
try { try {
const { WebviewWindow } = await import("@tauri-apps/api/webviewWindow");
const fileName = entry.path.split("/").pop() || entry.path; const fileName = entry.path.split("/").pop() || entry.path;
const label = `editor-${Date.now()}`;
const sessionId = activeSessionId.value; const sessionId = activeSessionId.value;
await invoke("open_child_window", {
const wv = new WebviewWindow(label, { label: `editor-${Date.now()}`,
title: `${fileName} — Wraith Editor`, title: `${fileName} — Wraith Editor`,
width: 800,
height: 600,
resizable: true,
center: true,
url: `index.html#/tool/editor?sessionId=${sessionId}&path=${encodeURIComponent(entry.path)}`, url: `index.html#/tool/editor?sessionId=${sessionId}&path=${encodeURIComponent(entry.path)}`,
width: 800, height: 600,
}); });
wv.once("tauri://error", (e) => { console.error("Editor window error:", e); alert("Window error: " + JSON.stringify(e.payload)); });
} catch (err) { console.error("Failed to open editor:", err); } } catch (err) { console.error("Failed to open editor:", err); }
} }