feat: rebuild desktop sync as linked server accounts, fix packaged plugin bundles and UI scaling

This commit is contained in:
LukeGus committed 2026-09-26 00:41:25 -05:00
1 parent 7c02daccf4
commit 6cd9a2846d
242 files changed
+39127 -8558

No files matched your search

+2 -1
View File
@@ -117,5 +117,6 @@
}
},
"locales": "locales",
"platforms": ["linux", "win32", "darwin"]
"platforms": ["linux", "win32", "darwin"],
"desktop": "server"
}
+2 -1
View File
@@ -125,5 +125,6 @@
"platforms": ["linux", "win32", "darwin"],
"dependencies": {
"snippets": "^1.0.0"
}
},
"desktop": "server"
}
@@ -44,7 +44,7 @@ type ConnectErrorResponse = {
};
/**
* The interactive-auth branches /ssh/connect can take before a session exists.
* The interactive-auth branches /connect can take before a session exists.
* Callers switch on these, so they cannot stay behind Record<string, unknown>.
*/
export interface SSHConnectResult {
@@ -106,7 +106,7 @@ export async function connectSSH(
): Promise<SSHConnectResult> {
try {
const response = await getFileManagerApiForSession(sessionId).post(
"/ssh/connect",
"/connect",
{ sessionId, ...config },
{ timeout: 120000 },
);
@@ -156,7 +156,7 @@ export async function disconnectSSH(
): Promise<Record<string, unknown>> {
try {
const response = await getFileManagerApiForSession(sessionId).post(
"/ssh/disconnect",
"/disconnect",
{ sessionId },
);
return response.data;
@@ -173,7 +173,7 @@ export async function verifySSHTOTP(
): Promise<Record<string, unknown>> {
try {
const response = await getFileManagerApiForSession(sessionId).post(
"/ssh/connect-totp",
"/connect-totp",
{ sessionId, totpCode },
);
return response.data;
@@ -187,7 +187,7 @@ export async function verifySSHBrowserSignIn(
): Promise<Record<string, unknown>> {
try {
const response = await getFileManagerApiForSession(sessionId).post(
"/ssh/connect-browser-sign-in",
"/connect-browser-sign-in",
{ sessionId },
);
return response.data;
@@ -201,7 +201,7 @@ export async function getSSHStatus(
): Promise<{ connected: boolean }> {
try {
const response = await getFileManagerApiForSession(sessionId).get(
"/ssh/status",
"/status",
{ params: { sessionId } },
);
return response.data;
@@ -215,7 +215,7 @@ export async function keepSSHAlive(
): Promise<Record<string, unknown>> {
try {
const response = await getFileManagerApiForSession(sessionId).post(
"/ssh/keepalive",
"/keepalive",
{ sessionId },
);
return response.data;
@@ -235,7 +235,7 @@ export async function listSSHFiles(
async () => {
try {
const response = await getFileManagerApiForSession(sessionId).get(
"/ssh/listFiles",
"/listFiles",
{ params: { sessionId, path } },
);
return response.data || { files: [], path };
@@ -253,7 +253,7 @@ export async function identifySSHSymlink(
): Promise<{ path: string; target: string; type: "directory" | "file" }> {
try {
const response = await getFileManagerApiForSession(sessionId).get(
"/ssh/identifySymlink",
"/identifySymlink",
{ params: { sessionId, path } },
);
return response.data;
@@ -268,7 +268,7 @@ export async function resolveSSHPath(
): Promise<string> {
try {
const response = await getFileManagerApiForSession(sessionId).get(
"/ssh/resolvePath",
"/resolvePath",
{ params: { sessionId, path } },
);
return response.data?.resolvedPath || path;
@@ -288,7 +288,7 @@ export async function readSSHFile(
async () => {
try {
const response = await getFileManagerApiForSession(sessionId).get(
"/ssh/readFile",
"/readFile",
{ params: { sessionId, path } },
);
return response.data;
@@ -326,7 +326,7 @@ export async function writeSSHFile(
): Promise<Record<string, unknown>> {
try {
const response = await getFileManagerApiForSession(sessionId).post(
"/ssh/writeFile",
"/writeFile",
{ sessionId, path, content, hostId, userId },
);
@@ -385,7 +385,7 @@ export async function uploadSSHFile(
const chunkBlob = file.slice(start, end);
const response = await getFileManagerApiForSession(sessionId).post(
"/ssh/uploadFileChunk",
"/uploadFileChunk",
chunkBlob,
{
params: {
@@ -429,7 +429,7 @@ export async function uploadSSHFile(
form.append("file", file, fileName);
const response = await getFileManagerApiForSession(sessionId).postForm(
"/ssh/uploadFileStream",
"/uploadFileStream",
form,
{
timeout: 0,
@@ -470,7 +470,7 @@ export async function downloadSSHFile(
): Promise<DownloadedSSHFile> {
try {
const response = await getFileManagerApiForSession(sessionId).post(
"/ssh/downloadFile",
"/downloadFile",
{
sessionId,
path: filePath,
@@ -496,7 +496,7 @@ export async function downloadSSHFileStream(
onProgress?: (event: DownloadProgressEvent) => void,
): Promise<void> {
const response = await getFileManagerApiForSession(sessionId).post(
"/ssh/downloadFileStream",
"/downloadFileStream",
{ sessionId, path: filePath },
{
responseType: "blob",
@@ -521,7 +521,7 @@ export async function createSSHFile(
): Promise<Record<string, unknown>> {
try {
const response = await getFileManagerApiForSession(sessionId).post(
"/ssh/createFile",
"/createFile",
{ sessionId, path, fileName, content, hostId, userId },
);
return response.data;
@@ -539,7 +539,7 @@ export async function createSSHFolder(
): Promise<Record<string, unknown>> {
try {
const response = await getFileManagerApiForSession(sessionId).post(
"/ssh/createFolder",
"/createFolder",
{ sessionId, path, folderName, hostId, userId },
);
return response.data;
@@ -558,7 +558,7 @@ export async function deleteSSHItem(
): Promise<Record<string, unknown>> {
try {
const response = await getFileManagerApiForSession(sessionId).delete(
"/ssh/deleteItem",
"/deleteItem",
{
data: {
sessionId,
@@ -599,7 +599,7 @@ export async function getSSHTrash(sessionId: string): Promise<{
}> {
try {
const response = await getFileManagerApiForSession(sessionId).get(
"/ssh/trash",
"/trash",
{ params: { sessionId } },
);
return response.data;
@@ -611,7 +611,7 @@ export async function getSSHTrash(sessionId: string): Promise<{
export async function restoreSSHTrashItem(sessionId: string, id: string) {
try {
const response = await getFileManagerApiForSession(sessionId).post(
`/ssh/trash/${encodeURIComponent(id)}/restore`,
`/trash/${encodeURIComponent(id)}/restore`,
{ sessionId },
);
return response.data;
@@ -626,7 +626,7 @@ export async function permanentlyDeleteSSHTrashItem(
) {
try {
await getFileManagerApiForSession(sessionId).delete(
`/ssh/trash/${encodeURIComponent(id)}`,
`/trash/${encodeURIComponent(id)}`,
{ data: { sessionId } },
);
} catch (error) {
@@ -636,7 +636,7 @@ export async function permanentlyDeleteSSHTrashItem(
export async function emptySSHTrash(sessionId: string) {
try {
await getFileManagerApiForSession(sessionId).delete("/ssh/trash", {
await getFileManagerApiForSession(sessionId).delete("/trash", {
data: { sessionId },
});
} catch (error) {
@@ -649,7 +649,7 @@ export async function updateSSHTrashRetention(
retentionDays: number,
) {
try {
await getFileManagerApiForSession(sessionId).put("/ssh/trash-retention", {
await getFileManagerApiForSession(sessionId).put("/trash-retention", {
retentionDays,
});
} catch (error) {
@@ -687,7 +687,7 @@ export async function copySSHItem(
): Promise<CopySSHItemResult> {
try {
const response = await getFileManagerApiForSession(sessionId).post(
"/ssh/copyItem",
"/copyItem",
{
sessionId,
sourcePath,
@@ -715,7 +715,7 @@ export async function renameSSHItem(
): Promise<Record<string, unknown>> {
try {
const response = await getFileManagerApiForSession(sessionId).put(
"/ssh/renameItem",
"/renameItem",
{ sessionId, oldPath, newName, hostId, userId },
);
invalidateCachedFileContent(sessionId, oldPath);
@@ -738,7 +738,7 @@ export async function moveSSHItem(
): Promise<Record<string, unknown>> {
try {
const response = await getFileManagerApiForSession(sessionId).put(
"/ssh/moveItem",
"/moveItem",
{
sessionId,
oldPath,
@@ -777,7 +777,7 @@ export async function changeSSHPermissions(
});
const response = await getFileManagerApiForSession(sessionId).post(
"/ssh/changePermissions",
"/changePermissions",
{ sessionId, path, permissions, hostId, userId },
);
@@ -819,7 +819,7 @@ export async function extractSSHArchive(
});
const response = await getFileManagerApiForSession(sessionId).post(
"/ssh/extractArchive",
"/extractArchive",
{ sessionId, archivePath, extractPath, hostId, userId },
);
@@ -863,7 +863,7 @@ export async function compressSSHFiles(
});
const response = await getFileManagerApiForSession(sessionId).post(
"/ssh/compressFiles",
"/compressFiles",
{
sessionId,
paths,
@@ -0,0 +1,43 @@
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { pluginDir } from "./helpers";
function read(relative: string): string {
return fs.readFileSync(path.join(pluginDir, relative), "utf8");
}
/** Every "/path" the frontend's SSH client sends, without its parameters. */
function frontendPaths(): string[] {
const source = read("src/frontend/api/ssh-file-operations-api.ts");
const paths = new Set<string>();
for (const match of source.matchAll(/["`](\/[A-Za-z][\w-]*(?:\/[\w-]+)*)/g)) {
paths.add(match[1]);
}
return [...paths];
}
/** Every route the backend's express app registers. */
function backendPaths(): Set<string> {
const paths = new Set<string>();
const dir = path.join(pluginDir, "src/backend");
for (const file of fs
.readdirSync(dir)
.filter((name) => name.endsWith(".ts"))
.map((name) => `src/backend/${name}`)) {
for (const match of read(file).matchAll(
/app\.(?:get|post|put|delete|patch)\(\s*"(\/[^"]*)"/g,
)) {
paths.add(match[1].replace(/\/:[\w]+$/, ""));
}
}
return paths;
}
describe("file manager routes", () => {
it("serves every path the frontend's SSH client calls", () => {
const served = backendPaths();
const missing = frontendPaths().filter((p) => !served.has(p));
expect(missing).toEqual([]);
});
});
@@ -50,7 +50,7 @@ describe("chunked SSH file uploads", () => {
expect(fileManagerApiMock.post).toHaveBeenCalledTimes(193);
expect(fileManagerApiMock.post).toHaveBeenNthCalledWith(
1,
"/ssh/uploadFileChunk",
"/uploadFileChunk",
expect.any(Blob),
{
params: {
@@ -65,7 +65,7 @@ describe("chunked SSH file uploads", () => {
},
);
expect(fileManagerApiMock.post).toHaveBeenLastCalledWith(
"/ssh/uploadFileChunk",
"/uploadFileChunk",
expect.any(Blob),
expect.objectContaining({
params: expect.objectContaining({ offset: 1.5 * 1024 * 1024 * 1024 }),
+2 -1
View File
@@ -47,6 +47,7 @@
],
"actionSlots": [
{ "id": "dashboard.secondaryView", "accepts": ["component"] }
]
],
"syncEntities": ["dashboardServiceLinks", "homepageItems"]
}
}
+13 -1
View File
@@ -26,7 +26,19 @@ export async function activate(ctx: PluginContext) {
registerHomepageRoutes(router, repo, ctx);
registerOutboundRoutes(router, ctx);
ctx.sync.registerEntity({ type: "homepageItems", table: items, order: 80 });
ctx.sync.registerEntity({
type: "homepageItems",
table: items,
order: 80,
// A tile inside a folder points at the folder's row.
references: [
{
field: "folderId",
syncField: "folderSyncId",
entityType: "homepageItems",
},
],
});
ctx.sync.registerEntity({
type: "dashboardServiceLinks",
table: serviceLinks,
-6
View File
@@ -219,7 +219,6 @@ export function registerHomepageRoutes(
try {
const deleted = await repo.deleteItem(userId, id);
if (!deleted) return res.status(404).json({ error: "Not found" });
await ctx.sync.recordTombstone(userId, "homepageItems", deleted.syncId);
res.json({ message: "Homepage item deleted" });
} catch (err) {
logError(
@@ -506,11 +505,6 @@ export function registerHomepageRoutes(
try {
const deleted = await repo.deleteServiceLink(userId, id);
if (!deleted) return res.status(404).json({ error: "Not found" });
await ctx.sync.recordTombstone(
userId,
"dashboardServiceLinks",
deleted.syncId,
);
res.json({ message: "Service link deleted" });
} catch (err) {
logError(
@@ -161,7 +161,9 @@ export function ProcessInspectorCard({ hostId }: { hostId: number | null }) {
<span
className="truncate"
title={`${p.user} · ${p.args}`}
style={depth ? { paddingLeft: depth * 12 } : undefined}
style={
depth ? { paddingLeft: `${(depth * 12) / 14}rem` } : undefined
}
>
{depth > 0 && (
<span className="text-muted-foreground/40">└ </span>
+2 -1
View File
@@ -45,5 +45,6 @@
}
},
"locales": "locales",
"platforms": ["linux", "win32", "darwin"]
"platforms": ["linux", "win32", "darwin"],
"desktop": "server"
}
+2 -1
View File
@@ -45,7 +45,8 @@
"descriptionKey": "permissions.use.description",
"defaultRoles": ["admin", "user"]
}
]
],
"syncEntities": ["networkTopology"]
},
"locales": "locales",
"platforms": ["linux", "win32", "darwin"]
@@ -34,6 +34,7 @@ import {
Input,
PasswordInput,
isElectron,
linkedServerUrl,
resolveConnectionOrigin,
useConnectionLog,
type ConnectionOrigin,
@@ -309,10 +310,7 @@ const GuacamoleAppInner = React.forwardRef<
{ defaultRemote: true },
);
if (resolvedOrigin === "remote") {
const remoteConfig = (await window.electronAPI?.invoke?.(
"get-remote-sync-config",
)) as { serverUrl?: string } | null;
if (!remoteConfig?.serverUrl) {
if (!(await linkedServerUrl())) {
throw new Error(t("errors.remoteServerRequired"));
}
}
+2 -1
View File
@@ -34,5 +34,6 @@
"icon": "Usb"
}
]
}
},
"desktop": "local"
}
+2 -1
View File
@@ -81,6 +81,7 @@
"default": true
}
]
}
},
"syncEntities": ["snippetFolders", "snippets"]
}
}
+2 -3
View File
@@ -74,6 +74,8 @@ export async function activate(ctx: PluginContext) {
type: "snippets",
table: snippetsTable,
order: 60,
// Host quick actions and keybindings in core point at snippets by this.
answersTo: ["commandSnippet"],
});
// The user row survives a password-reset data wipe, so it never triggers
@@ -152,9 +154,6 @@ export async function activate(ctx: PluginContext) {
if (!userId) throw new Error("snippets.access.remove needs an actor");
await requirePermission("delete");
const existing = await repo.deleteSnippet(userId, id);
if (existing?.syncId) {
await ctx.sync.recordTombstone(userId, "snippets", existing.syncId);
}
return !!existing;
},
};
+1 -7
View File
@@ -319,12 +319,7 @@ export function registerSnippetRoutes(
try {
const folderName = decodeURIComponent(name);
const deleted = await repo.deleteFolder(userId, folderName);
await ctx.sync.recordTombstone(
userId,
"snippetFolders",
deleted?.syncId,
);
await repo.deleteFolder(userId, folderName);
res.json({ success: true });
} catch (err) {
logError(
@@ -921,7 +916,6 @@ export function registerSnippetRoutes(
const existing = await repo.deleteSnippet(userId, parseInt(id, 10));
if (!existing)
return res.status(404).json({ error: "Snippet not found" });
await ctx.sync.recordTombstone(userId, "snippets", existing.syncId);
res.json({ success: true });
} catch (err) {
logError(ctx, "Failed to delete snippet", err, "snippet_delete_failed");
+2 -1
View File
@@ -64,5 +64,6 @@
}
},
"locales": "locales",
"platforms": ["linux", "win32", "darwin"]
"platforms": ["linux", "win32", "darwin"],
"desktop": "server"
}
@@ -12,6 +12,12 @@ import locales from "../../locales/en.json";
const manifest = manifestJson as unknown as PluginManifest;
const linked = vi.hoisted(() => ({ serverUrl: null as string | null }));
vi.mock("@/lib/linked-server", () => ({
getLinkedSession: async () =>
linked.serverUrl ? { serverUrl: linked.serverUrl, token: "t" } : null,
}));
let rendered: RenderedPluginApp | null = null;
type ElectronWindow = { electronAPI?: unknown };
@@ -95,10 +101,10 @@ describe(`${manifest.id} activate`, () => {
});
it("hides the rail item in a standalone desktop app", async () => {
linked.serverUrl = null;
(window as unknown as ElectronWindow).electronAPI = {
isElectron: true,
invoke: vi.fn(async () => null),
onRemoteSyncStatusChanged: () => () => {},
};
rendered = await renderWithApp(plugin, { manifest, locales });
await waitFor(() =>
@@ -108,24 +114,19 @@ describe(`${manifest.id} activate`, () => {
);
});
it("shows the rail item once the desktop app has a remote server", async () => {
let notify = () => {};
let serverUrl: string | null = null;
it("shows the rail item once the desktop app is linked to a server", async () => {
linked.serverUrl = null;
(window as unknown as ElectronWindow).electronAPI = {
isElectron: true,
invoke: vi.fn(async () => (serverUrl ? { serverUrl } : null)),
onRemoteSyncStatusChanged: (callback: () => void) => {
notify = callback;
return () => {};
},
invoke: vi.fn(async () => null),
};
rendered = await renderWithApp(plugin, { manifest, locales });
await waitFor(() =>
expect(rendered!.registered.railItems()[0]?.hidden).toBe(true),
);
serverUrl = "https://termix.example";
notify();
linked.serverUrl = "https://termix.example";
window.dispatchEvent(new CustomEvent("termix:sync-changed"));
await waitFor(() =>
expect(rendered!.registered.railItems()).toEqual([
expect.objectContaining({ id: "termix-id", hidden: false }),
+2 -1
View File
@@ -27,5 +27,6 @@
}
},
"locales": "locales",
"platforms": ["linux", "win32", "darwin"]
"platforms": ["linux", "win32", "darwin"],
"desktop": "server"
}
+2 -1
View File
@@ -74,7 +74,8 @@
"to": "/oidc/callback"
}
]
}
},
"syncEntities": ["vaultProfiles"]
},
"locales": "locales",
"platforms": ["linux", "win32", "darwin"]
@@ -134,7 +134,6 @@ export function createProfileStore(
await db.delete(tokensTable).where(eq(tokensTable.profileId, row.id));
await db.delete(table).where(eq(table.id, row.id));
await ctx.db.persist();
await ctx.sync.recordTombstone(row.userId, SYNC_ENTITY, row.syncId);
},
};
}
+1 -9
View File
@@ -124,7 +124,7 @@ describe("profiles", () => {
expect((await server.request("GET", "/profiles")).body).toEqual([]);
});
it("records a tombstone and drops cached certificates on delete", async () => {
it("drops cached certificates on delete", async () => {
server = await startServer();
const { id } = await profileOnHost();
server.db.sqlite
@@ -132,17 +132,9 @@ describe("profiles", () => {
"INSERT INTO p_vault_tokens (user_id, profile_id, ssh_cert, private_key, expires_at) VALUES (?, ?, ?, ?, ?)",
)
.run("user-1", id, "c", "k", "2999-01-01T00:00:00.000Z");
const syncId = (
server.db.sqlite
.prepare("SELECT sync_id FROM p_vault_profiles WHERE id = ?")
.get(id) as { sync_id: string }
).sync_id;
await server.request("DELETE", `/profiles/${id}`);
expect(server.mock.tombstones).toEqual([
{ userId: "user-1", entityType: "vaultProfiles", syncId },
]);
expect(
server.db.sqlite
.prepare("SELECT COUNT(*) AS n FROM p_vault_tokens")
+2 -1
View File
@@ -27,5 +27,6 @@
}
},
"locales": "locales",
"platforms": ["linux", "win32", "darwin"]
"platforms": ["linux", "win32", "darwin"],
"desktop": "server"
}
+2 -1
View File
@@ -39,6 +39,7 @@
"descriptionKey": "permissions.use.description",
"defaultRoles": ["admin", "user"]
}
]
],
"syncEntities": ["workspaces"]
}
}