mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 15:15:24 +00:00
feat: rebuild desktop sync as linked server accounts, fix packaged plugin bundles and UI scaling
This commit is contained in:
1 parent
7c02daccf4
commit
6cd9a2846d
242 files changed
+39127
-8558
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Fired whenever the desktop's link or its synced data changed. */
|
||||
export const SYNC_CHANGED_EVENT = "termix:sync-changed";
|
||||
@@ -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
@@ -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 }[] = [
|
||||
|
||||
Reference in new issue
Block a user