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
View File
@@ -232,6 +232,7 @@ export async function updateFolderMetadata(
color?: string,
icon?: string,
credentialId?: number | null,
localOnly?: boolean,
): Promise<void> {
try {
sshLogger.info("Updating folder metadata", {
@@ -247,6 +248,7 @@ export async function updateFolderMetadata(
color,
icon,
credentialId,
localOnly,
});
invalidateSSHFoldersCache();
+32 -21
View File
@@ -8,21 +8,11 @@ import {
import type { ServerStatus } from "@/main-axios";
import { getCachedServerStatuses } from "@/lib/hosts-request-cache";
import { resolveConnectionOrigin } from "@/lib/connection-origin";
import { getLinkedSession } from "@/lib/linked-server";
import type { SSHHost } from "@/types/index";
// Core's host status checks (/host/status). On the desktop app the aggregate
// read is merged across the local backend and the connected remote server.
async function isRemoteSyncConnected(): Promise<boolean> {
if (!isElectron()) return false;
try {
const config = (await window.electronAPI?.invoke?.(
"get-remote-sync-config",
)) as { serverUrl?: string } | null;
return !!config?.serverUrl;
} catch {
return false;
}
}
// Core's host status checks (/host/status). On a linked desktop, hosts that
// connect through the server take their status from it, matched by sync id.
// HOST STATUS
// ============================================================================
@@ -78,6 +68,7 @@ export async function getAllServerStatuses(): Promise<
let lastError: unknown = null;
let localStatuses: Record<number, ServerStatus> = {};
let localHostIds: number[] | null = null;
let remoteHostsBySyncId: Map<string, number> | null = null;
if (isElectron()) {
try {
@@ -85,11 +76,21 @@ export async function getAllServerStatuses(): Promise<
const defaultOrigin = await resolveConnectionOrigin({
connectionOrigin: null,
});
localHostIds = (response.data || [])
const hosts = response.data || [];
localHostIds = hosts
.filter(
(host) => (host.connectionOrigin ?? defaultOrigin) === "local",
)
.map((host) => host.id);
remoteHostsBySyncId = new Map(
hosts
.filter(
(host) =>
(host.connectionOrigin ?? defaultOrigin) === "remote" &&
!!host.syncId,
)
.map((host) => [host.syncId as string, host.id]),
);
} catch {
// A host-list failure must not start local probes for hosts whose
// configured origin may be remote.
@@ -132,15 +133,25 @@ export async function getAllServerStatuses(): Promise<
return {};
}
if (await isRemoteSyncConnected()) {
if (remoteHostsBySyncId?.size && (await getLinkedSession())) {
try {
const remoteResult = await getRemoteCoreApi().get("/host/status", {
timeout: 8000,
__silentRetry: true,
} as AxiosRequestConfig & { __silentRetry?: boolean });
return { ...localStatuses, ...(remoteResult.data || {}) };
const remoteResult = await getRemoteCoreApi().get(
"/sync/v2/host-status",
{
timeout: 8000,
__silentRetry: true,
} as AxiosRequestConfig & { __silentRetry?: boolean },
);
const merged = { ...localStatuses };
for (const [syncId, status] of Object.entries(
(remoteResult.data || {}) as Record<string, ServerStatus>,
)) {
const localId = remoteHostsBySyncId.get(syncId);
if (localId !== undefined) merged[localId] = status;
}
return merged;
} catch {
// remote unreachable this tick -- fall back to local-only statuses
// The server is unreachable this tick; local statuses still stand.
}
}
+19 -12
View File
@@ -21,6 +21,17 @@ async function getSharingTarget(hostId: number, syncId?: string | null) {
return { api, hostId: remoteHostId };
}
/** A shared host's copy on a linked desktop is managed on the server. */
async function getSharedCopyTarget(hostId: number, syncId?: string | null) {
if (!syncId) return { api: rbacApi, hostId };
const api = await getConnectedRemoteApi();
const remoteHostId = api ? await resolveRemoteHostId(syncId) : null;
if (!api || remoteHostId === null) {
throw new Error("The linked server is not reachable");
}
return { api, hostId: remoteHostId };
}
export async function getRoles(): Promise<{ roles: Role[] }> {
try {
const api = (await getConnectedRemoteApi()) ?? rbacApi;
@@ -358,14 +369,12 @@ export async function revokeHostAccess(
export async function getHostAuthOverride(
hostId: number,
protocol: AuthOverrideProtocol,
remoteShared = false,
sharedCopySyncId?: string | null,
): Promise<{ protocol: AuthOverrideProtocol; credentialId: number | null }> {
try {
const api = remoteShared ? await getConnectedRemoteApi() : rbacApi;
if (!api) throw new Error("Remote server is not connected");
const targetHostId = remoteShared ? Math.abs(hostId) : hostId;
const response = await api.get(
`/rbac/host-access/${targetHostId}/auth/${protocol}`,
const target = await getSharedCopyTarget(hostId, sharedCopySyncId);
const response = await target.api.get(
`/rbac/host-access/${target.hostId}/auth/${protocol}`,
);
return response.data;
} catch (error) {
@@ -377,18 +386,16 @@ export async function setHostAuthOverride(
hostId: number,
protocol: AuthOverrideProtocol,
credentialId: number | null,
remoteShared = false,
sharedCopySyncId?: string | null,
): Promise<{
success: boolean;
protocol: AuthOverrideProtocol;
credentialId: number | null;
}> {
try {
const api = remoteShared ? await getConnectedRemoteApi() : rbacApi;
if (!api) throw new Error("Remote server is not connected");
const targetHostId = remoteShared ? Math.abs(hostId) : hostId;
const response = await api.put(
`/rbac/host-access/${targetHostId}/auth/${protocol}`,
const target = await getSharedCopyTarget(hostId, sharedCopySyncId);
const response = await target.api.put(
`/rbac/host-access/${target.hostId}/auth/${protocol}`,
{ credentialId },
);
return response.data;
+1 -26
View File
@@ -6,11 +6,6 @@ import {
getCachedSSHHosts,
invalidateHostsAndStatusCaches,
} from "@/lib/hosts-request-cache";
import { requestRemoteSync } from "@/lib/remote-sync-trigger";
import {
getConnectedRemoteApi,
markRemoteSharedHosts,
} from "@/lib/remote-server-api";
// SSH HOST MANAGEMENT
// ============================================================================
@@ -22,22 +17,7 @@ export type GetSSHHostsOptions = {
async function loadSSHHostsFromApi(): Promise<SSHHost[]> {
const hostsResponse = await sshHostApi.get("/db/host");
const localHosts = Array.isArray(hostsResponse.data)
? hostsResponse.data
: [];
const remoteApi = await getConnectedRemoteApi();
if (!remoteApi) return localHosts;
try {
const remoteResponse = await remoteApi.get("/host/db/host");
const remoteSharedHosts = Array.isArray(remoteResponse.data)
? markRemoteSharedHosts(remoteResponse.data)
: [];
return [...localHosts, ...remoteSharedHosts];
} catch {
// Keep the last locally synced host set usable while the server is offline.
return localHosts;
}
return Array.isArray(hostsResponse.data) ? hostsResponse.data : [];
}
export async function getSSHHosts(
@@ -82,12 +62,10 @@ export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
headers: { "Content-Type": "multipart/form-data" },
});
invalidateHostsAndStatusCaches();
void requestRemoteSync();
return response.data;
}
const response = await sshHostApi.post("/db/host", hostData);
invalidateHostsAndStatusCaches();
void requestRemoteSync();
return response.data;
} catch (error) {
throw handleApiError(error, "create SSH host");
@@ -108,12 +86,10 @@ export async function updateSSHHost(
headers: { "Content-Type": "multipart/form-data" },
});
invalidateHostsAndStatusCaches();
void requestRemoteSync();
return response.data;
}
const response = await sshHostApi.put(`/db/host/${hostId}`, hostData);
invalidateHostsAndStatusCaches();
void requestRemoteSync();
return response.data;
} catch (error) {
throw handleApiError(error, "update SSH host");
@@ -202,7 +178,6 @@ export async function deleteSSHHost(
try {
const response = await sshHostApi.delete(`/db/host/${hostId}`);
invalidateHostsAndStatusCaches();
void requestRemoteSync();
return response.data;
} catch (error) {
handleApiError(error, "delete SSH host");
+155
View File
@@ -0,0 +1,155 @@
import { authApi } from "@/main-axios";
export type SyncState = "idle" | "syncing" | "offline" | "error" | "signed_out";
export interface SyncProxyHeader {
name: string;
value: string;
/** A saved header whose value is not sent back. */
set?: boolean;
}
export interface SyncBasicAuth {
username: string;
password: string;
set?: boolean;
}
export interface SyncEntityInfo {
type: string;
owner: string;
ownerName: string | null;
readOnly: boolean;
onServer: boolean;
onDevice: boolean;
enabled: boolean;
}
export interface SyncStatus {
linked: boolean;
serverUrl?: string;
serverName?: string | null;
serverVersion?: string | null;
account?: {
username?: string;
isAdmin?: boolean;
roles?: string[];
permissions?: string[];
};
status?: SyncState;
lastError?: string | null;
linkedAt?: string;
lastSyncAt?: string | null;
pending?: number;
conflicts?: number;
errors?: number;
firstSyncDone?: boolean;
allowInvalidCertificate?: boolean;
customHeaders?: SyncProxyHeader[];
basicAuth?: SyncBasicAuth | null;
entities: SyncEntityInfo[];
}
export type ProbeProblem =
| "invalid_url"
| "unreachable"
| "tls_untrusted"
| "basic_auth_required"
| "proxy_login"
| "not_termix"
| "version_mismatch"
| "different_account";
export interface ProbeResult {
ok: boolean;
serverUrl?: string;
name?: string;
version?: string | null;
problem?: ProbeProblem;
message?: string;
}
export interface ServerTarget {
serverUrl: string;
customHeaders?: SyncProxyHeader[];
basicAuth?: SyncBasicAuth | null;
allowInvalidCertificate?: boolean;
}
export interface SyncConflictItem {
id: number;
entityType: string;
syncId: string;
name: string;
createdAt: string;
}
export interface SyncErrorItem {
entityType: string;
syncId: string;
reason: string | null;
}
export async function getSyncStatus(): Promise<SyncStatus> {
return (await authApi.get("/sync/link/status")).data;
}
export async function probeServer(target: ServerTarget): Promise<ProbeResult> {
return (await authApi.post("/sync/link/probe", target)).data;
}
export async function getLinkPreview(): Promise<Record<string, number>> {
return (await authApi.get("/sync/link/preview")).data?.counts ?? {};
}
export async function completeLink(
input: ServerTarget & {
token: string;
mode: "merge" | "replace";
serverName?: string;
},
): Promise<SyncStatus> {
return (await authApi.post("/sync/link/complete", input)).data;
}
export async function reloginLink(token: string): Promise<SyncStatus> {
return (await authApi.post("/sync/link/relogin", { token })).data;
}
export async function unlinkServer(keepData: boolean): Promise<SyncStatus> {
return (await authApi.post("/sync/link/unlink", { keepData })).data;
}
export async function syncNow(): Promise<SyncStatus> {
return (await authApi.post("/sync/link/sync", {}, { timeout: 300_000 })).data;
}
export async function updateSyncSettings(settings: {
disabledTypes?: string[];
customHeaders?: SyncProxyHeader[];
basicAuth?: SyncBasicAuth | null;
allowInvalidCertificate?: boolean;
}): Promise<SyncStatus> {
return (await authApi.put("/sync/link/settings", settings)).data;
}
export async function getSyncConflicts(): Promise<SyncConflictItem[]> {
return (await authApi.get("/sync/link/conflicts")).data?.conflicts ?? [];
}
export async function settleSyncConflict(
id: number,
keep: "mine" | "server",
): Promise<void> {
await authApi.post(`/sync/link/conflicts/${id}`, { keep });
}
export async function getSyncErrors(): Promise<SyncErrorItem[]> {
return (await authApi.get("/sync/link/errors")).data?.errors ?? [];
}
export async function retrySyncErrors(): Promise<SyncStatus> {
return (
await authApi.post("/sync/link/errors/retry", {}, { timeout: 300_000 })
).data;
}