feat: move the sync screens and wizard out of core into the plugin (#1580)

This commit is contained in:
LukeGus committed 2026-10-19 16:42:29 -05:00
1 parent ee0d1a5d8e
commit b0f2213b15
30 files changed
+9 -1788

No files matched your search

+1 -1
View File
@@ -26,7 +26,7 @@
### Changed
- Server and desktop sync moved out of the core app into a plugin. Existing links and offline copies keep working
- Server and desktop sync moved out of the core app into a plugin, including the Sync screen, the link wizard and the first run step. Existing links and offline copies keep working
- The icon rail, sidebar, tab bar, dock, Dashboard, Manage, Settings and command palette now come from a bundled layout. It can be swapped for another layout but not removed, and a fix to it no longer needs a Termix release
- Your theme is saved to your account so your other devices use it too, unless you keep settings on this device only
- The themes besides light and dark now come from a bundled themes plugin. It can be swapped but not removed, and Termix keeps a basic light and dark theme of its own
+4
View File
@@ -8,6 +8,10 @@ Plugin API 1.8.0.
- `ctx.sync.provide` and the `sync:provide` capability, so a plugin can answer every /sync request
### Removed
- The sync screens and calls in `@termix-ssh/plugin-sdk/ui`: `LinkServerDialog`, `ProxySettingsFields`, `useSyncStatus`, `refreshSyncStatus`, `setSyncStatus`, `getSyncConflicts`, `getSyncErrors`, `retrySyncErrors`, `settleSyncConflict`, `syncNow`, `unlinkServer`, `updateSyncSettings` and their types. They moved into the sync plugin
## 1.0.15
Plugin API 1.7.0.
-18
View File
@@ -176,7 +176,6 @@ LANGUAGES (value): readonly { code: string; label: string; }[]
Label (value): ({ className, ...props }: LabelProps & RefAttributes<HTMLLabelElement>) => Element
LineChart (value): ({ series, timestamps, domain, yFormatter, height, className, }: LineChartProps) => Element
LineChartSeries (type): LineChartSeries
LinkServerDialog (value): ({ open, onOpenChange, onLinked, relogin, }: LinkServerDialogProps) => Element
LinkedAccountInfo (type): LinkedAccountInfo
ListBadge (value): ({ tone, className, children, }: { tone?: "destructive" | "muted" | "brand" | "warning" | "success"; className?: string; children: ReactNode; }) => Element
ListRow (value): ({ title, icon, badges, meta, stripe, tone, color, selected, active, dimmed, leading, trailing, actions, onClick, className, children, ...rest }: { title: ReactNode; icon?: ReactNode; badges?: ReactNode; meta?: ReactNode; stripe?: number; tone?: ListRowTone; color?: string; selected?: boolean; active?: boolean; dimmed?: boolean; leading?: ReactNode; trailing?: ReactNode; actions?: ReactNode; onClick?: () => void; className?: string; children?: ReactNode; } & Omit<HTMLAttributes<HTMLDivElement>, "title" | "children" | "onClick">) => Element
@@ -227,7 +226,6 @@ PopoverAnchor (value): ({ ...props }: PopoverAnchorProps & RefAttributes<HTMLDiv
PopoverContent (value): ({ className, align, sideOffset, ...props }: PopoverContentProps & RefAttributes<HTMLDivElement>) => Element
PopoverTrigger (value): ({ ...props }: PopoverTriggerProps & RefAttributes<HTMLButtonElement>) => Element
ProxyNode (type): ProxyNode
ProxySettingsFields (value): ({ customHeaders, basicAuth, onChange, }: ProxySettingsFieldsProps) => Element
RadialGauge (value): ({ value, label, caption, size, strokeWidth, className, colorClassName, }: RadialGaugeProps) => Element
RadialGaugeProps (type): RadialGaugeProps
RailGroup (type): RailGroup
@@ -288,12 +286,6 @@ SurfaceKind (type): SurfaceKind
SurfaceScope (value): ({ kind, onEditingChange, onClose, className, children, }: { kind?: SurfaceKind; onClose?: () => void; onEditingChange?: (editing: EditingWidth) => void; className?: string; children: ReactNode; }) => Element
Switch (value): ({ className, ...props }: SwitchProps & RefAttributes<HTMLButtonElement>) => Element
SwitchRow (value): ({ label, description, badge, checked, onChange, disabled, defaultKey, rowId, }: { label: string; description?: ReactNode; badge?: string; checked: boolean; onChange: (value: boolean) => void; disabled?: boolean; defaultKey?: string; rowId?: string; }) => Element
SyncBasicAuth (type): SyncBasicAuth
SyncConflictItem (type): SyncConflictItem
SyncEntityInfo (type): SyncEntityInfo
SyncErrorItem (type): SyncErrorItem
SyncProxyHeader (type): SyncProxyHeader
SyncStatus (type): SyncStatus
TOTPDialog (value): ({ isOpen, prompt, mode, waiting, onSubmit, onCancel, backgroundColor, }: TOTPDialogProps) => Element
Tab (type): Tab
TabShellCallbacks (type): TabShellCallbacks
@@ -443,8 +435,6 @@ getSessionTimeoutMinutes (value): () => Promise<number>
getSessions (value): () => Promise<{ sessions: { id: string; userId: string; username?: string; deviceType: string; deviceInfo: string; createdAt: string; expiresAt: string; lastActiveAt: string; isRevoked?: boolean; isCurrentSession?: boolean; }[]; }>
getStatusCheckSettings (value): () => Promise<{ statusCheckInterval: number; }>
getStatusClasses (value): (status: boolean | "online" | "offline" | "unknown", scheme: StatusColorScheme, variant: "dot" | "stripe" | "badge", loading?: boolean) => string
getSyncConflicts (value): () => Promise<SyncConflictItem[]>
getSyncErrors (value): () => Promise<SyncErrorItem[]>
getTabType (value): (id: string) => TabTypeDef
getTlsStatus (value): () => Promise<TlsStatus>
getUpdateChannel (value): () => Promise<UpdateChannelState>
@@ -509,7 +499,6 @@ quickConnectTargets (value): (all: HostActionDef[], host: Host) => HostActionDef
railItemLabel (value): (id: string, t: (key: string) => string) => string
readFromClipboard (value): () => Promise<string>
recordHostActionPreference (value): (hostId: string, action: TabType) => void
refreshSyncStatus (value): () => void
registerLayout (value): (item: RegisteredLayout) => () => void
registerTabCloseGuard (value): (tabId: string, guard: Guard) => () => void
releaseUrlFrom (value): (info: VersionInfo) => string
@@ -532,7 +521,6 @@ resolveConnectionOrigin (value): (host: OriginResolvableHost, options?: { defaul
resolveHostDefaults (value): (where: { hostId?: number; folder?: string; parentHostId?: number; }, adminTargetUserId?: string) => Promise<Record<string, ResolvedHostDefault>>
resolveHostTabType (value): (host: Host, preferredType?: TabType) => TabType
resolveRemoteHostId (value): (syncId: string) => Promise<number>
retrySyncErrors (value): () => Promise<SyncStatus>
revokeAllUserSessions (value): (userId: string) => Promise<{ success: boolean; message: string; }>
revokeCredentialAccess (value): (credentialId: number, accessId: number) => Promise<void>
revokeFolderAccess (value): (ruleId: number) => Promise<void>
@@ -556,21 +544,17 @@ setDeveloperMode (value): (enabled: boolean) => Promise<void>
setHostAuthOverride (value): (hostId: number, protocol: string, credentialId: number, sharedCopySyncId?: string) => Promise<{ success: boolean; protocol: string; credentialId: number; }>
setNotificationPrivateEndpoints (value): (hosts: string[]) => Promise<string[]>
setPluginOptions (value): (pluginId: string, options: { autoUpdate?: boolean; pinned?: boolean; channel?: PluginChannel; }) => Promise<{ autoUpdate: boolean; pinnedVersion: string; channel: PluginChannel; }>
setSyncStatus (value): (next: SyncStatus) => void
setUpdateChannel (value): (channel: UpdateChannel) => Promise<UpdateChannelState>
settleSyncConflict (value): (id: number, keep: "mine" | "server") => Promise<void>
shareCredential (value): (credentialId: number, targets: ShareTarget[], permissionLevel: CredentialPermissionLevel, durationHours?: number) => Promise<{ success: boolean; expiresAt: string; }>
shareFolder (value): (folder: string, shareData: { targets: ShareTarget[]; permissionLevel: SharePermissionLevel; durationHours?: number; }) => Promise<{ success: boolean; expiresAt: string; hostsShared: number; hostsTotal: number; hostResults: { hostId: number; shared: boolean; reason?: string; }[]; }>
shareHost (value): (hostId: number, shareData: { targets: ShareTarget[]; permissionLevel: SharePermissionLevel; durationHours?: number; }, syncId?: string) => Promise<{ success: boolean; expiresAt: string; results: { type: "role" | "user"; id: string | number; accessId: number; created: boolean; }[]; }>
shell (value): TabShellCallbacks
splitDefaultKey (value): (fullKey: string) => [string, string]
sshHostToHost (value): (h: SSHHostWithStatus) => Host
syncNow (value): () => Promise<SyncStatus>
tabIcon (value): (type: TabType) => Element
tabJumpHotkeyKeys (value): () => string[]
takePendingManageRequest (value): () => ManageRequest
unlinkExternalFromPasswordAccount (value): (userId: string) => Promise<{ success: boolean; message: string; }>
unlinkServer (value): (keepData: boolean) => Promise<SyncStatus>
updateCredential (value): (credentialId: number, credentialData: Record<string, unknown>) => Promise<Record<string, unknown>>
updateExternalAutoProvision (value): (enabled: boolean) => Promise<Record<string, unknown>>
updateFolderMetadata (value): (name: string, color?: string, icon?: string, credentialId?: number, localOnly?: boolean) => Promise<void>
@@ -588,7 +572,6 @@ updateSSHHost (value): (hostId: number, hostData: HostData) => Promise<Host>
updateSecondFactorAfterExternalLogin (value): (enabled: boolean) => Promise<Record<string, unknown>>
updateSessionTimeout (value): (timeoutHours: number) => Promise<void>
updateStatusCheckSettings (value): (settings: { statusCheckInterval: number; }) => Promise<void>
updateSyncSettings (value): (settings: { disabledTypes?: string[]; customHeaders?: SyncProxyHeader[]; basicAuth?: SyncBasicAuth; allowInvalidCertificate?: boolean; }) => Promise<SyncStatus>
uploadTlsCertificate (value): (payload: { certificate: string; privateKey: string; }) => Promise<TlsStatus & { success: boolean; reloadMessage?: string; }>
usageBarColor (value): (percent: number) => string
usageColor (value): (percent: number) => string
@@ -635,7 +618,6 @@ useSshAuthEditors (value): () => SshAuthEditorDef[]
useSshAuthProviders (value): () => { providers: SshAuthTypeOption[]; loaded: boolean; find: (type: string) => SshAuthTypeOption; }
useStatusColorScheme (value): () => StatusColorScheme
useSurfaceKind (value): () => SurfaceKind
useSyncStatus (value): () => any
useTabHandlesVersion (value): () => number
useTabs (value): () => TabContextType
useTabsSafe (value): () => TabContextType
-18
View File
@@ -197,7 +197,6 @@ const ALLOWED = [
"Label",
"LineChart",
"LineChartSeries",
"LinkServerDialog",
"LinkedAccountInfo",
"ListBadge",
"ListRow",
@@ -248,7 +247,6 @@ const ALLOWED = [
"PopoverContent",
"PopoverTrigger",
"ProxyNode",
"ProxySettingsFields",
"RadialGauge",
"RadialGaugeProps",
"RailGroup",
@@ -309,12 +307,6 @@ const ALLOWED = [
"SurfaceScope",
"Switch",
"SwitchRow",
"SyncBasicAuth",
"SyncConflictItem",
"SyncEntityInfo",
"SyncErrorItem",
"SyncProxyHeader",
"SyncStatus",
"TOTPDialog",
"Tab",
"TabShellCallbacks",
@@ -464,8 +456,6 @@ const ALLOWED = [
"getSessions",
"getStatusCheckSettings",
"getStatusClasses",
"getSyncConflicts",
"getSyncErrors",
"getTabType",
"getTlsStatus",
"getUpdateChannel",
@@ -530,7 +520,6 @@ const ALLOWED = [
"railItemLabel",
"readFromClipboard",
"recordHostActionPreference",
"refreshSyncStatus",
"registerLayout",
"registerTabCloseGuard",
"releaseUrlFrom",
@@ -553,7 +542,6 @@ const ALLOWED = [
"resolveHostDefaults",
"resolveHostTabType",
"resolveRemoteHostId",
"retrySyncErrors",
"revokeAllUserSessions",
"revokeCredentialAccess",
"revokeFolderAccess",
@@ -577,21 +565,17 @@ const ALLOWED = [
"setHostAuthOverride",
"setNotificationPrivateEndpoints",
"setPluginOptions",
"setSyncStatus",
"setUpdateChannel",
"settleSyncConflict",
"shareCredential",
"shareFolder",
"shareHost",
"shell",
"splitDefaultKey",
"sshHostToHost",
"syncNow",
"tabIcon",
"tabJumpHotkeyKeys",
"takePendingManageRequest",
"unlinkExternalFromPasswordAccount",
"unlinkServer",
"updateCredential",
"updateExternalAutoProvision",
"updateFolderMetadata",
@@ -609,7 +593,6 @@ const ALLOWED = [
"updateSessionTimeout",
"updateHttpsSettings",
"updateStatusCheckSettings",
"updateSyncSettings",
"uploadTlsCertificate",
"usageBarColor",
"usageColor",
@@ -656,7 +639,6 @@ const ALLOWED = [
"useSshAuthProviders",
"useStatusColorScheme",
"useSurfaceKind",
"useSyncStatus",
"useTabHandlesVersion",
"useTabs",
"useTabsSafe",
@@ -1,26 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import fs from "node:fs";
vi.mock("../../database/db/index.js", () => ({}));
import { registerCoreSyncEntities } from "../../sync/entities.js";
import {
listEntityTypes,
resetSyncRegistry,
} from "../../plugins/sync-registry.js";
afterEach(() => resetSyncRegistry());
describe("core sync entities", () => {
it("each has a label in the Sync panel", () => {
registerCoreSyncEntities();
const en = JSON.parse(
fs.readFileSync(
new URL("../../../ui/locales/en.json", import.meta.url),
"utf8",
),
);
const missing = listEntityTypes().filter((type) => !en.sync.entities[type]);
expect(missing).toEqual([]);
});
});
-2
View File
@@ -10,7 +10,6 @@
export const CORE_ONBOARDING_STEPS = {
plugins: 1,
"desktop-sync": 1,
"desktop-updates": 1,
security: 1,
} as const;
@@ -24,7 +23,6 @@ export type CoreOnboardingStepKey = keyof typeof CORE_ONBOARDING_STEPS;
*/
export const LEGACY_SEEN: Readonly<Record<string, number>> = Object.freeze({
plugins: 1,
"desktop-sync": 1,
preset: 1,
appearance: 1,
security: 1,
-155
View File
@@ -1,155 +0,0 @@
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;
}
-319
View File
@@ -1,319 +0,0 @@
import { getErrorMessage } from "../lib/error-message.js";
import React, { useState, useEffect, useRef, useCallback } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/alert.tsx";
import { useTranslation } from "react-i18next";
import { AlertCircle, Loader2, ArrowLeft, RefreshCw } from "lucide-react";
interface ElectronLoginFormProps {
serverUrl: string;
onAuthSuccess: (token: string | null) => void | Promise<void>;
onChangeServer: () => void;
// "local" (default): the app's own login, the token goes to localStorage
// like every other client. "link": signing in to a server to link this
// desktop to it; the token is only handed to onAuthSuccess, which trades
// it for the desktop's own session.
targetPurpose?: "local" | "link";
}
const AUTH_MESSAGE_SOURCES = new Set([
"auth_component",
"second_factor_auth_component",
"method_auth_component",
"oidc_callback",
]);
export function ElectronLoginForm({
serverUrl,
onAuthSuccess,
onChangeServer,
targetPurpose = "local",
}: ElectronLoginFormProps) {
const { t } = useTranslation();
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isAuthenticating, setIsAuthenticating] = useState(false);
const isAuthenticatingRef = useRef(false);
const iframeRef = useRef<HTMLIFrameElement>(null);
const hasAuthenticatedRef = useRef(false);
const [currentUrl, setCurrentUrl] = useState(serverUrl);
const hasLoadedOnce = useRef(false);
const onAuthSuccessRef = useRef(onAuthSuccess);
useEffect(() => {
onAuthSuccessRef.current = onAuthSuccess;
}, [onAuthSuccess]);
const handleAuthSuccess = useCallback(
async (token: string | null) => {
if (hasAuthenticatedRef.current || isAuthenticatingRef.current) return;
hasAuthenticatedRef.current = true;
isAuthenticatingRef.current = true;
setIsAuthenticating(true);
try {
if (targetPurpose === "link") {
if (!token) {
throw new Error(t("errors.authTokenMissing"));
}
} else if (token) {
localStorage.setItem("jwt", token);
}
await onAuthSuccessRef.current(token);
} catch (err) {
setError(
err instanceof Error && err.message
? err.message
: t("errors.authTokenSaveFailed"),
);
isAuthenticatingRef.current = false;
setIsAuthenticating(false);
hasAuthenticatedRef.current = false;
}
},
[t, targetPurpose],
);
// postMessage from server Auth.tsx after the backend has set the HttpOnly cookie.
// Only the server's own origin is trusted: the iframe can navigate away
// (identity provider, redirects), and those pages must not hand us a token,
// start a system browser login, or receive the replies.
useEffect(() => {
let serverOrigin: string;
try {
serverOrigin = new URL(serverUrl).origin;
} catch {
return;
}
const handleMessage = async (event: MessageEvent) => {
try {
if (event.source !== iframeRef.current?.contentWindow) return;
if (event.origin !== serverOrigin) return;
if (!event.data || typeof event.data !== "object") return;
const {
type,
platform,
source,
token,
authUrl,
callbackPort,
providerId,
} = event.data;
if (
type === "AUTH_SUCCESS" &&
platform === "desktop" &&
AUTH_MESSAGE_SOURCES.has(source)
) {
await handleAuthSuccess(token ?? null);
return;
}
// OIDC login requested from inside the iframe, open the system browser
// so captcha stages (e.g. Cloudflare Turnstile) render correctly.
if (type === "OIDC_SYSTEM_BROWSER_AUTH" && authUrl && callbackPort) {
const sendResultToIframe = (result: {
success: boolean;
error?: string;
secondFactor?: boolean;
tempToken?: string;
factors?: string;
}) => {
iframeRef.current?.contentWindow?.postMessage(
{
type: "OIDC_SYSTEM_BROWSER_AUTH_RESULT",
source: "oidc_system_browser_auth",
providerId,
...result,
},
serverOrigin,
);
};
const electronAPI = (
window as unknown as {
electronAPI?: {
externalBrowserLogin?: (
url: string,
port: number,
) => Promise<{
success: boolean;
token?: string;
error?: string;
secondFactor?: boolean;
tempToken?: string;
factors?: string;
}>;
};
}
).electronAPI;
if (!electronAPI?.externalBrowserLogin) {
sendResultToIframe({
success: false,
error: t("errors.failedExternalLogin"),
});
return;
}
const result = await electronAPI.externalBrowserLogin(
authUrl,
callbackPort,
);
if (result.success && result.token) {
await handleAuthSuccess(result.token);
return;
}
// The login stopped for a second factor; the iframe asks for it.
if (result.secondFactor) {
sendResultToIframe({
success: false,
secondFactor: true,
tempToken: result.tempToken,
factors: result.factors,
});
return;
}
sendResultToIframe({
success: false,
error: result.error || t("errors.failedExternalLogin"),
});
}
} catch (err) {
const providerId =
event.data &&
typeof event.data === "object" &&
typeof event.data.providerId === "number"
? event.data.providerId
: undefined;
const error = getErrorMessage(err, t("errors.failedExternalLogin"));
iframeRef.current?.contentWindow?.postMessage(
{
type: "OIDC_SYSTEM_BROWSER_AUTH_RESULT",
source: "oidc_system_browser_auth",
providerId,
success: false,
error,
},
serverOrigin,
);
}
};
window.addEventListener("message", handleMessage);
return () => window.removeEventListener("message", handleMessage);
}, [handleAuthSuccess, serverUrl, t]);
useEffect(() => {
const iframe = iframeRef.current;
if (!iframe) return;
const handleLoad = () => {
setLoading(false);
hasLoadedOnce.current = true;
setError(null);
try {
if (iframe.contentWindow) {
setCurrentUrl(iframe.contentWindow.location.href);
}
} catch {
setCurrentUrl(serverUrl);
}
};
const handleError = () => {
setLoading(false);
if (hasLoadedOnce.current) {
setError(t("errors.failedToLoadServer"));
}
};
iframe.addEventListener("load", handleLoad);
iframe.addEventListener("error", handleError);
return () => {
iframe.removeEventListener("load", handleLoad);
iframe.removeEventListener("error", handleError);
};
}, [serverUrl, t]);
const handleRefresh = () => {
if (iframeRef.current) {
iframeRef.current.src = serverUrl;
setLoading(true);
setError(null);
}
};
const displayUrl = currentUrl.replace(/^https?:\/\//, "");
return (
<div className="relative w-full h-full bg-background flex flex-col">
{isAuthenticating && (
<div className="absolute inset-0 flex items-center justify-center bg-background z-50">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
)}
{!isAuthenticating && (
<div className="flex items-center justify-between p-4 pr-12 bg-background border-b border-border">
<button
onClick={onChangeServer}
className="flex items-center gap-2 text-foreground hover:text-primary transition-colors"
>
<ArrowLeft className="h-5 w-5" />
<span className="text-base font-medium">
{t("sync.wizard.back")}
</span>
</button>
<div className="flex-1 mx-4 text-center">
<span className="text-muted-foreground text-sm truncate block">
{displayUrl}
</span>
</div>
<button
onClick={handleRefresh}
className="p-2 text-foreground hover:text-primary transition-colors"
disabled={loading}
>
<RefreshCw className={`h-5 w-5 ${loading ? "animate-spin" : ""}`} />
</button>
</div>
)}
{error && !isAuthenticating && (
<div className="absolute top-20 left-1/2 transform -translate-x-1/2 z-50 w-full max-w-md px-4">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>{t("common.error")}</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
</div>
)}
{loading && !isAuthenticating && (
<div
className="absolute inset-0 flex items-center justify-center bg-background z-40"
style={{ marginTop: "60px" }}
>
<div className="flex items-center">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<span className="ml-3 text-muted-foreground">
{t("auth.loadingServer")}
</span>
</div>
</div>
)}
<div
className="flex-1 overflow-hidden"
style={{ visibility: isAuthenticating ? "hidden" : "visible" }}
>
<iframe
ref={iframeRef}
src={serverUrl}
className="w-full h-full border-0"
title={t("auth.serverAuthentication")}
sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-storage-access-by-user-activation allow-top-navigation allow-top-navigation-by-user-activation allow-modals allow-downloads"
allow="clipboard-read; clipboard-write; publickey-credentials-get; publickey-credentials-create"
/>
</div>
</div>
);
}
-22
View File
@@ -1,22 +0,0 @@
import { useSyncExternalStore } from "react";
import { getSyncStatusSnapshot, subscribeSyncStatus } from "@/lib/sync-status";
/** The desktop's sync status, kept current while the component is mounted. */
export function useSyncStatus() {
return useSyncExternalStore(
subscribeSyncStatus,
getSyncStatusSnapshot,
() => null,
);
}
/** What needs the user's attention in Sync, for the rail badge. */
export function useSyncAttentionCount(): number | null {
const status = useSyncStatus();
if (!status?.linked) return null;
const count =
(status.conflicts ?? 0) +
(status.errors ?? 0) +
(status.status === "signed_out" ? 1 : 0);
return count || null;
}
-86
View File
@@ -1,86 +0,0 @@
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();
}
-89
View File
@@ -215,13 +215,6 @@
"pluginsLoadFailed": "The plugin list could not be loaded. You can choose plugins later from the Plugins tab.",
"securityTitle": "Secure your account",
"securityIntro": "Add a passkey or an authenticator app to your account. You can also do this later in Settings, under Security.",
"desktopTitle": "How do you want to use Termix?",
"desktopBody": "You can change this any time from Sync in the sidebar.",
"desktopLocalTitle": "On this device only",
"desktopLocalBody": "Everything is stored here. Nothing to set up.",
"desktopLinkTitle": "With my Termix server",
"desktopLinkBody": "Sign in to your server to use the same hosts and settings on every device.",
"desktopLinked": "Linked to {{server}}",
"pluginsDocsLink": "Read about each one"
},
"desktopUpdates": {
@@ -252,7 +245,6 @@
"hosts": "Hosts",
"credentials": "Credentials",
"connections": "Connections",
"sync": "Sync",
"reconnectDisconnectedTerminals": "Reconnect all disconnected terminals",
"reconnectingTerminals_one": "Reconnecting {{count}} terminal",
"reconnectingTerminals_other": "Reconnecting {{count}} terminals",
@@ -1623,87 +1615,6 @@
"noData": "No data",
"filter": "Filter..."
},
"sync": {
"minutesAgo_one": "{{count}} minute ago",
"minutesAgo_other": "{{count}} minutes ago",
"hoursAgo_one": "{{count}} hour ago",
"hoursAgo_other": "{{count}} hours ago",
"pending_one": "{{count}} change waiting to sync",
"pending_other": "{{count}} changes waiting to sync",
"proxy": {
"headersTitle": "Extra headers",
"headersHint": "Sent with every request to the server, for example a Cloudflare Access service token.",
"headerName": "Header name",
"headerValue": "Value",
"savedValue": "Saved, type to change",
"addHeader": "Add header",
"removeHeader": "Remove header",
"basicAuthTitle": "Username and password",
"basicAuthHint": "For a proxy that asks for a username and password before Termix.",
"username": "Username",
"password": "Password",
"allowInvalidCertificate": "Trust a self-signed certificate",
"allowInvalidCertificateHint": "Only for your own server, when its certificate is not from a known authority."
},
"problems": {
"invalidUrl": "That does not look like a web address.",
"unreachable": "Could not reach the server. Check the address and that the server is running.",
"unreachableWithReason": "Could not reach the server: {{reason}}",
"tlsUntrusted": "The server certificate is not trusted. If this is your own server with a self-signed certificate, turn on the option below.",
"basicAuthRequired": "Something in front of the server asks for a username and password. Enter them below.",
"proxyLogin": "A login page or proxy answered instead of Termix. Add the headers it needs below, or check the address.",
"notTermix": "There is no Termix server at that address. If it runs under a path, include it, like https://example.com/termix.",
"versionMismatch": "That server runs an older Termix. Update it to 2.9 or newer to link.",
"differentAccount": "You signed in to a different account. Sign in to the account this device is linked to, or unlink first."
},
"wizard": {
"title": "Link to a Termix server",
"reloginTitle": "Sign in again",
"savedServers": "Recent servers",
"removeServer": "Remove from the list",
"serverDescription": "Enter the address you use to open Termix in your browser.",
"signinDescription": "Sign in to {{url}}",
"chooseDescription": "This device already has data. What should happen to it?",
"finishingDescription": "Linking this device.",
"serverUrl": "Server address",
"serverUrlHint": "For example https://termix.example.com",
"showProxySettings": "Reverse proxy settings",
"hideProxySettings": "Hide reverse proxy settings",
"continue": "Continue",
"back": "Back",
"mergeTitle": "Add it to my account",
"mergeBody_one": "The item on this device is added to your account. Items your account already has stay as they are on the server.",
"mergeBody_other": "The {{count}} items on this device are added to your account. Items your account already has stay as they are on the server.",
"replaceTitle": "Replace it with my account",
"replaceBody_one": "The item on this device is removed, and this device takes everything from your account. Hosts kept on this device only stay.",
"replaceBody_other": "The {{count}} items on this device are removed, and this device takes everything from your account. Hosts kept on this device only stay.",
"link": "Link",
"linking": "Linking and running the first sync...",
"linked": "Linked. Your data is syncing now.",
"signedInAgain": "Signed in. Sync is running again."
},
"entities": {
"hosts": "Hosts",
"sshCredentials": "Credentials",
"sshFolders": "Folders",
"userPreferences": "Preferences",
"pluginUserSettings": "Plugin settings",
"sharedHosts": "Hosts shared with you",
"sharedCredentials": "Credentials shared with you",
"vaultProfiles": "Vault profiles",
"snippetFolders": "Snippet folders",
"snippets": "Snippets",
"dashboardServiceLinks": "Service links",
"homepageItems": "Homepage items",
"networkTopology": "Network map",
"workspaces": "Workspaces",
"hostDefaults": "Host defaults",
"hostDefaultsAdmin": "Server host defaults",
"aiProviders": "AI providers",
"homepageLayouts": "Homepage layouts",
"accountProfile": "Account profile"
}
},
"hostDefaults": {
"source": {
"folder": "Folder {{name}}",
+1 -12
View File
@@ -1,8 +1,7 @@
import { Monitor, MonitorDown, Puzzle, Shield } from "lucide-react";
import { MonitorDown, Puzzle, Shield } from "lucide-react";
import { CORE_ONBOARDING_STEPS } from "@/types/onboarding";
import { PluginsStep } from "./steps/PluginsStep";
import { SecurityStep } from "./steps/SecurityStep";
import { DesktopSyncStep } from "./steps/DesktopSyncStep";
import { DesktopUpdatesStep } from "./steps/DesktopUpdatesStep";
import { desktopUpdates } from "@/lib/desktop-updates";
import type { OnboardingStepDef } from "./onboarding-registry";
@@ -28,16 +27,6 @@ export const CORE_STEPS: OnboardingStepDef[] = [
isRelevant: (ctx) => !ctx.pluginsManagedElsewhere && ctx.pluginSetupPending,
forcePending: (ctx) => ctx.pluginSetupPending,
},
{
id: "desktop-sync",
version: CORE_ONBOARDING_STEPS["desktop-sync"],
titleKey: "onboarding.desktopTitle",
icon: Monitor,
Component: DesktopSyncStep,
audience: "all",
position: 20,
isRelevant: (ctx) => ctx.isDesktop,
},
{
id: "desktop-updates",
version: CORE_ONBOARDING_STEPS["desktop-updates"],
@@ -1,79 +0,0 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Cloud, Laptop } from "lucide-react";
import { LinkServerDialog } from "@/settings/sync/LinkServerDialog";
import { useSyncStatus } from "@/hooks/use-sync-status";
import { setSyncStatus } from "@/lib/sync-status";
import { notifySyncChanged } from "@/lib/linked-server";
/**
* Desktop only: use Termix on this device alone, or link it to a server.
* Either choice can be changed later from Sync.
*/
export function DesktopSyncStep() {
const { t } = useTranslation();
const status = useSyncStatus();
const [linkOpen, setLinkOpen] = useState(false);
const linked = !!status?.linked;
const options = [
{
id: "local",
icon: Laptop,
title: t("onboarding.desktopLocalTitle"),
body: t("onboarding.desktopLocalBody"),
selected: !linked,
onClick: () => {},
},
{
id: "link",
icon: Cloud,
title: t("onboarding.desktopLinkTitle"),
body: linked
? t("onboarding.desktopLinked", {
server: status?.serverName || status?.serverUrl,
})
: t("onboarding.desktopLinkBody"),
selected: linked,
onClick: () => !linked && setLinkOpen(true),
},
];
return (
<div className="flex flex-col gap-3">
<p className="text-xs text-muted-foreground">
{t("onboarding.desktopBody")}
</p>
<div className="flex flex-col gap-1.5">
{options.map(({ id, icon: Icon, title, body, selected, onClick }) => (
<button
key={id}
type="button"
onClick={onClick}
className={`flex items-start gap-2.5 border p-2.5 text-left transition-colors ${
selected
? "border-accent-brand bg-accent-brand/10"
: "border-border bg-card hover:bg-muted/40"
}`}
>
<Icon size={14} className="mt-0.5 shrink-0 text-accent-brand" />
<span className="flex flex-col gap-1">
<span className="text-xs font-medium">{title}</span>
<span className="text-[10px] leading-snug text-muted-foreground">
{body}
</span>
</span>
</button>
))}
</div>
<LinkServerDialog
open={linkOpen}
onOpenChange={setLinkOpen}
onLinked={(next) => {
setSyncStatus(next);
notifySyncChanged();
}}
/>
</div>
);
}
-19
View File
@@ -491,21 +491,6 @@ export {
type ShareTarget,
} from "@/api/rbac-api";
export { type BrandingSettings, getBranding } from "@/api/settings-api";
export {
getSyncConflicts,
getSyncErrors,
retrySyncErrors,
settleSyncConflict,
type SyncBasicAuth,
type SyncConflictItem,
type SyncEntityInfo,
type SyncErrorItem,
syncNow,
type SyncProxyHeader,
type SyncStatus,
unlinkServer,
updateSyncSettings,
} from "@/api/sync-api";
export {
getChannelReleases,
getUpdateChannel,
@@ -553,7 +538,6 @@ export {
getStatusClasses,
useStatusColorScheme,
} from "@/hooks/use-status-color-scheme";
export { useSyncStatus } from "@/hooks/use-sync-status";
export { useSshAuthProviders } from "@/hooks/useSshAuthProviders";
export { changeAppLanguage, normalizeLanguageCode } from "@/i18n/i18n";
export { LANGUAGES } from "@/i18n/languages";
@@ -603,7 +587,6 @@ export { getConnectedRemoteApi } from "@/lib/remote-server-api";
export { roleLabel } from "@/lib/role-label";
export type { StatusValue } from "@/lib/server-status-store";
export { parseServerTime } from "@/lib/server-time";
export { refreshSyncStatus, setSyncStatus } from "@/lib/sync-status";
export { tabJumpHotkeyKeys } from "@/lib/tab-jump-hotkey";
export {
ACCENT_PRESET_COLORS,
@@ -740,8 +723,6 @@ export {
hasVisibleFields,
isFieldShown,
} from "@/settings/settings-fields-util";
export { LinkServerDialog } from "@/settings/sync/LinkServerDialog";
export { ProxySettingsFields } from "@/settings/sync/ProxySettingsFields";
export { useShellController } from "@/shell/controller/ShellControllerProvider";
export {
type KeybindingActionDef,
-455
View File
@@ -1,455 +0,0 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { InlineView } from "@/components/surface/surface-scope";
import { toast } from "sonner";
import { AlertCircle, Loader2, X } from "lucide-react";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { Switch } from "@/components/switch";
import { ElectronLoginForm } from "@/auth/ElectronLoginForm";
import {
completeLink,
getLinkPreview,
probeServer,
reloginLink,
type ProbeProblem,
type ServerTarget,
type SyncStatus,
} from "@/api/sync-api";
import { ProxySettingsFields } from "./ProxySettingsFields";
import {
forgetServerUrl,
getSavedServerUrls,
rememberServerUrl,
} from "./saved-servers";
import { DocsLink } from "@/components/docs-link";
type Step = "server" | "signin" | "choose" | "finishing";
interface LinkServerDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onLinked: (status: SyncStatus) => void;
/** Sign back in to the server this desktop is already linked to. */
relogin?: { serverUrl: string };
}
function problemText(
t: (key: string, options?: Record<string, unknown>) => string,
problem: ProbeProblem | undefined,
message: string | undefined,
): string {
switch (problem) {
case "invalid_url":
return t("sync.problems.invalidUrl");
case "tls_untrusted":
return t("sync.problems.tlsUntrusted");
case "basic_auth_required":
return t("sync.problems.basicAuthRequired");
case "proxy_login":
return t("sync.problems.proxyLogin");
case "not_termix":
return t("sync.problems.notTermix");
case "version_mismatch":
return t("sync.problems.versionMismatch");
case "different_account":
return t("sync.problems.differentAccount");
default:
return message
? t("sync.problems.unreachableWithReason", { reason: message })
: t("sync.problems.unreachable");
}
}
export function LinkServerDialog({
open,
onOpenChange,
onLinked,
relogin,
}: LinkServerDialogProps) {
const { t } = useTranslation();
const [step, setStep] = useState<Step>(relogin ? "signin" : "server");
const [savedUrls, setSavedUrls] = useState<string[]>(getSavedServerUrls);
const [target, setTarget] = useState<ServerTarget>({
serverUrl: relogin?.serverUrl ?? "",
customHeaders: [],
basicAuth: null,
allowInvalidCertificate: false,
});
const [showAdvanced, setShowAdvanced] = useState(false);
const [checking, setChecking] = useState(false);
const [problem, setProblem] = useState<string | null>(null);
const [problemKind, setProblemKind] = useState<ProbeProblem | null>(null);
const [serverName, setServerName] = useState<string | undefined>();
const [token, setToken] = useState<string | null>(null);
const [counts, setCounts] = useState<Record<string, number>>({});
const [mode, setMode] = useState<"merge" | "replace">("merge");
const [finishError, setFinishError] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
setStep(relogin ? "signin" : "server");
setProblem(null);
setProblemKind(null);
setToken(null);
setFinishError(null);
setMode("merge");
if (relogin) {
setTarget((current) => ({ ...current, serverUrl: relogin.serverUrl }));
return;
}
// An older version of the app may have saved a server address.
window.electronAPI
?.invoke?.("get-previous-server-url")
.then((result) => {
const url = (result as { serverUrl?: string | null } | null)?.serverUrl;
if (url) {
setTarget((current) =>
current.serverUrl ? current : { ...current, serverUrl: url },
);
}
})
.catch(() => {});
}, [open, relogin]);
const handleCheck = async () => {
setChecking(true);
setProblem(null);
setProblemKind(null);
try {
const result = await probeServer(target);
if (!result.ok) {
setProblemKind(result.problem ?? null);
setProblem(problemText(t, result.problem, result.message));
if (
result.problem === "basic_auth_required" ||
result.problem === "proxy_login"
) {
setShowAdvanced(true);
}
if (result.problem === "basic_auth_required" && !target.basicAuth) {
setTarget((current) => ({
...current,
basicAuth: { username: "", password: "" },
}));
}
return;
}
setTarget((current) => ({
...current,
serverUrl: result.serverUrl ?? current.serverUrl,
}));
setServerName(result.name);
setStep("signin");
} catch {
setProblem(problemText(t, "unreachable", undefined));
} finally {
setChecking(false);
}
};
const finish = async (sessionToken: string, chosen: "merge" | "replace") => {
setStep("finishing");
setFinishError(null);
try {
const status = await completeLink({
...target,
token: sessionToken,
mode: chosen,
serverName,
});
rememberServerUrl(target.serverUrl);
toast.success(t("sync.wizard.linked"));
onLinked(status);
onOpenChange(false);
} catch (error) {
const data = (
error as {
response?: { data?: { problem?: ProbeProblem; message?: string } };
}
)?.response?.data;
setFinishError(problemText(t, data?.problem, data?.message));
setStep("choose");
}
};
const handleSignedIn = async (sessionToken: string | null) => {
if (!sessionToken) return;
if (relogin) {
try {
const status = await reloginLink(sessionToken);
toast.success(t("sync.wizard.signedInAgain"));
onLinked(status);
onOpenChange(false);
} catch (error) {
const data = (
error as {
response?: {
data?: {
error?: string;
problem?: ProbeProblem;
message?: string;
};
};
}
)?.response?.data;
throw new Error(
data?.error === "different_account"
? t("sync.problems.differentAccount")
: problemText(t, data?.problem, data?.message),
);
}
return;
}
setToken(sessionToken);
const preview = await getLinkPreview().catch(() => ({}));
setCounts(preview);
const total = Object.values(preview).reduce((sum, count) => sum + count, 0);
if (total === 0) {
await finish(sessionToken, "merge");
return;
}
setStep("choose");
};
const localTotal = Object.values(counts).reduce(
(sum, count) => sum + count,
0,
);
const wide = step === "signin";
const title = relogin
? t("sync.wizard.reloginTitle")
: t("sync.wizard.title");
const description =
step === "server"
? t("sync.wizard.serverDescription")
: step === "signin"
? t("sync.wizard.signinDescription", { url: target.serverUrl })
: step === "choose"
? t("sync.wizard.chooseDescription")
: t("sync.wizard.finishingDescription");
const content = (
<>
{step === "server" && (
<form
className="flex flex-col gap-4"
onSubmit={(event) => {
event.preventDefault();
void handleCheck();
}}
>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold" htmlFor="sync-server-url">
{t("sync.wizard.serverUrl")}
</label>
<Input
id="sync-server-url"
autoFocus
className="rounded-none"
placeholder="https://termix.example.com"
value={target.serverUrl}
onChange={(event) =>
setTarget({ ...target, serverUrl: event.target.value })
}
/>
<p className="text-[10px] text-muted-foreground">
{t("sync.wizard.serverUrlHint")}
</p>
{!relogin && savedUrls.length > 0 && (
<div className="flex flex-col gap-1 pt-1">
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{t("sync.wizard.savedServers")}
</span>
{savedUrls.map((url) => (
<div
key={url}
className="flex items-center border border-border hover:bg-muted"
>
<button
type="button"
className="flex-1 min-w-0 truncate px-2 py-1.5 text-left font-mono text-xs"
onClick={() => setTarget({ ...target, serverUrl: url })}
>
{url}
</button>
<button
type="button"
title={t("sync.wizard.removeServer")}
className="px-2 py-1.5 text-muted-foreground hover:text-destructive"
onClick={() => setSavedUrls(forgetServerUrl(url))}
>
<X className="size-3" />
</button>
</div>
))}
</div>
)}
</div>
{problem && (
<div className="flex gap-2 border border-destructive/40 bg-destructive/10 p-2.5 text-xs">
<AlertCircle className="size-4 shrink-0 text-destructive" />
<span>{problem}</span>
</div>
)}
{(problemKind === "tls_untrusted" ||
target.allowInvalidCertificate) && (
<div className="flex items-center justify-between gap-3 border border-border p-2.5">
<div className="flex flex-col gap-0.5">
<span className="text-xs font-medium">
{t("sync.proxy.allowInvalidCertificate")}
</span>
<span className="text-[10px] text-muted-foreground">
{t("sync.proxy.allowInvalidCertificateHint")}
</span>
</div>
<Switch
checked={!!target.allowInvalidCertificate}
onCheckedChange={(checked) =>
setTarget({ ...target, allowInvalidCertificate: checked })
}
/>
</div>
)}
<button
type="button"
className="self-start text-[10px] font-bold uppercase tracking-widest text-muted-foreground hover:text-foreground"
onClick={() => setShowAdvanced((value) => !value)}
>
{showAdvanced
? t("sync.wizard.hideProxySettings")
: t("sync.wizard.showProxySettings")}
</button>
{showAdvanced && (
<ProxySettingsFields
customHeaders={target.customHeaders ?? []}
basicAuth={target.basicAuth ?? null}
onChange={(next) => setTarget({ ...target, ...next })}
/>
)}
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
className="rounded-none"
onClick={() => onOpenChange(false)}
>
{t("common.cancel")}
</Button>
<Button
variant="outline"
type="submit"
className="rounded-none border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10"
disabled={checking || !target.serverUrl.trim()}
>
{checking && <Loader2 className="size-4 animate-spin" />}
{t("sync.wizard.continue")}
</Button>
</div>
</form>
)}
{step === "signin" && (
<div className="flex-1 min-h-0">
<ElectronLoginForm
serverUrl={target.serverUrl}
targetPurpose="link"
onAuthSuccess={handleSignedIn}
onChangeServer={() =>
relogin ? onOpenChange(false) : setStep("server")
}
/>
</div>
)}
{step === "choose" && (
<div className="flex flex-col gap-3">
{finishError && (
<div className="flex gap-2 border border-destructive/40 bg-destructive/10 p-2.5 text-xs">
<AlertCircle className="size-4 shrink-0 text-destructive" />
<span>{finishError}</span>
</div>
)}
{(
[
{
value: "merge",
title: t("sync.wizard.mergeTitle"),
body: t("sync.wizard.mergeBody", { count: localTotal }),
},
{
value: "replace",
title: t("sync.wizard.replaceTitle"),
body: t("sync.wizard.replaceBody", { count: localTotal }),
},
] as const
).map((option) => (
<button
key={option.value}
type="button"
onClick={() => setMode(option.value)}
className={`text-left border p-3 flex flex-col gap-1 transition-colors ${
mode === option.value
? "border-accent-brand bg-accent-brand/10"
: "border-border hover:bg-muted/40"
}`}
>
<span className="text-sm font-semibold">{option.title}</span>
<span className="text-xs text-muted-foreground">
{option.body}
</span>
</button>
))}
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
className="rounded-none"
onClick={() => onOpenChange(false)}
>
{t("common.cancel")}
</Button>
<Button
variant="outline"
type="button"
className="rounded-none border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10"
disabled={!token}
onClick={() => token && void finish(token, mode)}
>
{t("sync.wizard.link")}
</Button>
</div>
</div>
)}
{step === "finishing" && (
<div className="flex items-center gap-3 py-6 text-sm text-muted-foreground">
<Loader2 className="size-5 animate-spin text-accent-brand" />
{t("sync.wizard.linking")}
</div>
)}
</>
);
// The wizard takes over the panel, tab or onboarding step it opens from.
return (
<InlineView
open={open}
onOpenChange={onOpenChange}
title={title}
width="wide"
scroll={!wide}
>
{!wide && (
<p className="text-xs text-muted-foreground">
{description} {step === "server" && <DocsLink core="desktopSync" />}
</p>
)}
{content}
</InlineView>
);
}
@@ -1,147 +0,0 @@
import { useTranslation } from "react-i18next";
import { Plus, Trash2 } from "lucide-react";
import { Input } from "@/components/input";
import { PasswordInput } from "@/components/password-input";
import { Switch } from "@/components/switch";
import type { SyncBasicAuth, SyncProxyHeader } from "@/api/sync-api";
interface ProxySettingsFieldsProps {
customHeaders: SyncProxyHeader[];
basicAuth: SyncBasicAuth | null;
onChange: (next: {
customHeaders?: SyncProxyHeader[];
basicAuth?: SyncBasicAuth | null;
}) => void;
}
/**
* What a reverse proxy in front of the server wants: extra headers (a
* Cloudflare Access service token, a gateway key) and basic auth.
*/
export function ProxySettingsFields({
customHeaders,
basicAuth,
onChange,
}: ProxySettingsFieldsProps) {
const { t } = useTranslation();
const setHeader = (index: number, patch: Partial<SyncProxyHeader>) => {
onChange({
customHeaders: customHeaders.map((header, i) =>
i === index ? { ...header, ...patch } : header,
),
});
};
return (
<div className="flex flex-col gap-4 border border-border p-3">
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-0.5">
<span className="text-xs font-semibold">
{t("sync.proxy.headersTitle")}
</span>
<span className="text-[10px] text-muted-foreground">
{t("sync.proxy.headersHint")}
</span>
</div>
{customHeaders.map((header, index) => (
<div key={index} className="flex items-center gap-2">
<Input
className="rounded-none h-8 text-xs"
placeholder={t("sync.proxy.headerName")}
value={header.name}
onChange={(event) =>
setHeader(index, { name: event.target.value })
}
/>
<PasswordInput
className="rounded-none h-8 text-xs"
placeholder={
header.set
? t("sync.proxy.savedValue")
: t("sync.proxy.headerValue")
}
value={header.value}
onChange={(event) =>
setHeader(index, { value: event.target.value })
}
/>
<button
type="button"
aria-label={t("sync.proxy.removeHeader")}
className="p-1.5 text-muted-foreground hover:text-destructive"
onClick={() =>
onChange({
customHeaders: customHeaders.filter((_, i) => i !== index),
})
}
>
<Trash2 className="size-3.5" />
</button>
</div>
))}
<button
type="button"
className="self-start flex items-center gap-1 text-[10px] font-bold uppercase tracking-widest text-muted-foreground hover:text-foreground"
onClick={() =>
onChange({
customHeaders: [...customHeaders, { name: "", value: "" }],
})
}
>
<Plus className="size-3" />
{t("sync.proxy.addHeader")}
</button>
</div>
<div className="flex flex-col gap-2 border-t border-border pt-3">
<div className="flex items-center justify-between gap-3">
<div className="flex flex-col gap-0.5">
<span className="text-xs font-semibold">
{t("sync.proxy.basicAuthTitle")}
</span>
<span className="text-[10px] text-muted-foreground">
{t("sync.proxy.basicAuthHint")}
</span>
</div>
<Switch
checked={!!basicAuth}
onCheckedChange={(checked) =>
onChange({
basicAuth: checked ? { username: "", password: "" } : null,
})
}
/>
</div>
{basicAuth && (
<div className="flex items-center gap-2">
<Input
className="rounded-none h-8 text-xs"
placeholder={t("sync.proxy.username")}
value={basicAuth.username}
onChange={(event) =>
onChange({
basicAuth: { ...basicAuth, username: event.target.value },
})
}
/>
<PasswordInput
className="rounded-none h-8 text-xs"
placeholder={
basicAuth.set
? t("sync.proxy.savedValue")
: t("sync.proxy.password")
}
value={basicAuth.password}
onChange={(event) =>
onChange({
basicAuth: { ...basicAuth, password: event.target.value },
})
}
/>
</div>
)}
</div>
</div>
);
}
-39
View File
@@ -1,39 +0,0 @@
// Same key as 2.8, so a desktop keeps the servers it already used.
const SAVED_URLS_KEY = "termix_saved_server_urls";
const MAX_SAVED_URLS = 5;
export function getSavedServerUrls(): string[] {
try {
const parsed = JSON.parse(localStorage.getItem(SAVED_URLS_KEY) ?? "[]");
return Array.isArray(parsed)
? parsed.filter((url): url is string => typeof url === "string")
: [];
} catch {
return [];
}
}
function write(urls: string[]): void {
try {
localStorage.setItem(SAVED_URLS_KEY, JSON.stringify(urls));
} catch {
// Storage can be unavailable.
}
}
export function rememberServerUrl(url: string): string[] {
const trimmed = url.trim().replace(/\/+$/, "");
if (!trimmed) return getSavedServerUrls();
const urls = [
trimmed,
...getSavedServerUrls().filter((saved) => saved !== trimmed),
].slice(0, MAX_SAVED_URLS);
write(urls);
return urls;
}
export function forgetServerUrl(url: string): string[] {
const urls = getSavedServerUrls().filter((saved) => saved !== url);
write(urls);
return urls;
}
@@ -11,7 +11,6 @@ import {
useState,
} from "react";
import { useIsMobile } from "@/hooks/use-mobile";
import { useSyncStatus } from "@/hooks/use-sync-status";
import { isElectron } from "@/lib/electron";
import {
useConfirm,
@@ -128,10 +127,6 @@ export function ShellControllerProvider({
openSingletonRef.current = tabsState.openSingletonTab;
const { tabs, activeTabId } = tabsState;
// Keeps the desktop's sync status polled while the app is open; a pass
// that changed data tells the panels to reload.
useSyncStatus();
const palette: ShellPalette = {
isOpen: paletteOpen,
open: () => setPaletteOpen(true),
-11
View File
@@ -1,5 +1,4 @@
import {
Cloud,
KeyRound,
Plug,
Server,
@@ -12,7 +11,6 @@ import {
import { useMemo, useSyncExternalStore } from "react";
import { usePermissions } from "@/hooks/use-permissions";
import { useAreaPreferences } from "@/contexts/UiPreferencesContext";
import { useSyncAttentionCount } from "@/hooks/use-sync-status";
import { isElectron } from "@/lib/electron";
import { createRegistry } from "@/lib/registry";
import { getPanel } from "@/shell/panel-registry";
@@ -111,15 +109,6 @@ export const RAIL_ITEMS: RailItemDef[] = [
mobilePrimary: true,
group: "tools",
},
{
id: "sync",
icon: Cloud,
labelKey: "nav.sync",
electronOnly: true,
placement: "footer",
group: "system",
useBadge: useSyncAttentionCount,
},
{
id: "plugins",
icon: Puzzle,
@@ -1,140 +0,0 @@
import { render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { ElectronLoginForm } from "../../auth/ElectronLoginForm";
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
describe("ElectronLoginForm", () => {
it("delegates WebAuthn permissions to the embedded login page", () => {
render(
<ElectronLoginForm
serverUrl="https://termix.example.com"
onAuthSuccess={vi.fn()}
onChangeServer={vi.fn()}
/>,
);
const permissions = screen
.getByTitle("auth.serverAuthentication")
.getAttribute("allow");
expect(permissions).toContain("publickey-credentials-get");
expect(permissions).toContain("publickey-credentials-create");
});
it.each([
"auth_component",
"second_factor_auth_component",
"method_auth_component",
])("accepts a login hand-off from %s", async (source) => {
const onAuthSuccess = vi.fn();
render(
<ElectronLoginForm
serverUrl="https://termix.example.com"
onAuthSuccess={onAuthSuccess}
onChangeServer={vi.fn()}
/>,
);
const frame = screen.getByTitle(
"auth.serverAuthentication",
) as HTMLIFrameElement;
window.dispatchEvent(
new MessageEvent("message", {
source: frame.contentWindow,
origin: "https://termix.example.com",
data: {
type: "AUTH_SUCCESS",
platform: "desktop",
source,
token: "jwt-1",
},
}),
);
await waitFor(() => expect(onAuthSuccess).toHaveBeenCalledWith("jwt-1"));
});
it("ignores messages once the frame has left the server's origin", async () => {
const onAuthSuccess = vi.fn();
const externalBrowserLogin = vi.fn();
(window as unknown as { electronAPI?: unknown }).electronAPI = {
externalBrowserLogin,
};
render(
<ElectronLoginForm
serverUrl="https://termix.example.com/"
onAuthSuccess={onAuthSuccess}
onChangeServer={vi.fn()}
/>,
);
const frame = screen.getByTitle(
"auth.serverAuthentication",
) as HTMLIFrameElement;
for (const data of [
{
type: "AUTH_SUCCESS",
platform: "desktop",
source: "auth_component",
token: "attacker-jwt",
},
{
type: "OIDC_SYSTEM_BROWSER_AUTH",
authUrl: "https://evil.example/",
callbackPort: 17832,
},
]) {
window.dispatchEvent(
new MessageEvent("message", {
source: frame.contentWindow,
origin: "https://evil.example",
data,
}),
);
}
await new Promise((resolve) => setTimeout(resolve, 0));
expect(onAuthSuccess).not.toHaveBeenCalled();
expect(externalBrowserLogin).not.toHaveBeenCalled();
delete (window as unknown as { electronAPI?: unknown }).electronAPI;
});
it("replies to the server's origin only", async () => {
(window as unknown as { electronAPI?: unknown }).electronAPI = {
externalBrowserLogin: vi.fn().mockResolvedValue({
success: false,
secondFactor: true,
tempToken: "temp-1",
}),
};
render(
<ElectronLoginForm
serverUrl="https://termix.example.com/"
onAuthSuccess={vi.fn()}
onChangeServer={vi.fn()}
/>,
);
const frame = screen.getByTitle(
"auth.serverAuthentication",
) as HTMLIFrameElement;
const postMessage = vi.spyOn(frame.contentWindow!, "postMessage");
window.dispatchEvent(
new MessageEvent("message", {
source: frame.contentWindow,
origin: "https://termix.example.com",
data: {
type: "OIDC_SYSTEM_BROWSER_AUTH",
authUrl: "https://idp.example.com/authorize",
callbackPort: 17832,
providerId: -1,
},
}),
);
await waitFor(() =>
expect(postMessage).toHaveBeenCalledWith(
expect.objectContaining({ tempToken: "temp-1" }),
"https://termix.example.com",
),
);
delete (window as unknown as { electronAPI?: unknown }).electronAPI;
});
});
-55
View File
@@ -1,55 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const getSyncStatus = vi.hoisted(() => vi.fn());
const notifySyncChanged = vi.hoisted(() => vi.fn());
vi.mock("@/lib/electron", () => ({ isElectron: () => true }));
vi.mock("@/api/sync-api", () => ({ getSyncStatus }));
vi.mock("@/lib/linked-server", () => ({ notifySyncChanged }));
const store = await import("@/lib/sync-status");
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
describe("sync status store", () => {
let unsubscribe: () => void = () => {};
const events: string[] = [];
const record = (event: Event) => events.push(event.type);
beforeEach(() => {
events.length = 0;
for (const type of ["hosts:refresh", "termix:hosts-changed"]) {
window.addEventListener(type, record);
}
});
afterEach(() => {
unsubscribe();
for (const type of ["hosts:refresh", "termix:hosts-changed"]) {
window.removeEventListener(type, record);
}
});
it("polls while subscribed and tells views to reload after a pass", async () => {
getSyncStatus.mockResolvedValue({
linked: true,
lastSyncAt: "2026-09-25T10:00:00Z",
entities: [],
});
const listener = vi.fn();
unsubscribe = store.subscribeSyncStatus(listener);
await flush();
expect(store.getSyncStatusSnapshot()?.linked).toBe(true);
expect(listener).toHaveBeenCalled();
// The first read only sets the baseline.
expect(events).toEqual([]);
store.setSyncStatus({
linked: true,
lastSyncAt: "2026-09-25T10:05:00Z",
entities: [],
});
expect(events).toEqual(["hosts:refresh", "termix:hosts-changed"]);
expect(notifySyncChanged).toHaveBeenCalled();
});
});
@@ -83,18 +83,11 @@ describe("CORE_STEPS", () => {
expect(CORE_STEPS.find((s) => s.id === "plugins")?.required).toBe(true);
});
it("asks how the desktop app is used only on the desktop", () => {
expect(ids(ctx({ isDesktop: true }))).toEqual(["desktop-sync"]);
});
it("asks how the desktop app updates when it has an updater", () => {
const win = window as unknown as { electronAPI?: unknown };
win.electronAPI = { isElectron: true, updates: {} };
try {
expect(ids(ctx({ isDesktop: true }))).toEqual([
"desktop-sync",
"desktop-updates",
]);
expect(ids(ctx({ isDesktop: true }))).toEqual(["desktop-updates"]);
expect(ids(ctx())).not.toContain("desktop-updates");
} finally {
delete win.electronAPI;
@@ -1,31 +0,0 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("@/api/sync-api", () => ({
probeServer: vi.fn(),
getLinkPreview: vi.fn(),
completeLink: vi.fn(),
reloginLink: vi.fn(),
}));
vi.mock("@/auth/ElectronLoginForm", () => ({ ElectronLoginForm: () => null }));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
const { SurfaceScope } = await import("@/components/surface/surface-scope");
const { LinkServerDialog } = await import("@/settings/sync/LinkServerDialog");
afterEach(cleanup);
describe("LinkServerDialog", () => {
it("opens inline in the surface instead of a popup", () => {
render(
<SurfaceScope kind="tab">
<LinkServerDialog open onOpenChange={() => {}} onLinked={() => {}} />
</SurfaceScope>,
);
expect(screen.getByText("sync.wizard.title")).toBeTruthy();
expect(screen.queryByRole("dialog")).toBeNull();
});
});
@@ -1,29 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import {
forgetServerUrl,
getSavedServerUrls,
rememberServerUrl,
} from "@/settings/sync/saved-servers";
afterEach(() => localStorage.clear());
describe("saved server urls", () => {
it("keeps the newest five, without duplicates or trailing slashes", () => {
for (let i = 1; i <= 6; i++) rememberServerUrl(`https://s${i}.example/`);
rememberServerUrl("https://s4.example");
expect(getSavedServerUrls()).toEqual([
"https://s4.example",
"https://s6.example",
"https://s5.example",
"https://s3.example",
"https://s2.example",
]);
});
it("forgets a server and survives bad storage", () => {
rememberServerUrl("https://a.example");
expect(forgetServerUrl("https://a.example")).toEqual([]);
localStorage.setItem("termix_saved_server_urls", "{broken");
expect(getSavedServerUrls()).toEqual([]);
});
});
@@ -15,10 +15,6 @@ const api = vi.hoisted(() => ({
createSSHHost: vi.fn(async () => ({})),
}));
vi.mock("@/main-axios", () => api);
vi.mock("@/hooks/use-sync-status", () => ({
useSyncStatus: () => undefined,
useSyncAttentionCount: () => 0,
}));
import {
ShellControllerProvider,
@@ -15,10 +15,6 @@ vi.mock("@/main-axios", () => ({
patchOpenTab: vi.fn(async () => {}),
createSSHHost: vi.fn(async () => ({})),
}));
vi.mock("@/hooks/use-sync-status", () => ({
useSyncStatus: () => undefined,
useSyncAttentionCount: () => 0,
}));
vi.mock("@/hooks/use-permissions", () => ({
usePermissions: () => ({
permissions: ["hosts.view"],
@@ -14,10 +14,6 @@ vi.mock("@/main-axios", () => ({
patchOpenTab: vi.fn(async () => {}),
createSSHHost: vi.fn(async () => ({})),
}));
vi.mock("@/hooks/use-sync-status", () => ({
useSyncStatus: () => undefined,
useSyncAttentionCount: () => 0,
}));
const lookApi = vi.hoisted(() => ({
getLook: vi.fn(),
@@ -15,10 +15,6 @@ vi.mock("@/main-axios", () => ({
patchOpenTab: vi.fn(async () => {}),
createSSHHost: vi.fn(async () => ({})),
}));
vi.mock("@/hooks/use-sync-status", () => ({
useSyncStatus: () => undefined,
useSyncAttentionCount: () => 0,
}));
const lookApi = vi.hoisted(() => ({
getLook: vi.fn(),
saveUserLook: vi.fn(async () => {}),
@@ -15,10 +15,6 @@ vi.mock("@/main-axios", () => ({
patchOpenTab: vi.fn(async () => {}),
createSSHHost: vi.fn(async () => ({})),
}));
vi.mock("@/hooks/use-sync-status", () => ({
useSyncStatus: () => undefined,
useSyncAttentionCount: () => 0,
}));
import type { TabRenderProps } from "@/shell/tab-registry";
import { registerTabType } from "@/shell/tab-registry";
+2 -2
View File
@@ -58,13 +58,13 @@ describe("RAIL_ITEMS", () => {
// "port-forwarding" to the tunnels plugin's, "serial" to the serial
// plugin's, "collab" to the session-sharing plugin's, and "session-logs"
// to the session-recording plugin's, "termix-id" to the termix-identity
// plugin's, "ssh-tools" and "macros" to the ssh-terminal plugin's.
// plugin's, "ssh-tools" and "macros" to the ssh-terminal plugin's, and "sync" to
// the sync plugin's.
expect(RAIL_ITEMS.map((item) => item.id)).toEqual([
"hosts",
"credentials",
"connections",
"quick-connect",
"sync",
"plugins",
]);
});