feat: wire all remaining stubs — settings, SFTP, RDP, credentials, FreeRDP callbacks
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 1m4s

Four-agent parallel deployment:

1. Settings persistence — all 5 settings wired to SettingsService.Set/Get,
   theme picker persists, update check calls real UpdateService, external
   links use Browser.OpenURL, SFTP file open/save calls real service,
   Quick Connect creates real connection + session, exit uses Wails quit

2. SSH key management — credential dropdown in ConnectionEditDialog,
   collapsible "Add New Credential" panel with password/SSH key modes,
   CredentialService proxied through WraithApp (vault-locked guard),
   new CreateSSHKeyCredential method for atomic key+credential creation

3. RDP frontend wiring — useRdp.ts calls real RDPGetFrame/SendMouse/
   SendKey/SendClipboard via Wails bindings, ConnectRDP on WraithApp
   resolves credentials and builds RDPConfig, session store handles
   RDP protocol, frame pipeline uses polling at 30fps

4. FreeRDP3 callback registration — PostConnect and BitmapUpdate callbacks
   via syscall.NewCallback, GDI mode for automatic frame decoding,
   freerdp_context_new() call added, settings/input/context pointers
   extracted from struct offsets, BGRA→RGBA channel swap in frame copy,
   event loop fixed to pass context not instance

11 files changed. Zero build errors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell 2026-03-17 11:25:03 -04:00
parent df23cecdbd
commit b46c20b0d0
11 changed files with 1056 additions and 144 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

View File

@ -268,8 +268,11 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from "vue"; import { ref, watch, onMounted } from "vue";
import { Call } from "@wailsio/runtime"; import { Call, Browser } from "@wailsio/runtime";
const SETTINGS = "github.com/vstockwell/wraith/internal/settings.SettingsService";
const UPDATER = "github.com/vstockwell/wraith/internal/updater.UpdateService";
type Section = "general" | "terminal" | "vault" | "about"; type Section = "general" | "terminal" | "vault" | "about";
@ -317,6 +320,43 @@ const settings = ref({
scrollbackBuffer: 5000, scrollbackBuffer: 5000,
}); });
/** Load saved settings from Go backend on mount. */
onMounted(async () => {
try {
const [protocol, sidebarW, theme, fontSize, scrollback] = await Promise.all([
Call.ByName(`${SETTINGS}.Get`, "default_protocol") as Promise<string>,
Call.ByName(`${SETTINGS}.Get`, "sidebar_width") as Promise<string>,
Call.ByName(`${SETTINGS}.Get`, "terminal_theme") as Promise<string>,
Call.ByName(`${SETTINGS}.Get`, "font_size") as Promise<string>,
Call.ByName(`${SETTINGS}.Get`, "scrollback_buffer") as Promise<string>,
]);
if (protocol) settings.value.defaultProtocol = protocol as "ssh" | "rdp";
if (sidebarW) settings.value.sidebarWidth = Number(sidebarW);
if (theme) settings.value.terminalTheme = theme;
if (fontSize) settings.value.fontSize = Number(fontSize);
if (scrollback) settings.value.scrollbackBuffer = Number(scrollback);
} catch (err) {
console.error("Failed to load settings:", err);
}
});
/** Persist settings changes to Go backend as they change. */
watch(() => settings.value.defaultProtocol, (val) => {
Call.ByName(`${SETTINGS}.Set`, "default_protocol", val).catch(console.error);
});
watch(() => settings.value.sidebarWidth, (val) => {
Call.ByName(`${SETTINGS}.Set`, "sidebar_width", String(val)).catch(console.error);
});
watch(() => settings.value.terminalTheme, (val) => {
Call.ByName(`${SETTINGS}.Set`, "terminal_theme", val).catch(console.error);
});
watch(() => settings.value.fontSize, (val) => {
Call.ByName(`${SETTINGS}.Set`, "font_size", String(val)).catch(console.error);
});
watch(() => settings.value.scrollbackBuffer, (val) => {
Call.ByName(`${SETTINGS}.Set`, "scrollback_buffer", String(val)).catch(console.error);
});
// --- Update check state --- // --- Update check state ---
type UpdateCheckState = "idle" | "checking" | "found" | "up-to-date" | "error"; type UpdateCheckState = "idle" | "checking" | "found" | "up-to-date" | "error";
const updateCheckState = ref<UpdateCheckState>("idle"); const updateCheckState = ref<UpdateCheckState>("idle");
@ -375,30 +415,32 @@ async function checkForUpdates(): Promise<void> {
updateCheckState.value = "checking"; updateCheckState.value = "checking";
try { try {
// TODO: replace with Wails binding UpdateService.CheckForUpdate() const info = await Call.ByName(`${UPDATER}.CheckForUpdate`) as {
// const info = await UpdateService.CheckForUpdate(); available: boolean;
// currentVersion.value = info.currentVersion; currentVersion: string;
// if (info.available) { latestVersion: string;
// latestVersion.value = info.latestVersion; downloadUrl: string;
// updateCheckState.value = "found"; sha256: string;
// } else { };
// updateCheckState.value = "up-to-date"; currentVersion.value = info.currentVersion || currentVersion.value;
// } if (info.available) {
latestVersion.value = info.latestVersion;
// Placeholder until Wails bindings are generated: updateCheckState.value = "found";
} else {
updateCheckState.value = "up-to-date"; updateCheckState.value = "up-to-date";
}
} catch { } catch {
updateCheckState.value = "error"; updateCheckState.value = "error";
} }
} }
function openLink(target: string): void { function openLink(target: string): void {
// TODO: Replace with Wails runtime.BrowserOpenURL(url)
const urls: Record<string, string> = { const urls: Record<string, string> = {
docs: "https://github.com/wraith/docs", docs: "https://github.com/wraith/docs",
repo: "https://github.com/wraith", repo: "https://github.com/wraith",
}; };
console.log("Open link:", urls[target] ?? target); const url = urls[target] ?? target;
Browser.OpenURL(url).catch(console.error);
} }
defineExpose({ open, close, visible }); defineExpose({ open, close, visible });

View File

