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

+6 -17
View File
@@ -1,5 +1,6 @@
import { isElectron } from "@/lib/electron";
import { websocketAuthProtocols } from "@/lib/ws-auth";
import { getLinkedSession } from "@/lib/linked-server";
export type ConnectionOrigin = "local" | "remote";
@@ -10,7 +11,7 @@ interface OriginResolvableHost {
/**
* Resolves which backend a given host's interactive connection (SSH, Docker
* console, RDP/VNC/Telnet) should dial: the desktop app's embedded local
* backend, or a connected remote sync server. A plugin whose connection is
* backend, or the server it is linked to. A plugin whose connection is
* always local regardless of this setting (serial: the hardware is
* physically attached to this desktop machine) skips this helper entirely
* and calls app.wsUrl() without an origin option, which defaults to "local".
@@ -55,26 +56,14 @@ export interface RemoteConnectionTarget {
}
async function getRemoteConnectionTarget(): Promise<RemoteConnectionTarget | null> {
try {
const [config, jwt] = await Promise.all([
window.electronAPI?.invoke?.("get-remote-sync-config") as Promise<{
serverUrl?: string;
} | null>,
window.electronAPI?.invoke?.("get-remote-sync-jwt") as Promise<
string | null
>,
]);
if (!config?.serverUrl) return null;
return { serverUrl: config.serverUrl, jwt: jwt ?? null };
} catch {
return null;
}
const linked = await getLinkedSession();
return linked ? { serverUrl: linked.serverUrl, jwt: linked.token } : null;
}
/**
* Builds the base WebSocket URL for an interactive connection protocol,
* given a resolved origin. Returns null when origin is "remote" but no
* remote server is connected -- callers must show a blocking message
* given a resolved origin. Returns null when origin is "remote" but this
* desktop is not linked to a server -- callers must show a blocking message
* rather than attempting to connect.
*/
export interface WebSocketConnectionTarget {
+5 -28
View File
@@ -1,6 +1,6 @@
import type { SSHHost, SSHFolder } from "@/types/index";
import type { ServerStatus } from "@/main-axios";
import { isElectron } from "./electron";
import { SYNC_CHANGED_EVENT } from "./sync-events";
import { createTtlRequestCache } from "./ttl-request-cache";
/** Host list changes less often than status; keep a short shared window. */
@@ -16,7 +16,6 @@ const statusCache =
const foldersCache = createTtlRequestCache<SSHFolder[]>(FOLDERS_TTL_MS);
let listenersBound = false;
let lastRemoteSyncCompleteAt: string | null = null;
function bindInvalidationListeners(): void {
if (listenersBound || typeof window === "undefined") return;
@@ -29,32 +28,10 @@ function bindInvalidationListeners(): void {
window.addEventListener("ssh-hosts:changed", invalidateHosts);
window.addEventListener("hosts:refresh", invalidateHosts);
if (isElectron()) {
window.electronAPI
?.invoke?.("get-remote-sync-status")
.then((status) => {
const syncedAt =
status && typeof status === "object"
? (status as { lastSyncedAt?: unknown }).lastSyncedAt
: null;
lastRemoteSyncCompleteAt =
typeof syncedAt === "string" ? syncedAt : null;
})
.catch(() => {});
window.electronAPI?.onRemoteSyncStatusChanged?.((status) => {
if (!status || status.syncing || status.lastError) return;
const syncedAt =
typeof status.lastSyncedAt === "string" ? status.lastSyncedAt : null;
if (!syncedAt || syncedAt === lastRemoteSyncCompleteAt) return;
lastRemoteSyncCompleteAt = syncedAt;
invalidateHostsAndStatusCaches();
window.dispatchEvent(new CustomEvent("hosts:refresh"));
window.dispatchEvent(new CustomEvent("termix:hosts-changed"));
window.dispatchEvent(new CustomEvent("termix:credentials-changed"));
});
}
// A finished sync pass may have changed hosts and their statuses.
window.addEventListener(SYNC_CHANGED_EVENT, () => {
invalidateHostsAndStatusCaches();
});
}
export function getCachedSSHHosts(
+50
View File
@@ -0,0 +1,50 @@
import { isElectron } from "@/lib/electron";
import { authApi } from "@/main-axios";
import { SYNC_CHANGED_EVENT } from "@/lib/sync-events";
export { SYNC_CHANGED_EVENT };
/** The server this desktop is linked to, and the session it syncs with. */
export interface LinkedSession {
serverUrl: string;
token: string;
}
const CACHE_MS = 60_000;
let cached: { at: number; value: LinkedSession | null } | null = null;
let inflight: Promise<LinkedSession | null> | null = null;
if (typeof window !== "undefined") {
window.addEventListener(SYNC_CHANGED_EVENT, () => {
cached = null;
});
}
/** Null on the web, or when this desktop is not linked or signed out. */
export async function getLinkedSession(): Promise<LinkedSession | null> {
if (!isElectron()) return null;
if (cached && Date.now() - cached.at < CACHE_MS) return cached.value;
if (!inflight) {
inflight = authApi
.get("/sync/link/session")
.then((response) => {
const { serverUrl, token } = response.data ?? {};
const value =
typeof serverUrl === "string" && typeof token === "string"
? { serverUrl: serverUrl.replace(/\/$/, ""), token }
: null;
cached = { at: Date.now(), value };
return value;
})
.catch(() => null)
.finally(() => {
inflight = null;
});
}
return inflight;
}
export function notifySyncChanged(): void {
cached = null;
window.dispatchEvent(new CustomEvent(SYNC_CHANGED_EVENT));
}
+17
View File
@@ -0,0 +1,17 @@
const ROOT_PX = 14;
/**
* A length in px at the Normal interface size, as rem, so it follows the
* interface size setting like Tailwind classes do.
*/
export function rem(px: number): string {
return `${px / ROOT_PX}rem`;
}
/** How many real pixels one Normal-size pixel takes right now. */
export function remScale(): number {
const size = parseFloat(
getComputedStyle(document.documentElement).fontSize || `${ROOT_PX}`,
);
return Number.isFinite(size) && size > 0 ? size / ROOT_PX : 1;
}
+19 -82
View File
@@ -1,97 +1,34 @@
import type { AxiosInstance } from "axios";
import { getRemoteCoreApi } from "@/main-axios";
import { isElectron } from "@/lib/electron";
import type { SSHHost } from "@/types/index";
import { getLinkedSession } from "@/lib/linked-server";
export function markRemoteSharedHosts(hosts: SSHHost[]): SSHHost[] {
return hosts
.filter((host) => host.isShared)
.map((host) => ({
...host,
// Local SQLite ids are positive. Negative ids keep remote-only shared
// rows distinct while syncId remains the delegated backend identity.
id: -Math.abs(host.id),
}));
}
export interface SharedHostConnectionAuth {
username?: string | null;
authType?: string | null;
password?: string | null;
key?: string | null;
keyPassword?: string | null;
keyType?: string | null;
}
async function getRemoteSharedHostConnectionAuth(
localHostId: number,
): Promise<SharedHostConnectionAuth> {
if (localHostId >= 0) {
throw new Error("Expected a remote shared host id");
}
const api = await getConnectedRemoteApi();
if (!api) throw new Error("Remote server is not connected");
const response = await api.get(
`/host/db/host/${Math.abs(localHostId)}/local-connection-auth`,
);
return response.data;
}
export async function hydrateLocalSharedHostAuth<
T extends {
id?: number;
isShared?: unknown;
syncId?: string | null;
credentialId?: number;
username: string;
authType?: string;
password?: string;
key?: string;
keyPassword?: string;
keyType?: string;
},
>(host: T): Promise<T> {
if (!host.isShared || typeof host.id !== "number" || host.id >= 0) {
return host;
}
const auth = await getRemoteSharedHostConnectionAuth(host.id);
return {
...host,
// This row deliberately does not exist in the embedded database. Avoid
// asking the local backend to resolve its remote sync identity again.
syncId: null,
credentialId: undefined,
username: auth.username || host.username,
authType: auth.authType || host.authType,
password: auth.password || undefined,
key: auth.key || undefined,
keyPassword: auth.keyPassword || undefined,
keyType: auth.keyType || undefined,
};
/**
* Hosts shared with a linked account arrive through sync as read-only local
* copies that already carry the auth the owner shared, so a connection needs
* nothing extra. Kept for plugins that still call it.
*/
export async function hydrateLocalSharedHostAuth<T>(host: T): Promise<T> {
return host;
}
/** The linked server's core API, or null when this desktop is not linked. */
export async function getConnectedRemoteApi(): Promise<AxiosInstance | null> {
if (!isElectron()) return null;
try {
const config = (await window.electronAPI?.invoke?.(
"get-remote-sync-config",
)) as { serverUrl?: string } | null;
return config?.serverUrl ? getRemoteCoreApi() : null;
} catch {
return null;
}
return (await getLinkedSession()) ? getRemoteCoreApi() : null;
}
/** The linked server's own id for a host, found by its sync id. */
export async function resolveRemoteHostId(
syncId: string | null | undefined,
): Promise<number | null> {
if (!syncId) return null;
const api = await getConnectedRemoteApi();
if (!api) return null;
const response = await api.get("/sync/hosts");
const host = (response.data?.rows ?? []).find(
(row: { syncId?: string }) => row.syncId === syncId,
);
return typeof host?.id === "number" ? host.id : null;
try {
const response = await api.get(
`/sync/v2/hosts/${encodeURIComponent(syncId)}`,
);
return typeof response.data?.id === "number" ? response.data.id : null;
} catch {
return null;
}
}
-16
View File
@@ -1,16 +0,0 @@
import { isElectron } from "@/lib/electron";
export async function requestRemoteSync(): Promise<void> {
if (!isElectron()) return;
try {
const config = (await window.electronAPI?.invoke?.(
"get-remote-sync-config",
)) as { serverUrl?: string } | null;
if (config?.serverUrl) {
await window.electronAPI?.invoke?.("remote-sync-now");
}
} catch {
// The periodic sync remains the fallback when IPC or the server is down.
}
}
+2
View File
@@ -0,0 +1,2 @@
/** Fired whenever the desktop's link or its synced data changed. */
export const SYNC_CHANGED_EVENT = "termix:sync-changed";
+86
View File
@@ -0,0 +1,86 @@
import { isElectron } from "@/lib/electron";
import { getSyncStatus, type SyncStatus } from "@/api/sync-api";
import { notifySyncChanged } from "@/lib/linked-server";
/**
* The desktop's sync status, polled from the embedded backend while anything
* shows it. When a pass finishes, the views that show synced data are told
* to reload.
*/
const IDLE_MS = 15_000;
const BUSY_MS = 2_000;
let snapshot: SyncStatus | null = null;
let timer: ReturnType<typeof setTimeout> | null = null;
let lastSyncAt: string | null | undefined;
const listeners = new Set<() => void>();
function emit(): void {
for (const listener of listeners) listener();
}
function announce(): void {
notifySyncChanged();
window.dispatchEvent(new CustomEvent("hosts:refresh"));
window.dispatchEvent(new CustomEvent("termix:hosts-changed"));
window.dispatchEvent(new CustomEvent("termix:credentials-changed"));
window.dispatchEvent(new CustomEvent("termix:plugins-changed"));
}
async function poll(): Promise<void> {
timer = null;
try {
const next = await getSyncStatus();
const syncedAt = next.lastSyncAt ?? null;
if (lastSyncAt !== undefined && syncedAt !== lastSyncAt) announce();
if (snapshot && snapshot.linked !== next.linked) notifySyncChanged();
lastSyncAt = syncedAt;
snapshot = next;
emit();
} catch {
// The backend may still be starting; the next poll tries again.
}
schedule();
}
function schedule(delay?: number): void {
if (listeners.size === 0 || timer) return;
const busy = snapshot?.status === "syncing" || !!snapshot?.pending;
timer = setTimeout(() => void poll(), delay ?? (busy ? BUSY_MS : IDLE_MS));
}
export function subscribeSyncStatus(listener: () => void): () => void {
listeners.add(listener);
if (isElectron() && listeners.size === 1 && !timer) void poll();
return () => {
listeners.delete(listener);
if (listeners.size === 0 && timer) {
clearTimeout(timer);
timer = null;
}
};
}
export function getSyncStatusSnapshot(): SyncStatus | null {
return snapshot;
}
/** Takes a status a request already returned, and checks again soon. */
export function setSyncStatus(next: SyncStatus): void {
snapshot = next;
if (lastSyncAt !== undefined && (next.lastSyncAt ?? null) !== lastSyncAt) {
announce();
}
lastSyncAt = next.lastSyncAt ?? null;
emit();
if (timer) clearTimeout(timer);
timer = null;
schedule(BUSY_MS);
}
export function refreshSyncStatus(): void {
if (timer) clearTimeout(timer);
timer = null;
void poll();
}
+20 -2
View File
@@ -65,11 +65,29 @@ export const FONT_SIZES: { id: FontSizeId; label: string }[] = [
{ id: "xl", label: "XL" },
];
/** The root font size of each interface size, relative to Normal. */
export const FONT_SIZE_SCALE: Record<FontSizeId, number> = {
xs: 12 / 14,
sm: 13 / 14,
md: 1,
lg: 17 / 14,
xl: 20 / 14,
};
export function applyFontSize(id: FontSizeId) {
const root = document.documentElement;
const size = id in FONT_SIZE_SCALE ? id : "md";
root.classList.remove("fs-xs", "fs-sm", "fs-md", "fs-lg", "fs-xl");
root.classList.add(`fs-${id}`);
localStorage.setItem("termix-font-size", id);
root.classList.add(`fs-${size}`);
localStorage.setItem("termix-font-size", size);
// Every length is in rem, so the root size scales the whole interface. An
// earlier desktop build zoomed the window instead; undo that if it did.
(
window as Window & {
electronAPI?: { setZoomFactor?: (factor: number) => void };
}
).electronAPI?.setZoomFactor?.(1);
}
export const UI_FONTS: { id: UiFontId; label: string; family: string }[] = [