mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35: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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user