@ -75,6 +75,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from "vue"; import { ref } from "vue";
import { Call } from "@wailsio/runtime";
const SETTINGS = "github.com/vstockwell/wraith/internal/settings.SettingsService";
export interface ThemeDefinition { export interface ThemeDefinition {
name: string; name: string;
@ -185,7 +188,7 @@ const emit = defineEmits<{
function selectTheme(theme: ThemeDefinition): void { function selectTheme(theme: ThemeDefinition): void {
activeTheme.value = theme.name; activeTheme.value = theme.name;
emit("select", theme); emit("select", theme);
// TODO: Apply theme to xterm.js via Wails binding ThemeService.SetActive(theme.name) Call.ByName(`${SETTINGS}.Set`, "active_theme", theme.name).catch(console.error);
} }
function open(): void { function open(): void {

View File

@ -27,7 +27,7 @@
</div> </div>
<!-- Body --> <!-- Body -->
<div class="px-4 py-4 space-y-3 max-h-[60vh] overflow-y-auto"> <div class="px-4 py-4 space-y-3 max-h-[80vh] overflow-y-auto">
<!-- Name --> <!-- Name -->
<div> <div>
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1">Name</label> <label class="block text-xs text-[var(--wraith-text-secondary)] mb-1">Name</label>
@ -138,6 +138,154 @@
class="w-full px-3 py-2 text-sm rounded bg-[#0d1117] border border-[#30363d] text-[var(--wraith-text-primary)] placeholder-[var(--wraith-text-muted)] outline-none focus:border-[var(--wraith-accent-blue)] transition-colors resize-none" class="w-full px-3 py-2 text-sm rounded bg-[#0d1117] border border-[#30363d] text-[var(--wraith-text-primary)] placeholder-[var(--wraith-text-muted)] outline-none focus:border-[var(--wraith-accent-blue)] transition-colors resize-none"
/> />
</div> </div>
<!-- Divider -->
<div class="border-t border-[#30363d]" />
<!-- Credentials -->
<div>
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1">Credential</label>
<select
v-model="form.credentialId"
class="w-full px-3 py-2 text-sm rounded bg-[#0d1117] border border-[#30363d] text-[var(--wraith-text-primary)] outline-none focus:border-[var(--wraith-accent-blue)] transition-colors cursor-pointer"
>
<option :value="null">None</option>
<option
v-for="cred in credentials"
:key="cred.id"
:value="cred.id"
>
{{ cred.name }}
<template v-if="cred.type === 'ssh_key'"> (SSH Key)</template>
<template v-else> (Password{{ cred.username ? ` ${cred.username}` : '' }})</template>
</option>
</select>
</div>
<!-- Add New Credential (collapsible) -->
<div class="rounded border border-[#30363d] overflow-hidden">
<!-- Toggle header -->
<button
class="w-full flex items-center justify-between px-3 py-2 text-xs text-[var(--wraith-text-secondary)] hover:bg-[#1c2128] transition-colors cursor-pointer"
@click="showNewCred = !showNewCred"
>
<span class="font-medium">Add New Credential</span>
<svg
class="w-3.5 h-3.5 transition-transform"
:class="showNewCred ? 'rotate-180' : ''"
viewBox="0 0 16 16"
fill="currentColor"
>
<path d="M4.427 7.427l3.396 3.396a.25.25 0 0 0 .354 0l3.396-3.396A.25.25 0 0 0 11.396 7H4.604a.25.25 0 0 0-.177.427Z" />
</svg>
</button>
<!-- Collapsed content -->
<div v-if="showNewCred" class="px-3 pb-3 pt-2 space-y-3 bg-[#0d1117]">
<!-- Credential type selector -->
<div class="flex gap-2">
<button
class="flex-1 py-1.5 text-xs rounded border transition-colors cursor-pointer"
:class="newCredType === 'password'
? 'bg-[#3fb950]/10 border-[#3fb950] text-[#3fb950]'
: 'bg-transparent border-[#30363d] text-[var(--wraith-text-muted)] hover:border-[var(--wraith-text-secondary)]'
"
@click="newCredType = 'password'"
>
Password
</button>
<button
class="flex-1 py-1.5 text-xs rounded border transition-colors cursor-pointer"
:class="newCredType === 'ssh_key'
? 'bg-[#58a6ff]/10 border-[#58a6ff] text-[#58a6ff]'
: 'bg-transparent border-[#30363d] text-[var(--wraith-text-muted)] hover:border-[var(--wraith-text-secondary)]'
"
@click="newCredType = 'ssh_key'"
>
SSH Key
</button>
</div>
<!-- Password fields -->
<template v-if="newCredType === 'password'">
<div>
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1">Credential Name</label>
<input
v-model="newCred.name"
type="text"
placeholder="e.g. prod-admin"
class="w-full px-3 py-2 text-sm rounded bg-[#161b22] border border-[#30363d] text-[var(--wraith-text-primary)] placeholder-[var(--wraith-text-muted)] outline-none focus:border-[var(--wraith-accent-blue)] transition-colors"
/>
</div>
<div>
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1">Username</label>
<input
v-model="newCred.username"
type="text"
placeholder="admin"
class="w-full px-3 py-2 text-sm rounded bg-[#161b22] border border-[#30363d] text-[var(--wraith-text-primary)] placeholder-[var(--wraith-text-muted)] outline-none focus:border-[var(--wraith-accent-blue)] transition-colors"
/>
</div>
<div>
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1">Password</label>
<input
v-model="newCred.password"
type="password"
placeholder="••••••••"
class="w-full px-3 py-2 text-sm rounded bg-[#161b22] border border-[#30363d] text-[var(--wraith-text-primary)] placeholder-[var(--wraith-text-muted)] outline-none focus:border-[var(--wraith-accent-blue)] transition-colors"
/>
</div>
</template>
<!-- SSH Key fields -->
<template v-if="newCredType === 'ssh_key'">
<div>
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1">Credential Name</label>
<input
v-model="newCred.name"
type="text"
placeholder="e.g. my-ssh-key"
class="w-full px-3 py-2 text-sm rounded bg-[#161b22] border border-[#30363d] text-[var(--wraith-text-primary)] placeholder-[var(--wraith-text-muted)] outline-none focus:border-[var(--wraith-accent-blue)] transition-colors"
/>
</div>
<div>
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1">Private Key (PEM)</label>
<textarea
v-model="newCred.privateKeyPEM"
rows="5"
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----&#10;...&#10;-----END OPENSSH PRIVATE KEY-----"
class="w-full px-3 py-2 text-sm rounded bg-[#161b22] border border-[#30363d] text-[var(--wraith-text-primary)] placeholder-[var(--wraith-text-muted)] outline-none focus:border-[var(--wraith-accent-blue)] transition-colors resize-none font-mono"
spellcheck="false"
/>
</div>
<div>
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1">Passphrase <span class="text-[var(--wraith-text-muted)]">(optional)</span></label>
<input
v-model="newCred.passphrase"
type="password"
placeholder="Leave blank if key is unencrypted"
class="w-full px-3 py-2 text-sm rounded bg-[#161b22] border border-[#30363d] text-[var(--wraith-text-primary)] placeholder-[var(--wraith-text-muted)] outline-none focus:border-[var(--wraith-accent-blue)] transition-colors"
/>
</div>
</template>
<!-- Error message -->
<p v-if="newCredError" class="text-xs text-[#f85149]">{{ newCredError }}</p>
<!-- Save credential button -->
<button
class="w-full py-1.5 text-xs rounded border transition-colors cursor-pointer"
:class="isNewCredValid
? 'bg-[#238636] border-[#238636] text-white hover:bg-[#2ea043] hover:border-[#2ea043]'
: 'bg-transparent border-[#30363d] text-[var(--wraith-text-muted)] cursor-not-allowed opacity-50'
"
:disabled="!isNewCredValid || savingCred"
@click="saveNewCredential"
>
{{ savingCred ? 'Saving...' : 'Save Credential' }}
</button>
</div>
</div>
</div> </div>
<!-- Footer --> <!-- Footer -->
@ -169,6 +317,20 @@ import { Call } from "@wailsio/runtime";
/** Fully qualified Go method name prefix for ConnectionService bindings. */ /** Fully qualified Go method name prefix for ConnectionService bindings. */
const CONN = "github.com/vstockwell/wraith/internal/connections.ConnectionService"; const CONN = "github.com/vstockwell/wraith/internal/connections.ConnectionService";
/**
* Credential methods are proxied through WraithApp (registered at startup)
* because CredentialService is nil until vault unlock and can't be pre-registered.
*/
const APP = "github.com/vstockwell/wraith/internal/app.WraithApp";
interface Credential {
id: number;
name: string;
username: string;
domain: string;
type: "password" | "ssh_key";
sshKeyId: number | null;
}
interface ConnectionForm { interface ConnectionForm {
name: string; name: string;
@ -176,10 +338,19 @@ interface ConnectionForm {
port: number; port: number;
protocol: "ssh" | "rdp"; protocol: "ssh" | "rdp";
groupId: number | null; groupId: number | null;
credentialId: number | null;
color: string; color: string;
notes: string; notes: string;
} }
interface NewCredForm {
name: string;
username: string;
password: string;
privateKeyPEM: string;
passphrase: string;
}
const connectionStore = useConnectionStore(); const connectionStore = useConnectionStore();
const visible = ref(false); const visible = ref(false);
@ -187,12 +358,28 @@ const isEditing = ref(false);
const editingId = ref<number | null>(null); const editingId = ref<number | null>(null);
const tagsInput = ref(""); const tagsInput = ref("");
const credentials = ref<Credential[]>([]);
// New credential panel state
const showNewCred = ref(false);
const newCredType = ref<"password" | "ssh_key">("password");
const savingCred = ref(false);
const newCredError = ref("");
const newCred = ref<NewCredForm>({
name: "",
username: "",
password: "",
privateKeyPEM: "",
passphrase: "",
});
const form = ref<ConnectionForm>({ const form = ref<ConnectionForm>({
name: "", name: "",
hostname: "", hostname: "",
port: 22, port: 22,
protocol: "ssh", protocol: "ssh",
groupId: null, groupId: null,
credentialId: null,
color: "", color: "",
notes: "", notes: "",
}); });
@ -211,6 +398,15 @@ const isValid = computed(() => {
return form.value.name.trim() !== "" && form.value.hostname.trim() !== "" && form.value.port > 0; return form.value.name.trim() !== "" && form.value.hostname.trim() !== "" && form.value.port > 0;
}); });
const isNewCredValid = computed(() => {
if (!newCred.value.name.trim()) return false;
if (newCredType.value === "password") {
return newCred.value.password.length > 0;
}
// ssh_key: must have PEM content
return newCred.value.privateKeyPEM.trim().length > 0;
});
function setProtocol(protocol: "ssh" | "rdp"): void { function setProtocol(protocol: "ssh" | "rdp"): void {
form.value.protocol = protocol; form.value.protocol = protocol;
if (protocol === "ssh" && form.value.port === 3389) { if (protocol === "ssh" && form.value.port === 3389) {
@ -220,6 +416,65 @@ function setProtocol(protocol: "ssh" | "rdp"): void {
} }
} }
function resetNewCredForm(): void {
newCred.value = { name: "", username: "", password: "", privateKeyPEM: "", passphrase: "" };
newCredError.value = "";
}
async function loadCredentials(): Promise<void> {
try {
const result = await Call.ByName(`${APP}.ListCredentials`) as Credential[];
credentials.value = result || [];
} catch (err) {
credentials.value = [];
}
}
async function saveNewCredential(): Promise<void> {
if (!isNewCredValid.value || savingCred.value) return;
savingCred.value = true;
newCredError.value = "";
try {
let created: Credential | null = null;
if (newCredType.value === "password") {
created = await Call.ByName(
`${APP}.CreatePassword`,
newCred.value.name.trim(),
newCred.value.username.trim(),
newCred.value.password,
"", // domain not collected in this form
) as Credential;
} else {
// SSH Key: CreateSSHKeyCredential(name, username string, privateKeyPEM []byte, passphrase string)
// Wails serialises []byte as base64. We encode the raw PEM string with btoa().
const pemBase64 = btoa(newCred.value.privateKeyPEM.trim());
created = await Call.ByName(
`${APP}.CreateSSHKeyCredential`,
newCred.value.name.trim(),
newCred.value.username.trim(),
pemBase64,
newCred.value.passphrase,
) as Credential;
}
// Refresh list and auto-select the new credential
await loadCredentials();
if (created) {
form.value.credentialId = created.id;
}
resetNewCredForm();
showNewCred.value = false;
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
newCredError.value = msg || "Failed to save credential.";
} finally {
savingCred.value = false;
}
}
function openNew(groupId?: number): void { function openNew(groupId?: number): void {
isEditing.value = false; isEditing.value = false;
editingId.value = null; editingId.value = null;
@ -229,10 +484,14 @@ function openNew(groupId?: number): void {
port: 22, port: 22,
protocol: "ssh", protocol: "ssh",
groupId: groupId ?? null, groupId: groupId ?? null,
credentialId: null,
color: "", color: "",
notes: "", notes: "",
}; };
tagsInput.value = ""; tagsInput.value = "";
resetNewCredForm();
showNewCred.value = false;
void loadCredentials();
visible.value = true; visible.value = true;
} }
@ -245,10 +504,14 @@ function openEdit(conn: Connection): void {
port: conn.port, port: conn.port,
protocol: conn.protocol, protocol: conn.protocol,
groupId: conn.groupId, groupId: conn.groupId,
color: "", credentialId: conn.credentialId ?? null,
notes: "", color: conn.color ?? "",
notes: conn.notes ?? "",
}; };
tagsInput.value = conn.tags?.join(", ") ?? ""; tagsInput.value = conn.tags?.join(", ") ?? "";
resetNewCredForm();
showNewCred.value = false;
void loadCredentials();
visible.value = true; visible.value = true;
} }
@ -271,6 +534,7 @@ async function save(): Promise<void> {
hostname: form.value.hostname, hostname: form.value.hostname,
port: form.value.port, port: form.value.port,
groupId: form.value.groupId, groupId: form.value.groupId,
credentialId: form.value.credentialId,
color: form.value.color, color: form.value.color,
tags, tags,
notes: form.value.notes, notes: form.value.notes,
@ -282,6 +546,7 @@ async function save(): Promise<void> {
port: form.value.port, port: form.value.port,
protocol: form.value.protocol, protocol: form.value.protocol,
groupId: form.value.groupId, groupId: form.value.groupId,
credentialId: form.value.credentialId,
color: form.value.color, color: form.value.color,
tags, tags,
notes: form.value.notes, notes: form.value.notes,

View File

@ -50,6 +50,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch } from "vue"; import { ref, onMounted, onBeforeUnmount, watch } from "vue";
import { Call } from "@wailsio/runtime";
const SFTP = "github.com/vstockwell/wraith/internal/sftp.SFTPService";
import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightSpecialChars } from "@codemirror/view"; import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightSpecialChars } from "@codemirror/view";
import { EditorState } from "@codemirror/state"; import { EditorState } from "@codemirror/state";
import { defaultKeymap, history, historyKeymap } from "@codemirror/commands"; import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
@ -217,13 +220,15 @@ watch(
}, },
); );
function handleSave(): void { async function handleSave(): Promise<void> {
if (!view || !hasUnsavedChanges.value) return; if (!view || !hasUnsavedChanges.value) return;
const _currentContent = view.state.doc.toString(); const currentContent = view.state.doc.toString();
// TODO: Replace with Wails binding call SFTPService.WriteFile(sessionId, filePath, currentContent) try {
void props.sessionId; await Call.ByName(`${SFTP}.WriteFile`, props.sessionId, props.filePath, currentContent);
hasUnsavedChanges.value = false; hasUnsavedChanges.value = false;
} catch (err) {
console.error("Failed to save file:", err);
}
} }
</script> </script>

View File

@ -1,4 +1,7 @@
import { ref, onBeforeUnmount } from "vue"; import { ref, onBeforeUnmount } from "vue";
import { Call } from "@wailsio/runtime";
const APP = "github.com/vstockwell/wraith/internal/app.WraithApp";
/** /**
* RDP mouse event flags match the Go constants in internal/rdp/input.go * RDP mouse event flags match the Go constants in internal/rdp/input.go
@ -199,71 +202,50 @@ export function useRdp(): UseRdpReturn {
let frameCount = 0; let frameCount = 0;
/** /**
* Fetch the current frame from the backend. * Fetch the current frame from the Go RDP backend.
* TODO: Replace with Wails binding RDPService.GetFrame(sessionId) *
* Mock: generates a gradient test pattern. * Go's GetFrame returns []byte (raw RGBA, Width*Height*4 bytes).
* Wails serialises Go []byte as a base64-encoded string over the JSON bridge,
* so we decode it back to a Uint8ClampedArray and wrap it in an ImageData.
*/ */
async function fetchFrame( async function fetchFrame(
sessionId: string, sessionId: string,
width = 1920, width = 1920,
height = 1080, height = 1080,
): Promise<ImageData | null> { ): Promise<ImageData | null> {
void sessionId; let raw: string;
try {
// Mock: generate a test frame with animated gradient raw = (await Call.ByName(`${APP}.RDPGetFrame`, sessionId)) as string;
const imageData = new ImageData(width, height); } catch {
const data = imageData.data; // Session may not be connected yet or backend returned an error — skip frame
const t = Date.now() / 1000; return null;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const i = (y * width + x) * 4;
const nx = x / width;
const ny = y / height;
const diag = (nx + ny) / 2;
data[i + 0] = Math.floor(20 + diag * 40); // R
data[i + 1] = Math.floor(25 + (1 - diag) * 30); // G
data[i + 2] = Math.floor(80 + diag * 100); // B
data[i + 3] = 255; // A
// Grid lines every 100px
if (x % 100 === 0 || y % 100 === 0) {
data[i + 0] = Math.min(data[i + 0] + 20, 255);
data[i + 1] = Math.min(data[i + 1] + 20, 255);
data[i + 2] = Math.min(data[i + 2] + 20, 255);
}
}
} }
// Animated pulsing circle at center if (!raw) return null;
const cx = width / 2;
const cy = height / 2;
const radius = 40 + 20 * Math.sin(t * 2);
for (let dy = -70; dy <= 70; dy++) { // Decode base64 → binary string → Uint8ClampedArray
for (let dx = -70; dx <= 70; dx++) { const binaryStr = atob(raw);
const dist = Math.sqrt(dx * dx + dy * dy); const bytes = new Uint8ClampedArray(binaryStr.length);
if (dist <= radius && dist >= radius - 4) { for (let i = 0; i < binaryStr.length; i++) {
const px = Math.floor(cx + dx); bytes[i] = binaryStr.charCodeAt(i);
const py = Math.floor(cy + dy);
if (px >= 0 && px < width && py >= 0 && py < height) {
const i = (py * width + px) * 4;
data[i + 0] = 88;
data[i + 1] = 166;
data[i + 2] = 255;
data[i + 3] = 255;
}
}
}
} }
return imageData; // Validate: RGBA requires exactly width * height * 4 bytes
const expected = width * height * 4;
if (bytes.length !== expected) {
console.warn(
`[useRdp] Frame size mismatch: got ${bytes.length}, expected ${expected}`,
);
return null;
}
return new ImageData(bytes, width, height);
} }
/** /**
* Send a mouse event. * Send a mouse event to the remote session.
* TODO: Replace with Wails binding RDPService.SendMouse(sessionId, x, y, flags) * Calls Go WraithApp.RDPSendMouse(sessionId, x, y, flags).
* Fire-and-forget mouse events are best-effort.
*/ */
function sendMouse( function sendMouse(
sessionId: string, sessionId: string,
@ -271,16 +253,17 @@ export function useRdp(): UseRdpReturn {
y: number, y: number,
flags: number, flags: number,
): void { ): void {
void sessionId; Call.ByName(`${APP}.RDPSendMouse`, sessionId, x, y, flags).catch(
void x; (err: unknown) => {
void y; console.warn("[useRdp] sendMouse failed:", err);
void flags; },
// Mock: no-op — will call Wails binding when wired );
} }
/** /**
* Send a key event, mapping JS code to RDP scancode. * Send a key event, mapping the JS KeyboardEvent.code to an RDP scancode.
* TODO: Replace with Wails binding RDPService.SendKey(sessionId, scancode, pressed) * Calls Go WraithApp.RDPSendKey(sessionId, scancode, pressed).
* Unmapped keys are silently dropped not every JS key has an RDP scancode.
*/ */
function sendKey( function sendKey(
sessionId: string, sessionId: string,
@ -290,19 +273,23 @@ export function useRdp(): UseRdpReturn {
const scancode = jsKeyToScancode(code); const scancode = jsKeyToScancode(code);
if (scancode === null) return; if (scancode === null) return;
void sessionId; Call.ByName(`${APP}.RDPSendKey`, sessionId, scancode, pressed).catch(
void pressed; (err: unknown) => {
// Mock: no-op — will call Wails binding when wired console.warn("[useRdp] sendKey failed:", err);
},
);
} }
/** /**
* Send clipboard text to the remote session. * Send clipboard text to the remote RDP session.
* TODO: Replace with Wails binding RDPService.SendClipboard(sessionId, text) * Calls Go WraithApp.RDPSendClipboard(sessionId, text).
*/ */
function sendClipboard(sessionId: string, text: string): void { function sendClipboard(sessionId: string, text: string): void {
void sessionId; Call.ByName(`${APP}.RDPSendClipboard`, sessionId, text).catch(
void text; (err: unknown) => {
// Mock: no-op console.warn("[useRdp] sendClipboard failed:", err);
},
);
} }
/** /**

View File

@ -212,6 +212,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue"; import { ref, onMounted, onUnmounted } from "vue";
import { Call, Application } from "@wailsio/runtime";
import { useAppStore } from "@/stores/app.store"; import { useAppStore } from "@/stores/app.store";
import { useConnectionStore } from "@/stores/connection.store"; import { useConnectionStore } from "@/stores/connection.store";
import { useSessionStore } from "@/stores/session.store"; import { useSessionStore } from "@/stores/session.store";
@ -230,6 +231,9 @@ import ImportDialog from "@/components/common/ImportDialog.vue";
import SettingsModal from "@/components/common/SettingsModal.vue"; import SettingsModal from "@/components/common/SettingsModal.vue";
import ConnectionEditDialog from "@/components/connections/ConnectionEditDialog.vue"; import ConnectionEditDialog from "@/components/connections/ConnectionEditDialog.vue";
import CopilotPanel from "@/components/copilot/CopilotPanel.vue"; import CopilotPanel from "@/components/copilot/CopilotPanel.vue";
const SFTP = "github.com/vstockwell/wraith/internal/sftp.SFTPService";
const CONN = "github.com/vstockwell/wraith/internal/connections.ConnectionService";
import type { ThemeDefinition } from "@/components/common/ThemePicker.vue"; import type { ThemeDefinition } from "@/components/common/ThemePicker.vue";
import type { SidebarTab } from "@/components/sidebar/SidebarToggle.vue"; import type { SidebarTab } from "@/components/sidebar/SidebarToggle.vue";
import type { FileEntry } from "@/composables/useSftp"; import type { FileEntry } from "@/composables/useSftp";
@ -275,30 +279,29 @@ function handleFileMenuAction(action: string): void {
settingsModal.value?.open(); settingsModal.value?.open();
break; break;
case "exit": case "exit":
// TODO: Replace with Wails runtime.Quit() Application.Quit().catch(() => window.close());
window.close();
break; break;
} }
} }
/** Handle file open from SFTP sidebar -- loads mock content for now. */ /** Handle file open from SFTP sidebar -- reads real content via SFTPService. */
function handleOpenFile(entry: FileEntry): void { async function handleOpenFile(entry: FileEntry): Promise<void> {
if (!sessionStore.activeSession) return; if (!sessionStore.activeSession) {
console.error("No active session to read file from");
// TODO: Replace with Wails binding call -- SFTPService.ReadFile(sessionId, entry.path) return;
// Mock file content for development }
const mockContent = `# ${entry.name}\n\n` +
`# File: ${entry.path}\n` +
`# Size: ${entry.size} bytes\n` +
`# Permissions: ${entry.permissions}\n` +
`# Modified: ${entry.modTime}\n\n` +
`# TODO: Content will be loaded from SFTPService.ReadFile()\n`;
const sessionId = sessionStore.activeSession.id;
try {
const content = await Call.ByName(`${SFTP}.ReadFile`, sessionId, entry.path) as string;
editorFile.value = { editorFile.value = {
content: mockContent, content,
path: entry.path, path: entry.path,
sessionId: sessionStore.activeSession.id, sessionId,
}; };
} catch (err) {
console.error("Failed to read file:", err);
}
} }
/** Handle theme selection from the ThemePicker. */ /** Handle theme selection from the ThemePicker. */
@ -311,7 +314,7 @@ function handleThemeSelect(theme: ThemeDefinition): void {
* Default protocol: SSH, default port: 22. * Default protocol: SSH, default port: 22.
* If port is 3389, use RDP. * If port is 3389, use RDP.
*/ */
function handleQuickConnect(): void { async function handleQuickConnect(): Promise<void> {
const raw = quickConnectInput.value.trim(); const raw = quickConnectInput.value.trim();
if (!raw) return; if (!raw) return;
@ -348,23 +351,39 @@ function handleQuickConnect(): void {
protocol = "rdp"; protocol = "rdp";
} }
// Create a temporary connection and session
// TODO: Replace with Wails binding create ephemeral session via SSHService.Connect / RDPService.Connect
const tempId = Math.max(...connectionStore.connections.map((c) => c.id), 0) + 1;
const name = username ? `${username}@${hostname}` : hostname; const name = username ? `${username}@${hostname}` : hostname;
connectionStore.connections.push({ try {
id: tempId, // Create a persistent connection record then connect to it
const conn = await Call.ByName(`${CONN}.CreateConnection`, {
name, name,
hostname, hostname,
port, port,
protocol, protocol,
groupId: 1, groupId: null,
tags: [], credentialId: null,
color: "",
tags: username ? [username] : [],
notes: "",
options: username ? JSON.stringify({ username }) : "{}",
}) as { id: number };
// Add to local store so sessionStore.connect can find it
connectionStore.connections.push({
id: conn.id,
name,
hostname,
port,
protocol,
groupId: null,
tags: username ? [username] : [],
}); });
sessionStore.connect(tempId); await sessionStore.connect(conn.id);
quickConnectInput.value = ""; quickConnectInput.value = "";
} catch (err) {
console.error("Quick connect failed:", err);
}
} }
/** Global keyboard shortcut handler. */ /** Global keyboard shortcut handler. */

View File

@ -89,8 +89,22 @@ export const useSessionStore = defineStore("session", () => {
}); });
activeSessionId.value = sessionId; activeSessionId.value = sessionId;
} else if (conn.protocol === "rdp") { } else if (conn.protocol === "rdp") {
// TODO: Wire RDP connect when ready // Call Go backend — resolves credentials, builds RDPConfig, returns sessionID
console.warn("RDP connections not yet wired"); const sessionId = await Call.ByName(
`${APP}.ConnectRDP`,
connectionId,
1920, // initial width — resized by the RDP view on mount
1080, // initial height
) as string;
sessions.value.push({
id: sessionId,
connectionId,
name: conn.name,
protocol: "rdp",
active: true,
});
activeSessionId.value = sessionId;
} }
} catch (err) { } catch (err) {
console.error("Connection failed:", err); console.error("Connection failed:", err);

View File

@ -343,6 +343,129 @@ func (a *WraithApp) DisconnectSession(sessionID string) error {
return a.SSH.Disconnect(sessionID) return a.SSH.Disconnect(sessionID)
} }
// ConnectRDP opens an RDP session to the given connection ID.
// It resolves credentials from the vault, builds an RDPConfig, and returns a session ID.
// width and height are the initial desktop dimensions in pixels.
func (a *WraithApp) ConnectRDP(connectionID int64, width, height int) (string, error) {
conn, err := a.Connections.GetConnection(connectionID)
if err != nil {
return "", fmt.Errorf("connection not found: %w", err)
}
config := rdp.RDPConfig{
Hostname: conn.Hostname,
Port: conn.Port,
Width: width,
Height: height,
}
if conn.CredentialID != nil && a.Credentials != nil {
cred, err := a.Credentials.GetCredential(*conn.CredentialID)
if err != nil {
slog.Warn("failed to load credential", "id", *conn.CredentialID, "error", err)
} else {
if cred.Username != "" {
config.Username = cred.Username
}
if cred.Domain != "" {
config.Domain = cred.Domain
}
if cred.Type == "password" {
pw, err := a.Credentials.DecryptPassword(cred.ID)
if err != nil {
slog.Warn("failed to decrypt password", "error", err)
} else {
config.Password = pw
}
}
}
}
sessionID, err := a.RDP.Connect(config, connectionID)
if err != nil {
return "", fmt.Errorf("RDP connect failed: %w", err)
}
// Update last_connected timestamp
if _, err := a.db.Exec("UPDATE connections SET last_connected = CURRENT_TIMESTAMP WHERE id = ?", connectionID); err != nil {
slog.Warn("failed to update last_connected", "error", err)
}
slog.Info("RDP session started", "sessionID", sessionID, "host", conn.Hostname)
return sessionID, nil
}
// RDPGetFrame returns the current frame for an RDP session as a base64-encoded
// string. Go []byte is serialised by Wails as a base64 string over the JSON
// bridge, so the frontend decodes it with atob() to recover the raw RGBA bytes.
func (a *WraithApp) RDPGetFrame(sessionID string) (string, error) {
raw, err := a.RDP.GetFrame(sessionID)
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(raw), nil
}
// RDPSendMouse forwards a mouse event to an RDP session.
// flags uses the RDP mouse-event flag constants defined in internal/rdp/input.go.
func (a *WraithApp) RDPSendMouse(sessionID string, x, y int, flags uint32) error {
return a.RDP.SendMouse(sessionID, x, y, flags)
}
// RDPSendKey forwards a key event (scancode + press/release) to an RDP session.
func (a *WraithApp) RDPSendKey(sessionID string, scancode uint32, pressed bool) error {
return a.RDP.SendKey(sessionID, scancode, pressed)
}
// RDPSendClipboard forwards clipboard text to an RDP session.
func (a *WraithApp) RDPSendClipboard(sessionID string, text string) error {
return a.RDP.SendClipboard(sessionID, text)
}
// RDPDisconnect tears down an RDP session.
func (a *WraithApp) RDPDisconnect(sessionID string) error {
return a.RDP.Disconnect(sessionID)
}
// ---------- Credential proxy methods ----------
// CredentialService is nil until the vault is unlocked. These proxies expose
// it via WraithApp (which IS registered as a Wails service at startup).
// ListCredentials returns all stored credentials (no encrypted values).
func (a *WraithApp) ListCredentials() ([]credentials.Credential, error) {
if a.Credentials == nil {
return nil, fmt.Errorf("vault is locked")
}
return a.Credentials.ListCredentials()
}
// CreatePassword creates a password credential encrypted via the vault.
func (a *WraithApp) CreatePassword(name, username, password, domain string) (*credentials.Credential, error) {
if a.Credentials == nil {
return nil, fmt.Errorf("vault is locked")
}
return a.Credentials.CreatePassword(name, username, password, domain)
}
// CreateSSHKeyCredential imports an SSH private key and creates a Credential
// record referencing it. privateKeyPEM is the raw PEM bytes (Wails serialises
// []byte as base64 over the JSON bridge, so the frontend passes btoa(pem)).
func (a *WraithApp) CreateSSHKeyCredential(name, username string, privateKeyPEM []byte, passphrase string) (*credentials.Credential, error) {
if a.Credentials == nil {
return nil, fmt.Errorf("vault is locked")
}
return a.Credentials.CreateSSHKeyCredential(name, username, privateKeyPEM, passphrase)
}
// DeleteCredential removes a credential by ID.
func (a *WraithApp) DeleteCredential(id int64) error {
if a.Credentials == nil {
return fmt.Errorf("vault is locked")
}
return a.Credentials.DeleteCredential(id)
}
// ImportMobaConf parses a MobaXTerm .mobaconf file and imports its contents // ImportMobaConf parses a MobaXTerm .mobaconf file and imports its contents
// (groups, connections, host keys) into the database. // (groups, connections, host keys) into the database.
func (a *WraithApp) ImportMobaConf(fileContent string) (*plugin.ImportResult, error) { func (a *WraithApp) ImportMobaConf(fileContent string) (*plugin.ImportResult, error) {

View File

@ -251,6 +251,34 @@ func (s *CredentialService) DecryptSSHKey(sshKeyID int64) (privateKey []byte, pa
return []byte(decryptedKey), passphrase, nil return []byte(decryptedKey), passphrase, nil
} }
// CreateSSHKeyCredential imports an SSH key and creates a matching credentials
// row in a single transaction, returning the Credential record that the
// frontend can immediately use as a credentialId on a connection.
func (s *CredentialService) CreateSSHKeyCredential(name, username string, privateKeyPEM []byte, passphrase string) (*Credential, error) {
sshKey, err := s.CreateSSHKey(name, privateKeyPEM, passphrase)
if err != nil {
return nil, err
}
result, err := s.db.Exec(
`INSERT INTO credentials (name, username, type, ssh_key_id)
VALUES (?, ?, 'ssh_key', ?)`,
name, username, sshKey.ID,
)
if err != nil {
// Best-effort cleanup of the orphaned ssh_key row
_, _ = s.db.Exec("DELETE FROM ssh_keys WHERE id = ?", sshKey.ID)
return nil, fmt.Errorf("insert ssh_key credential: %w", err)
}
id, err := result.LastInsertId()
if err != nil {
return nil, fmt.Errorf("get credential id: %w", err)
}
return s.getCredential(id)
}
// DeleteCredential removes a credential. // DeleteCredential removes a credential.
func (s *CredentialService) DeleteCredential(id int64) error { func (s *CredentialService) DeleteCredential(id int64) error {
_, err := s.db.Exec("DELETE FROM credentials WHERE id = ?", id) _, err := s.db.Exec("DELETE FROM credentials WHERE id = ?", id)

View File

@ -20,6 +20,9 @@ var (
procFreerdpConnect = libfreerdp.NewProc("freerdp_connect") procFreerdpConnect = libfreerdp.NewProc("freerdp_connect")
procFreerdpDisconnect = libfreerdp.NewProc("freerdp_disconnect") procFreerdpDisconnect = libfreerdp.NewProc("freerdp_disconnect")
// Context
procContextNew = libfreerdp.NewProc("freerdp_context_new")
// Settings // Settings
procSettingsSetString = libfreerdp.NewProc("freerdp_settings_set_string") procSettingsSetString = libfreerdp.NewProc("freerdp_settings_set_string")
procSettingsSetUint32 = libfreerdp.NewProc("freerdp_settings_set_uint32") procSettingsSetUint32 = libfreerdp.NewProc("freerdp_settings_set_uint32")
@ -29,15 +32,24 @@ var (
procInputSendMouse = libfreerdp.NewProc("freerdp_input_send_mouse_event") procInputSendMouse = libfreerdp.NewProc("freerdp_input_send_mouse_event")
procInputSendKeyboard = libfreerdp.NewProc("freerdp_input_send_keyboard_event") procInputSendKeyboard = libfreerdp.NewProc("freerdp_input_send_keyboard_event")
// Event loop // Event loop — NOTE: takes rdpContext*, not freerdp*
procCheckEventHandles = libfreerdp.NewProc("freerdp_check_event_handles") procCheckEventHandles = libfreerdp.NewProc("freerdp_check_event_handles")
// GDI subsystem (libfreerdp3.dll exports these)
procGdiInit = libfreerdp.NewProc("gdi_init")
procGdiFree = libfreerdp.NewProc("gdi_free")
// Client helpers // Client helpers
procClientNew = libfreerdpClient.NewProc("freerdp_client_context_new") procClientNew = libfreerdpClient.NewProc("freerdp_client_context_new")
procClientFree = libfreerdpClient.NewProc("freerdp_client_context_free") procClientFree = libfreerdpClient.NewProc("freerdp_client_context_free")
) )
// FreeRDP settings IDs (from FreeRDP3 freerdp/settings.h) // ============================================================================
// FreeRDP3 settings IDs
//
// Source: FreeRDP 3.10.3 — include/freerdp/settings_types_private.h
// Each ID is the ALIGN64 slot index from the rdp_settings struct.
// ============================================================================
const ( const (
FreeRDP_ServerHostname = 20 FreeRDP_ServerHostname = 20
FreeRDP_ServerPort = 21 FreeRDP_ServerPort = 21
@ -48,6 +60,7 @@ const (
FreeRDP_DesktopHeight = 1026 FreeRDP_DesktopHeight = 1026
FreeRDP_ColorDepth = 1027 FreeRDP_ColorDepth = 1027
FreeRDP_FullscreenMode = 1028 FreeRDP_FullscreenMode = 1028
FreeRDP_SoftwareGdi = 1601 // BOOL — enable software GDI rendering
FreeRDP_IgnoreCertificate = 4556 FreeRDP_IgnoreCertificate = 4556
FreeRDP_AuthenticationOnly = 4554 FreeRDP_AuthenticationOnly = 4554
FreeRDP_NlaSecurity = 4560 FreeRDP_NlaSecurity = 4560
@ -62,19 +75,293 @@ const (
KBD_FLAGS_RELEASE = 0x8000 KBD_FLAGS_RELEASE = 0x8000
) )
// ============================================================================
// FreeRDP3 struct offsets — x86_64 / Windows amd64
//
// IMPORTANT: These offsets are specific to FreeRDP 3.10.3 compiled with
// WITH_FREERDP_DEPRECATED=OFF (the default, and what our CI uses).
//
// Every ALIGN64 slot is 8 bytes. The slot number from the FreeRDP headers
// gives the byte offset as: slot * 8.
//
// Source: FreeRDP 3.10.3 — include/freerdp/freerdp.h, include/freerdp/update.h
// ============================================================================
const (
// rdp_freerdp struct (ALIGN64 slots, 8 bytes each)
// slot 0: context (rdpContext*)
// slot 48: PreConnect callback (pConnectCallback)
// slot 49: PostConnect callback (pConnectCallback)
// slot 55: PostDisconnect callback (pPostDisconnect)
freerdpOffsetContext = 0 * 8 // rdpContext* — slot 0
freerdpOffsetPreConnect = 48 * 8 // pConnectCallback — slot 48
freerdpOffsetPostConnect = 49 * 8 // pConnectCallback — slot 49
freerdpOffsetPostDisconnect = 55 * 8 // pPostDisconnect — slot 55
// rdp_context struct (ALIGN64 slots, 8 bytes each)
// slot 33: gdi (rdpGdi*)
// slot 38: input (rdpInput*)
// slot 39: update (rdpUpdate*)
// slot 40: settings (rdpSettings*)
contextOffsetGdi = 33 * 8 // rdpGdi*
contextOffsetInput = 38 * 8 // rdpInput*
contextOffsetUpdate = 39 * 8 // rdpUpdate*
contextOffsetSettings = 40 * 8 // rdpSettings*
// rdp_update struct (ALIGN64 slots, 8 bytes each)
// slot 21: BitmapUpdate callback (pBitmapUpdate)
updateOffsetBitmapUpdate = 21 * 8
// rdpGdi struct (NOT ALIGN64 — regular C struct with natural alignment)
// On x86_64:
// offset 0: rdpContext* context (8 bytes)
// offset 8: INT32 width (4 bytes)
// offset 12: INT32 height (4 bytes)
// offset 16: UINT32 stride (4 bytes)
// offset 20: UINT32 dstFormat (4 bytes)
// offset 24: UINT32 cursor_x (4 bytes)
// offset 28: UINT32 cursor_y (4 bytes)
// offset 32: HGDI_DC hdc (8 bytes, pointer)
// offset 40: gdiBitmap* primary (8 bytes)
// offset 48: gdiBitmap* drawing (8 bytes)
// offset 56: UINT32 bitmap_size (4 bytes)
// offset 60: UINT32 bitmap_stride (4 bytes)
// offset 64: BYTE* primary_buffer (8 bytes)
gdiOffsetWidth = 8
gdiOffsetHeight = 12
gdiOffsetStride = 16
gdiOffsetPrimaryBuffer = 64
)
// Pixel format constants from FreeRDP3 codec/color.h.
// Formula: (bpp << 24) | (type << 16) | (a << 12) | (r << 8) | (g << 4) | b
// BGRA type = 4, RGBA type = 3
const (
PIXEL_FORMAT_BGRA32 = 0x20040888
)
// ============================================================================
// Instance-to-backend registry
//
// syscall.NewCallback produces a bare C function pointer — it cannot capture
// Go closures. We use a global map keyed by the freerdp instance pointer to
// recover the FreeRDPBackend from inside callbacks.
// ============================================================================
var (
instanceRegistry = make(map[uintptr]*FreeRDPBackend)
instanceRegistryMu sync.RWMutex
)
func registerInstance(instance uintptr, backend *FreeRDPBackend) {
instanceRegistryMu.Lock()
instanceRegistry[instance] = backend
instanceRegistryMu.Unlock()
}
func unregisterInstance(instance uintptr) {
instanceRegistryMu.Lock()
delete(instanceRegistry, instance)
instanceRegistryMu.Unlock()
}
func lookupInstance(instance uintptr) *FreeRDPBackend {
instanceRegistryMu.RLock()
b := instanceRegistry[instance]
instanceRegistryMu.RUnlock()
return b
}
// ============================================================================
// Unsafe pointer helpers
// ============================================================================
// readPtr reads a pointer-sized value at the given byte offset from base.
func readPtr(base uintptr, offsetBytes uintptr) uintptr {
return *(*uintptr)(unsafe.Pointer(base + offsetBytes))
}
// writePtr writes a pointer-sized value at the given byte offset from base.
func writePtr(base uintptr, offsetBytes uintptr, val uintptr) {
*(*uintptr)(unsafe.Pointer(base + offsetBytes)) = val
}
// readU32 reads a uint32 at the given byte offset from base.
func readU32(base uintptr, offsetBytes uintptr) uint32 {
return *(*uint32)(unsafe.Pointer(base + offsetBytes))
}
// ============================================================================
// Callbacks — registered via syscall.NewCallback (stdcall on Windows)
//
// FreeRDP callback signatures (from freerdp.h):
// typedef BOOL (*pConnectCallback)(freerdp* instance);
// typedef BOOL (*pBitmapUpdate)(rdpContext* context, const BITMAP_UPDATE* bitmap);
// ============================================================================
// postConnectCallback is the global PostConnect handler. FreeRDP calls this
// after the RDP connection is fully established. We use it to:
// 1. Initialize the GDI subsystem (software rendering into a memory buffer)
// 2. Extract the GDI primary_buffer pointer for frame capture
// 3. Register the BitmapUpdate callback for partial screen updates
var postConnectCallbackPtr = syscall.NewCallback(postConnectCallback)
func postConnectCallback(instance uintptr) uintptr {
backend := lookupInstance(instance)
if backend == nil {
return 0 // FALSE — unknown instance
}
// Read context from instance->context (slot 0).
context := readPtr(instance, freerdpOffsetContext)
if context == 0 {
return 0
}
// Initialize the GDI subsystem with BGRA32 pixel format.
// gdi_init(freerdp* instance, UINT32 format) -> BOOL
// This allocates the primary surface and registers internal paint callbacks.
ret, _, _ := procGdiInit.Call(instance, uintptr(PIXEL_FORMAT_BGRA32))
if ret == 0 {
return 0 // gdi_init failed
}
// Read the rdpGdi pointer from context->gdi (slot 33).
gdi := readPtr(context, contextOffsetGdi)
if gdi == 0 {
return 0
}
// Extract frame dimensions and primary buffer pointer from rdpGdi.
gdiWidth := readU32(gdi, gdiOffsetWidth)
gdiHeight := readU32(gdi, gdiOffsetHeight)
gdiStride := readU32(gdi, gdiOffsetStride)
primaryBuf := readPtr(gdi, gdiOffsetPrimaryBuffer)
if primaryBuf == 0 {
return 0
}
// Store the GDI surface info on the backend for frame reads.
backend.gdiPrimaryBuf = primaryBuf
backend.gdiWidth = int(gdiWidth)
backend.gdiHeight = int(gdiHeight)
backend.gdiStride = int(gdiStride)
// Re-create the pixel buffer if GDI negotiated a different resolution
// (the server may reject our requested size).
if int(gdiWidth) != backend.buffer.Width || int(gdiHeight) != backend.buffer.Height {
backend.buffer = NewPixelBuffer(int(gdiWidth), int(gdiHeight))
}
// Register the BitmapUpdate callback on update->BitmapUpdate (slot 21).
// With GDI mode enabled, FreeRDP's internal GDI layer handles most
// rendering via BeginPaint/EndPaint. The BitmapUpdate callback fires for
// uncompressed bitmap transfers that bypass GDI. We register it as a
// safety net to capture any frames that arrive through this path.
update := readPtr(context, contextOffsetUpdate)
if update != 0 {
writePtr(update, updateOffsetBitmapUpdate, bitmapUpdateCallbackPtr)
}
backend.gdiReady = true
return 1 // TRUE — success
}
// preConnectCallback is called before the connection is fully established.
// We use it to enable SoftwareGdi mode so FreeRDP handles bitmap decoding.
var preConnectCallbackPtr = syscall.NewCallback(preConnectCallback)
func preConnectCallback(instance uintptr) uintptr {
backend := lookupInstance(instance)
if backend == nil {
return 0
}
// Enable software GDI — FreeRDP will decode bitmaps into a memory surface.
backend.setBool(FreeRDP_SoftwareGdi, true)
return 1 // TRUE
}
// bitmapUpdateCallback handles BITMAP_UPDATE messages. With GDI mode active,
// most rendering goes through the GDI pipeline and lands in primary_buffer
// automatically. This callback catches any bitmap updates that come through
// the legacy path and blits them into our pixel buffer.
//
// BITMAP_UPDATE layout (FreeRDP 3.10.3):
// UINT32 number — offset 0, number of rectangles
// BITMAP_DATA* rectangles — offset 8 (pointer, 8-byte aligned)
//
// BITMAP_DATA layout (packed, not ALIGN64):
// UINT32 destLeft — offset 0
// UINT32 destTop — offset 4
// UINT32 destRight — offset 8
// UINT32 destBottom — offset 12
// UINT32 width — offset 16
// UINT32 height — offset 20
// UINT32 bitsPerPixel — offset 24
// UINT32 flags — offset 28
// UINT32 bitmapLength — offset 32
// UINT32 cbCompFirstRowSize — offset 36
// UINT32 cbCompMainBodySize — offset 40
// UINT32 cbScanWidth — offset 44
// UINT32 cbUncompressedSize — offset 48
// BYTE* bitmapDataStream — offset 56 (pointer, 8-byte aligned after padding)
// BOOL compressed — offset 64
var bitmapUpdateCallbackPtr = syscall.NewCallback(bitmapUpdateCallback)
func bitmapUpdateCallback(context uintptr, bitmapUpdate uintptr) uintptr {
if context == 0 || bitmapUpdate == 0 {
return 1 // TRUE — don't break the pipeline
}
// Recover the instance from context->instance (slot 0 of rdpContext).
instance := readPtr(context, 0)
backend := lookupInstance(instance)
if backend == nil || backend.buffer == nil {
return 1
}
// With GDI mode enabled, the primary_buffer is already updated by FreeRDP's
// internal GDI renderer before this callback fires. We just need to mark
// the pixel buffer as dirty so the next GetFrame picks up changes.
//
// For the bitmap update path specifically, we parse the rectangles and
// copy each one from the GDI primary buffer into our PixelBuffer.
if backend.gdiReady && backend.gdiPrimaryBuf != 0 {
numRects := readU32(bitmapUpdate, 0)
if numRects > 0 {
backend.copyGdiToBuffer()
}
}
return 1 // TRUE
}
// ============================================================================
// FreeRDPBackend
// ============================================================================
// FreeRDPBackend implements RDPBackend using the FreeRDP3 library loaded at // FreeRDPBackend implements RDPBackend using the FreeRDP3 library loaded at
// runtime via syscall.NewLazyDLL. This avoids any CGO dependency and allows // runtime via syscall.NewLazyDLL. This avoids any CGO dependency and allows
// cross-compilation from Linux while the DLLs are resolved at runtime on // cross-compilation from Linux while the DLLs are resolved at runtime on
// Windows. // Windows.
type FreeRDPBackend struct { type FreeRDPBackend struct {
instance uintptr // freerdp* instance uintptr // freerdp*
settings uintptr // rdpSettings* context uintptr // rdpContext* (extracted from instance->context)
input uintptr // rdpInput* settings uintptr // rdpSettings* (extracted from context->settings)
input uintptr // rdpInput* (extracted from context->input)
buffer *PixelBuffer buffer *PixelBuffer
connected bool connected bool
config RDPConfig config RDPConfig
mu sync.Mutex mu sync.Mutex
stopCh chan struct{} stopCh chan struct{}
// GDI surface state — populated by PostConnect callback.
gdiPrimaryBuf uintptr // BYTE* — FreeRDP's GDI framebuffer
gdiWidth int
gdiHeight int
gdiStride int // bytes per row (may include padding)
gdiReady bool
} }
// NewFreeRDPBackend creates a new FreeRDP-backed RDP backend. The underlying // NewFreeRDPBackend creates a new FreeRDP-backed RDP backend. The underlying
@ -87,6 +374,14 @@ func NewFreeRDPBackend() *FreeRDPBackend {
// Connect establishes an RDP session using FreeRDP3. It creates a new FreeRDP // Connect establishes an RDP session using FreeRDP3. It creates a new FreeRDP
// instance, configures connection settings, and starts the event loop. // instance, configures connection settings, and starts the event loop.
//
// Initialization order (required by FreeRDP3):
// 1. freerdp_new() — allocate instance
// 2. Register callbacks — PreConnect, PostConnect on the instance struct
// 3. freerdp_context_new() — allocate context (also allocates settings, input, update)
// 4. Configure settings — via freerdp_settings_set_* on context->settings
// 5. freerdp_connect() — triggers PreConnect -> TCP -> PostConnect
// 6. Event loop — freerdp_check_event_handles in a goroutine
func (f *FreeRDPBackend) Connect(config RDPConfig) error { func (f *FreeRDPBackend) Connect(config RDPConfig) error {
f.mu.Lock() f.mu.Lock()
defer f.mu.Unlock() defer f.mu.Unlock()
@ -97,14 +392,60 @@ func (f *FreeRDPBackend) Connect(config RDPConfig) error {
f.config = config f.config = config
// Create a bare FreeRDP instance. // ── Step 1: Create a bare FreeRDP instance ──
ret, _, err := procFreerdpNew.Call() ret, _, err := procFreerdpNew.Call()
if ret == 0 { if ret == 0 {
return fmt.Errorf("freerdp_new failed: %v", err) return fmt.Errorf("freerdp_new failed: %v", err)
} }
f.instance = ret f.instance = ret
// Configure connection settings via the settings accessor functions. // Register this instance in the global map so callbacks can find us.
registerInstance(f.instance, f)
// ── Step 2: Register callbacks on the instance struct ──
// PreConnect (slot 48): called before RDP negotiation, used to enable GDI.
writePtr(f.instance, freerdpOffsetPreConnect, preConnectCallbackPtr)
// PostConnect (slot 49): called after connection, sets up GDI rendering.
writePtr(f.instance, freerdpOffsetPostConnect, postConnectCallbackPtr)
// ── Step 3: Create the context ──
// freerdp_context_new(freerdp* instance) -> BOOL
// This allocates rdpContext and populates instance->context, including
// the settings, input, and update sub-objects.
ret, _, err = procContextNew.Call(f.instance)
if ret == 0 {
unregisterInstance(f.instance)
procFreerdpFree.Call(f.instance)
f.instance = 0
return fmt.Errorf("freerdp_context_new failed: %v", err)
}
// ── Extract context, settings, and input pointers ──
f.context = readPtr(f.instance, freerdpOffsetContext)
if f.context == 0 {
unregisterInstance(f.instance)
procFreerdpFree.Call(f.instance)
f.instance = 0
return fmt.Errorf("freerdp context is null after context_new")
}
f.settings = readPtr(f.context, contextOffsetSettings)
if f.settings == 0 {
unregisterInstance(f.instance)
procFreerdpFree.Call(f.instance)
f.instance = 0
return fmt.Errorf("freerdp settings is null after context_new")
}
f.input = readPtr(f.context, contextOffsetInput)
if f.input == 0 {
unregisterInstance(f.instance)
procFreerdpFree.Call(f.instance)
f.instance = 0
return fmt.Errorf("freerdp input is null after context_new")
}
// ── Step 4: Configure connection settings ──
f.setString(FreeRDP_ServerHostname, config.Hostname) f.setString(FreeRDP_ServerHostname, config.Hostname)
f.setUint32(FreeRDP_ServerPort, uint32(config.Port)) f.setUint32(FreeRDP_ServerPort, uint32(config.Port))
f.setString(FreeRDP_Username, config.Username) f.setString(FreeRDP_Username, config.Username)
@ -146,15 +487,20 @@ func (f *FreeRDPBackend) Connect(config RDPConfig) error {
// Accept all server certificates. Per-host pinning is a future enhancement. // Accept all server certificates. Per-host pinning is a future enhancement.
f.setBool(FreeRDP_IgnoreCertificate, true) f.setBool(FreeRDP_IgnoreCertificate, true)
// Enable software GDI rendering. This tells FreeRDP to decode all bitmap
// data into a memory framebuffer that we can read directly. The PreConnect
// callback also sets this, but we set it here as well for clarity —
// FreeRDP reads it during connection negotiation.
f.setBool(FreeRDP_SoftwareGdi, true)
// Allocate the pixel buffer for frame capture. // Allocate the pixel buffer for frame capture.
f.buffer = NewPixelBuffer(width, height) f.buffer = NewPixelBuffer(width, height)
// TODO: Register PostConnect callback to set up bitmap update handler. // ── Step 5: Initiate the RDP connection ──
// TODO: Register BitmapUpdate callback to write frames into f.buffer. // freerdp_connect calls PreConnect -> TCP/TLS/NLA -> PostConnect internally.
// Initiate the RDP connection.
ret, _, err = procFreerdpConnect.Call(f.instance) ret, _, err = procFreerdpConnect.Call(f.instance)
if ret == 0 { if ret == 0 {
unregisterInstance(f.instance)
procFreerdpFree.Call(f.instance) procFreerdpFree.Call(f.instance)
f.instance = 0 f.instance = 0
return fmt.Errorf("freerdp_connect failed: %v", err) return fmt.Errorf("freerdp_connect failed: %v", err)
@ -162,7 +508,7 @@ func (f *FreeRDPBackend) Connect(config RDPConfig) error {
f.connected = true f.connected = true
// Start the event processing loop in a background goroutine. // ── Step 6: Start the event processing loop ──
go f.eventLoop() go f.eventLoop()
return nil return nil
@ -181,13 +527,80 @@ func (f *FreeRDPBackend) eventLoop() {
f.mu.Unlock() f.mu.Unlock()
return return
} }
procCheckEventHandles.Call(f.instance) // freerdp_check_event_handles takes rdpContext*, NOT freerdp*.
procCheckEventHandles.Call(f.context)
// After processing events, copy the GDI framebuffer into our
// PixelBuffer so GetFrame returns current data.
if f.gdiReady {
f.copyGdiToBuffer()
}
f.mu.Unlock() f.mu.Unlock()
time.Sleep(16 * time.Millisecond) // ~60 fps time.Sleep(16 * time.Millisecond) // ~60 fps
} }
} }
} }
// copyGdiToBuffer copies the FreeRDP GDI primary surface into the PixelBuffer.
// The GDI buffer is BGRA32; our PixelBuffer expects RGBA. We do the channel
// swap during the copy.
//
// Must be called with f.mu held.
func (f *FreeRDPBackend) copyGdiToBuffer() {
if f.gdiPrimaryBuf == 0 || f.buffer == nil {
return
}
w := f.gdiWidth
h := f.gdiHeight
stride := f.gdiStride
if stride == 0 {
stride = w * 4 // fallback: assume tightly packed BGRA32
}
// Total bytes in the GDI surface.
totalBytes := stride * h
if totalBytes <= 0 {
return
}
// Read the raw GDI framebuffer. This is a direct memory read from the
// FreeRDP-managed buffer. The GDI layer updates this buffer during
// BeginPaint/EndPaint cycles triggered by freerdp_check_event_handles.
src := unsafe.Slice((*byte)(unsafe.Pointer(f.gdiPrimaryBuf)), totalBytes)
// Build the RGBA frame, swapping B and R channels (BGRA -> RGBA).
buf := f.buffer
buf.mu.Lock()
defer buf.mu.Unlock()
dstLen := w * h * 4
if len(buf.Data) != dstLen {
buf.Data = make([]byte, dstLen)
buf.Width = w
buf.Height = h
}
for y := 0; y < h; y++ {
srcRow := y * stride
dstRow := y * w * 4
for x := 0; x < w; x++ {
si := srcRow + x*4
di := dstRow + x*4
if si+3 >= totalBytes || di+3 >= dstLen {
break
}
// BGRA -> RGBA
buf.Data[di+0] = src[si+2] // R <- B
buf.Data[di+1] = src[si+1] // G <- G
buf.Data[di+2] = src[si+0] // B <- R
buf.Data[di+3] = src[si+3] // A <- A
}
}
buf.dirty = true
}
// Disconnect tears down the RDP session and frees the FreeRDP instance. // Disconnect tears down the RDP session and frees the FreeRDP instance.
func (f *FreeRDPBackend) Disconnect() error { func (f *FreeRDPBackend) Disconnect() error {
f.mu.Lock() f.mu.Lock()
@ -199,10 +612,22 @@ func (f *FreeRDPBackend) Disconnect() error {
close(f.stopCh) close(f.stopCh)
f.connected = false f.connected = false
f.gdiReady = false
// Free GDI resources before disconnecting.
if f.gdiPrimaryBuf != 0 {
procGdiFree.Call(f.instance)
f.gdiPrimaryBuf = 0
}
procFreerdpDisconnect.Call(f.instance) procFreerdpDisconnect.Call(f.instance)
unregisterInstance(f.instance)
procFreerdpFree.Call(f.instance) procFreerdpFree.Call(f.instance)
f.instance = 0 f.instance = 0
f.context = 0
f.settings = 0
f.input = 0
return nil return nil
} }
@ -250,7 +675,8 @@ func (f *FreeRDPBackend) SendClipboard(data string) error {
} }
// GetFrame returns the current full-frame RGBA pixel buffer. The frame is // GetFrame returns the current full-frame RGBA pixel buffer. The frame is
// populated by bitmap update callbacks registered during PostConnect. // populated from the FreeRDP GDI primary surface during the event loop and
// via the PostConnect/BitmapUpdate callbacks.
func (f *FreeRDPBackend) GetFrame() ([]byte, error) { func (f *FreeRDPBackend) GetFrame() ([]byte, error) {
if f.buffer == nil { if f.buffer == nil {
return nil, fmt.Errorf("no frame buffer") return nil, fmt.Errorf("no frame buffer")
@ -265,18 +691,18 @@ func (f *FreeRDPBackend) IsConnected() bool {
return f.connected return f.connected
} }
// setString sets a string setting on the FreeRDP instance. // setString sets a string setting on the FreeRDP settings object.
func (f *FreeRDPBackend) setString(id int, value string) { func (f *FreeRDPBackend) setString(id int, value string) {
b, _ := syscall.BytePtrFromString(value) b, _ := syscall.BytePtrFromString(value)
procSettingsSetString.Call(f.settings, uintptr(id), uintptr(unsafe.Pointer(b))) procSettingsSetString.Call(f.settings, uintptr(id), uintptr(unsafe.Pointer(b)))
} }
// setUint32 sets a uint32 setting on the FreeRDP instance. // setUint32 sets a uint32 setting on the FreeRDP settings object.
func (f *FreeRDPBackend) setUint32(id int, value uint32) { func (f *FreeRDPBackend) setUint32(id int, value uint32) {
procSettingsSetUint32.Call(f.settings, uintptr(id), uintptr(value)) procSettingsSetUint32.Call(f.settings, uintptr(id), uintptr(value))
} }
// setBool sets a boolean setting on the FreeRDP instance. // setBool sets a boolean setting on the FreeRDP settings object.
func (f *FreeRDPBackend) setBool(id int, value bool) { func (f *FreeRDPBackend) setBool(id int, value bool) {
v := uintptr(0) v := uintptr(0)
if value { if value {