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

+25 -58
View File
@@ -86,6 +86,11 @@ const UserProfilePanel = lazy(() =>
default: m.UserProfilePanel,
})),
);
const SyncPanel = lazy(() =>
import("@/settings/sync/SyncPanel").then((m) => ({
default: m.SyncPanel,
})),
);
const AdminSettingsPanel = lazy(() =>
import("@/sidebar/AdminSettingsPanel").then((m) => ({
default: m.AdminSettingsPanel,
@@ -136,7 +141,6 @@ import {
getUserPreferences,
saveUserPreferences,
dismissDonationModal,
isElectron,
type UserPreferences,
type OpenTabRecord,
} from "@/main-axios";
@@ -147,8 +151,8 @@ import {
snapshotData,
} from "@/shell/shell-layout";
import { DonationReminderModal } from "@/user/DonationReminderModal.tsx";
import { RemoteSyncBanner } from "@/components/RemoteSyncBanner.tsx";
import { MigrationNoticeDialog } from "@/components/MigrationNoticeDialog.tsx";
import { useSyncStatus } from "@/hooks/use-sync-status";
import { rem, remScale } from "@/lib/rem";
import { dbHealthMonitor } from "@/lib/db-health-monitor";
import { ServerStatusProvider } from "@/lib/ServerStatusContext";
import { sshHostToHost } from "@/sidebar/HostManagerData";
@@ -298,11 +302,6 @@ export function AppShell({
const [sidebarOpen, setSidebarOpen] = useState(true);
const [railView, setRailView] = useState<RailView>("hosts");
const [remoteSyncInitialServerUrl, setRemoteSyncInitialServerUrl] = useState<
string | undefined
>(undefined);
const [remoteSyncReconnectRequested, setRemoteSyncReconnectRequested] =
useState(false);
const [sidebarWidth, setSidebarWidth] = useState(() => {
const saved = localStorage.getItem("termix_sidebarWidth");
return saved ? parseInt(saved, 10) : 291;
@@ -1094,6 +1093,7 @@ export function AppShell({
icon?: string;
credentialId?: number | null;
sortOrder?: number | null;
localOnly?: boolean;
}
>();
for (const f of folders) {
@@ -1102,6 +1102,7 @@ export function AppShell({
icon: f.icon ?? undefined,
credentialId: f.credentialId ?? null,
sortOrder: f.sortOrder ?? null,
localOnly: !!f.localOnly,
});
}
setRealHostTree(buildHostTree(raw, folderMeta));
@@ -1131,27 +1132,9 @@ export function AppShell({
};
}, [loadHosts]);
// The Electron main process runs remote sync (pull/push hosts and
// credentials with a connected Termix server) on its own timer, entirely
// outside any renderer-initiated action, so nothing normally dispatches
// the termix:hosts-changed / termix:credentials-changed events that
// panels rely on to refetch. Without this, newly-synced hosts/credentials
// only show up after a manual refresh or app restart.
useEffect(() => {
if (!isElectron()) return;
let wasSyncing = false;
const unsubscribe = window.electronAPI?.onRemoteSyncStatusChanged?.(
(status: { syncing: boolean; lastError: string | null }) => {
const justFinished = wasSyncing && !status.syncing && !status.lastError;
wasSyncing = status.syncing;
if (justFinished) {
window.dispatchEvent(new CustomEvent("termix:hosts-changed"));
window.dispatchEvent(new CustomEvent("termix:credentials-changed"));
}
},
);
return () => unsubscribe?.();
}, []);
// Keeps the desktop's sync status polled while the app is open; a pass
// that changed data tells the panels to reload.
useSyncStatus();
// Sync tab host data when allHosts updates (e.g. after editing terminal theme in host settings)
useEffect(() => {
@@ -2067,9 +2050,11 @@ export function AppShell({
setSidebarDragging(true);
const startX = e.clientX;
const startW = sidebarWidth;
// Widths are kept in Normal-size pixels, so a drag is scaled back.
const scale = remScale();
function onMove(ev: MouseEvent) {
setSidebarWidth(
Math.max(160, Math.min(480, startW + ev.clientX - startX)),
Math.max(160, Math.min(480, startW + (ev.clientX - startX) / scale)),
);
}
function onUp() {
@@ -2090,9 +2075,10 @@ export function AppShell({
setRightSidebarDragging(true);
const startX = e.clientX;
const startW = rightSidebarWidth;
const scale = remScale();
function onMove(ev: MouseEvent) {
setRightSidebarWidth(
Math.max(160, Math.min(480, startW - (ev.clientX - startX))),
Math.max(160, Math.min(480, startW - (ev.clientX - startX) / scale)),
);
}
function onUp() {
@@ -2496,15 +2482,16 @@ export function AppShell({
onPrefsChange={(updates) =>
setUserPrefs((current) => ({ ...current, ...updates }))
}
remoteSyncInitialServerUrl={remoteSyncInitialServerUrl}
remoteSyncReconnectRequested={remoteSyncReconnectRequested}
onRemoteSyncReconnectHandled={() =>
setRemoteSyncReconnectRequested(false)
}
/>
</div>
)}
{railView === "sync" && (
<div className="flex-1 min-h-0 overflow-y-auto">
<SyncPanel />
</div>
)}
{railView === "admin-settings" && showAdminUI && (
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
<AdminSettingsPanel
@@ -2630,24 +2617,6 @@ export function AppShell({
className="flex flex-col w-screen bg-background"
style={{ height: "100dvh" }}
>
{isElectron() && (
<>
<RemoteSyncBanner
onReconnect={() => {
setRailView("user-profile");
if (!sidebarOpen) setSidebarOpen(true);
setRemoteSyncReconnectRequested(true);
}}
/>
<MigrationNoticeDialog
onOpenRemoteSync={(url) => {
setRemoteSyncInitialServerUrl(url);
setRailView("user-profile");
if (!sidebarOpen) setSidebarOpen(true);
}}
/>
</>
)}
<div className="flex flex-1 min-h-0">
{/* Skinny icon rail — desktop only, hidden on mobile */}
{!settingsFullscreen && (
@@ -2673,9 +2642,7 @@ export function AppShell({
width: settingsFullscreen
? "100vw"
: sidebarOpen
? sidebarEditing
? 560
: sidebarWidth
? rem(sidebarEditing ? 560 : sidebarWidth)
: 0,
transition: sidebarDragging ? "none" : "width 0.2s",
}}
@@ -2836,7 +2803,7 @@ export function AppShell({
<div
className={`relative flex flex-col min-h-0 bg-sidebar shrink-0 overflow-hidden border-l transition-colors ${rightSidebarDragging ? "border-accent-brand/60" : "border-border"}`}
style={{
width: rightSidebarWidth,
width: rem(rightSidebarWidth),
transition: rightSidebarDragging ? "none" : "width 0.2s",
}}
>
+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;
}
+57 -64
View File
@@ -59,6 +59,7 @@ import {
shouldTriggerSilentSignin,
} from "./silent-signin";
import { Select2 } from "@/components/select2";
import { cn } from "@/lib/utils";
const LANGUAGES = [
{ code: "en", label: "English" },
@@ -98,6 +99,38 @@ const LANGUAGES = [
{ code: "vi", label: "Tiếng Việt" },
];
function LanguageRow({
label,
value,
onChange,
className,
}: {
label: string;
value: string;
onChange: (code: string) => void;
className?: string;
}) {
return (
<div className={cn("flex items-center justify-between gap-4", className)}>
<span className="text-xs text-muted-foreground shrink-0">{label}</span>
<Select2
value={value}
onChange={(e) => onChange(e.target.value)}
aria-label={label}
align="end"
contentClassName="w-56"
className="h-8 w-40 shrink-0 px-2.5 text-xs"
>
{LANGUAGES.map((lang) => (
<option key={lang.code} value={lang.code}>
{lang.label}
</option>
))}
</Select2>
</div>
);
}
const STORAGE_KEY = "termix_auth";
/** An empty list means "whichever factors this screen has UI for". */
@@ -1118,22 +1151,12 @@ export function Auth({ onLogin }: AuthProps) {
</p>
<p className="text-sm text-muted-foreground">{detail}</p>
</div>
<div className="flex items-center justify-between pt-2 border-t border-border">
<span className="text-xs text-muted-foreground">
{t("common.language")}
</span>
<select
value={language}
onChange={(e) => handleLanguageChange(e.target.value)}
className="px-2.5 py-1.5 text-xs bg-background border border-border text-foreground outline-none focus:ring-1 focus:ring-ring"
>
{LANGUAGES.map((lang) => (
<option key={lang.code} value={lang.code}>
{lang.label}
</option>
))}
</select>
</div>
<LanguageRow
label={t("common.language")}
value={language}
onChange={handleLanguageChange}
className="pt-2 border-t border-border"
/>
</div>
</div>
);
@@ -1176,22 +1199,12 @@ export function Auth({ onLogin }: AuthProps) {
<Button onClick={() => window.location.reload()}>
{t("common.refresh")}
</Button>
<div className="flex items-center justify-between pt-2 border-t border-border">
<span className="text-xs text-muted-foreground">
{t("common.language")}
</span>
<Select2
value={language}
onChange={(e) => handleLanguageChange(e.target.value)}
className="px-2.5 py-1.5 text-xs bg-background border border-border text-foreground outline-none focus:ring-1 focus:ring-ring"
>
{LANGUAGES.map((lang) => (
<option key={lang.code} value={lang.code}>
{lang.label}
</option>
))}
</Select2>
</div>
<LanguageRow
label={t("common.language")}
value={language}
onChange={handleLanguageChange}
className="pt-2 border-t border-border"
/>
</div>
</div>
);
@@ -1253,22 +1266,12 @@ export function Auth({ onLogin }: AuthProps) {
: "Retry local desktop session"}
</Button>
<Separator />
<div className="flex items-center justify-between pt-1">
<span className="text-xs text-muted-foreground">
{t("common.language")}
</span>
<Select2
value={language}
onChange={(e) => handleLanguageChange(e.target.value)}
className="px-2.5 py-1.5 text-xs bg-background border border-border text-foreground outline-none focus:ring-1 focus:ring-ring"
>
{LANGUAGES.map((lang) => (
<option key={lang.code} value={lang.code}>
{lang.label}
</option>
))}
</Select2>
</div>
<LanguageRow
label={t("common.language")}
value={language}
onChange={handleLanguageChange}
className="pt-1"
/>
</div>
</div>
</div>
@@ -1746,22 +1749,12 @@ export function Auth({ onLogin }: AuthProps) {
</>
) : null}
</p>
<div className="flex items-center justify-between pt-1">
<span className="text-xs text-muted-foreground">
{t("common.language")}
</span>
<Select2
value={language}
onChange={(e) => handleLanguageChange(e.target.value)}
className="px-2.5 py-1.5 text-xs bg-background border border-border text-foreground outline-none focus:ring-1 focus:ring-ring"
>
{LANGUAGES.map((lang) => (
<option key={lang.code} value={lang.code}>
{lang.label}
</option>
))}
</Select2>
</div>
<LanguageRow
label={t("common.language")}
value={language}
onChange={handleLanguageChange}
className="pt-1"
/>
</div>
)}
</div>
+8 -41
View File
@@ -8,22 +8,11 @@ interface ElectronLoginFormProps {
serverUrl: string;
onAuthSuccess: (token: string | null) => void | Promise<void>;
onChangeServer: () => void;
// "local" (default): the app's own login, JWT goes to localStorage like
// every other client. "remoteSync": this iframe is authenticating a
// Settings-triggered connection to a remote Termix server for the sync
// engine -- the JWT is handed to the Electron main process's encrypted
// store instead, never exposed to the renderer's localStorage.
targetPurpose?: "local" | "remoteSync";
}
interface SaveRemoteSyncJwtResult {
success: boolean;
reason?: string;
error?: string;
status?: {
needsReauth?: boolean;
lastError?: string | null;
} | null;
// "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([
@@ -61,31 +50,10 @@ export function ElectronLoginForm({
setIsAuthenticating(true);
try {
if (targetPurpose === "remoteSync") {
if (targetPurpose === "link") {
if (!token) {
throw new Error(t("errors.authTokenMissing"));
}
// The main process refuses to persist the JWT when it has no OS
// keyring to encrypt it with, and reports that by resolving with
// success: false. Dropping the result signs the user in against a
// store that kept nothing, so the next sync tick calls a session
// that was never saved expired.
const result = (await window.electronAPI?.invoke?.(
"save-remote-sync-jwt",
token,
)) as SaveRemoteSyncJwtResult | undefined;
if (!result?.success) {
throw new Error(
result?.reason === "encryption_unavailable"
? t("errors.keyringUnavailable")
: result?.error || t("errors.authTokenSaveFailed"),
);
}
if (result.status?.needsReauth || result.status?.lastError) {
throw new Error(
result.status.lastError || t("errors.authTokenRejected"),
);
}
} else if (token) {
localStorage.setItem("jwt", token);
}
@@ -264,7 +232,6 @@ export function ElectronLoginForm({
};
const displayUrl = currentUrl.replace(/^https?:\/\//, "");
const isEmbeddedServer = serverUrl.includes("localhost:30001");
return (
<div className="relative w-full h-full bg-background flex flex-col">
@@ -282,12 +249,12 @@ export function ElectronLoginForm({
>
<ArrowLeft className="h-5 w-5" />
<span className="text-base font-medium">
{t("serverConfig.changeServer")}
{t("sync.wizard.back")}
</span>
</button>
<div className="flex-1 mx-4 text-center">
<span className="text-muted-foreground text-sm truncate block">
{isEmbeddedServer ? t("serverConfig.localServer") : displayUrl}
{displayUrl}
</span>
</div>
<button
-107
View File
@@ -1,107 +0,0 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Server } from "lucide-react";
import { isElectron } from "@/lib/electron";
import { Button } from "@/components/button.tsx";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/dialog.tsx";
type DesktopSettings = {
defaultConnectionOrigin: "local" | "remote";
migrationNoticeAcknowledged?: boolean;
};
// One-time notice for Electron installs that previously pointed the whole
// app at a remote Termix server (the pre-2.6.0 architecture). That install
// now runs a fully local, standalone backend by default -- the hosts,
// credentials, and snippets that lived on the old remote server won't show
// up here until the user explicitly turns on Remote Sync and reconnects to
// that same server. A fresh install never had a legacy serverUrl, so this
// never fires for anyone who didn't go through the old flow, and it only
// ever shows once per install (tracked in desktop-settings.json).
export function MigrationNoticeDialog({
onOpenRemoteSync,
}: {
onOpenRemoteSync: (serverUrl: string) => void;
}) {
const { t } = useTranslation();
const [legacyServerUrl, setLegacyServerUrl] = useState<string | null>(null);
const [open, setOpen] = useState(false);
useEffect(() => {
if (!isElectron()) return;
let cancelled = false;
Promise.all([
window.electronAPI?.invoke?.("get-legacy-server-config") as Promise<{
serverUrl: string | null;
} | null>,
window.electronAPI?.invoke?.("get-desktop-settings") as Promise<
DesktopSettings | undefined
>,
])
.then(([legacyConfig, settings]) => {
if (cancelled) return;
const serverUrl = legacyConfig?.serverUrl || null;
if (serverUrl && !settings?.migrationNoticeAcknowledged) {
setLegacyServerUrl(serverUrl);
setOpen(true);
}
})
.catch(() => {});
return () => {
cancelled = true;
};
}, []);
const acknowledge = async () => {
setOpen(false);
const settings = ((await window.electronAPI?.invoke?.(
"get-desktop-settings",
)) as DesktopSettings | undefined) ?? { defaultConnectionOrigin: "local" };
await window.electronAPI?.invoke?.("save-desktop-settings", {
...settings,
migrationNoticeAcknowledged: true,
});
};
const handleSetUpSync = async () => {
const url = legacyServerUrl || "";
await acknowledge();
onOpenRemoteSync(url);
};
if (!legacyServerUrl) return null;
return (
<Dialog open={open} onOpenChange={(next) => !next && acknowledge()}>
<DialogContent className="bg-card border border-border max-w-lg">
<DialogHeader>
<div className="flex items-center gap-2">
<Server className="size-4 text-accent-brand" />
<DialogTitle>{t("migrationNotice.title")}</DialogTitle>
</div>
<DialogDescription className="text-sm text-foreground/90 pt-2 space-y-2">
<p>{t("migrationNotice.body1")}</p>
<p>{t("migrationNotice.body2", { url: legacyServerUrl })}</p>
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex items-center gap-2 sm:justify-between">
<Button variant="outline" size="sm" onClick={acknowledge}>
{t("migrationNotice.dismiss")}
</Button>
<Button size="sm" onClick={handleSetUpSync}>
{t("migrationNotice.setUpSync")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
-50
View File
@@ -1,50 +0,0 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { AlertTriangle } from "lucide-react";
import { isElectron } from "@/lib/electron";
interface RemoteSyncStatus {
connected: boolean;
syncing: boolean;
lastSyncedAt: string | null;
lastError: string | null;
needsReauth: boolean;
}
// Non-blocking banner shown when a connected remote sync server needs
// re-authentication. Never gates or hides any other UI -- the local app
// keeps working fully regardless of remote sync state.
export function RemoteSyncBanner({ onReconnect }: { onReconnect: () => void }) {
const { t } = useTranslation();
const [status, setStatus] = useState<RemoteSyncStatus | null>(null);
useEffect(() => {
if (!isElectron()) return;
window.electronAPI
?.invoke?.("get-remote-sync-status")
.then((s) => setStatus((s as RemoteSyncStatus) ?? null))
.catch(() => {});
const unsubscribe = window.electronAPI?.onRemoteSyncStatusChanged?.(
(nextStatus: RemoteSyncStatus) => setStatus(nextStatus),
);
return () => unsubscribe?.();
}, []);
if (!status?.connected || !status.needsReauth) return null;
return (
<div className="flex items-center justify-between gap-3 px-4 py-2 bg-yellow-500/10 border-b border-yellow-500/30 text-xs">
<div className="flex items-center gap-2">
<AlertTriangle className="size-3.5 text-yellow-600 dark:text-yellow-400 shrink-0" />
<span>{t("remoteSync.bannerMessage")}</span>
</div>
<button
type="button"
onClick={onReconnect}
className="font-bold text-accent-brand hover:text-accent-brand/70 transition-colors shrink-0"
>
{t("remoteSync.bannerReconnect")}
</button>
</div>
);
}
+15 -7
View File
@@ -1,3 +1,4 @@
import { rem, remScale } from "@/lib/rem";
import {
useCallback,
useEffect,
@@ -212,7 +213,8 @@ function MasonryTile({
const el = innerRef.current;
if (!el) return;
const report = () => {
const h = fixedHeight ?? el.getBoundingClientRect().height;
// Row units are Normal-size pixels; a measured height is scaled back.
const h = fixedHeight ?? el.getBoundingClientRect().height / remScale();
onReportRows(heightToRowSpan(h, ROW_UNIT, ROW_GAP));
};
report();
@@ -247,7 +249,7 @@ function MasonryTile({
"min-h-0",
fixedHeight != null && "overflow-hidden [&>*]:h-full",
)}
style={fixedHeight != null ? { height: fixedHeight } : undefined}
style={fixedHeight != null ? { height: rem(fixedHeight) } : undefined}
>
{children}
</div>
@@ -358,10 +360,16 @@ export function CardGridCanvas({
e.preventDefault();
e.stopPropagation();
const startY = e.clientY;
// Heights are stored in Normal-size pixels, whatever the interface size.
const scale = remScale();
const startH =
tileRefs.current.get(id)?.getBoundingClientRect().height ?? 120;
(tileRefs.current.get(id)?.getBoundingClientRect().height ??
120 * scale) / scale;
const onMove = (ev: globalThis.MouseEvent) => {
onChange(setHeight(slots, id, startH + (ev.clientY - startY)), columns);
onChange(
setHeight(slots, id, startH + (ev.clientY - startY) / scale),
columns,
);
};
const onUp = () => {
window.removeEventListener("mousemove", onMove);
@@ -396,10 +404,10 @@ export function CardGridCanvas({
className="grid items-start"
style={{
gridTemplateColumns: `repeat(${effectiveColumns}, minmax(0, 1fr))`,
gridAutoRows: `${ROW_UNIT}px`,
gridAutoRows: rem(ROW_UNIT),
gridAutoFlow: "row dense",
columnGap: `${ROW_GAP}px`,
rowGap: `${ROW_GAP}px`,
columnGap: rem(ROW_GAP),
rowGap: rem(ROW_GAP),
}}
>
{sorted.map((slot) => {
+9 -2
View File
@@ -21,6 +21,8 @@ type Select2Props = Omit<
"value" | "defaultValue" | "onChange" | "required"
> & {
placeholder?: string;
align?: "start" | "center" | "end";
contentClassName?: string;
};
function getText(children: React.ReactNode): string {
@@ -85,6 +87,8 @@ function Select2({
onChange,
disabled,
placeholder,
align = "start",
contentClassName,
name,
id,
required,
@@ -171,8 +175,11 @@ function Select2({
</button>
</PopoverTrigger>
<PopoverContent
align="start"
className="z-[99999] w-[var(--radix-popover-trigger-width)] min-w-52 p-0"
align={align}
className={cn(
"z-[99999] w-[var(--radix-popover-trigger-width)] min-w-52 p-0",
contentClassName,
)}
>
<div className="flex items-center border-b border-border px-2">
<Search className="mr-2 size-4 shrink-0 text-muted-foreground" />
-124
View File
@@ -1,124 +0,0 @@
import React from "react";
import { Alert, AlertTitle, AlertDescription } from "@/components/alert.tsx";
import { Button } from "@/components/button.tsx";
import { ExternalLink, Download, AlertTriangle, Info } from "lucide-react";
import { useTranslation } from "react-i18next";
interface VersionAlertProps {
updateInfo: {
success: boolean;
status?: "up_to_date" | "requires_update" | "beta";
localVersion?: string;
remoteVersion?: string;
latest_release?: {
tag_name: string;
name: string;
published_at: string;
html_url: string;
body: string;
};
cached?: boolean;
cache_age?: number;
error?: string;
};
onDownload?: () => void;
}
export function VersionAlert({ updateInfo, onDownload }: VersionAlertProps) {
const { t } = useTranslation();
if (!updateInfo.success) {
return (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>{t("versionCheck.error")}</AlertTitle>
<AlertDescription>
{updateInfo.error || t("versionCheck.checkFailed")}
</AlertDescription>
</Alert>
);
}
if (updateInfo.status === "up_to_date") {
return (
<Alert>
<Download className="h-4 w-4" />
<AlertTitle>{t("versionCheck.upToDate")}</AlertTitle>
<AlertDescription>
{t("versionCheck.currentVersion", {
version: updateInfo.localVersion,
})}
</AlertDescription>
</Alert>
);
}
if (updateInfo.status === "beta") {
return (
<Alert>
<Info className="h-4 w-4" />
<AlertTitle>{t("versionCheck.betaVersion")}</AlertTitle>
<AlertDescription>
{t("versionCheck.betaVersionDesc", {
current: updateInfo.localVersion,
latest: updateInfo.remoteVersion,
})}
</AlertDescription>
</Alert>
);
}
if (updateInfo.status === "requires_update") {
return (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>{t("versionCheck.updateAvailable")}</AlertTitle>
<AlertDescription className="space-y-3">
<div>
{t("versionCheck.newVersionAvailable", {
current: updateInfo.localVersion,
latest: updateInfo.remoteVersion,
})}
</div>
{updateInfo.latest_release && (
<div className="text-sm text-muted-foreground">
<div className="font-medium">
{updateInfo.latest_release.name}
</div>
<div className="text-xs">
{t("versionCheck.releasedOn", {
date: new Date(
updateInfo.latest_release.published_at,
).toLocaleDateString(),
})}
</div>
</div>
)}
<div className="flex gap-2 pt-2">
{updateInfo.latest_release?.html_url && (
<Button
variant="outline"
size="sm"
onClick={() => {
if (onDownload) {
onDownload();
} else {
window.open(updateInfo.latest_release!.html_url, "_blank");
}
}}
className="flex items-center gap-1"
>
<ExternalLink className="h-3 w-3" />
{t("versionCheck.downloadUpdate")}
</Button>
)}
</div>
</AlertDescription>
</Alert>
);
}
return null;
}
+3 -5
View File
@@ -1129,11 +1129,9 @@ export function DashboardTab({
getUserInfo()
.then((info) => {
// Remote sync is not yet configurable (added in a later phase), so
// a standalone desktop install never shows admin/user-management
// UI -- it has exactly one implicit user and nothing to administer.
const isRemoteSyncConnected = false;
setIsAdmin(!!info.is_admin && (!isElectron() || isRemoteSyncConnected));
// A desktop on its own has one implicit user and nothing to
// administer; linked, it is an admin when the server account is.
setIsAdmin(isElectron() ? !!info.linked?.isAdmin : !!info.is_admin);
})
.catch(() => {});
getUptime()
+22
View File
@@ -0,0 +1,22 @@
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;
}
+126
View File
@@ -484,6 +484,132 @@
}
}
/* Icons sized with the size prop carry width/height attributes in px, which
the interface size setting cannot reach. These restate them in CSS, where
they become rem like every other length. A size class on the icon wins. */
@layer base {
svg.lucide[width="8"] {
width: 8px;
height: 8px;
}
svg.lucide[width="9"] {
width: 9px;
height: 9px;
}
svg.lucide[width="10"] {
width: 10px;
height: 10px;
}
svg.lucide[width="11"] {
width: 11px;
height: 11px;
}
svg.lucide[width="12"] {
width: 12px;
height: 12px;
}
svg.lucide[width="13"] {
width: 13px;
height: 13px;
}
svg.lucide[width="14"] {
width: 14px;
height: 14px;
}
svg.lucide[width="15"] {
width: 15px;
height: 15px;
}
svg.lucide[width="16"] {
width: 16px;
height: 16px;
}
svg.lucide[width="17"] {
width: 17px;
height: 17px;
}
svg.lucide[width="18"] {
width: 18px;
height: 18px;
}
svg.lucide[width="19"] {
width: 19px;
height: 19px;
}
svg.lucide[width="20"] {
width: 20px;
height: 20px;
}
svg.lucide[width="21"] {
width: 21px;
height: 21px;
}
svg.lucide[width="22"] {
width: 22px;
height: 22px;
}
svg.lucide[width="23"] {
width: 23px;
height: 23px;
}
svg.lucide[width="24"] {
width: 24px;
height: 24px;
}
svg.lucide[width="25"] {
width: 25px;
height: 25px;
}
svg.lucide[width="26"] {
width: 26px;
height: 26px;
}
svg.lucide[width="27"] {
width: 27px;
height: 27px;
}
svg.lucide[width="28"] {
width: 28px;
height: 28px;
}
svg.lucide[width="29"] {
width: 29px;
height: 29px;
}
svg.lucide[width="30"] {
width: 30px;
height: 30px;
}
svg.lucide[width="31"] {
width: 31px;
height: 31px;
}
svg.lucide[width="32"] {
width: 32px;
height: 32px;
}
svg.lucide[width="36"] {
width: 36px;
height: 36px;
}
svg.lucide[width="40"] {
width: 40px;
height: 40px;
}
svg.lucide[width="48"] {
width: 48px;
height: 48px;
}
svg.lucide[width="64"] {
width: 64px;
height: 64px;
}
svg.lucide[width="96"] {
width: 96px;
height: 96px;
}
}
/* Font size scale applied to html element */
html.fs-xs {
font-size: 12px;
+6 -17
View File
@@ -1,5 +1,6 @@
import { isElectron } from "@/lib/electron";
import { websocketAuthProtocols } from "@/lib/ws-auth";
import { getLinkedSession } from "@/lib/linked-server";
export type ConnectionOrigin = "local" | "remote";
@@ -10,7 +11,7 @@ interface OriginResolvableHost {
/**
* Resolves which backend a given host's interactive connection (SSH, Docker
* console, RDP/VNC/Telnet) should dial: the desktop app's embedded local
* backend, or a connected remote sync server. A plugin whose connection is
* backend, or the server it is linked to. A plugin whose connection is
* always local regardless of this setting (serial: the hardware is
* physically attached to this desktop machine) skips this helper entirely
* and calls app.wsUrl() without an origin option, which defaults to "local".
@@ -55,26 +56,14 @@ export interface RemoteConnectionTarget {
}
async function getRemoteConnectionTarget(): Promise<RemoteConnectionTarget | null> {
try {
const [config, jwt] = await Promise.all([
window.electronAPI?.invoke?.("get-remote-sync-config") as Promise<{
serverUrl?: string;
} | null>,
window.electronAPI?.invoke?.("get-remote-sync-jwt") as Promise<
string | null
>,
]);
if (!config?.serverUrl) return null;
return { serverUrl: config.serverUrl, jwt: jwt ?? null };
} catch {
return null;
}
const linked = await getLinkedSession();
return linked ? { serverUrl: linked.serverUrl, jwt: linked.token } : null;
}
/**
* Builds the base WebSocket URL for an interactive connection protocol,
* given a resolved origin. Returns null when origin is "remote" but no
* remote server is connected -- callers must show a blocking message
* given a resolved origin. Returns null when origin is "remote" but this
* desktop is not linked to a server -- callers must show a blocking message
* rather than attempting to connect.
*/
export interface WebSocketConnectionTarget {
+5 -28
View File
@@ -1,6 +1,6 @@
import type { SSHHost, SSHFolder } from "@/types/index";
import type { ServerStatus } from "@/main-axios";
import { isElectron } from "./electron";
import { SYNC_CHANGED_EVENT } from "./sync-events";
import { createTtlRequestCache } from "./ttl-request-cache";
/** Host list changes less often than status; keep a short shared window. */
@@ -16,7 +16,6 @@ const statusCache =
const foldersCache = createTtlRequestCache<SSHFolder[]>(FOLDERS_TTL_MS);
let listenersBound = false;
let lastRemoteSyncCompleteAt: string | null = null;
function bindInvalidationListeners(): void {
if (listenersBound || typeof window === "undefined") return;
@@ -29,32 +28,10 @@ function bindInvalidationListeners(): void {
window.addEventListener("ssh-hosts:changed", invalidateHosts);
window.addEventListener("hosts:refresh", invalidateHosts);
if (isElectron()) {
window.electronAPI
?.invoke?.("get-remote-sync-status")
.then((status) => {
const syncedAt =
status && typeof status === "object"
? (status as { lastSyncedAt?: unknown }).lastSyncedAt
: null;
lastRemoteSyncCompleteAt =
typeof syncedAt === "string" ? syncedAt : null;
})
.catch(() => {});
window.electronAPI?.onRemoteSyncStatusChanged?.((status) => {
if (!status || status.syncing || status.lastError) return;
const syncedAt =
typeof status.lastSyncedAt === "string" ? status.lastSyncedAt : null;
if (!syncedAt || syncedAt === lastRemoteSyncCompleteAt) return;
lastRemoteSyncCompleteAt = syncedAt;
invalidateHostsAndStatusCaches();
window.dispatchEvent(new CustomEvent("hosts:refresh"));
window.dispatchEvent(new CustomEvent("termix:hosts-changed"));
window.dispatchEvent(new CustomEvent("termix:credentials-changed"));
});
}
// A finished sync pass may have changed hosts and their statuses.
window.addEventListener(SYNC_CHANGED_EVENT, () => {
invalidateHostsAndStatusCaches();
});
}
export function getCachedSSHHosts(
+50
View File
@@ -0,0 +1,50 @@
import { isElectron } from "@/lib/electron";
import { authApi } from "@/main-axios";
import { SYNC_CHANGED_EVENT } from "@/lib/sync-events";
export { SYNC_CHANGED_EVENT };
/** The server this desktop is linked to, and the session it syncs with. */
export interface LinkedSession {
serverUrl: string;
token: string;
}
const CACHE_MS = 60_000;
let cached: { at: number; value: LinkedSession | null } | null = null;
let inflight: Promise<LinkedSession | null> | null = null;
if (typeof window !== "undefined") {
window.addEventListener(SYNC_CHANGED_EVENT, () => {
cached = null;
});
}
/** Null on the web, or when this desktop is not linked or signed out. */
export async function getLinkedSession(): Promise<LinkedSession | null> {
if (!isElectron()) return null;
if (cached && Date.now() - cached.at < CACHE_MS) return cached.value;
if (!inflight) {
inflight = authApi
.get("/sync/link/session")
.then((response) => {
const { serverUrl, token } = response.data ?? {};
const value =
typeof serverUrl === "string" && typeof token === "string"
? { serverUrl: serverUrl.replace(/\/$/, ""), token }
: null;
cached = { at: Date.now(), value };
return value;
})
.catch(() => null)
.finally(() => {
inflight = null;
});
}
return inflight;
}
export function notifySyncChanged(): void {
cached = null;
window.dispatchEvent(new CustomEvent(SYNC_CHANGED_EVENT));
}
+17
View File
@@ -0,0 +1,17 @@
const ROOT_PX = 14;
/**
* A length in px at the Normal interface size, as rem, so it follows the
* interface size setting like Tailwind classes do.
*/
export function rem(px: number): string {
return `${px / ROOT_PX}rem`;
}
/** How many real pixels one Normal-size pixel takes right now. */
export function remScale(): number {
const size = parseFloat(
getComputedStyle(document.documentElement).fontSize || `${ROOT_PX}`,
);
return Number.isFinite(size) && size > 0 ? size / ROOT_PX : 1;
}
+19 -82
View File
@@ -1,97 +1,34 @@
import type { AxiosInstance } from "axios";
import { getRemoteCoreApi } from "@/main-axios";
import { isElectron } from "@/lib/electron";
import type { SSHHost } from "@/types/index";
import { getLinkedSession } from "@/lib/linked-server";
export function markRemoteSharedHosts(hosts: SSHHost[]): SSHHost[] {
return hosts
.filter((host) => host.isShared)
.map((host) => ({
...host,
// Local SQLite ids are positive. Negative ids keep remote-only shared
// rows distinct while syncId remains the delegated backend identity.
id: -Math.abs(host.id),
}));
}
export interface SharedHostConnectionAuth {
username?: string | null;
authType?: string | null;
password?: string | null;
key?: string | null;
keyPassword?: string | null;
keyType?: string | null;
}
async function getRemoteSharedHostConnectionAuth(
localHostId: number,
): Promise<SharedHostConnectionAuth> {
if (localHostId >= 0) {
throw new Error("Expected a remote shared host id");
}
const api = await getConnectedRemoteApi();
if (!api) throw new Error("Remote server is not connected");
const response = await api.get(
`/host/db/host/${Math.abs(localHostId)}/local-connection-auth`,
);
return response.data;
}
export async function hydrateLocalSharedHostAuth<
T extends {
id?: number;
isShared?: unknown;
syncId?: string | null;
credentialId?: number;
username: string;
authType?: string;
password?: string;
key?: string;
keyPassword?: string;
keyType?: string;
},
>(host: T): Promise<T> {
if (!host.isShared || typeof host.id !== "number" || host.id >= 0) {
return host;
}
const auth = await getRemoteSharedHostConnectionAuth(host.id);
return {
...host,
// This row deliberately does not exist in the embedded database. Avoid
// asking the local backend to resolve its remote sync identity again.
syncId: null,
credentialId: undefined,
username: auth.username || host.username,
authType: auth.authType || host.authType,
password: auth.password || undefined,
key: auth.key || undefined,
keyPassword: auth.keyPassword || undefined,
keyType: auth.keyType || undefined,
};
/**
* Hosts shared with a linked account arrive through sync as read-only local
* copies that already carry the auth the owner shared, so a connection needs
* nothing extra. Kept for plugins that still call it.
*/
export async function hydrateLocalSharedHostAuth<T>(host: T): Promise<T> {
return host;
}
/** The linked server's core API, or null when this desktop is not linked. */
export async function getConnectedRemoteApi(): Promise<AxiosInstance | null> {
if (!isElectron()) return null;
try {
const config = (await window.electronAPI?.invoke?.(
"get-remote-sync-config",
)) as { serverUrl?: string } | null;
return config?.serverUrl ? getRemoteCoreApi() : null;
} catch {
return null;
}
return (await getLinkedSession()) ? getRemoteCoreApi() : null;
}
/** The linked server's own id for a host, found by its sync id. */
export async function resolveRemoteHostId(
syncId: string | null | undefined,
): Promise<number | null> {
if (!syncId) return null;
const api = await getConnectedRemoteApi();
if (!api) return null;
const response = await api.get("/sync/hosts");
const host = (response.data?.rows ?? []).find(
(row: { syncId?: string }) => row.syncId === syncId,
);
return typeof host?.id === "number" ? host.id : null;
try {
const response = await api.get(
`/sync/v2/hosts/${encodeURIComponent(syncId)}`,
);
return typeof response.data?.id === "number" ? response.data.id : null;
} catch {
return null;
}
}
-16
View File
@@ -1,16 +0,0 @@
import { isElectron } from "@/lib/electron";
export async function requestRemoteSync(): Promise<void> {
if (!isElectron()) return;
try {
const config = (await window.electronAPI?.invoke?.(
"get-remote-sync-config",
)) as { serverUrl?: string } | null;
if (config?.serverUrl) {
await window.electronAPI?.invoke?.("remote-sync-now");
}
} catch {
// The periodic sync remains the fallback when IPC or the server is down.
}
}
+2
View File
@@ -0,0 +1,2 @@
/** Fired whenever the desktop's link or its synced data changed. */
export const SYNC_CHANGED_EVENT = "termix:sync-changed";
+86
View File
@@ -0,0 +1,86 @@
import { isElectron } from "@/lib/electron";
import { getSyncStatus, type SyncStatus } from "@/api/sync-api";
import { notifySyncChanged } from "@/lib/linked-server";
/**
* The desktop's sync status, polled from the embedded backend while anything
* shows it. When a pass finishes, the views that show synced data are told
* to reload.
*/
const IDLE_MS = 15_000;
const BUSY_MS = 2_000;
let snapshot: SyncStatus | null = null;
let timer: ReturnType<typeof setTimeout> | null = null;
let lastSyncAt: string | null | undefined;
const listeners = new Set<() => void>();
function emit(): void {
for (const listener of listeners) listener();
}
function announce(): void {
notifySyncChanged();
window.dispatchEvent(new CustomEvent("hosts:refresh"));
window.dispatchEvent(new CustomEvent("termix:hosts-changed"));
window.dispatchEvent(new CustomEvent("termix:credentials-changed"));
window.dispatchEvent(new CustomEvent("termix:plugins-changed"));
}
async function poll(): Promise<void> {
timer = null;
try {
const next = await getSyncStatus();
const syncedAt = next.lastSyncAt ?? null;
if (lastSyncAt !== undefined && syncedAt !== lastSyncAt) announce();
if (snapshot && snapshot.linked !== next.linked) notifySyncChanged();
lastSyncAt = syncedAt;
snapshot = next;
emit();
} catch {
// The backend may still be starting; the next poll tries again.
}
schedule();
}
function schedule(delay?: number): void {
if (listeners.size === 0 || timer) return;
const busy = snapshot?.status === "syncing" || !!snapshot?.pending;
timer = setTimeout(() => void poll(), delay ?? (busy ? BUSY_MS : IDLE_MS));
}
export function subscribeSyncStatus(listener: () => void): () => void {
listeners.add(listener);
if (isElectron() && listeners.size === 1 && !timer) void poll();
return () => {
listeners.delete(listener);
if (listeners.size === 0 && timer) {
clearTimeout(timer);
timer = null;
}
};
}
export function getSyncStatusSnapshot(): SyncStatus | null {
return snapshot;
}
/** Takes a status a request already returned, and checks again soon. */
export function setSyncStatus(next: SyncStatus): void {
snapshot = next;
if (lastSyncAt !== undefined && (next.lastSyncAt ?? null) !== lastSyncAt) {
announce();
}
lastSyncAt = next.lastSyncAt ?? null;
emit();
if (timer) clearTimeout(timer);
timer = null;
schedule(BUSY_MS);
}
export function refreshSyncStatus(): void {
if (timer) clearTimeout(timer);
timer = null;
void poll();
}
+20 -2
View File
@@ -65,11 +65,29 @@ export const FONT_SIZES: { id: FontSizeId; label: string }[] = [
{ id: "xl", label: "XL" },
];
/** The root font size of each interface size, relative to Normal. */
export const FONT_SIZE_SCALE: Record<FontSizeId, number> = {
xs: 12 / 14,
sm: 13 / 14,
md: 1,
lg: 17 / 14,
xl: 20 / 14,
};
export function applyFontSize(id: FontSizeId) {
const root = document.documentElement;
const size = id in FONT_SIZE_SCALE ? id : "md";
root.classList.remove("fs-xs", "fs-sm", "fs-md", "fs-lg", "fs-xl");
root.classList.add(`fs-${id}`);
localStorage.setItem("termix-font-size", id);
root.classList.add(`fs-${size}`);
localStorage.setItem("termix-font-size", size);
// Every length is in rem, so the root size scales the whole interface. An
// earlier desktop build zoomed the window instead; undo that if it did.
(
window as Window & {
electronAPI?: { setZoomFactor?: (factor: number) => void };
}
).electronAPI?.setZoomFactor?.(1);
}
export const UI_FONTS: { id: UiFontId; label: string; family: string }[] = [
+145 -87
View File
@@ -112,86 +112,8 @@
"arrangeUnlockedSwitchedToManual": "Switched to manual sort so your order sticks",
"arrangeUnlocked": "Unlock drag to rearrange"
},
"serverConfig": {
"title": "Server Configuration",
"description": "Configure the Termix server URL to connect to your backend services",
"serverUrl": "Server URL",
"enterServerUrl": "Please enter a server URL",
"saveFailed": "Failed to save configuration",
"saveError": "Error saving configuration",
"saving": "Saving...",
"saveConfig": "Save Configuration",
"helpText": "Enter the URL where your Termix server is running (e.g., http://localhost:30001 or https://your-server.com)",
"changeServer": "Change Server",
"mustIncludeProtocol": "Server URL must start with http:// or https://",
"allowInvalidCertificate": "Allow invalid certificate",
"allowInvalidCertificateDesc": "Use only for trusted self-hosted servers with self-signed or IP-address certificates.",
"useEmbedded": "Use Local Server",
"embeddedDesc": "Run Termix with the built-in local server (no remote server needed)",
"embeddedConnecting": "Connecting to local server...",
"embeddedNotReady": "Local server is not ready yet. Please wait a moment and try again.",
"localServer": "Local Server",
"savedServers": "Saved Servers",
"noSavedServers": "No saved servers",
"removeServer": "Remove"
},
"migrationNotice": {
"title": "Termix Desktop now runs standalone",
"body1": "This app used to connect straight to your Termix server. It's been reworked to run fully on its own, storing hosts and credentials locally so it keeps working offline. Two-way sync with a self-hosted Termix server is now optional.",
"body2": "Your existing hosts and credentials are still on {{url}}. Turn on Remote Sync and reconnect to that server to bring them back and keep both in sync going forward.",
"dismiss": "Not now",
"setUpSync": "Set Up Remote Sync"
},
"remoteSync": {
"title": "Remote Sync",
"description": "Optionally connect this desktop app to a self-hosted Termix server to sync your hosts, credentials, and snippets across devices. The app always works fully offline whether or not you connect.",
"notConnected": "Not connected",
"connected": "Connected",
"connectedTo": "Connected to {{url}}",
"lastSynced": "Last synced {{time}}",
"neverSynced": "Never synced",
"syncError": "Sync error: {{message}}",
"needsReauth": "Sign-in expired",
"connectButton": "Connect to Server",
"disconnectButton": "Disconnect",
"syncNowButton": "Sync Now",
"syncing": "Syncing...",
"syncSuccess": "Remote sync completed",
"syncUnavailable": "Remote sync could not be started",
"serverUrl": "Server URL",
"enterServerUrl": "Please enter a server URL",
"mustIncludeProtocol": "Server URL must start with http:// or https://",
"connectionTestFailed": "Could not reach a Termix server at that URL",
"allowInvalidCertificate": "Allow invalid certificate",
"allowInvalidCertificateDesc": "Use only for trusted self-hosted servers with self-signed or IP-address certificates.",
"savedServers": "Saved Servers",
"removeServer": "Remove",
"continueButton": "Continue",
"cancelButton": "Cancel",
"signInTitle": "Sign in to {{url}}",
"originTitle": "Connection Origin",
"originDescription": "Choose where SSH connections originate from by default. This can be overridden per host.",
"originLocal": "This device (local network)",
"originRemote": "Remote server",
"bannerReconnect": "Reconnect",
"bannerMessage": "Remote sync needs re-authentication"
},
"versionCheck": {
"error": "Version Check Error",
"checkFailed": "Failed to check for updates",
"upToDate": "App is Up to Date",
"currentVersion": "You are running version {{version}}",
"updateAvailable": "Update Available",
"updateLinkLabel": "Update available — open the release notes on GitHub",
"newVersionAvailable": "A new version is available! You are running {{current}}, but {{latest}} is available.",
"betaVersion": "Beta Version",
"betaVersionDesc": "You are running {{current}}, which is newer than the latest stable release {{latest}}.",
"releasedOn": "Released on {{date}}",
"downloadUpdate": "Download Update",
"checking": "Checking for updates...",
"checkUpdates": "Check for Updates",
"checkingUpdates": "Checking for updates...",
"updateRequired": "Update Required"
"updateLinkLabel": "Update available. Open the release notes on GitHub"
},
"common": {
"close": "Close",
@@ -327,7 +249,14 @@
"done_docs_link": "Open the docs",
"done_cli": "Command line tool",
"done_cli_desc": "Manage your servers from a terminal, and use Termix in your own scripts.",
"done_cli_link": "Open the CLI docs"
"done_cli_link": "Open the CLI docs",
"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}}"
},
"nav": {
"home": "Home",
@@ -374,7 +303,8 @@
"keepSideMenuOpen": "Keep Menu Open",
"collapseSideMenu": "Collapse Menu",
"roleAdministrator": "Administrator",
"roleUser": "User"
"roleUser": "User",
"sync": "Sync"
},
"hosts": {
"hosts": "Hosts",
@@ -1072,7 +1002,11 @@
"authFieldsAfterSave": "Save the host first, then fill in these fields.",
"enablePluginFeature": "Enable {{name}}",
"disablePluginFeature": "Disable {{name}}",
"filterFeaturesGroup": "Features"
"filterFeaturesGroup": "Features",
"localOnly": "Keep on this device only",
"localOnlyDesc": "This host is not sent to the server and does not appear on your other devices.",
"folderLocalOnly": "Keep on this device only",
"folderLocalOnlyDesc": "This folder and every host in it stay on this device and are not sent to the server."
},
"commandPalette": {
"searchPlaceholder": "Search hosts, commands, or settings...",
@@ -1404,8 +1338,6 @@
"resetCodeRateLimitedWithTime": "Rate limited: Too many verification attempts. Please wait {{time}} seconds before trying again.",
"authTokenSaveFailed": "Failed to save authentication token",
"authTokenMissing": "The server did not return a new authentication token. Please sign in again.",
"authTokenRejected": "The remote server rejected the new authentication token",
"keyringUnavailable": "Signed in, but the session could not be stored: this system has no keyring available to encrypt it with. Start a Secret Service (such as gnome-keyring or KWallet) and sign in again.",
"failedToLoadServer": "Failed to load server",
"remoteServerRequired": "Remote server required. Connect a remote server in Settings to use this connection type."
},
@@ -2085,8 +2017,8 @@
"resetToDefaultsSuccess": "Settings reset to defaults.",
"storageModeSwitch": "Preference Storage",
"sectionAccount": "Account",
"desktopProfileTitle": "Automatic local desktop profile",
"desktopProfileDescription": "This profile is restricted to the embedded backend and signs in automatically. It has no login password; Remote Sync below uses a separate server account.",
"desktopProfileTitle": "This device only",
"desktopProfileDescription": "This profile lives on this device and signs in by itself. Link it to a Termix server to use the same hosts and settings on every device.",
"sectionInterface": "Interface",
"interfacePresetDesc": "How much detail Termix shows. Every feature stays available.",
"preset_simple": "Simple",
@@ -2266,7 +2198,11 @@
"themeSolarized": "Solarized",
"themeTokyoNight": "Tokyo Night",
"themeOneDark": "One Dark",
"themeGruvbox": "Gruvbox"
"themeGruvbox": "Gruvbox",
"linkedProfileTitle": "Signed in to {{server}}",
"linkedProfileDescription": "This device uses your account on the server and keeps a copy of your data, so it still works offline.",
"manageSync": "Manage sync",
"setUpSync": "Link to a server"
}
}
},
@@ -2501,5 +2437,127 @@
"sudoHint": "Set a sudo password for this host in the host editor to enable privileged actions.",
"noData": "No data",
"filter": "Filter..."
},
"sync": {
"server": "Termix server",
"account": "Account",
"status": "Status",
"never": "never",
"justNow": "just now",
"minutesAgo_one": "{{count}} minute ago",
"minutesAgo_other": "{{count}} minutes ago",
"hoursAgo_one": "{{count}} hour ago",
"hoursAgo_other": "{{count}} hours ago",
"actionFailed": "That did not work. Try again.",
"unlinkedTitle": "This device only",
"unlinkedBody": "Termix works on its own on this device. Link it to your Termix server to keep everything in step.",
"unlinkedPointHosts": "Your hosts, credentials, snippets and settings follow you to every device.",
"unlinkedPointOffline": "Everything still works when the server cannot be reached, and catches up later.",
"unlinkedPointAccount": "You sign in with your server account, and hosts people shared with you show up here too.",
"linkButton": "Link to a server",
"state": {
"syncing": "Syncing",
"offline": "Server not reachable",
"error": "Sync stopped",
"signedOut": "Signed out",
"synced": "Synced {{time}}"
},
"pending_one": "{{count}} change waiting to sync",
"pending_other": "{{count}} changes waiting to sync",
"signInAgain": "Sign in again",
"syncNow": "Sync now",
"unlink": "Unlink",
"unlinked": "This device is no longer linked",
"unlinkTitle": "Unlink this device?",
"unlinkDescription": "It stops syncing and signs out of the server. Hosts shared with you by others are removed from this device either way.",
"unlinkKeepTitle": "Keep my data here",
"unlinkKeepBody": "Your hosts, credentials and settings stay on this device and keep working on their own.",
"unlinkRemoveTitle": "Remove synced data",
"unlinkRemoveBody": "Everything that came from the server is removed from this device. Hosts kept on this device only stay.",
"conflictsTitle": "Conflicts",
"conflictsDescription": "These were changed here and on the server at the same time. The server version is in use. Keep yours to send it to the server instead.",
"keepMine": "Keep mine",
"keepServer": "Keep server",
"errorsTitle": "Not synced",
"errorsDescription": "The server did not accept these changes. They stay on this device.",
"errorPermission": "Your account on the server is not allowed to make this change.",
"retry": "Try again",
"whatSyncs": "What syncs",
"whatSyncsDescription": "Turn off anything you want to keep on this device only.",
"coreGroup": "Termix",
"notOnServer": "The server does not have this",
"notOnDevice": "Not on this device yet",
"localOnlyHint": "You can also keep single hosts or folders on this device from their settings.",
"originTitle": "Where connections start",
"originDescription": "Connect to hosts from this device, or through the server. You can change this per host.",
"originLocal": "This device",
"originRemote": "The server",
"entities": {
"hosts": "Hosts",
"sshCredentials": "Credentials",
"sshFolders": "Folders",
"userPreferences": "Preferences",
"pluginUserSettings": "Feature 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"
},
"proxy": {
"title": "Reverse proxy",
"description": "Only needed when something like Cloudflare Access or a login page sits in front of your server.",
"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.",
"saved": "Saved"
},
"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",
"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": "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": "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."
}
}
}
+1 -81
View File
@@ -232,86 +232,8 @@
"widgetTunnelDesc": "Ingeboude SSH-tonnelbestuurder vir 'n gekonfigureerde gasheer",
"widgetNoHostSelected": "Geen gasheer gekonfigureer nie"
},
"serverConfig": {
"title": "Bedienerkonfigurasie",
"description": "Konfigureer die Termix-bediener-URL om aan jou backend-dienste te koppel",
"serverUrl": "Bediener-URL",
"enterServerUrl": "Voer asseblief 'n bediener-URL in",
"saveFailed": "Kon nie konfigurasie stoor nie",
"saveError": "Fout met die stoor van die konfigurasie",
"saving": "Stoor...",
"saveConfig": "Stoor Konfigurasie",
"helpText": "Voer die URL in waar jou Termix-bediener loop (bv. http://localhost:30001 of https://your-server.com)",
"changeServer": "Verander bediener",
"mustIncludeProtocol": "Bediener-URL moet met http:// of https:// begin",
"allowInvalidCertificate": "Laat ongeldige sertifikaat toe",
"allowInvalidCertificateDesc": "Gebruik slegs vir vertroude selfgehoste bedieners met selfgetekende of IP-adres sertifikate.",
"useEmbedded": "Gebruik plaaslike bediener",
"embeddedDesc": "Begin Termix met die ingeboude plaaslike bediener (geen afstandbediener nodig nie)",
"embeddedConnecting": "Verbind met plaaslike bediener...",
"embeddedNotReady": "Die plaaslike bediener is nog nie gereed nie. Wag asseblief 'n oomblik en probeer weer.",
"localServer": "Plaaslike bediener",
"savedServers": "Gestoorde bedieners",
"noSavedServers": "Geen gestoorde bedieners nie",
"removeServer": "Verwyder"
},
"migrationNotice": {
"title": "Termix Desktop loop nou alleenstaande",
"body1": "Hierdie toepassing het voorheen direk aan jou Termix-bediener gekoppel. Dit is herontwerp om volledig op sy eie te loop, en stoor gashere en geloofsbriewe plaaslik sodat dit vanlyn kan bly werk. Tweerigting-sinchronisasie met 'n self-gehoste Termix-bediener is nou opsioneel.",
"body2": "Jou bestaande gashere en geloofsbriewe is steeds op {{url}}. Skakel Afstandsinchronisasie aan en koppel weer aan daardie bediener om hulle terug te bring en albei vorentoe gesinchroniseer te hou.",
"dismiss": "Nie nou nie",
"setUpSync": "Stel afstandsinchronisasie op"
},
"remoteSync": {
"title": "Afstandsinchronisasie",
"description": "Koppel opsioneel hierdie lessenaar-app aan 'n self-gehoste Termix-bediener om jou gashere, geloofsbriewe en brokkies oor toestelle te sinkroniseer. Die app werk altyd ten volle vanlyn, of jy nou koppel of nie.",
"notConnected": "Nie gekoppel nie",
"connected": "Verbonde",
"connectedTo": "Gekoppel aan {{url}}",
"lastSynced": "Laas gesinkroniseer {{time}}",
"neverSynced": "Nooit gesinkroniseer nie",
"syncError": "Sinkronisasiefout: {{message}}",
"needsReauth": "Aanmelding het verval",
"connectButton": "Koppel aan bediener",
"disconnectButton": "Ontkoppel",
"syncNowButton": "Sinkroniseer nou",
"syncing": "Sinkroniseer...",
"syncSuccess": "Afstandsinchronisasie voltooi",
"syncUnavailable": "Afstandsinchronisasie kon nie begin word nie",
"serverUrl": "Bediener-URL",
"enterServerUrl": "Voer asseblief 'n bediener-URL in",
"mustIncludeProtocol": "Bediener-URL moet met http:// of https:// begin",
"connectionTestFailed": "Kon nie 'n Termix-bediener by daardie URL bereik nie",
"allowInvalidCertificate": "Laat ongeldige sertifikaat toe",
"allowInvalidCertificateDesc": "Gebruik slegs vir vertroude selfgehoste bedieners met selfgetekende of IP-adres sertifikate.",
"savedServers": "Gestoorde bedieners",
"removeServer": "Verwyder",
"continueButton": "Gaan voort",
"cancelButton": "Kanselleer",
"signInTitle": "Meld aan by {{url}}",
"originTitle": "Verbindingsoorsprong",
"originDescription": "Kies waar SSH-verbindings standaard vandaan kom. Dit kan per gasheer oorskryf word.",
"originLocal": "Hierdie toestel (plaaslike netwerk)",
"originRemote": "Afstandbediener",
"bannerReconnect": "Herkoppel",
"bannerMessage": "Afstandsinchronisasie benodig herverifikasie"
},
"versionCheck": {
"error": "Weergawekontrolefout",
"checkFailed": "Kon nie vir opdaterings kyk nie",
"upToDate": "Toepassing is op datum",
"currentVersion": "Jy gebruik weergawe {{version}}",
"updateAvailable": "Opdatering beskikbaar",
"updateLinkLabel": "Opdatering beskikbaar — maak die vrystellingsnotas op GitHub oop",
"newVersionAvailable": "'n Nuwe weergawe is beskikbaar! Jy gebruik {{current}}, maar {{latest}} is beskikbaar.",
"betaVersion": "Beta-weergawe",
"betaVersionDesc": "Jy gebruik {{current}}, wat nuwer is as die nuutste stabiele weergawe {{latest}}.",
"releasedOn": "Uitgereik op {{date}}",
"downloadUpdate": "Laai opdatering af",
"checking": "Kontroleer tans vir opdaterings...",
"checkUpdates": "Kyk vir opdaterings",
"checkingUpdates": "Kontroleer tans vir opdaterings...",
"updateRequired": "Opdatering Vereis"
"updateLinkLabel": "Opdatering beskikbaar — maak die vrystellingsnotas op GitHub oop"
},
"common": {
"close": "Maak toe",
@@ -1565,8 +1487,6 @@
"resetCodeRateLimitedWithTime": "Tempo beperk: Te veel verifikasiepogings. Wag asseblief {{time}} sekondes voordat u weer probeer.",
"authTokenSaveFailed": "Kon nie verifikasietoken stoor nie",
"authTokenMissing": "Die bediener het nie 'n nuwe verifikasietoken teruggegee nie. Meld asseblief weer aan.",
"authTokenRejected": "Die afgeleë bediener het die nuwe verifikasieteken verwerp",
"keyringUnavailable": "Aangemeld, maar die sessie kon nie gestoor word nie: hierdie stelsel het geen sleutelring beskikbaar om dit mee te enkripteer nie. Begin 'n Geheime Diens (soos gnome-sleutelring of KWallet) en meld weer aan.",
"failedToLoadServer": "Kon nie bediener laai nie",
"remoteServerRequired": "Afstandbediener word benodig. Koppel 'n afstandbediener in Instellings om hierdie verbindingstipe te gebruik."
},
+1 -81
View File
@@ -232,86 +232,8 @@
"widgetTunnelDesc": "مدير أنفاق SSH مدمج لمضيف مكوّن",
"widgetNoHostSelected": "لم يتم تكوين أي مضيف"
},
"serverConfig": {
"title": "إعدادات الخادم",
"description": "تكوين رابط خادم Termix للاتصال بخدماتك الخلفية",
"serverUrl": "رابط الخادم",
"enterServerUrl": "يرجى إدخال رابط الخادم",
"saveFailed": "فشل حفظ الإعدادات",
"saveError": "خطأ في حفظ الإعدادات",
"saving": "جاري الحفظ...",
"saveConfig": "حفظ الإعدادات",
"helpText": "أدخل الرابط حيث يعمل خادم Termix (مثال: http://localhost:30001 أو https://your-server.com)",
"changeServer": "تغيير الخادم",
"mustIncludeProtocol": "يجب أن يبدأ رابط الخادم بـ http:// أو https://",
"allowInvalidCertificate": "السماح بشهادة غير صالحة",
"allowInvalidCertificateDesc": "استخدم فقط للخوادم الموثوقة ذاتية الاستضافة التي تحمل شهادات موقعة ذاتيًا أو بعنوان IP.",
"useEmbedded": "استخدام الخادم المحلي",
"embeddedDesc": "تشغيل Termix مع الخادم المحلي المدمج (لا حاجة لخادم بعيد)",
"embeddedConnecting": "جاري الاتصال بالخادم المحلي...",
"embeddedNotReady": "الخادم المحلي غير جاهز بعد. يرجى الانتظار قليلاً ثم المحاولة مرة أخرى.",
"localServer": "الخادم المحلي",
"savedServers": "الخوادم المحفوظة",
"noSavedServers": "لا توجد خوادم محفوظة",
"removeServer": "إزالة"
},
"migrationNotice": {
"title": "يعمل برنامج Termix Desktop الآن بشكل مستقل",
"body1": "كان هذا التطبيق يتصل مباشرةً بخادم Termix الخاص بك. تم تطويره ليعمل بشكل مستقل تمامًا، حيث يخزن بيانات المضيفين وبيانات الاعتماد محليًا، مما يضمن استمرارية عمله دون اتصال بالإنترنت. أصبح التزامن ثنائي الاتجاه مع خادم Termix مُستضاف ذاتيًا اختياريًا الآن.",
"body2": "لا تزال بيانات المضيفين وبيانات الاعتماد الحالية موجودة على {{url}}. قم بتشغيل المزامنة عن بُعد وأعد الاتصال بهذا الخادم لاستعادتها والحفاظ على تزامنها في المستقبل.",
"dismiss": "ليس الآن",
"setUpSync": "إعداد المزامنة عن بُعد"
},
"remoteSync": {
"title": "المزامنة عن بعد",
"description": "يمكنك ربط تطبيق سطح المكتب هذا بخادم Termix مُستضاف ذاتيًا لمزامنة بيانات المضيفين وبيانات الاعتماد والقصاصات البرمجية عبر الأجهزة. يعمل التطبيق دائمًا بشكل كامل دون اتصال بالإنترنت سواءً كنت متصلاً أم لا.",
"notConnected": "غير متصل",
"connected": "متصل",
"connectedTo": "متصل بـ {{url}}",
"lastSynced": "آخر مزامنة {{time}}",
"neverSynced": "لم تتم المزامنة مطلقاً",
"syncError": "خطأ في المزامنة: {{message}}",
"needsReauth": "انتهت صلاحية تسجيل الدخول",
"connectButton": "الاتصال بالخادم",
"disconnectButton": "قطع الاتصال",
"syncNowButton": "مزامنة الآن",
"syncing": "جارٍ المزامنة...",
"syncSuccess": "اكتملت المزامنة عن بعد",
"syncUnavailable": "تعذر بدء المزامنة عن بعد",
"serverUrl": "عنوان الخادم",
"enterServerUrl": "الرجاء إدخال عنوان URL للخادم",
"mustIncludeProtocol": "يجب أن يبدأ عنوان URL للخادم بـ http:// أو https://",
"connectionTestFailed": "تعذر الوصول إلى خادم Termix على عنوان URL هذا",
"allowInvalidCertificate": "السماح بشهادة غير صالحة",
"allowInvalidCertificateDesc": "استخدمه فقط للخوادم الموثوقة ذاتية الاستضافة التي تحتوي على شهادات موقعة ذاتيًا أو شهادات عناوين IP.",
"savedServers": "الخوادم المحفوظة",
"removeServer": "يزيل",
"continueButton": "يكمل",
"cancelButton": "يلغي",
"signInTitle": "سجّل الدخول إلى {{url}}",
"originTitle": "أصل الاتصالات",
"originDescription": "اختر مصدر اتصالات SSH الافتراضي. يمكن تغيير هذا الإعداد لكل مضيف على حدة.",
"originLocal": "هذا الجهاز (الشبكة المحلية)",
"originRemote": "خادم بعيد",
"bannerReconnect": "إعادة الاتصال",
"bannerMessage": "يتطلب المزامنة عن بُعد إعادة المصادقة"
},
"versionCheck": {
"error": "خطأ في التحقق من الإصدار",
"checkFailed": "فشل التحقق من وجود تحديثات",
"upToDate": "التطبيق محدث",
"currentVersion": "أنت تعمل على الإصدار {{version}}",
"updateAvailable": "تحديث متاح",
"updateLinkLabel": "يتوفر تحديث — افتح ملاحظات الإصدار على GitHub",
"newVersionAvailable": "إصدار جديد متاح! أنت تعمل على {{current}}، لكن {{latest}} متاح.",
"betaVersion": "إصدار تجريبي",
"betaVersionDesc": "أنت تعمل على {{current}}، وهو أحدث من آخر إصدار مستقر {{latest}}.",
"releasedOn": "صدر في {{date}}",
"downloadUpdate": "تنزيل التحديث",
"checking": "جارٍ التحقق من التحديثات...",
"checkUpdates": "التحقق من التحديثات",
"checkingUpdates": "جارٍ التحقق من التحديثات...",
"updateRequired": "التحديث مطلوب"
"updateLinkLabel": "يتوفر تحديث — افتح ملاحظات الإصدار على GitHub"
},
"common": {
"close": "إغلاق",
@@ -1565,8 +1487,6 @@
"resetCodeRateLimitedWithTime": "محدودية المعدل: عدد كبير جداً من محاولات التحقق. يرجى الانتظار {{time}} ثانية قبل المحاولة مرة أخرى.",
"authTokenSaveFailed": "فشل في حفظ رمز المصادقة",
"authTokenMissing": "لم يُرسل الخادم رمز مصادقة جديدًا. يُرجى تسجيل الدخول مرة أخرى.",
"authTokenRejected": "رفض الخادم البعيد رمز المصادقة الجديد",
"keyringUnavailable": "تم تسجيل الدخول، ولكن تعذر حفظ الجلسة: لا يتوفر لدى هذا النظام سلسلة مفاتيح لتشفيرها. شغّل خدمة سرية (مثل gnome-keyring أو KWallet) وسجّل الدخول مرة أخرى.",
"failedToLoadServer": "فشل في تحميل الخادم",
"remoteServerRequired": "يلزم وجود خادم بعيد. قم بتوصيل خادم بعيد في الإعدادات لاستخدام نوع الاتصال هذا."
},
+1 -81
View File
@@ -232,86 +232,8 @@
"widgetTunnelDesc": "Вграден SSH тунелен мениджър за конфигуриран хост",
"widgetNoHostSelected": "Няма конфигуриран хост"
},
"serverConfig": {
"title": "Конфигурация на сървъра",
"description": "Конфигурирайте URL адреса на Termix сървъра, за да се свържете с вашите backend услуги",
"serverUrl": "URL адрес на сървъра",
"enterServerUrl": "Моля, въведете URL адрес на сървъра",
"saveFailed": "Запазването на конфигурацията не бе успешно",
"saveError": "Грешка при запазване на конфигурацията",
"saving": "Запазване...",
"saveConfig": "Запазване на конфигурацията",
"helpText": "Въведете URL адреса, където работи вашият Termix сървър (напр. http://localhost:30001 или https://your-server.com)",
"changeServer": "Промяна на сървъра",
"mustIncludeProtocol": "URL адресът на сървъра трябва да започва с http:// или https://",
"allowInvalidCertificate": "Разрешаване на невалиден сертификат",
"allowInvalidCertificateDesc": "Използвайте само за надеждни самостоятелно хоствани сървъри със самоподписани или IP-адресни сертификати.",
"useEmbedded": "Използване на локален сървър",
"embeddedDesc": "Стартирайте Termix с вградения локален сървър (не е необходим отдалечен сървър)",
"embeddedConnecting": "Свързване с локален сървър...",
"embeddedNotReady": "Локалният сървър все още не е готов. Моля, изчакайте малко и опитайте отново.",
"localServer": "Локален сървър",
"savedServers": "Запазени сървъри",
"noSavedServers": "Няма запазени сървъри",
"removeServer": "Премахване"
},
"migrationNotice": {
"title": "Termix Desktop вече работи самостоятелно",
"body1": "Това приложение се свързваше директно с вашия Termix сървър. То е преработено, за да работи напълно самостоятелно, съхранявайки хостове и идентификационни данни локално, така че да продължи да работи офлайн. Двупосочната синхронизация със самостоятелно хостван Termix сървър вече е по избор.",
"body2": "Вашите съществуващи хостове и идентификационни данни все още са на {{url}}. Включете отдалечената синхронизация и се свържете отново с този сървър, за да ги върнете и да поддържате синхронизирани занапред.",
"dismiss": "Не сега",
"setUpSync": "Настройване на отдалечена синхронизация"
},
"remoteSync": {
"title": "Дистанционна синхронизация",
"description": "По желание свържете това настолно приложение към самостоятелно хостван Termix сървър, за да синхронизирате вашите хостове, идентификационни данни и фрагменти на различни устройства. Приложението винаги работи напълно офлайн, независимо дали се свързвате или не.",
"notConnected": "Няма връзка",
"connected": "Свързан",
"connectedTo": "Свързано с {{url}}",
"lastSynced": "Последна синхронизация {{time}}",
"neverSynced": "Никога не е синхронизирано",
"syncError": "Грешка при синхронизиране: {{message}}",
"needsReauth": "Входът е изтекъл",
"connectButton": "Свързване със сървъра",
"disconnectButton": "Изключване",
"syncNowButton": "Синхронизирай сега",
"syncing": "Синхронизиране...",
"syncSuccess": "Дистанционната синхронизация е завършена",
"syncUnavailable": "Дистанционното синхронизиране не можа да се стартира",
"serverUrl": "URL адрес на сървъра",
"enterServerUrl": "Моля, въведете URL адрес на сървъра",
"mustIncludeProtocol": "URL адресът на сървъра трябва да започва с http:// или https://",
"connectionTestFailed": "Не можа да се свърже със сървър на Termix на този URL адрес",
"allowInvalidCertificate": "Разрешаване на невалиден сертификат",
"allowInvalidCertificateDesc": "Използвайте само за надеждни самостоятелно хоствани сървъри със самоподписани или IP-адресни сертификати.",
"savedServers": "Запазени сървъри",
"removeServer": "Премахване",
"continueButton": "Продължи",
"cancelButton": "Отказ",
"signInTitle": "Влезте в {{url}}",
"originTitle": "Произход на връзката",
"originDescription": "Изберете откъде произлизат SSH връзките по подразбиране. Това може да се промени за всеки хост.",
"originLocal": "Това устройство (локална мрежа)",
"originRemote": "Отдалечен сървър",
"bannerReconnect": "Свържете се отново",
"bannerMessage": "Дистанционната синхронизация изисква повторно удостоверяване"
},
"versionCheck": {
"error": "Грешка при проверка на версията",
"checkFailed": "Проверката за актуализации не бе успешна",
"upToDate": "Приложението е актуално",
"currentVersion": "Използвате версия {{version}}",
"updateAvailable": "Налична е актуализация",
"updateLinkLabel": "Налична е актуализация — отворете бележките за изданието в GitHub",
"newVersionAvailable": "Налична е нова версия! Използвате {{current}}, но {{latest}} е налична.",
"betaVersion": "Бета версия",
"betaVersionDesc": "Изпълнявате {{current}}, която е по-нова от последната стабилна версия {{latest}}.",
"releasedOn": "Публикувано на {{date}}",
"downloadUpdate": "Изтегляне на актуализация",
"checking": "Проверка за актуализации...",
"checkUpdates": "Проверка за актуализации",
"checkingUpdates": "Проверка за актуализации...",
"updateRequired": "Необходима е актуализация"
"updateLinkLabel": "Налична е актуализация — отворете бележките за изданието в GitHub"
},
"common": {
"close": "Затвори",
@@ -1565,8 +1487,6 @@
"resetCodeRateLimitedWithTime": "Ограничена скорост: Твърде много опити за потвърждение. Моля, изчакайте {{time}} секунди, преди да опитате отново.",
"authTokenSaveFailed": "Запазването на токена за удостоверяване не бе успешно",
"authTokenMissing": "Сървърът не върна нов токен за удостоверяване. Моля, влезте отново.",
"authTokenRejected": "Отдалеченият сървър отхвърли новия токен за удостоверяване",
"keyringUnavailable": "Влезли сте, но сесията не можа да бъде съхранена: тази система няма наличен ключодържател, с който да я криптира. Стартирайте секретна услуга (като gnome-keyring или KWallet) и влезте отново.",
"failedToLoadServer": "Зареждането на сървъра не бе успешно",
"remoteServerRequired": "Изисква се отдалечен сървър. Свържете се с отдалечен сървър в „Настройки“, за да използвате този тип връзка."
},
+1 -81
View File
@@ -232,86 +232,8 @@
"widgetTunnelDesc": "একটি কনফিগার করা হোস্টের জন্য এমবেডেড SSH টানেল ম্যানেজার",
"widgetNoHostSelected": "কোন হোস্ট কনফিগার করা নেই"
},
"serverConfig": {
"title": "সার্ভার কনফিগারেশন",
"description": "আপনার ব্যাকএন্ড পরিষেবাগুলির সাথে সংযোগ করতে Termix সার্ভার URL কনফিগার করুন।",
"serverUrl": "সার্ভার ইউআরএল",
"enterServerUrl": "অনুগ্রহ করে একটি সার্ভার URL লিখুন",
"saveFailed": "কনফিগারেশন সংরক্ষণ করতে ব্যর্থ হয়েছে",
"saveError": "কনফিগারেশন সংরক্ষণ করতে ত্রুটি হয়েছে",
"saving": "সঞ্চয়...",
"saveConfig": "কনফিগারেশন সংরক্ষণ করুন",
"helpText": "আপনার Termix সার্ভারটি যেখানে চলছে সেই URL-টি লিখুন (যেমন, http://localhost:30001 অথবা https://your-server.com)",
"changeServer": "সার্ভার পরিবর্তন করুন",
"mustIncludeProtocol": "সার্ভার URL অবশ্যই http:// অথবা https:// দিয়ে শুরু হতে হবে।",
"allowInvalidCertificate": "অবৈধ সার্টিফিকেট অনুমোদন করুন",
"allowInvalidCertificateDesc": "শুধুমাত্র সেলফ-সাইন্ড বা আইপি-অ্যাড্রেস সার্টিফিকেটযুক্ত বিশ্বস্ত সেলফ-হোস্টেড সার্ভারের জন্য ব্যবহার করুন।",
"useEmbedded": "স্থানীয় সার্ভার ব্যবহার করুন",
"embeddedDesc": "অন্তর্নির্মিত স্থানীয় সার্ভার দিয়ে টারমিক্স চালান (কোন রিমোট সার্ভারের প্রয়োজন নেই)।",
"embeddedConnecting": "স্থানীয় সার্ভারের সাথে সংযোগ স্থাপন করা হচ্ছে...",
"embeddedNotReady": "স্থানীয় সার্ভার এখনও প্রস্তুত নয়। অনুগ্রহ করে কিছুক্ষণ অপেক্ষা করে আবার চেষ্টা করুন।",
"localServer": "স্থানীয় সার্ভার",
"savedServers": "সংরক্ষিত সার্ভার",
"noSavedServers": "কোনো সংরক্ষিত সার্ভার নেই",
"removeServer": "অপসারণ করুন"
},
"migrationNotice": {
"title": "টার্মিক্স ডেস্কটপ এখন স্বতন্ত্রভাবে চলে।",
"body1": "এই অ্যাপটি আগে সরাসরি আপনার টারমিক্স সার্ভারের সাথে সংযুক্ত হতো। এটিকে এখন সম্পূর্ণ স্বাধীনভাবে চলার জন্য নতুন করে তৈরি করা হয়েছে, যা হোস্ট এবং ক্রেডেনশিয়াল স্থানীয়ভাবে সংরক্ষণ করে, ফলে এটি অফলাইনেও কাজ করতে থাকে। সেলফ-হোস্টেড টারমিক্স সার্ভারের সাথে দ্বিমুখী সিঙ্ক এখন ঐচ্ছিক।",
"body2": "আপনার বর্তমান হোস্ট এবং ক্রেডেনশিয়াল এখনও {{url}}-এ রয়েছে। সেগুলোকে ফিরিয়ে আনতে এবং ভবিষ্যতে উভয়কে সিঙ্কে রাখতে রিমোট সিঙ্ক চালু করুন এবং সেই সার্ভারে পুনরায় সংযোগ করুন।",
"dismiss": "এখন নয়",
"setUpSync": "রিমোট সিঙ্ক সেট আপ করুন"
},
"remoteSync": {
"title": "রিমোট সিঙ্ক",
"description": "ঐচ্ছিকভাবে, আপনার হোস্ট, ক্রেডেনশিয়াল এবং স্নিপেটগুলো বিভিন্ন ডিভাইসে সিঙ্ক করার জন্য এই ডেস্কটপ অ্যাপটিকে একটি সেলফ-হোস্টেড টারমিক্স সার্ভারের সাথে সংযুক্ত করুন। আপনি সংযুক্ত করুন বা না করুন, অ্যাপটি সর্বদা সম্পূর্ণ অফলাইনে কাজ করে।",
"notConnected": "সংযুক্ত নয়",
"connected": "সংযুক্ত",
"connectedTo": "{{url}} এর সাথে সংযুক্ত",
"lastSynced": "সর্বশেষ সিঙ্ক করা হয়েছে {{time}}",
"neverSynced": "কখনো সিঙ্ক করা হয়নি",
"syncError": "সিঙ্ক ত্রুটি: {{message}}",
"needsReauth": "সাইন-ইন এর মেয়াদ শেষ হয়ে গেছে",
"connectButton": "সার্ভারে সংযোগ করুন",
"disconnectButton": "সংযোগ বিচ্ছিন্ন করুন",
"syncNowButton": "এখন সিঙ্ক করুন",
"syncing": "সিঙ্ক হচ্ছে...",
"syncSuccess": "রিমোট সিঙ্ক সম্পন্ন হয়েছে",
"syncUnavailable": "রিমোট সিঙ্ক শুরু করা যায়নি",
"serverUrl": "সার্ভার ইউআরএল",
"enterServerUrl": "অনুগ্রহ করে একটি সার্ভার URL লিখুন",
"mustIncludeProtocol": "সার্ভার URL অবশ্যই http:// অথবা https:// দিয়ে শুরু হতে হবে।",
"connectionTestFailed": "ঐ URL-এ কোনো Termix সার্ভারে পৌঁছানো যায়নি।",
"allowInvalidCertificate": "অবৈধ সার্টিফিকেট অনুমোদন করুন",
"allowInvalidCertificateDesc": "শুধুমাত্র সেলফ-সাইন্ড বা আইপি-অ্যাড্রেস সার্টিফিকেটযুক্ত বিশ্বস্ত সেলফ-হোস্টেড সার্ভারের জন্য ব্যবহার করুন।",
"savedServers": "সংরক্ষিত সার্ভার",
"removeServer": "অপসারণ করুন",
"continueButton": "চালিয়ে যান",
"cancelButton": "বাতিল করুন",
"signInTitle": "{{url}} এ সাইন ইন করুন",
"originTitle": "সংযোগের উৎস",
"originDescription": "ডিফল্টরূপে SSH সংযোগগুলি কোথা থেকে শুরু হবে তা বেছে নিন। এটি প্রতিটি হোস্টের জন্য আলাদাভাবে পরিবর্তন করা যেতে পারে।",
"originLocal": "এই ডিভাইস (স্থানীয় নেটওয়ার্ক)",
"originRemote": "রিমোট সার্ভার",
"bannerReconnect": "পুনরায় সংযোগ করুন",
"bannerMessage": "রিমোট সিঙ্কের জন্য পুনরায় প্রমাণীকরণ প্রয়োজন।"
},
"versionCheck": {
"error": "সংস্করণ যাচাই ত্রুটি",
"checkFailed": "আপডেট পরীক্ষা করতে ব্যর্থ হয়েছে",
"upToDate": "অ্যাপটি হালনাগাদ করা হয়েছে",
"currentVersion": "আপনি {{version}} সংস্করণটি চালাচ্ছেন।",
"updateAvailable": "আপডেট উপলব্ধ",
"updateLinkLabel": "আপডেট উপলব্ধ — গিটহাবে রিলিজ নোট খুলুন।",
"newVersionAvailable": "একটি নতুন সংস্করণ উপলব্ধ! আপনি {{current}}ব্যবহার করছেন, কিন্তু {{latest}} উপলব্ধ আছে।",
"betaVersion": "বিটা সংস্করণ",
"betaVersionDesc": "আপনি {{current}}ব্যবহার করছেন, যা সর্বশেষ স্থিতিশীল রিলিজ {{latest}} এর চেয়ে নতুন।",
"releasedOn": "{{date}} তারিখে প্রকাশিত।",
"downloadUpdate": "আপডেট ডাউনলোড করুন",
"checking": "আপডেট যাচাই করা হচ্ছে...",
"checkUpdates": "আপডেটগুলির জন্য পরীক্ষা করুন",
"checkingUpdates": "আপডেট যাচাই করা হচ্ছে...",
"updateRequired": "আপডেট প্রয়োজন"
"updateLinkLabel": "আপডেট উপলব্ধ — গিটহাবে রিলিজ নোট খুলুন।"
},
"common": {
"close": "বন্ধ করুন",
@@ -1565,8 +1487,6 @@
"resetCodeRateLimitedWithTime": "রেট সীমিত: অনেক বেশি যাচাইকরণ প্রচেষ্টা করা হয়েছে। অনুগ্রহ করে আবার চেষ্টা করার আগে {{time}} সেকেন্ড অপেক্ষা করুন।",
"authTokenSaveFailed": "প্রমাণীকরণ টোকেন সংরক্ষণ করতে ব্যর্থ হয়েছে",
"authTokenMissing": "সার্ভারটি নতুন কোনো প্রমাণীকরণ টোকেন ফেরত দেয়নি। অনুগ্রহ করে আবার সাইন ইন করুন।",
"authTokenRejected": "দূরবর্তী সার্ভার নতুন প্রমাণীকরণ টোকেনটি প্রত্যাখ্যান করেছে।",
"keyringUnavailable": "সাইন ইন করা হয়েছে, কিন্তু সেশনটি সংরক্ষণ করা যায়নি: এটিকে এনক্রিপ্ট করার জন্য এই সিস্টেমে কোনো কী-রিং উপলব্ধ নেই। একটি সিক্রেট সার্ভিস (যেমন gnome-keyring বা KWallet) চালু করুন এবং আবার সাইন ইন করুন।",
"failedToLoadServer": "সার্ভার লোড করতে ব্যর্থ হয়েছে",
"remoteServerRequired": "রিমোট সার্ভার প্রয়োজন। এই সংযোগের ধরণটি ব্যবহার করতে সেটিংস-এ একটি রিমোট সার্ভার সংযুক্ত করুন।"
},
+1 -81
View File
@@ -232,86 +232,8 @@
"widgetTunnelDesc": "Gestor de túnels SSH integrat per a un host configurat",
"widgetNoHostSelected": "No hi ha cap amfitrió configurat"
},
"serverConfig": {
"title": "Configuració del servidor",
"description": "Configureu l'URL del servidor Termix per connectar-vos als vostres serveis de backend",
"serverUrl": "URL del servidor",
"enterServerUrl": "Si us plau, introduïu una URL de servidor",
"saveFailed": "No s'ha pogut desar la configuració",
"saveError": "S'ha produït un error en desar la configuració",
"saving": "Desant...",
"saveConfig": "Desa la configuració",
"helpText": "Introduïu l'URL on s'executa el vostre servidor Termix (per exemple, http://localhost:30001 o https://el-vostre-servidor.com)",
"changeServer": "Canviar servidor",
"mustIncludeProtocol": "L'URL del servidor ha de començar per http:// o https://",
"allowInvalidCertificate": "Permetre certificat no vàlid",
"allowInvalidCertificateDesc": "Utilitzeu-ho només per a servidors autoallotjats de confiança amb certificats autosignats o d'adreça IP.",
"useEmbedded": "Utilitza el servidor local",
"embeddedDesc": "Executeu Termix amb el servidor local integrat (no cal cap servidor remot)",
"embeddedConnecting": "Connectant al servidor local...",
"embeddedNotReady": "El servidor local encara no està a punt. Espereu un moment i torneu-ho a intentar.",
"localServer": "Servidor local",
"savedServers": "Servidors desats",
"noSavedServers": "Cap servidor desat",
"removeServer": "Elimina"
},
"migrationNotice": {
"title": "Termix Desktop ara funciona de manera autònoma",
"body1": "Aquesta aplicació abans es connectava directament al vostre servidor Termix. S'ha reelaborat per funcionar completament per si sola, emmagatzemant els hosts i les credencials localment per tal que continuï funcionant fora de línia. La sincronització bidireccional amb un servidor Termix autoallotjat ara és opcional.",
"body2": "Els vostres amfitrions i credencials existents encara són a {{url}}. Activeu la sincronització remota i torneu a connectar-vos a aquest servidor per tornar-los a sincronitzar i mantenir-los tots dos sincronitzats en el futur.",
"dismiss": "Ara no",
"setUpSync": "Configura la sincronització remota"
},
"remoteSync": {
"title": "Sincronització remota",
"description": "Opcionalment, podeu connectar aquesta aplicació d'escriptori a un servidor Termix autoallotjat per sincronitzar els vostres hosts, credencials i fragments entre dispositius. L'aplicació sempre funciona completament fora de línia, tant si us connecteu com si no.",
"notConnected": "No connectat",
"connected": "Connectat",
"connectedTo": "Connectat a {{url}}",
"lastSynced": "Última sincronització {{time}}",
"neverSynced": "Mai sincronitzat",
"syncError": "Error de sincronització: {{message}}",
"needsReauth": "L'inici de sessió ha caducat",
"connectButton": "Connecta't al servidor",
"disconnectButton": "Desconnecta",
"syncNowButton": "Sincronitza ara",
"syncing": "Sincronitzant...",
"syncSuccess": "Sincronització remota completada",
"syncUnavailable": "No s'ha pogut iniciar la sincronització remota",
"serverUrl": "URL del servidor",
"enterServerUrl": "Si us plau, introduïu una URL de servidor",
"mustIncludeProtocol": "L'URL del servidor ha de començar per http:// o https://",
"connectionTestFailed": "No s'ha pogut connectar amb un servidor Termix en aquesta URL.",
"allowInvalidCertificate": "Permetre certificat no vàlid",
"allowInvalidCertificateDesc": "Utilitzeu-ho només per a servidors autoallotjats de confiança amb certificats autosignats o d'adreça IP.",
"savedServers": "Servidors desats",
"removeServer": "Elimina",
"continueButton": "Continua",
"cancelButton": "Cancel·la",
"signInTitle": "Inicia la sessió a {{url}}",
"originTitle": "Origen de la connexió",
"originDescription": "Trieu l'origen de les connexions SSH per defecte. Això es pot anul·lar per cada host.",
"originLocal": "Aquest dispositiu (xarxa local)",
"originRemote": "Servidor remot",
"bannerReconnect": "Reconnecta",
"bannerMessage": "La sincronització remota necessita una reautenticació"
},
"versionCheck": {
"error": "Error de comprovació de versió",
"checkFailed": "No s'ha pogut comprovar si hi ha actualitzacions",
"upToDate": "L'aplicació està actualitzada",
"currentVersion": "Esteu executant la versió {{version}}",
"updateAvailable": "Actualització disponible",
"updateLinkLabel": "Actualització disponible: obriu les notes de la versió al GitHub",
"newVersionAvailable": "Hi ha una nova versió disponible! Esteu executant {{current}}, però {{latest}} està disponible.",
"betaVersion": "Versió beta",
"betaVersionDesc": "Esteu executant {{current}}, que és més recent que l'última versió estable {{latest}}.",
"releasedOn": "Publicat el {{date}}",
"downloadUpdate": "Descarrega l'actualització",
"checking": "S'estan buscant actualitzacions...",
"checkUpdates": "Comprova si hi ha actualitzacions",
"checkingUpdates": "S'estan buscant actualitzacions...",
"updateRequired": "Actualització necessària"
"updateLinkLabel": "Actualització disponible: obriu les notes de la versió al GitHub"
},
"common": {
"close": "Tanca",
@@ -1565,8 +1487,6 @@
"resetCodeRateLimitedWithTime": "Taxa limitada: massa intents de verificació. Espereu {{time}} segons abans de tornar-ho a intentar.",
"authTokenSaveFailed": "No s'ha pogut desar el testimoni d'autenticació",
"authTokenMissing": "El servidor no ha retornat un testimoni d'autenticació nou. Si us plau, torneu a iniciar la sessió.",
"authTokenRejected": "El servidor remot ha rebutjat el nou testimoni d'autenticació.",
"keyringUnavailable": "S'ha iniciat la sessió, però no s'ha pogut emmagatzemar la sessió: aquest sistema no té cap anell de claus disponible per xifrar-lo. Inicieu un servei secret (com ara gnome-keyring o KWallet) i torneu a iniciar la sessió.",
"failedToLoadServer": "No s'ha pogut carregar el servidor",
"remoteServerRequired": "Cal un servidor remot. Connecteu un servidor remot a Configuració per utilitzar aquest tipus de connexió."
},
+1 -81
View File
@@ -232,86 +232,8 @@
"widgetTunnelDesc": "Vestavěný správce tunelů SSH pro nakonfigurovaný hostitel",
"widgetNoHostSelected": "Žádný hostitel není nakonfigurován"
},
"serverConfig": {
"title": "Konfigurace serveru",
"description": "Nakonfigurujte URL adresu serveru Termix pro připojení k vašim backendovým službám",
"serverUrl": "URL serveru",
"enterServerUrl": "Zadejte prosím URL adresu serveru",
"saveFailed": "Nepodařilo se uložit konfiguraci",
"saveError": "Chyba při ukládání konfigurace",
"saving": "Ukládání...",
"saveConfig": "Uložit konfiguraci",
"helpText": "Zadejte URL adresu, na které běží váš server Termix (např. http://localhost:30001 nebo https://vas-server.com)",
"changeServer": "Změnit server",
"mustIncludeProtocol": "URL serveru musí začínat http:// nebo https://",
"allowInvalidCertificate": "Povolit neplatný certifikát",
"allowInvalidCertificateDesc": "Používejte pouze pro důvěryhodné servery s vlastním hostováním a certifikáty s vlastním podpisem nebo certifikáty IP adresy.",
"useEmbedded": "Použít lokální server",
"embeddedDesc": "Spusťte Termix s vestavěným lokálním serverem (není potřeba vzdálený server)",
"embeddedConnecting": "Připojování k lokálnímu serveru...",
"embeddedNotReady": "Místní server ještě není připraven. Počkejte chvíli a zkuste to znovu.",
"localServer": "Místní server",
"savedServers": "Uložené servery",
"noSavedServers": "Žádné uložené servery",
"removeServer": "Odstranit"
},
"migrationNotice": {
"title": "Termix Desktop nyní běží samostatně",
"body1": "Tato aplikace se dříve připojovala přímo k vašemu serveru Termix. Byla přepracována tak, aby běžela plně samostatně a lokálně ukládala hostitele a přihlašovací údaje, takže fungovala i offline. Obousměrná synchronizace se samostatně hostovaným serverem Termix je nyní volitelná.",
"body2": "Vaši stávající hostitelé a přihlašovací údaje jsou stále na {{url}}. Zapněte vzdálenou synchronizaci a znovu se připojte k danému serveru, abyste je obnovili a udrželi obojí synchronizované i v budoucnu.",
"dismiss": "Teď ne",
"setUpSync": "Nastavení vzdálené synchronizace"
},
"remoteSync": {
"title": "Vzdálená synchronizace",
"description": "Volitelně můžete tuto desktopovou aplikaci připojit k serveru Termix hostovanému na vlastním serveru a synchronizovat hostitele, přihlašovací údaje a úryvky kódu napříč zařízeními. Aplikace vždy funguje plně offline, ať už se připojíte, nebo ne.",
"notConnected": "Nepřipojeno",
"connected": "Připojeno",
"connectedTo": "Připojeno k {{url}}",
"lastSynced": "Naposledy synchronizováno {{time}}",
"neverSynced": "Nikdy nesynchronizováno",
"syncError": "Chyba synchronizace: {{message}}",
"needsReauth": "Platnost přihlášení vypršela",
"connectButton": "Připojení k serveru",
"disconnectButton": "Odpojit",
"syncNowButton": "Synchronizovat nyní",
"syncing": "Synchronizace...",
"syncSuccess": "Vzdálená synchronizace dokončena",
"syncUnavailable": "Vzdálenou synchronizaci se nepodařilo spustit",
"serverUrl": "URL serveru",
"enterServerUrl": "Zadejte prosím URL adresu serveru",
"mustIncludeProtocol": "URL serveru musí začínat http:// nebo https://",
"connectionTestFailed": "Na dané URL adrese se nepodařilo spojit se se serverem Termix.",
"allowInvalidCertificate": "Povolit neplatný certifikát",
"allowInvalidCertificateDesc": "Používejte pouze pro důvěryhodné servery s vlastním hostováním a certifikáty s vlastním podpisem nebo certifikáty IP adresy.",
"savedServers": "Uložené servery",
"removeServer": "Odstranit",
"continueButton": "Pokračovat",
"cancelButton": "Zrušit",
"signInTitle": "Přihlaste se do {{url}}",
"originTitle": "Původ připojení",
"originDescription": "Ve výchozím nastavení vyberte, odkud SSH připojení pocházejí. Toto nastavení lze u jednotlivých hostitelů přepsat.",
"originLocal": "Toto zařízení (lokální síť)",
"originRemote": "Vzdálený server",
"bannerReconnect": "Znovu se připojte",
"bannerMessage": "Vzdálená synchronizace vyžaduje opětovné ověření"
},
"versionCheck": {
"error": "Chyba kontroly verze",
"checkFailed": "Nepodařilo se zkontrolovat aktualizace",
"upToDate": "Aplikace je aktuální",
"currentVersion": "Používáte verzi {{version}}",
"updateAvailable": "Aktualizace k dispozici",
"updateLinkLabel": "Aktualizace k dispozici – otevřete si poznámky k verzi na GitHubu",
"newVersionAvailable": "Je k dispozici nová verze! Používáte {{current}}, ale je k dispozici {{latest}}.",
"betaVersion": "Beta verze",
"betaVersionDesc": "Používáte verzi {{current}}, která je novější než nejnovější stabilní verze {{latest}}.",
"releasedOn": "Vydáno dne {{date}}",
"downloadUpdate": "Stáhnout aktualizaci",
"checking": "Kontrola aktualizací...",
"checkUpdates": "Zkontrolovat aktualizace",
"checkingUpdates": "Kontrola aktualizací...",
"updateRequired": "Vyžadována aktualizace"
"updateLinkLabel": "Aktualizace k dispozici – otevřete si poznámky k verzi na GitHubu"
},
"common": {
"close": "Blízko",
@@ -1565,8 +1487,6 @@
"resetCodeRateLimitedWithTime": "Omezená rychlost: Příliš mnoho pokusů o ověření. Před dalším pokusem prosím počkejte {{time}} sekund.",
"authTokenSaveFailed": "Uložení ověřovacího tokenu se nezdařilo",
"authTokenMissing": "Server nevrátil nový ověřovací token. Přihlaste se prosím znovu.",
"authTokenRejected": "Vzdálený server odmítl nový ověřovací token.",
"keyringUnavailable": "Přihlášen/a jsem, ale relaci se nepodařilo uložit: tento systém nemá k dispozici žádný svazek klíčů pro její zašifrování. Spusťte tajnou službu (například gnome-keyring nebo KWallet) a znovu se přihlaste.",
"failedToLoadServer": "Nepodařilo se načíst server",
"remoteServerRequired": "Je vyžadován vzdálený server. Pro použití tohoto typu připojení se připojte ke vzdálenému serveru v Nastavení."
},
+1 -81
View File
@@ -232,86 +232,8 @@
"widgetTunnelDesc": "Indlejret SSH-tunnelmanager til en konfigureret vært",
"widgetNoHostSelected": "Ingen vært konfigureret"
},
"serverConfig": {
"title": "Serverkonfiguration",
"description": "Konfigurer Termix-serverens URL for at oprette forbindelse til dine backend-tjenester",
"serverUrl": "Server-URL",
"enterServerUrl": "Indtast venligst en server-URL",
"saveFailed": "Konfigurationen kunne ikke gemmes",
"saveError": "Fejl ved lagring af konfiguration",
"saving": "Gemmer...",
"saveConfig": "Gem konfiguration",
"helpText": "Indtast URL'en, hvor din Termix-server kører (f.eks. http://localhost:30001 eller https://your-server.com)",
"changeServer": "Skift server",
"mustIncludeProtocol": "Server-URL skal starte med http:// eller https://",
"allowInvalidCertificate": "Tillad ugyldigt certifikat",
"allowInvalidCertificateDesc": "Brug kun til betroede, selvhostede servere med selvsignerede certifikater eller IP-adressecertifikater.",
"useEmbedded": "Brug lokal server",
"embeddedDesc": "Kør Termix med den indbyggede lokale server (ingen fjernserver nødvendig)",
"embeddedConnecting": "Forbinder til lokal server...",
"embeddedNotReady": "Den lokale server er ikke klar endnu. Vent et øjeblik, og prøv igen.",
"localServer": "Lokal server",
"savedServers": "Gemte servere",
"noSavedServers": "Ingen gemte servere",
"removeServer": "Fjerne"
},
"migrationNotice": {
"title": "Termix Desktop kører nu selvstændigt",
"body1": "Denne app plejede at oprette forbindelse direkte til din Termix-server. Den er blevet omarbejdet til at køre fuldt ud alene og gemmer værter og legitimationsoplysninger lokalt, så den fortsætter med at fungere offline. Tovejssynkronisering med en selvhostet Termix-server er nu valgfri.",
"body2": "Dine eksisterende værter og loginoplysninger er stadig på {{url}}. Aktivér fjernsynkronisering, og opret forbindelse til serveren igen for at få dem tilbage og holde begge synkroniseret fremadrettet.",
"dismiss": "Ikke nu",
"setUpSync": "Opsæt fjernsynkronisering"
},
"remoteSync": {
"title": "Fjernsynkronisering",
"description": "Du kan eventuelt tilslutte denne desktop-app til en selvhostet Termix-server for at synkronisere dine værter, legitimationsoplysninger og kodestykker på tværs af enheder. Appen fungerer altid fuldt offline, uanset om du opretter forbindelse eller ej.",
"notConnected": "Ikke forbundet",
"connected": "Forbundet",
"connectedTo": "Forbundet til {{url}}",
"lastSynced": "Sidst synkroniseret {{time}}",
"neverSynced": "Aldrig synkroniseret",
"syncError": "Synkroniseringsfejl: {{message}}",
"needsReauth": "Login udløbet",
"connectButton": "Opret forbindelse til serveren",
"disconnectButton": "Afbryde",
"syncNowButton": "Synkroniser nu",
"syncing": "Synkroniserer...",
"syncSuccess": "Fjernsynkronisering fuldført",
"syncUnavailable": "Fjernsynkronisering kunne ikke startes",
"serverUrl": "Server-URL",
"enterServerUrl": "Indtast venligst en server-URL",
"mustIncludeProtocol": "Server-URL skal starte med http:// eller https://",
"connectionTestFailed": "Kunne ikke nå en Termix-server på den URL",
"allowInvalidCertificate": "Tillad ugyldigt certifikat",
"allowInvalidCertificateDesc": "Brug kun til betroede, selvhostede servere med selvsignerede certifikater eller IP-adressecertifikater.",
"savedServers": "Gemte servere",
"removeServer": "Fjerne",
"continueButton": "Fortsætte",
"cancelButton": "Ophæve",
"signInTitle": "Log ind på {{url}}",
"originTitle": "Forbindelsens oprindelse",
"originDescription": "Vælg som standard, hvor SSH-forbindelser stammer fra. Dette kan tilsidesættes pr. vært.",
"originLocal": "Denne enhed (lokalt netværk)",
"originRemote": "Fjernserver",
"bannerReconnect": "Genopret forbindelse",
"bannerMessage": "Fjernsynkronisering skal godkendes igen"
},
"versionCheck": {
"error": "Versionskontrolfejl",
"checkFailed": "Kunne ikke søge efter opdateringer",
"upToDate": "Appen er opdateret",
"currentVersion": "Du kører version {{version}}",
"updateAvailable": "Opdatering tilgængelig",
"updateLinkLabel": "Opdatering tilgængelig — åbn udgivelsesnoterne på GitHub",
"newVersionAvailable": "En ny version er tilgængelig! Du kører {{current}}, men {{latest}} er tilgængelig.",
"betaVersion": "Betaversion",
"betaVersionDesc": "Du kører {{current}}, som er nyere end den seneste stabile udgivelse {{latest}}.",
"releasedOn": "Udgivet den {{date}}",
"downloadUpdate": "Download opdatering",
"checking": "Søger efter opdateringer...",
"checkUpdates": "Tjek for opdateringer",
"checkingUpdates": "Søger efter opdateringer...",
"updateRequired": "Opdatering påkrævet"
"updateLinkLabel": "Opdatering tilgængelig — åbn udgivelsesnoterne på GitHub"
},
"common": {
"close": "Tæt",
@@ -1565,8 +1487,6 @@
"resetCodeRateLimitedWithTime": "Begrænset hastighed: For mange bekræftelsesforsøg. Vent {{time}} sekunder, før du prøver igen.",
"authTokenSaveFailed": "Kunne ikke gemme godkendelsestoken",
"authTokenMissing": "Serveren returnerede ikke et nyt godkendelsestoken. Log venligst ind igen.",
"authTokenRejected": "Fjernserveren afviste det nye godkendelsestoken",
"keyringUnavailable": "Logget ind, men sessionen kunne ikke gemmes: dette system har ingen nøglering tilgængelig til at kryptere den med. Start en hemmelig tjeneste (såsom gnome-keyring eller KWallet) og log ind igen.",
"failedToLoadServer": "Kunne ikke indlæse serveren",
"remoteServerRequired": "Fjernserver kræves. Opret forbindelse til en fjernserver i Indstillinger for at bruge denne forbindelsestype."
},
+1 -81
View File
@@ -232,86 +232,8 @@
"widgetTunnelDesc": "Eingebetteter SSH-Tunnel-Manager für einen konfigurierten Host",
"widgetNoHostSelected": "Kein Host konfiguriert"
},
"serverConfig": {
"title": "Serverkonfiguration",
"description": "Konfigurieren Sie die Termix-Server-URL, um eine Verbindung zu Ihren Backend-Diensten herzustellen",
"serverUrl": "Server-URL",
"enterServerUrl": "Bitte geben Sie eine Server-URL ein",
"saveFailed": "Fehler beim Speichern der Konfiguration",
"saveError": "Fehler beim Speichern der Konfiguration",
"saving": "Speichern...",
"saveConfig": "Konfiguration speichern",
"helpText": "Geben Sie die URL ein, unter der Ihr Termix-Server läuft (z. B. http://localhost:30001 oder https://your-server.com)",
"changeServer": "Server wechseln",
"mustIncludeProtocol": "Die Server-URL muss mit http:// oder https:// beginnen",
"allowInvalidCertificate": "Ungültiges Zertifikat zulassen",
"allowInvalidCertificateDesc": "Nur für vertrauenswürdige selbst gehostete Server mit selbstsignierten oder IP-Adress-Zertifikaten verwenden.",
"useEmbedded": "Lokalen Server verwenden",
"embeddedDesc": "Termix mit dem integrierten lokalen Server ausführen (kein Remote-Server erforderlich)",
"embeddedConnecting": "Verbinde mit lokalem Server...",
"embeddedNotReady": "Der lokale Server ist noch nicht bereit. Bitte warten Sie einen Moment und versuchen Sie es erneut.",
"localServer": "Lokaler Server",
"savedServers": "Gespeicherte Server",
"noSavedServers": "Keine gespeicherten Server",
"removeServer": "Entfernen"
},
"migrationNotice": {
"title": "Termix Desktop läuft jetzt eigenständig",
"body1": "Diese App verband sich früher direkt mit Ihrem Termix-Server. Sie wurde überarbeitet und läuft nun vollständig eigenständig. Hosts und Zugangsdaten werden lokal gespeichert, sodass die App auch offline funktioniert. Die bidirektionale Synchronisierung mit einem selbst gehosteten Termix-Server ist jetzt optional.",
"body2": "Ihre bestehenden Hosts und Anmeldeinformationen befinden sich noch auf {{url}}. Aktivieren Sie die Remote-Synchronisierung und stellen Sie die Verbindung zu diesem Server wieder her, um sie wiederherzustellen und zukünftig synchron zu halten.",
"dismiss": "Nicht jetzt.",
"setUpSync": "Fernsynchronisierung einrichten"
},
"remoteSync": {
"title": "Fernsynchronisierung",
"description": "Sie können diese Desktop-App optional mit einem selbst gehosteten Termix-Server verbinden, um Ihre Hosts, Anmeldeinformationen und Code-Snippets geräteübergreifend zu synchronisieren. Die App funktioniert aber auch offline einwandfrei, unabhängig davon, ob eine Verbindung besteht oder nicht.",
"notConnected": "Nicht verbunden",
"connected": "Verbunden",
"connectedTo": "Verbunden mit {{url}}",
"lastSynced": "Zuletzt synchronisiert {{time}}",
"neverSynced": "Nie synchronisiert",
"syncError": "Synchronisierungsfehler: {{message}}",
"needsReauth": "Anmeldung abgelaufen",
"connectButton": "Verbindung zum Server herstellen",
"disconnectButton": "Trennen",
"syncNowButton": "Jetzt synchronisieren",
"syncing": "Synchronisierung läuft...",
"syncSuccess": "Remote-Synchronisierung abgeschlossen",
"syncUnavailable": "Die Remote-Synchronisierung konnte nicht gestartet werden.",
"serverUrl": "Server-URL",
"enterServerUrl": "Bitte geben Sie eine Server-URL ein.",
"mustIncludeProtocol": "Die Server-URL muss mit http:// oder https:// beginnen.",
"connectionTestFailed": "Unter dieser URL konnte kein Termix-Server erreicht werden.",
"allowInvalidCertificate": "Ungültiges Zertifikat zulassen",
"allowInvalidCertificateDesc": "Nur für vertrauenswürdige, selbstgehostete Server mit selbstsignierten oder IP-Adresszertifikaten verwenden.",
"savedServers": "Gespeicherte Server",
"removeServer": "Entfernen",
"continueButton": "Weitermachen",
"cancelButton": "Stornieren",
"signInTitle": "Melden Sie sich an bei {{url}}",
"originTitle": "Verbindungsursprung",
"originDescription": "Wählen Sie den Standardort für SSH-Verbindungen aus. Diese Einstellung kann für jeden Host individuell angepasst werden.",
"originLocal": "Dieses Gerät (lokales Netzwerk)",
"originRemote": "Remote-Server",
"bannerReconnect": "Wiederverbinden",
"bannerMessage": "Für die Remote-Synchronisierung ist eine erneute Authentifizierung erforderlich."
},
"versionCheck": {
"error": "Fehler bei der Versionsprüfung",
"checkFailed": "Fehler bei der Update-Prüfung",
"upToDate": "App ist auf dem neuesten Stand",
"currentVersion": "Sie verwenden Version {{version}}",
"updateAvailable": "Update verfügbar",
"updateLinkLabel": "Update verfügbar – die Versionshinweise finden Sie auf GitHub.",
"newVersionAvailable": "Eine neue Version ist verfügbar! Sie verwenden {{current}}, aber {{latest}} steht zur Verfügung.",
"betaVersion": "Beta-Version",
"betaVersionDesc": "Sie verwenden {{current}}, das neuer ist als die letzte stabile Version {{latest}}.",
"releasedOn": "Veröffentlicht am {{date}}",
"downloadUpdate": "Update herunterladen",
"checking": "Suche nach Updates...",
"checkUpdates": "Auf Updates prüfen",
"checkingUpdates": "Suche nach Updates...",
"updateRequired": "Update erforderlich"
"updateLinkLabel": "Update verfügbar – die Versionshinweise finden Sie auf GitHub."
},
"common": {
"close": "Schließen",
@@ -1565,8 +1487,6 @@
"resetCodeRateLimitedWithTime": "Ratenbegrenzung: Zu viele Verifizierungsversuche. Bitte warten Sie {{time}} Sekunden, bevor Sie es erneut versuchen.",
"authTokenSaveFailed": "Fehler beim Speichern des Authentifizierungstokens",
"authTokenMissing": "Der Server hat kein neues Authentifizierungstoken zurückgegeben. Bitte melden Sie sich erneut an.",
"authTokenRejected": "Der Remote-Server hat das neue Authentifizierungstoken abgelehnt.",
"keyringUnavailable": "Sie sind angemeldet, aber die Sitzung konnte nicht gespeichert werden: Für dieses System ist kein Schlüsselbund zur Verschlüsselung verfügbar. Starten Sie einen geheimen Dienst (z. B. gnome-keyring oder KWallet) und melden Sie sich erneut an.",
"failedToLoadServer": "Server konnte nicht geladen werden",
"remoteServerRequired": "Remote-Server erforderlich. Um diesen Verbindungstyp zu verwenden, verbinden Sie einen Remote-Server in den Einstellungen."
},
+1 -81
View File
@@ -232,86 +232,8 @@
"widgetTunnelDesc": "Ενσωματωμένος διαχειριστής σήραγγας SSH για έναν διαμορφωμένο κεντρικό υπολογιστή",
"widgetNoHostSelected": "Δεν έχει ρυθμιστεί κεντρικός υπολογιστής"
},
"serverConfig": {
"title": "Ρύθμιση παραμέτρων διακομιστή",
"description": "Ρυθμίστε τη διεύθυνση URL του διακομιστή Termix για να συνδεθείτε στις υπηρεσίες backend σας",
"serverUrl": "URL διακομιστή",
"enterServerUrl": "Παρακαλώ εισάγετε μια διεύθυνση URL διακομιστή",
"saveFailed": "Η αποθήκευση της διαμόρφωσης απέτυχε",
"saveError": "Σφάλμα κατά την αποθήκευση της διαμόρφωσης",
"saving": "Οικονομία...",
"saveConfig": "Αποθήκευση διαμόρφωσης",
"helpText": "Εισαγάγετε τη διεύθυνση URL όπου εκτελείται ο διακομιστής Termix (π.χ. http://localhost:30001 ή https://your-server.com)",
"changeServer": "Αλλαγή διακομιστή",
"mustIncludeProtocol": "Η διεύθυνση URL του διακομιστή πρέπει να ξεκινά με http:// ή https://",
"allowInvalidCertificate": "Να επιτρέπεται το μη έγκυρο πιστοποιητικό",
"allowInvalidCertificateDesc": "Χρησιμοποιήστε μόνο για αξιόπιστους αυτο-φιλοξενούμενους διακομιστές με αυτο-υπογεγραμμένα πιστοποιητικά ή πιστοποιητικά διεύθυνσης IP.",
"useEmbedded": "Χρήση τοπικού διακομιστή",
"embeddedDesc": "Εκτελέστε το Termix με τον ενσωματωμένο τοπικό διακομιστή (δεν απαιτείται απομακρυσμένος διακομιστής)",
"embeddedConnecting": "Σύνδεση με τοπικό διακομιστή...",
"embeddedNotReady": "Ο τοπικός διακομιστής δεν είναι ακόμη έτοιμος. Περιμένετε λίγο και δοκιμάστε ξανά.",
"localServer": "Τοπικός διακομιστής",
"savedServers": "Αποθηκευμένοι διακομιστές",
"noSavedServers": "Δεν υπάρχουν αποθηκευμένοι διακομιστές",
"removeServer": "Αφαιρώ"
},
"migrationNotice": {
"title": "Το Termix Desktop λειτουργεί πλέον αυτόνομα.",
"body1": "Αυτή η εφαρμογή συνδεόταν απευθείας με τον διακομιστή Termix. Έχει αναδιαμορφωθεί ώστε να λειτουργεί πλήρως αυτόνομα, αποθηκεύοντας τοπικά τους κεντρικούς υπολογιστές και τα διαπιστευτήρια, ώστε να συνεχίζει να λειτουργεί εκτός σύνδεσης. Ο αμφίδρομος συγχρονισμός με έναν αυτο-φιλοξενούμενο διακομιστή Termix είναι πλέον προαιρετικός.",
"body2": "Οι υπάρχοντες κεντρικοί υπολογιστές και τα διαπιστευτήριά σας εξακολουθούν να βρίσκονται στη θέση {{url}}. Ενεργοποιήστε τον Απομακρυσμένο συγχρονισμό και συνδεθείτε ξανά σε αυτόν τον διακομιστή για να τα επαναφέρετε και να διατηρήσετε και τα δύο συγχρονισμένα στο μέλλον.",
"dismiss": "Όχι τώρα",
"setUpSync": "Ρύθμιση απομακρυσμένου συγχρονισμού"
},
"remoteSync": {
"title": "Απομακρυσμένος συγχρονισμός",
"description": "Προαιρετικά, συνδέστε αυτήν την εφαρμογή για υπολογιστές σε έναν αυτο-φιλοξενούμενο διακομιστή Termix για να συγχρονίσετε τους κεντρικούς υπολογιστές, τα διαπιστευτήρια και τα αποσπάσματα σε όλες τις συσκευές. Η εφαρμογή λειτουργεί πάντα πλήρως εκτός σύνδεσης, ανεξάρτητα από το αν συνδεθείτε ή όχι.",
"notConnected": "Δεν είναι συνδεδεμένο",
"connected": "Συνδεδεμένος",
"connectedTo": "Συνδεδεμένο με {{url}}",
"lastSynced": "Τελευταίος συγχρονισμός {{time}}",
"neverSynced": "Δεν συγχρονίστηκε ποτέ",
"syncError": "Σφάλμα συγχρονισμού: {{message}}",
"needsReauth": "Η σύνδεση έληξε",
"connectButton": "Σύνδεση με διακομιστή",
"disconnectButton": "Αποσυνδέω",
"syncNowButton": "Συγχρονισμός τώρα",
"syncing": "Συγχρονισμός...",
"syncSuccess": "Ο απομακρυσμένος συγχρονισμός ολοκληρώθηκε",
"syncUnavailable": "Δεν ήταν δυνατή η έναρξη του απομακρυσμένου συγχρονισμού",
"serverUrl": "URL διακομιστή",
"enterServerUrl": "Παρακαλώ εισάγετε μια διεύθυνση URL διακομιστή",
"mustIncludeProtocol": "Η διεύθυνση URL του διακομιστή πρέπει να ξεκινά με http:// ή https://",
"connectionTestFailed": "Δεν ήταν δυνατή η επικοινωνία με έναν διακομιστή Termix σε αυτήν τη διεύθυνση URL",
"allowInvalidCertificate": "Να επιτρέπεται το μη έγκυρο πιστοποιητικό",
"allowInvalidCertificateDesc": "Χρησιμοποιήστε μόνο για αξιόπιστους αυτο-φιλοξενούμενους διακομιστές με αυτο-υπογεγραμμένα πιστοποιητικά ή πιστοποιητικά διεύθυνσης IP.",
"savedServers": "Αποθηκευμένοι διακομιστές",
"removeServer": "Αφαιρώ",
"continueButton": "Συνεχίζω",
"cancelButton": "Ματαίωση",
"signInTitle": "Συνδεθείτε στο {{url}}",
"originTitle": "Προέλευση σύνδεσης",
"originDescription": "Επιλέξτε από προεπιλογή την προέλευση των συνδέσεων SSH. Αυτό μπορεί να παρακαμφθεί ανά κεντρικό υπολογιστή.",
"originLocal": "Αυτή η συσκευή (τοπικό δίκτυο)",
"originRemote": "Απομακρυσμένος διακομιστής",
"bannerReconnect": "Επανασύνδεση",
"bannerMessage": "Ο απομακρυσμένος συγχρονισμός απαιτεί εκ νέου έλεγχο ταυτότητας"
},
"versionCheck": {
"error": "Σφάλμα ελέγχου έκδοσης",
"checkFailed": "Αποτυχία ελέγχου για ενημερώσεις",
"upToDate": "Η εφαρμογή είναι ενημερωμένη",
"currentVersion": "Χρησιμοποιείτε την έκδοση {{version}}",
"updateAvailable": "Διαθέσιμη ενημέρωση",
"updateLinkLabel": "Διαθέσιμη ενημέρωση — ανοίξτε τις σημειώσεις έκδοσης στο GitHub",
"newVersionAvailable": "Μια νέα έκδοση είναι διαθέσιμη! Χρησιμοποιείτε την έκδοση {{current}}, αλλά η έκδοση {{latest}} είναι διαθέσιμη.",
"betaVersion": "Έκδοση Beta",
"betaVersionDesc": "Εκτελείτε την έκδοση {{current}}, η οποία είναι νεότερη από την τελευταία σταθερή έκδοση {{latest}}.",
"releasedOn": "Κυκλοφόρησε στις {{date}}",
"downloadUpdate": "Λήψη ενημέρωσης",
"checking": "Έλεγχος για ενημερώσεις...",
"checkUpdates": "Έλεγχος για ενημερώσεις",
"checkingUpdates": "Έλεγχος για ενημερώσεις...",
"updateRequired": "Απαιτείται ενημέρωση"
"updateLinkLabel": "Διαθέσιμη ενημέρωση — ανοίξτε τις σημειώσεις έκδοσης στο GitHub"
},
"common": {
"close": "Κοντά",
@@ -1565,8 +1487,6 @@
"resetCodeRateLimitedWithTime": "Περιορισμένος ρυθμός: Πάρα πολλές προσπάθειες επαλήθευσης. Παρακαλώ περιμένετε {{time}} δευτερόλεπτα πριν προσπαθήσετε ξανά.",
"authTokenSaveFailed": "Αποτυχία αποθήκευσης διακριτικού ελέγχου ταυτότητας",
"authTokenMissing": "Ο διακομιστής δεν επέστρεψε νέο διακριτικό ελέγχου ταυτότητας. Συνδεθείτε ξανά.",
"authTokenRejected": "Ο απομακρυσμένος διακομιστής απέρριψε το νέο διακριτικό ελέγχου ταυτότητας",
"keyringUnavailable": "Συνδέθηκα, αλλά η συνεδρία δεν ήταν δυνατή: αυτό το σύστημα δεν έχει διαθέσιμο keyring για κρυπτογράφηση. Ξεκινήστε μια Μυστική Υπηρεσία (όπως το gnome-keyring ή το KWallet) και συνδεθείτε ξανά.",
"failedToLoadServer": "Αποτυχία φόρτωσης διακομιστή",
"remoteServerRequired": "Απαιτείται απομακρυσμένος διακομιστής. Συνδέστε έναν απομακρυσμένο διακομιστή στις Ρυθμίσεις για να χρησιμοποιήσετε αυτόν τον τύπο σύνδεσης."
},
+1 -81
View File
@@ -232,86 +232,8 @@
"widgetTunnelDesc": "Gestor de túneles SSH integrado para un host configurado",
"widgetNoHostSelected": "Sin host configurado"
},
"serverConfig": {
"title": "Configuración del servidor",
"description": "Configure la URL del servidor Termix para conectarse a sus servicios backend",
"serverUrl": "URL del servidor",
"enterServerUrl": "Introduzca una URL del servidor",
"saveFailed": "No se pudo guardar la configuración",
"saveError": "Error al guardar la configuración",
"saving": "Guardando...",
"saveConfig": "Guardar configuración",
"helpText": "Introduzca la URL donde se ejecuta su servidor Termix (por ejemplo, http://localhost:30001 o https://your-server.com)",
"changeServer": "Cambiar servidor",
"mustIncludeProtocol": "La URL del servidor debe comenzar con http:// o https://",
"allowInvalidCertificate": "Permitir certificado no válido",
"allowInvalidCertificateDesc": "Usar solo para servidores autoalojados de confianza con certificados autofirmados o de dirección IP.",
"useEmbedded": "Usar servidor local",
"embeddedDesc": "Ejecutar Termix con el servidor local integrado (no se necesita servidor remoto)",
"embeddedConnecting": "Conectando al servidor local...",
"embeddedNotReady": "El servidor local aún no está listo. Espere un momento e inténtelo de nuevo.",
"localServer": "Servidor local",
"savedServers": "Servidores guardados",
"noSavedServers": "No hay servidores guardados",
"removeServer": "Eliminar"
},
"migrationNotice": {
"title": "Termix Desktop ahora se ejecuta de forma independiente.",
"body1": "Esta aplicación solía conectarse directamente a tu servidor Termix. Se ha rediseñado para funcionar de forma totalmente independiente, almacenando los hosts y las credenciales localmente para que siga funcionando sin conexión. La sincronización bidireccional con un servidor Termix autohospedado ahora es opcional.",
"body2": "Tus hosts y credenciales actuales siguen en {{url}}. Activa la sincronización remota y vuelve a conectarte a ese servidor para recuperarlos y mantenerlos sincronizados en adelante.",
"dismiss": "Ahora no",
"setUpSync": "Configurar la sincronización remota"
},
"remoteSync": {
"title": "Sincronización remota",
"description": "Opcionalmente, conecta esta aplicación de escritorio a un servidor Termix autogestionado para sincronizar tus hosts, credenciales y fragmentos de código en todos tus dispositivos. La aplicación siempre funciona sin conexión, independientemente de si te conectas o no.",
"notConnected": "No conectado",
"connected": "Conectado",
"connectedTo": "Conectado a {{url}}",
"lastSynced": "Última sincronización {{time}}",
"neverSynced": "Nunca sincronizado",
"syncError": "Error de sincronización: {{message}}",
"needsReauth": "La sesión ha caducado.",
"connectButton": "Conéctese al servidor",
"disconnectButton": "Desconectar",
"syncNowButton": "Sincronizar ahora",
"syncing": "Sincronizando...",
"syncSuccess": "Sincronización remota completada",
"syncUnavailable": "No se pudo iniciar la sincronización remota.",
"serverUrl": "URL del servidor",
"enterServerUrl": "Por favor, introduzca una URL del servidor.",
"mustIncludeProtocol": "La URL del servidor debe comenzar con http:// o https://",
"connectionTestFailed": "No se pudo conectar con un servidor Termix en esa URL.",
"allowInvalidCertificate": "Permitir certificado no válido",
"allowInvalidCertificateDesc": "Utilizar únicamente en servidores autogestionados de confianza con certificados autofirmados o basados en direcciones IP.",
"savedServers": "Servidores guardados",
"removeServer": "Eliminar",
"continueButton": "Continuar",
"cancelButton": "Cancelar",
"signInTitle": "Iniciar sesión en {{url}}",
"originTitle": "Origen de la conexión",
"originDescription": "Se elige de forma predeterminada el origen de las conexiones SSH. Esta configuración se puede modificar para cada host.",
"originLocal": "Este dispositivo (red local)",
"originRemote": "Servidor remoto",
"bannerReconnect": "Reconectar",
"bannerMessage": "La sincronización remota requiere una nueva autenticación."
},
"versionCheck": {
"error": "Error de verificación de versión",
"checkFailed": "Fallo al comprobar actualizaciones",
"upToDate": "La aplicación está actualizada",
"currentVersion": "Estás usando la versión {{version}}",
"updateAvailable": "Actualización disponible",
"updateLinkLabel": "Actualización disponible: consulta las notas de la versión en GitHub.",
"newVersionAvailable": "¡Hay una nueva versión disponible! Estás usando la {{current}}, pero la {{latest}} está disponible.",
"betaVersion": "Versión Beta",
"betaVersionDesc": "Estás usando {{current}}, que es más reciente que la última versión estable {{latest}}.",
"releasedOn": "Publicado el {{date}}",
"downloadUpdate": "Descargar actualización",
"checking": "Comprobando actualizaciones...",
"checkUpdates": "Buscar actualizaciones",
"checkingUpdates": "Comprobando actualizaciones...",
"updateRequired": "Actualización necesaria"
"updateLinkLabel": "Actualización disponible: consulta las notas de la versión en GitHub."
},
"common": {
"close": "Cerrar",
@@ -1565,8 +1487,6 @@
"resetCodeRateLimitedWithTime": "Límite de intentos: Demasiados intentos de verificación. Espere {{time}} segundos antes de volver a intentarlo.",
"authTokenSaveFailed": "Error al guardar el token de autenticación",
"authTokenMissing": "El servidor no devolvió un nuevo token de autenticación. Inicie sesión de nuevo.",
"authTokenRejected": "El servidor remoto rechazó el nuevo token de autenticación.",
"keyringUnavailable": "Se ha iniciado sesión, pero no se ha podido guardar la sesión: este sistema no dispone de un llavero de claves para cifrarla. Inicie un servicio de cifrado (como gnome-keyring o KWallet) e inicie sesión de nuevo.",
"failedToLoadServer": "Error al cargar el servidor",
"remoteServerRequired": "Se requiere un servidor remoto. Conecte un servidor remoto en Configuración para usar este tipo de conexión."
},
+1 -81
View File
@@ -232,86 +232,8 @@
"widgetTunnelDesc": "Sulautettu SSH-tunnelinhallinta konfiguroidulle isännälle",
"widgetNoHostSelected": "Ei määritettyä isäntää"
},
"serverConfig": {
"title": "Palvelimen kokoonpano",
"description": "Määritä Termix-palvelimen URL-osoite yhteyden muodostamiseksi taustapalveluihin",
"serverUrl": "Palvelimen URL-osoite",
"enterServerUrl": "Anna palvelimen URL-osoite",
"saveFailed": "Määritysten tallennus epäonnistui",
"saveError": "Virhe tallennettaessa määritystä",
"saving": "Tallennetaan...",
"saveConfig": "Tallenna kokoonpano",
"helpText": "Anna URL-osoite, jossa Termix-palvelimesi toimii (esim. http://localhost:30001 tai https://your-server.com)",
"changeServer": "Vaihda palvelinta",
"mustIncludeProtocol": "Palvelimen URL-osoitteen on alettava http:// tai https://",
"allowInvalidCertificate": "Salli virheellinen varmenne",
"allowInvalidCertificateDesc": "Käytä vain luotettaville itse isännöidyille palvelimille, joissa on itse allekirjoitetut tai IP-osoitevarmenteet.",
"useEmbedded": "Käytä paikallista palvelinta",
"embeddedDesc": "Suorita Termix sisäänrakennetulla paikallisella palvelimella (etäpalvelinta ei tarvita)",
"embeddedConnecting": "Yhdistetään paikalliseen palvelimeen...",
"embeddedNotReady": "Paikallinen palvelin ei ole vielä valmis. Odota hetki ja yritä uudelleen.",
"localServer": "Paikallinen palvelin",
"savedServers": "Tallennetut palvelimet",
"noSavedServers": "Ei tallennettuja palvelimia",
"removeServer": "Poistaa"
},
"migrationNotice": {
"title": "Termix Desktop toimii nyt itsenäisesti",
"body1": "Tämä sovellus muodosti aiemmin yhteyden suoraan Termix-palvelimeesi. Se on uudistettu toimimaan täysin itsenäisesti tallentaen isännät ja tunnistetiedot paikallisesti, joten se toimii edelleen offline-tilassa. Kaksisuuntainen synkronointi itse isännöidyn Termix-palvelimen kanssa on nyt valinnainen.",
"body2": "Nykyiset isäntäsi ja tunnistetietosi ovat edelleen {{url}}. Ota etäsynkronointi käyttöön ja muodosta yhteys kyseiseen palvelimeen uudelleen palauttaaksesi ne ja pitääksesi molemmat synkronoituina jatkossa.",
"dismiss": "Ei nyt",
"setUpSync": "Etäsynkronoinnin määrittäminen"
},
"remoteSync": {
"title": "Etäsynkronointi",
"description": "Voit halutessasi yhdistää tämän työpöytäsovelluksen itse isännöityyn Termix-palvelimeen synkronoidaksesi isännöitsijäsi, tunnistetietosi ja koodinpätkäsi eri laitteiden välillä. Sovellus toimii aina täysin offline-tilassa riippumatta siitä, oletko yhteydessä palvelimeen vai et.",
"notConnected": "Ei yhdistetty",
"connected": "Yhdistetty",
"connectedTo": "Yhdistetty kohteeseen {{url}}",
"lastSynced": "Viimeksi synkronoitu {{time}}",
"neverSynced": "Ei koskaan synkronoitu",
"syncError": "Synkronointivirhe: {{message}}",
"needsReauth": "Kirjautuminen vanhentunut",
"connectButton": "Yhdistä palvelimeen",
"disconnectButton": "Katkaise yhteys",
"syncNowButton": "Synkronoi nyt",
"syncing": "Synkronoidaan...",
"syncSuccess": "Etäsynkronointi valmis",
"syncUnavailable": "Etäsynkronointia ei voitu aloittaa",
"serverUrl": "Palvelimen URL-osoite",
"enterServerUrl": "Anna palvelimen URL-osoite",
"mustIncludeProtocol": "Palvelimen URL-osoitteen on alettava http:// tai https://",
"connectionTestFailed": "Termix-palvelimeen ei saatu yhteyttä kyseisessä URL-osoitteessa",
"allowInvalidCertificate": "Salli virheellinen varmenne",
"allowInvalidCertificateDesc": "Käytä vain luotettaville itse isännöidyille palvelimille, joissa on itse allekirjoitetut tai IP-osoitevarmenteet.",
"savedServers": "Tallennetut palvelimet",
"removeServer": "Poistaa",
"continueButton": "Jatkaa",
"cancelButton": "Peruuttaa",
"signInTitle": "Kirjaudu sisään palveluun {{url}}",
"originTitle": "Yhteyden alkuperä",
"originDescription": "Valitse oletusarvoisesti SSH-yhteyksien lähtöpaikka. Tämä voidaan ohittaa isäntäkohtaisesti.",
"originLocal": "Tämä laite (lähiverkko)",
"originRemote": "Etäpalvelin",
"bannerReconnect": "Yhdistä uudelleen",
"bannerMessage": "Etäsynkronointi vaatii uudelleentunnistuksen"
},
"versionCheck": {
"error": "Versiotarkistusvirhe",
"checkFailed": "Päivitysten tarkistaminen epäonnistui",
"upToDate": "Sovellus on ajan tasalla",
"currentVersion": "Käytät versiota {{version}}",
"updateAvailable": "Päivitys saatavilla",
"updateLinkLabel": "Päivitys saatavilla — avaa julkaisutiedot GitHubissa",
"newVersionAvailable": "Uusi versio on saatavilla! Käytät {{current}}, mutta {{latest}} on saatavilla.",
"betaVersion": "Beta-versio",
"betaVersionDesc": "Käytät {{current}}, joka on uudempi kuin viimeisin vakaa julkaisu {{latest}}.",
"releasedOn": "Julkaistu {{date}}",
"downloadUpdate": "Lataa päivitys",
"checking": "Tarkistetaan päivityksiä...",
"checkUpdates": "Tarkista päivitykset",
"checkingUpdates": "Tarkistetaan päivityksiä...",
"updateRequired": "Päivitys vaaditaan"
"updateLinkLabel": "Päivitys saatavilla — avaa julkaisutiedot GitHubissa"
},
"common": {
"close": "Lähellä",
@@ -1565,8 +1487,6 @@
"resetCodeRateLimitedWithTime": "Nopeus rajoitettu: Liian monta vahvistusyritystä. Odota {{time}} sekuntia ennen kuin yrität uudelleen.",
"authTokenSaveFailed": "Todennustunnuksen tallennus epäonnistui",
"authTokenMissing": "Palvelin ei palauttanut uutta todennustunnusta. Kirjaudu uudelleen sisään.",
"authTokenRejected": "Etäpalvelin hylkäsi uuden todennustunnuksen",
"keyringUnavailable": "Kirjauduttu sisään, mutta istuntoa ei voitu tallentaa: järjestelmässä ei ole avainnippua sen salaamiseen. Käynnistä salainen palvelu (kuten gnome-keyring tai KWallet) ja kirjaudu uudelleen sisään.",
"failedToLoadServer": "Palvelimen lataus epäonnistui",
"remoteServerRequired": "Etäpalvelin vaaditaan. Yhdistä etäpalvelin asetuksissa käyttääksesi tätä yhteystyyppiä."
},
+1 -81
View File
@@ -232,86 +232,8 @@
"widgetTunnelDesc": "Gestionnaire de tunnels SSH intégré pour un hôte configuré",
"widgetNoHostSelected": "Aucun hôte configuré"
},
"serverConfig": {
"title": "Configuration du serveur",
"description": "Configurer l'URL du serveur Termix pour se connecter à vos services backend",
"serverUrl": "URL du serveur",
"enterServerUrl": "Veuillez saisir l'URL du serveur",
"saveFailed": "Échec de l'enregistrement de la configuration",
"saveError": "Erreur lors de l'enregistrement de la configuration",
"saving": "Enregistrement...",
"saveConfig": "Enregistrer la configuration",
"helpText": "Entrez l'URL où votre serveur Termix est en cours d'exécution (par ex., http://localhost:30001 ou https://votre-serveur.com)",
"changeServer": "Changer de serveur",
"mustIncludeProtocol": "L'URL du serveur doit commencer par http:// ou https://",
"allowInvalidCertificate": "Autoriser les certificats invalides",
"allowInvalidCertificateDesc": "À utiliser uniquement pour les serveurs auto-hébergés de confiance avec des certificats auto-signés ou d'adresse IP.",
"useEmbedded": "Utiliser le serveur local",
"embeddedDesc": "Exécuter Termix avec le serveur local intégré (aucun serveur distant nécessaire)",
"embeddedConnecting": "Connexion au serveur local...",
"embeddedNotReady": "Le serveur local n'est pas encore prêt. Veuillez patienter un instant et réessayer.",
"localServer": "Serveur local",
"savedServers": "Serveurs enregistrés",
"noSavedServers": "Aucun serveur enregistré",
"removeServer": "Supprimer"
},
"migrationNotice": {
"title": "Termix Desktop fonctionne désormais de manière autonome.",
"body1": "Cette application se connectait auparavant directement à votre serveur Termix. Elle a été repensée pour fonctionner de manière totalement autonome, en stockant localement les hôtes et les identifiants afin de rester opérationnelle hors ligne. La synchronisation bidirectionnelle avec un serveur Termix auto-hébergé est désormais optionnelle.",
"body2": "Vos hôtes et identifiants actuels sont toujours sur {{url}}. Activez la synchronisation à distance et reconnectez-vous à ce serveur pour les restaurer et les maintenir synchronisés.",
"dismiss": "Pas maintenant",
"setUpSync": "Configurer la synchronisation à distance"
},
"remoteSync": {
"title": "Synchronisation à distance",
"description": "Vous pouvez connecter cette application de bureau à un serveur Termix auto-hébergé pour synchroniser vos hôtes, identifiants et extraits de code sur tous vos appareils. L'application fonctionne toujours entièrement hors ligne, que vous soyez connecté ou non.",
"notConnected": "Non connecté",
"connected": "Connecté",
"connectedTo": "Connecté à {{url}}",
"lastSynced": "Dernière synchronisation {{time}}",
"neverSynced": "Jamais synchronisé",
"syncError": "Erreur de synchronisation : {{message}}",
"needsReauth": "Connexion expirée",
"connectButton": "Se connecter au serveur",
"disconnectButton": "Déconnecter",
"syncNowButton": "Synchroniser maintenant",
"syncing": "Synchronisation en cours...",
"syncSuccess": "Synchronisation à distance terminée",
"syncUnavailable": "La synchronisation à distance n'a pas pu démarrer.",
"serverUrl": "URL du serveur",
"enterServerUrl": "Veuillez saisir une URL de serveur",
"mustIncludeProtocol": "L'URL du serveur doit commencer par http:// ou https://",
"connectionTestFailed": "Impossible de joindre un serveur Termix à cette URL.",
"allowInvalidCertificate": "Autoriser un certificat invalide",
"allowInvalidCertificateDesc": "À utiliser uniquement pour des serveurs auto-hébergés de confiance avec des certificats auto-signés ou des certificats d'adresse IP.",
"savedServers": "Serveurs sauvegardés",
"removeServer": "Retirer",
"continueButton": "Continuer",
"cancelButton": "Annuler",
"signInTitle": "Connectez-vous à {{url}}",
"originTitle": "Origine de la connexion",
"originDescription": "Choisissez l'origine par défaut des connexions SSH. Ce paramètre peut être modifié pour chaque hôte.",
"originLocal": "Cet appareil (réseau local)",
"originRemote": "Serveur distant",
"bannerReconnect": "Reconnect",
"bannerMessage": "La synchronisation à distance nécessite une réauthentification."
},
"versionCheck": {
"error": "Erreur de vérification de version",
"checkFailed": "Échec de la vérification des mises à jour",
"upToDate": "L'application est à jour",
"currentVersion": "Vous utilisez la version {{version}}",
"updateAvailable": "Mise à jour disponible",
"updateLinkLabel": "Mise à jour disponible — consultez les notes de version sur GitHub",
"newVersionAvailable": "Une nouvelle version est disponible ! Vous utilisez {{current}}, mais la version {{latest}} est disponible.",
"betaVersion": "Version bêta",
"betaVersionDesc": "Vous utilisez {{current}}, qui est plus récente que la dernière version stable {{latest}}.",
"releasedOn": "Publiée le {{date}}",
"downloadUpdate": "Télécharger la mise à jour",
"checking": "Recherche des mises à jour...",
"checkUpdates": "Rechercher des mises à jour",
"checkingUpdates": "Recherche des mises à jour...",
"updateRequired": "Mise à jour requise"
"updateLinkLabel": "Mise à jour disponible — consultez les notes de version sur GitHub"
},
"common": {
"close": "Fermer",
@@ -1565,8 +1487,6 @@
"resetCodeRateLimitedWithTime": "Limitation de débit : trop de tentatives de vérification. Veuillez patienter {{time}} secondes avant de réessayer.",
"authTokenSaveFailed": "Échec de l'enregistrement du jeton d'authentification",
"authTokenMissing": "Le serveur n'a pas renvoyé de nouveau jeton d'authentification. Veuillez vous reconnecter.",
"authTokenRejected": "Le serveur distant a rejeté le nouveau jeton d'authentification.",
"keyringUnavailable": "Connexion impossible, la session n'a pas pu être enregistrée : ce système ne dispose d'aucun trousseau de clés pour la chiffrer. Veuillez démarrer un service de chiffrement (tel que gnome-keyring ou KWallet) et vous reconnecter.",
"failedToLoadServer": "Échec du chargement du serveur",
"remoteServerRequired": "Serveur distant requis. Connectez un serveur distant dans les paramètres pour utiliser ce type de connexion."
},
+1 -81
View File
@@ -232,86 +232,8 @@
"widgetTunnelDesc": "מנהל מנהרות SSH מוטמע עבור מארח מוגדר",
"widgetNoHostSelected": "לא הוגדר מארח"
},
"serverConfig": {
"title": "תצורת שרת",
"description": "הגדר את כתובת ה-URL של שרת Termix כדי להתחבר לשירותי ה-backend שלך",
"serverUrl": "כתובת URL של השרת",
"enterServerUrl": "אנא הזן כתובת URL של שרת",
"saveFailed": "שמירת התצורה נכשלה",
"saveError": "שגיאה בשמירת התצורה",
"saving": "חִסָכוֹן...",
"saveConfig": "שמור תצורה",
"helpText": "הזן את כתובת ה-URL שבה פועל שרת ה-Termix שלך (לדוגמה, http://localhost:30001 או https://your-server.com)",
"changeServer": "שנה שרת",
"mustIncludeProtocol": "כתובת השרת חייבת להתחיל ב-http:// או https://",
"allowInvalidCertificate": "אפשר אישור לא חוקי",
"allowInvalidCertificateDesc": "השתמש רק עבור שרתים מהימנים המתארחים בעצמם עם אישורי חתימה עצמית או אישורי כתובת IP.",
"useEmbedded": "השתמש בשרת מקומי",
"embeddedDesc": "הפעל את Termix עם השרת המקומי המובנה (אין צורך בשרת מרוחק)",
"embeddedConnecting": "מתחבר לשרת מקומי...",
"embeddedNotReady": "השרת המקומי עדיין לא מוכן. אנא המתן רגע ונסה שוב.",
"localServer": "שרת מקומי",
"savedServers": "שרתים שמורים",
"noSavedServers": "אין שרתים שמורים",
"removeServer": "לְהַסִיר"
},
"migrationNotice": {
"title": "שולחן העבודה של Termix פועל כעת באופן עצמאי",
"body1": "אפליקציה זו הייתה מתחברת ישירות לשרת Termix שלך. היא עובדה מחדש כדי לפעול באופן עצמאי לחלוטין, תוך אחסון מארחים ופרטי גישה מקומיים כך שהיא תמשיך לעבוד במצב לא מקוון. סנכרון דו-כיווני עם שרת Termix המתארח באופן עצמאי הוא כעת אופציונלי.",
"body2": "המארחים והאישורים הקיימים שלך עדיין נמצאים ב- {{url}}. הפעל את הסנכרון מרחוק והתחבר מחדש לשרת זה כדי להחזיר אותם ולשמור על שניהם מסונכרנים מעתה והלאה.",
"dismiss": "לא עכשיו",
"setUpSync": "הגדר סנכרון מרחוק"
},
"remoteSync": {
"title": "סנכרון מרחוק",
"description": "ניתן לחבר אפליקציית שולחן עבודה זו לשרת Termix המתארח באופן עצמאי כדי לסנכרן את המארחים, האישורים והקטעים שלך בין מכשירים. האפליקציה תמיד פועלת באופן מלא במצב לא מקוון, בין אם אתה מתחבר ובין אם לא.",
"notConnected": "לא מחובר",
"connected": "מְחוּבָּר",
"connectedTo": "מחובר אל {{url}}",
"lastSynced": "סנכרון אחרון {{time}}",
"neverSynced": "מעולם לא סנכרן",
"syncError": "שגיאת סינכרון: {{message}}",
"needsReauth": "פג תוקף הכניסה",
"connectButton": "התחברות לשרת",
"disconnectButton": "לְנַתֵק",
"syncNowButton": "סנכרן עכשיו",
"syncing": "מסנכרן...",
"syncSuccess": "סנכרון מרחוק הושלם",
"syncUnavailable": "לא ניתן היה להתחיל את הסנכרון מרחוק",
"serverUrl": "כתובת URL של השרת",
"enterServerUrl": "אנא הזן כתובת URL של שרת",
"mustIncludeProtocol": "כתובת השרת חייבת להתחיל ב-http:// או https://",
"connectionTestFailed": "לא ניתן היה להגיע לשרת Termix בכתובת URL זו",
"allowInvalidCertificate": "אפשר אישור לא חוקי",
"allowInvalidCertificateDesc": "השתמש רק עבור שרתים מהימנים המתארחים בעצמם עם אישורי חתימה עצמית או אישורי כתובת IP.",
"savedServers": "שרתים שמורים",
"removeServer": "לְהַסִיר",
"continueButton": "לְהַמשִׁיך",
"cancelButton": "לְבַטֵל",
"signInTitle": "היכנס אל {{url}}",
"originTitle": "מקור החיבור",
"originDescription": "בחר כברירת מחדל מהיכן חיבורי SSH מגיעים. ניתן לעקוף זאת לפי מארח.",
"originLocal": "מכשיר זה (רשת מקומית)",
"originRemote": "שרת מרוחק",
"bannerReconnect": "התחבר מחדש",
"bannerMessage": "סנכרון מרחוק דורש אימות מחדש"
},
"versionCheck": {
"error": "שגיאת בדיקת גרסה",
"checkFailed": "נכשל בבדיקת עדכונים",
"upToDate": "האפליקציה מעודכנת",
"currentVersion": "אתה משתמש בגרסה {{version}}",
"updateAvailable": "עדכון זמין",
"updateLinkLabel": "עדכון זמין - פתח את הערות הגרסה ב-GitHub",
"newVersionAvailable": "גרסה חדשה זמינה! אתה מפעיל את {{current}}, אבל {{latest}} זמין.",
"betaVersion": "גרסת בטא",
"betaVersionDesc": "אתה מפעיל את {{current}}, שהוא חדש יותר מהגרסה היציבה האחרונה {{latest}}.",
"releasedOn": "יצא לאור בתאריך {{date}}",
"downloadUpdate": "הורד עדכון",
"checking": "בודק עדכונים...",
"checkUpdates": "בדוק אם יש עדכונים",
"checkingUpdates": "בודק עדכונים...",
"updateRequired": "נדרש עדכון"
"updateLinkLabel": "עדכון זמין - פתח את הערות הגרסה ב-GitHub"
},
"common": {
"close": "לִסְגוֹר",
@@ -1565,8 +1487,6 @@
"resetCodeRateLimitedWithTime": "קצב מוגבל: יותר מדי ניסיונות אימות. אנא המתן {{time}} שניות לפני שתנסה שוב.",
"authTokenSaveFailed": "נכשלה שמירת אסימון האימות",
"authTokenMissing": "השרת לא החזיר אסימון אימות חדש. אנא היכנס שוב.",
"authTokenRejected": "השרת המרוחק דחה את אסימון האימות החדש",
"keyringUnavailable": "התחברתי, אך לא ניתן היה לאחסן את הסשן: למערכת זו אין טבעת מפתח זמינה להצפנתה. הפעל שירות חשאי (כגון gnome-keyring או KWallet) והתחבר שוב.",
"failedToLoadServer": "טעינת השרת נכשלה",
"remoteServerRequired": "נדרש שרת מרוחק. יש להתחבר לשרת מרוחק בהגדרות כדי להשתמש בסוג חיבור זה."
},
+1 -81
View File
@@ -232,86 +232,8 @@
"widgetTunnelDesc": "कॉन्फ़िगर किए गए होस्ट के लिए एम्बेडेड SSH टनल प्रबंधक",
"widgetNoHostSelected": "कोई होस्ट कॉन्फ़िगर नहीं किया गया"
},
"serverConfig": {
"title": "सर्वर कॉन्फ़िगरेशन",
"description": "अपनी बैकएंड सेवाओं से कनेक्ट करने के लिए Termix सर्वर URL कॉन्फ़िगर करें",
"serverUrl": "सर्वर URL",
"enterServerUrl": "कृपया एक सर्वर URL दर्ज करें",
"saveFailed": "कॉन्फ़िगरेशन सहेजने में विफल",
"saveError": "कॉन्फ़िगरेशन सहेजने में त्रुटि",
"saving": "सहेज रहा है...",
"saveConfig": "कॉन्फ़िगरेशन सहेजें",
"helpText": "URL दर्ज करें जहाँ आपका Termix सर्वर चल रहा है (उदा., http://localhost:30001 या https://your-server.com)",
"changeServer": "सर्वर बदलें",
"mustIncludeProtocol": "सर्वर URL http:// या https:// से शुरू होना चाहिए",
"allowInvalidCertificate": "अमान्य प्रमाणपत्र की अनुमति दें",
"allowInvalidCertificateDesc": "केवल स्व-हस्ताक्षरित या IP-पता प्रमाणपत्र वाले विश्वसनीय स्व-होस्टेड सर्वर के लिए उपयोग करें।",
"useEmbedded": "स्थानीय सर्वर का उपयोग करें",
"embeddedDesc": "Termix को अंतर्निहित स्थानीय सर्वर के साथ चलाएँ (कोई दूरस्थ सर्वर आवश्यक नहीं)",
"embeddedConnecting": "स्थानीय सर्वर से कनेक्ट हो रहा है...",
"embeddedNotReady": "स्थानीय सर्वर अभी तक तैयार नहीं है। कृपया कुछ क्षण प्रतीक्षा करें और पुनः प्रयास करें।",
"localServer": "स्थानीय सर्वर",
"savedServers": "सहेजे गए सर्वर",
"noSavedServers": "कोई सहेजे गए सर्वर नहीं",
"removeServer": "हटाएँ"
},
"migrationNotice": {
"title": "टर्मिक्स डेस्कटॉप अब स्टैंडअलोन मोड में चलता है।",
"body1": "यह ऐप पहले सीधे आपके टर्मिक्स सर्वर से कनेक्ट होता था। अब इसे पूरी तरह से स्वतंत्र रूप से चलने के लिए संशोधित किया गया है, जिसमें होस्ट और क्रेडेंशियल स्थानीय रूप से संग्रहीत होते हैं ताकि यह ऑफ़लाइन भी काम करता रहे। स्व-होस्टेड टर्मिक्स सर्वर के साथ दो-तरफ़ा सिंक अब वैकल्पिक है।",
"body2": "आपके मौजूदा होस्ट और क्रेडेंशियल अभी भी {{url}}पर हैं। रिमोट सिंक चालू करें और उन्हें वापस लाने और भविष्य में दोनों को सिंक में रखने के लिए उस सर्वर से पुनः कनेक्ट करें।",
"dismiss": "अभी नहीं",
"setUpSync": "रिमोट सिंक सेट अप करें"
},
"remoteSync": {
"title": "रिमोट सिंक",
"description": "आप चाहें तो इस डेस्कटॉप ऐप को अपने होस्ट, क्रेडेंशियल और स्निपेट्स को सभी डिवाइसों में सिंक करने के लिए सेल्फ-होस्टेड टर्मिक्स सर्वर से कनेक्ट कर सकते हैं। ऐप हमेशा पूरी तरह से ऑफलाइन काम करता है, चाहे आप कनेक्ट करें या न करें।",
"notConnected": "जुड़े नहीं हैं",
"connected": "जुड़े हुए",
"connectedTo": "{{url}} से जुड़ा हुआ",
"lastSynced": "अंतिम बार सिंक्रनाइज़ किया गया {{time}}",
"neverSynced": "कभी सिंक्रनाइज़ नहीं हुआ",
"syncError": "सिंक त्रुटि: {{message}}",
"needsReauth": "लॉग-इन की समय सीमा समाप्त हो गई है",
"connectButton": "सर्वर से कनेक्ट करें",
"disconnectButton": "डिस्कनेक्ट",
"syncNowButton": "अभी सिंक करें",
"syncing": "सिंक्रनाइज़ हो रहा है...",
"syncSuccess": "रिमोट सिंक पूरा हुआ",
"syncUnavailable": "रिमोट सिंक शुरू नहीं हो सका",
"serverUrl": "सर्वर यूआरएल",
"enterServerUrl": "कृपया सर्वर URL दर्ज करें",
"mustIncludeProtocol": "सर्वर URL की शुरुआत http:// या https:// से होनी चाहिए।",
"connectionTestFailed": "उस यूआरएल पर टर्मिक्स सर्वर तक नहीं पहुंचा जा सका।",
"allowInvalidCertificate": "अमान्य प्रमाणपत्र की अनुमति दें",
"allowInvalidCertificateDesc": "इसका उपयोग केवल विश्वसनीय सेल्फ-होस्टेड सर्वरों के लिए करें जिनमें सेल्फ-साइन किए गए या आईपी-एड्रेस प्रमाणपत्र हों।",
"savedServers": "सहेजे गए सर्वर",
"removeServer": "निकालना",
"continueButton": "जारी रखना",
"cancelButton": "रद्द करना",
"signInTitle": "{{url}} में साइन इन करें",
"originTitle": "कनेक्शन का स्रोत",
"originDescription": "डिफ़ॉल्ट रूप से SSH कनेक्शन कहाँ से शुरू होंगे, यह चुनें। इसे प्रत्येक होस्ट के लिए बदला जा सकता है।",
"originLocal": "यह डिवाइस (स्थानीय नेटवर्क)",
"originRemote": "रिमोट सर्वर",
"bannerReconnect": "रिकनेक्ट",
"bannerMessage": "रिमोट सिंक के लिए पुनः प्रमाणीकरण आवश्यक है।"
},
"versionCheck": {
"error": "संस्करण जाँच त्रुटि",
"checkFailed": "अपडेट की जाँच करने में विफल",
"upToDate": "ऐप अद्यतित है",
"currentVersion": "आप संस्करण {{version}} चला रहे हैं",
"updateAvailable": "अपडेट उपलब्ध है",
"updateLinkLabel": "अपडेट उपलब्ध है — GitHub पर रिलीज़ नोट्स देखें",
"newVersionAvailable": "एक नया संस्करण उपलब्ध है! आप {{current}} चला रहे हैं, लेकिन {{latest}} उपलब्ध है।",
"betaVersion": "बीटा संस्करण",
"betaVersionDesc": "आप {{current}} चला रहे हैं, जो नवीनतम स्थिर रिलीज़ {{latest}} से नया है।",
"releasedOn": "{{date}} को जारी किया गया",
"downloadUpdate": "अपडेट डाउनलोड करें",
"checking": "अपडेट की जाँच हो रही है...",
"checkUpdates": "अपडेट की जाँच करें",
"checkingUpdates": "अपडेट की जाँच हो रही है...",
"updateRequired": "अपडेट आवश्यक है"
"updateLinkLabel": "अपडेट उपलब्ध है — GitHub पर रिलीज़ नोट्स देखें"
},
"common": {
"close": "बंद करें",
@@ -1565,8 +1487,6 @@
"resetCodeRateLimitedWithTime": "दर सीमित: बहुत अधिक सत्यापन प्रयास। कृपया पुनः प्रयास करने से पहले {{time}} सेकंड प्रतीक्षा करें।",
"authTokenSaveFailed": "प्रमाणीकरण टोकन सहेजने में विफल",
"authTokenMissing": "सर्वर ने नया प्रमाणीकरण टोकन नहीं भेजा। कृपया दोबारा साइन इन करें।",
"authTokenRejected": "रिमोट सर्वर ने नए प्रमाणीकरण टोकन को अस्वीकार कर दिया।",
"keyringUnavailable": "लॉग इन तो हो गया, लेकिन सेशन सेव नहीं हो सका: इस सिस्टम में एन्क्रिप्शन के लिए कोई कीरिंग उपलब्ध नहीं है। कोई सीक्रेट सर्विस (जैसे gnome-keyring या KWallet) शुरू करें और दोबारा लॉग इन करें।",
"failedToLoadServer": "सर्वर लोड करने में विफल",
"remoteServerRequired": "रिमोट सर्वर आवश्यक है। इस कनेक्शन प्रकार का उपयोग करने के लिए सेटिंग्स में जाकर रिमोट सर्वर कनेक्ट करें।"
},
+1 -81
View File
@@ -232,86 +232,8 @@
"widgetTunnelDesc": "Beágyazott SSH alagútkezelő egy konfigurált gazdagéphez",
"widgetNoHostSelected": "Nincs konfigurálva gazdagép"
},
"serverConfig": {
"title": "Szerver konfiguráció",
"description": "Konfigurálja a Termix szerver URL-címét a háttérszolgáltatásokhoz való csatlakozáshoz",
"serverUrl": "Szerver URL-címe",
"enterServerUrl": "Kérjük, adjon meg egy szerver URL-címét",
"saveFailed": "Nem sikerült menteni a konfigurációt",
"saveError": "Hiba a konfiguráció mentése során",
"saving": "Megtakarítás...",
"saveConfig": "Konfiguráció mentése",
"helpText": "Add meg az URL-címet, ahol a Termix szervered fut (pl. http://localhost:30001 vagy https://your-server.com)",
"changeServer": "Szerverváltás",
"mustIncludeProtocol": "A szerver URL-címének http:// vagy https:// előtaggal kell kezdődnie.",
"allowInvalidCertificate": "Érvénytelen tanúsítvány engedélyezése",
"allowInvalidCertificateDesc": "Csak megbízható, saját üzemeltetésű szerverekhez használja, amelyek önaláírt vagy IP-cím alapú tanúsítvánnyal rendelkeznek.",
"useEmbedded": "Helyi szerver használata",
"embeddedDesc": "Futtassa a Termixet a beépített helyi szerverrel (nincs szükség távoli szerverre)",
"embeddedConnecting": "Kapcsolódás a helyi szerverhez...",
"embeddedNotReady": "A helyi szerver még nem áll készen. Kérjük, várjon egy pillanatot, és próbálja újra.",
"localServer": "Helyi szerver",
"savedServers": "Mentett szerverek",
"noSavedServers": "Nincsenek mentett szerverek",
"removeServer": "Eltávolítás"
},
"migrationNotice": {
"title": "A Termix Desktop mostantól önállóan is fut",
"body1": "Ez az alkalmazás korábban közvetlenül a Termix szerveredhez csatlakozott. Átdolgoztuk, hogy teljesen önállóan fusson, helyben tárolja a hosztokat és a hitelesítő adatokat, így offline is működik. A kétirányú szinkronizálás egy saját üzemeltetésű Termix szerverrel mostantól opcionális.",
"body2": "A meglévő hosztok és hitelesítő adatok továbbra is a {{url}}címen találhatók. Kapcsold be a távoli szinkronizálást, és csatlakozz újra ehhez a szerverhez, hogy visszahozd őket, és a jövőben is szinkronban maradjanak.",
"dismiss": "Most nem",
"setUpSync": "Távoli szinkronizálás beállítása"
},
"remoteSync": {
"title": "Távoli szinkronizálás",
"description": "Opcionálisan csatlakoztathatod ezt az asztali alkalmazást egy saját üzemeltetésű Termix szerverhez, hogy szinkronizálhasd a hosztjaidat, hitelesítő adataidat és kódrészleteidet az eszközök között. Az alkalmazás mindig teljesen offline működik, függetlenül attól, hogy csatlakozol-e vagy sem.",
"notConnected": "Nincs csatlakoztatva",
"connected": "Csatlakoztatva",
"connectedTo": "Csatlakoztatva ehhez: {{url}}",
"lastSynced": "Utolsó szinkronizálás {{time}}",
"neverSynced": "Soha nem szinkronizálva",
"syncError": "Szinkronizálási hiba: {{message}}",
"needsReauth": "Bejelentkezés lejárt",
"connectButton": "Kapcsolódás a szerverhez",
"disconnectButton": "Leválasztás",
"syncNowButton": "Szinkronizálás most",
"syncing": "Szinkronizálás...",
"syncSuccess": "Távoli szinkronizálás befejeződött",
"syncUnavailable": "A távoli szinkronizálás nem indítható el.",
"serverUrl": "Szerver URL-címe",
"enterServerUrl": "Kérjük, adjon meg egy szerver URL-címét",
"mustIncludeProtocol": "A szerver URL-címének http:// vagy https:// előtaggal kell kezdődnie.",
"connectionTestFailed": "Nem sikerült elérni a Termix szervert ezen az URL-címen.",
"allowInvalidCertificate": "Érvénytelen tanúsítvány engedélyezése",
"allowInvalidCertificateDesc": "Csak megbízható, saját üzemeltetésű szerverekhez használja, amelyek önaláírt vagy IP-cím alapú tanúsítvánnyal rendelkeznek.",
"savedServers": "Mentett szerverek",
"removeServer": "Eltávolítás",
"continueButton": "Folytatás",
"cancelButton": "Mégsem",
"signInTitle": "Jelentkezzen be ide: {{url}}",
"originTitle": "Kapcsolat eredete",
"originDescription": "Alapértelmezés szerint válassza ki az SSH-kapcsolatok forrását. Ez gazdagépenként felülbírálható.",
"originLocal": "Ez az eszköz (helyi hálózat)",
"originRemote": "Távoli szerver",
"bannerReconnect": "Újracsatlakozás",
"bannerMessage": "A távoli szinkronizáláshoz újra kell hitelesíteni"
},
"versionCheck": {
"error": "Verzióellenőrzési hiba",
"checkFailed": "Nem sikerült ellenőrizni a frissítéseket",
"upToDate": "Az alkalmazás naprakész",
"currentVersion": "A(z) {{version}} verziót futtatja",
"updateAvailable": "Frissítés elérhető",
"updateLinkLabel": "Frissítés elérhető — nyissa meg a kiadási megjegyzéseket a GitHubon",
"newVersionAvailable": "Új verzió érhető el! A(z) {{current}}verziót futtatod, de a(z) {{latest}} verzió már elérhető.",
"betaVersion": "Béta verzió",
"betaVersionDesc": "A(z) {{current}}verziót futtatod, ami újabb, mint a legújabb stabil kiadás {{latest}}.",
"releasedOn": "Megjelent: {{date}}",
"downloadUpdate": "Frissítés letöltése",
"checking": "Frissítések keresése...",
"checkUpdates": "Frissítések keresése",
"checkingUpdates": "Frissítések keresése...",
"updateRequired": "Frissítés szükséges"
"updateLinkLabel": "Frissítés elérhető — nyissa meg a kiadási megjegyzéseket a GitHubon"
},
"common": {
"close": "Közeli",
@@ -1565,8 +1487,6 @@
"resetCodeRateLimitedWithTime": "Korlátozott sebesség: Túl sok ellenőrzési kísérlet. Kérjük, várjon {{time}} másodpercet, mielőtt újra próbálkozik.",
"authTokenSaveFailed": "Nem sikerült menteni a hitelesítési tokent",
"authTokenMissing": "A szerver nem adott vissza új hitelesítési tokent. Kérjük, jelentkezzen be újra.",
"authTokenRejected": "A távoli szerver elutasította az új hitelesítési tokent",
"keyringUnavailable": "Bejelentkezve, de a munkamenetet nem sikerült tárolni: ennek a rendszernek nincs kulcstartója a titkosításhoz. Indítson el egy titkos szolgáltatást (például a gnome-keyringet vagy a KWalletet), és jelentkezzen be újra.",
"failedToLoadServer": "Nem sikerült betölteni a szervert",
"remoteServerRequired": "Távoli szerver szükséges. Csatlakoztasson egy távoli szervert a Beállításokban a kapcsolattípus használatához."
},
+1 -81
View File
@@ -232,86 +232,8 @@
"widgetTunnelDesc": "Pengelola terowongan SSH terintegrasi untuk host yang telah dikonfigurasi.",
"widgetNoHostSelected": "Tidak ada host yang dikonfigurasi."
},
"serverConfig": {
"title": "Konfigurasi Server",
"description": "Konfigurasikan URL server Termix untuk terhubung ke layanan backend Anda.",
"serverUrl": "URL Server",
"enterServerUrl": "Silakan masukkan URL server",
"saveFailed": "Gagal menyimpan konfigurasi",
"saveError": "Terjadi kesalahan saat menyimpan konfigurasi.",
"saving": "Penghematan...",
"saveConfig": "Simpan Konfigurasi",
"helpText": "Masukkan URL tempat server Termix Anda berjalan (misalnya, http://localhost:30001 atau https://your-server.com)",
"changeServer": "Ubah Server",
"mustIncludeProtocol": "URL server harus diawali dengan http:// atau https://",
"allowInvalidCertificate": "Izinkan sertifikat tidak valid",
"allowInvalidCertificateDesc": "Gunakan hanya untuk server mandiri tepercaya dengan sertifikat yang ditandatangani sendiri atau sertifikat alamat IP.",
"useEmbedded": "Gunakan Server Lokal",
"embeddedDesc": "Jalankan Termix dengan server lokal bawaan (tidak memerlukan server jarak jauh)",
"embeddedConnecting": "Menghubungkan ke server lokal...",
"embeddedNotReady": "Server lokal belum siap. Mohon tunggu sebentar dan coba lagi.",
"localServer": "Server Lokal",
"savedServers": "Server Tersimpan",
"noSavedServers": "Tidak ada server yang tersimpan.",
"removeServer": "Menghapus"
},
"migrationNotice": {
"title": "Termix Desktop sekarang berjalan secara mandiri.",
"body1": "Aplikasi ini sebelumnya terhubung langsung ke server Termix Anda. Aplikasi ini telah dirancang ulang agar dapat berjalan sepenuhnya secara mandiri, menyimpan host dan kredensial secara lokal sehingga tetap berfungsi saat offline. Sinkronisasi dua arah dengan server Termix yang dihosting sendiri sekarang bersifat opsional.",
"body2": "Host dan kredensial Anda yang ada masih berada di {{url}}. Aktifkan Sinkronisasi Jarak Jauh dan sambungkan kembali ke server tersebut untuk mengembalikannya dan menjaga agar keduanya tetap sinkron ke depannya.",
"dismiss": "Tidak sekarang",
"setUpSync": "Siapkan Sinkronisasi Jarak Jauh"
},
"remoteSync": {
"title": "Sinkronisasi Jarak Jauh",
"description": "Secara opsional, hubungkan aplikasi desktop ini ke server Termix yang dihosting sendiri untuk menyinkronkan host, kredensial, dan cuplikan Anda di berbagai perangkat. Aplikasi ini selalu berfungsi sepenuhnya secara offline, baik Anda terhubung atau tidak.",
"notConnected": "Tidak terhubung",
"connected": "Terhubung",
"connectedTo": "Terhubung ke {{url}}",
"lastSynced": "Sinkronisasi terakhir {{time}}",
"neverSynced": "Tidak pernah disinkronkan",
"syncError": "Kesalahan sinkronisasi: {{message}}",
"needsReauth": "Login telah kedaluwarsa.",
"connectButton": "Terhubung ke Server",
"disconnectButton": "Memutuskan",
"syncNowButton": "Sinkronkan Sekarang",
"syncing": "Sinkronisasi...",
"syncSuccess": "Sinkronisasi jarak jauh selesai",
"syncUnavailable": "Sinkronisasi jarak jauh tidak dapat dimulai.",
"serverUrl": "URL Server",
"enterServerUrl": "Silakan masukkan URL server",
"mustIncludeProtocol": "URL server harus diawali dengan http:// atau https://",
"connectionTestFailed": "Tidak dapat terhubung ke server Termix di URL tersebut.",
"allowInvalidCertificate": "Izinkan sertifikat tidak valid",
"allowInvalidCertificateDesc": "Gunakan hanya untuk server mandiri tepercaya dengan sertifikat yang ditandatangani sendiri atau sertifikat alamat IP.",
"savedServers": "Server Tersimpan",
"removeServer": "Menghapus",
"continueButton": "Melanjutkan",
"cancelButton": "Membatalkan",
"signInTitle": "Masuk ke {{url}}",
"originTitle": "Asal Koneksi",
"originDescription": "Pilih dari mana koneksi SSH berasal secara default. Pengaturan ini dapat diubah per host.",
"originLocal": "Perangkat ini (jaringan lokal)",
"originRemote": "Server jarak jauh",
"bannerReconnect": "Terhubung kembali",
"bannerMessage": "Sinkronisasi jarak jauh memerlukan autentikasi ulang."
},
"versionCheck": {
"error": "Kesalahan Pemeriksaan Versi",
"checkFailed": "Gagal memeriksa pembaruan.",
"upToDate": "Aplikasi sudah diperbarui.",
"currentVersion": "Anda menjalankan versi {{version}}",
"updateAvailable": "Pembaruan Tersedia",
"updateLinkLabel": "Pembaruan tersedia — buka catatan rilis di GitHub",
"newVersionAvailable": "Versi baru tersedia! Anda menjalankan {{current}}, tetapi {{latest}} tersedia.",
"betaVersion": "Versi Beta",
"betaVersionDesc": "Anda menjalankan {{current}}, yang lebih baru daripada rilis stabil terbaru {{latest}}.",
"releasedOn": "Dirilis pada {{date}}",
"downloadUpdate": "Unduh Pembaruan",
"checking": "Memeriksa pembaruan...",
"checkUpdates": "Periksa Pembaruan",
"checkingUpdates": "Memeriksa pembaruan...",
"updateRequired": "Pembaruan Diperlukan"
"updateLinkLabel": "Pembaruan tersedia — buka catatan rilis di GitHub"
},
"common": {
"close": "Menutup",
@@ -1565,8 +1487,6 @@
"resetCodeRateLimitedWithTime": "Pembatasan laju: Terlalu banyak upaya verifikasi. Harap tunggu {{time}} detik sebelum mencoba lagi.",
"authTokenSaveFailed": "Gagal menyimpan token otentikasi.",
"authTokenMissing": "Server tidak mengembalikan token otentikasi baru. Silakan masuk kembali.",
"authTokenRejected": "Server jarak jauh menolak token otentikasi baru tersebut.",
"keyringUnavailable": "Berhasil masuk, tetapi sesi tidak dapat disimpan: sistem ini tidak memiliki keyring yang tersedia untuk mengenkripsinya. Mulai Secret Service (seperti gnome-keyring atau KWallet) dan masuk lagi.",
"failedToLoadServer": "Server gagal dimuat.",
"remoteServerRequired": "Diperlukan server jarak jauh. Hubungkan server jarak jauh di Pengaturan untuk menggunakan jenis koneksi ini."
},
+1 -81
View File
@@ -232,86 +232,8 @@
"widgetTunnelDesc": "Gestore di tunnel SSH integrato per un host configurato",
"widgetNoHostSelected": "Nessun host configurato"
},
"serverConfig": {
"title": "Configurazione del Server",
"description": "Configura l'URL del server Termix per connetterti ai tuoi servizi backend",
"serverUrl": "URL del server",
"enterServerUrl": "Inserisci un URL del server",
"saveFailed": "Salvataggio della configurazione non riuscito",
"saveError": "Errore durante il salvataggio della configurazione",
"saving": "Salvataggio in corso...",
"saveConfig": "Salva configurazione",
"helpText": "Inserisci l'URL del server Termix (es. http://localhost:30001 o https://your-server.com)",
"changeServer": "Cambia server",
"mustIncludeProtocol": "L'URL del server deve iniziare con http:// o https://",
"allowInvalidCertificate": "Consenti certificato non valido",
"allowInvalidCertificateDesc": "Utilizzare solo per server self-hosted attendibili con certificati autofirmati o basati su indirizzo IP.",
"useEmbedded": "Usa server locale",
"embeddedDesc": "Esegui Termix con il server locale integrato (nessun server remoto richiesto)",
"embeddedConnecting": "Connessione al server locale in corso...",
"embeddedNotReady": "Il server locale non è ancora pronto. Attendere qualche istante e riprovare.",
"localServer": "Server locale",
"savedServers": "Server salvati",
"noSavedServers": "Nessun server salvato",
"removeServer": "Rimuovi"
},
"migrationNotice": {
"title": "Termix Desktop ora funziona in modalità standalone.",
"body1": "Questa app in precedenza si connetteva direttamente al server Termix. È stata rielaborata per funzionare in modo completamente autonomo, memorizzando host e credenziali localmente in modo da continuare a funzionare offline. La sincronizzazione bidirezionale con un server Termix self-hosted è ora opzionale.",
"body2": "I tuoi host e le tue credenziali attuali sono ancora su {{url}}. Attiva la sincronizzazione remota e riconnettiti a quel server per ripristinarli e mantenerli sincronizzati in futuro.",
"dismiss": "Non adesso",
"setUpSync": "Configura la sincronizzazione remota"
},
"remoteSync": {
"title": "Sincronizzazione remota",
"description": "Facoltativamente, è possibile collegare questa applicazione desktop a un server Termix autogestito per sincronizzare host, credenziali e snippet tra i dispositivi. L'applicazione funziona sempre completamente offline, indipendentemente dal fatto che sia connessa o meno.",
"notConnected": "Non connesso",
"connected": "Collegato",
"connectedTo": "Connesso a {{url}}",
"lastSynced": "Ultima sincronizzazione {{time}}",
"neverSynced": "Mai sincronizzato",
"syncError": "Errore di sincronizzazione: {{message}}",
"needsReauth": "Accesso scaduto",
"connectButton": "Connessione al server",
"disconnectButton": "Disconnetti",
"syncNowButton": "Sincronizza ora",
"syncing": "Sincronizzazione in corso...",
"syncSuccess": "Sincronizzazione remota completata",
"syncUnavailable": "Impossibile avviare la sincronizzazione remota",
"serverUrl": "URL del server",
"enterServerUrl": "Inserisci l'URL del server",
"mustIncludeProtocol": "L'URL del server deve iniziare con http:// o https://",
"connectionTestFailed": "Impossibile raggiungere un server Termix a quell'URL",
"allowInvalidCertificate": "Consenti certificato non valido",
"allowInvalidCertificateDesc": "Da utilizzare esclusivamente per server self-hosted affidabili con certificati autofirmati o certificati IP.",
"savedServers": "Server salvati",
"removeServer": "Rimuovere",
"continueButton": "Continuare",
"cancelButton": "Cancellare",
"signInTitle": "Accedi a {{url}}",
"originTitle": "Origine della connessione",
"originDescription": "Scegli da dove originare le connessioni SSH per impostazione predefinita. Questa impostazione può essere modificata per ogni singolo host.",
"originLocal": "Questo dispositivo (rete locale)",
"originRemote": "Server remoto",
"bannerReconnect": "Ricollega",
"bannerMessage": "La sincronizzazione remota richiede una nuova autenticazione."
},
"versionCheck": {
"error": "Errore controllo versione",
"checkFailed": "Controllo aggiornamenti non riuscito",
"upToDate": "App aggiornata",
"currentVersion": "Stai utilizzando la versione {{version}}",
"updateAvailable": "Aggiornamento disponibile",
"updateLinkLabel": "Aggiornamento disponibile: consulta le note di rilascio su GitHub.",
"newVersionAvailable": "È disponibile una nuova versione! Stai utilizzando {{current}}, ma è disponibile {{latest}}.",
"betaVersion": "Versione beta",
"betaVersionDesc": "Stai utilizzando {{current}}, che è più recente dell'ultima versione stabile {{latest}}.",
"releasedOn": "Rilasciato il {{date}}",
"downloadUpdate": "Scarica aggiornamento",
"checking": "Controllo aggiornamenti in corso...",
"checkUpdates": "Controlla aggiornamenti",
"checkingUpdates": "Controllo aggiornamenti in corso...",
"updateRequired": "Aggiornamento richiesto"
"updateLinkLabel": "Aggiornamento disponibile: consulta le note di rilascio su GitHub."
},
"common": {
"close": "Chiudi",
@@ -1565,8 +1487,6 @@
"resetCodeRateLimitedWithTime": "Limite di tentativi raggiunto: troppi tentativi di verifica. Attendere {{time}} secondi prima di riprovare.",
"authTokenSaveFailed": "Salvataggio del token di autenticazione non riuscito",
"authTokenMissing": "Il server non ha restituito un nuovo token di autenticazione. Effettua nuovamente l'accesso.",
"authTokenRejected": "Il server remoto ha rifiutato il nuovo token di autenticazione",
"keyringUnavailable": "Accesso effettuato, ma la sessione non è stata salvata: questo sistema non dispone di un portachiavi per crittografarla. Avvia un Servizio Segreto (come gnome-keyring o KWallet) e accedi di nuovo.",
"failedToLoadServer": "Caricamento del server non riuscito",
"remoteServerRequired": "È necessario un server remoto. Per utilizzare questo tipo di connessione, collega un server remoto nelle Impostazioni."
},
+1 -81
View File
@@ -232,86 +232,8 @@
"widgetTunnelDesc": "設定済みホスト用の組み込みSSHトンネルマネージャー",
"widgetNoHostSelected": "ホストが設定されていません"
},
"serverConfig": {
"title": "サーバー設定",
"description": "TermixサーバーのURLを設定してバックエンドサービスに接続します",
"serverUrl": "サーバーURL",
"enterServerUrl": "サーバーURLを入力してください",
"saveFailed": "設定の保存に失敗しました",
"saveError": "設定の保存中にエラーが発生しました",
"saving": "保存中...",
"saveConfig": "設定を保存",
"helpText": "Termixサーバーが実行されているURLを入力してください (例: http://localhost:30001 または https://your-server.com)",
"changeServer": "サーバー変更",
"mustIncludeProtocol": "サーバーURLはhttp://またはhttps://で始まる必要があります",
"allowInvalidCertificate": "無効な証明書を許可",
"allowInvalidCertificateDesc": "自己署名証明書やIPアドレス証明書を使った信頼できる自己ホストサーバーにのみ使用してください。",
"useEmbedded": "ローカルサーバーを使用",
"embeddedDesc": "組み込みのローカルサーバーでTermixを実行します(リモートサーバー不要)",
"embeddedConnecting": "ローカルサーバーに接続中...",
"embeddedNotReady": "ローカルサーバーの準備ができていません。しばらく待ってからやり直してください。",
"localServer": "ローカルサーバー",
"savedServers": "保存済みサーバー",
"noSavedServers": "保存されたサーバーはありません",
"removeServer": "削除"
},
"migrationNotice": {
"title": "Termix Desktopがスタンドアロンで動作するようになりました",
"body1": "このアプリは以前はTermixサーバーに直接接続していましたが、完全に独立して動作するように改良され、ホスト名と認証情報をローカルに保存することでオフラインでも動作し続けます。セルフホスト型のTermixサーバーとの双方向同期はオプションになりました。",
"body2": "既存のホストと認証情報はまだ {{url}}にあります。リモート同期を有効にして、そのサーバーに再接続すると、それらが復元され、今後両方のサーバーが同期された状態になります。",
"dismiss": "今じゃない",
"setUpSync": "リモート同期の設定"
},
"remoteSync": {
"title": "リモート同期",
"description": "オプションとして、このデスクトップアプリをセルフホスト型のTermixサーバーに接続することで、ホスト、認証情報、およびスニペットをデバイス間で同期できます。アプリは接続の有無に関わらず、常に完全にオフラインで動作します。",
"notConnected": "接続されていません",
"connected": "接続済み",
"connectedTo": "{{url}}に接続しました",
"lastSynced": "最終同期日時 {{time}}",
"neverSynced": "同期されたことはありません",
"syncError": "同期エラー: {{message}}",
"needsReauth": "ログイン期限が切れました",
"connectButton": "サーバーに接続",
"disconnectButton": "切断",
"syncNowButton": "今すぐ同期",
"syncing": "同期中...",
"syncSuccess": "リモート同期が完了しました",
"syncUnavailable": "リモート同期を開始できませんでした",
"serverUrl": "サーバーURL",
"enterServerUrl": "サーバーのURLを入力してください",
"mustIncludeProtocol": "サーバーURLはhttp://またはhttps://で始まる必要があります。",
"connectionTestFailed": "そのURLのTermixサーバーに接続できませんでした",
"allowInvalidCertificate": "無効な証明書を許可する",
"allowInvalidCertificateDesc": "自己署名証明書またはIPアドレス証明書を使用する、信頼できる自己ホスト型サーバーでのみ使用してください。",
"savedServers": "保存済みサーバー",
"removeServer": "取り除く",
"continueButton": "続く",
"cancelButton": "キャンセル",
"signInTitle": "{{url}}にサインインしてください。",
"originTitle": "接続元",
"originDescription": "SSH接続のデフォルトの発信元を選択します。この設定はホストごとに変更できます。",
"originLocal": "このデバイス(ローカルネットワーク)",
"originRemote": "リモートサーバー",
"bannerReconnect": "再接続",
"bannerMessage": "リモート同期には再認証が必要です"
},
"versionCheck": {
"error": "バージョン確認エラー",
"checkFailed": "更新の確認に失敗しました",
"upToDate": "アプリは最新です",
"currentVersion": "バージョン {{version}} を実行中です",
"updateAvailable": "アップデートがあります",
"updateLinkLabel": "アップデートが利用可能です — GitHubのリリースノートをご覧ください",
"newVersionAvailable": "新しいバージョンが利用可能です。現在 {{current}} を使用中ですが、{{latest}} が利用可能です。",
"betaVersion": "ベータ版",
"betaVersionDesc": "{{current}} を実行中です。これは最新安定版 {{latest}} よりも新しいバージョンです。",
"releasedOn": "リリース日: {{date}}",
"downloadUpdate": "アップデートをダウンロード",
"checking": "アップデートを確認中...",
"checkUpdates": "アップデートを確認",
"checkingUpdates": "アップデートを確認中...",
"updateRequired": "アップデートが必要です"
"updateLinkLabel": "アップデートが利用可能です — GitHubのリリースノートをご覧ください"
},
"common": {
"close": "閉じる",
@@ -1565,8 +1487,6 @@
"resetCodeRateLimitedWithTime": "レート制限: 認証試行が多すぎます。{{time}}秒待ってから再試行してください。",
"authTokenSaveFailed": "認証トークンの保存に失敗しました",
"authTokenMissing": "サーバーから新しい認証トークンが返されませんでした。再度ログインしてください。",
"authTokenRejected": "リモートサーバーが新しい認証トークンを拒否しました",
"keyringUnavailable": "ログインしましたが、セッションを保存できませんでした。このシステムには、セッションを暗号化するためのキーリングがありません。シークレットサービス(gnome-keyringやKWalletなど)を起動して、再度ログインしてください。",
"failedToLoadServer": "サーバーの読み込みに失敗しました",
"remoteServerRequired": "リモートサーバーが必要です。この接続タイプを使用するには、設定でリモートサーバーを接続してください。"
},
+1 -81
View File
@@ -232,86 +232,8 @@
"widgetTunnelDesc": "설정된 호스트를 위한 내장 SSH 터널 관리자",
"widgetNoHostSelected": "구성된 호스트 없음"
},
"serverConfig": {
"title": "서버 설정",
"description": "백엔드 서비스에 연결할 Termix 서버 URL을 구성합니다",
"serverUrl": "서버 URL",
"enterServerUrl": "서버 URL을 입력하세요",
"saveFailed": "설정 저장 실패",
"saveError": "설정 저장 오류",
"saving": "저장 중...",
"saveConfig": "설정 저장",
"helpText": "Termix 서버가 실행 중인 URL을 입력하세요 (예: http://localhost:30001 또는 https://your-server.com)",
"changeServer": "서버 변경",
"mustIncludeProtocol": "서버 URL은 http:// 또는 https://로 시작해야 합니다",
"allowInvalidCertificate": "유효하지 않은 인증서 허용",
"allowInvalidCertificateDesc": "자체 서명 또는 IP 주소 인증서를 사용하는 신뢰할 수 있는 자체 호스팅 서버에만 사용하세요.",
"useEmbedded": "로컬 서버 사용",
"embeddedDesc": "내장 로컬 서버로 Termix 실행 (원격 서버 불필요)",
"embeddedConnecting": "로컬 서버에 연결 중...",
"embeddedNotReady": "로컬 서버가 아직 준비되지 않았습니다. 잠시 후 다시 시도해 주세요.",
"localServer": "로컬 서버",
"savedServers": "저장된 서버",
"noSavedServers": "저장된 서버 없음",
"removeServer": "제거"
},
"migrationNotice": {
"title": "Termix Desktop이 이제 독립 실행형으로 실행됩니다.",
"body1": "이 앱은 이전에는 Termix 서버에 직접 연결되었지만, 이제 완전히 독립적으로 실행되도록 재설계되었습니다. 호스트와 자격 증명을 로컬에 저장하여 오프라인 상태에서도 작동합니다. 자체 호스팅 Termix 서버와의 양방향 동기화는 이제 선택 사항입니다.",
"body2": "기존 호스트 및 자격 증명은 여전히 {{url}}에 있습니다. 원격 동기화를 켜고 해당 서버에 다시 연결하여 정보를 복원하고 향후 두 상태를 동기화 상태로 유지하세요.",
"dismiss": "지금은 안 돼",
"setUpSync": "원격 동기화 설정"
},
"remoteSync": {
"title": "원격 동기화",
"description": "선택적으로 이 데스크톱 앱을 자체 호스팅 Termix 서버에 연결하여 호스트, 자격 증명 및 코드 조각을 여러 장치에서 동기화할 수 있습니다. 연결 여부와 관계없이 앱은 항상 완전히 오프라인에서 작동합니다.",
"notConnected": "연결되지 않음",
"connected": "연결됨",
"connectedTo": "{{url}}에 연결됨",
"lastSynced": "마지막 동기화 시간: {{time}}",
"neverSynced": "동기화되지 않음",
"syncError": "동기화 오류: {{message}}",
"needsReauth": "로그인 시간이 만료되었습니다",
"connectButton": "서버에 연결",
"disconnectButton": "연결을 끊으세요",
"syncNowButton": "지금 동기화하세요",
"syncing": "동기화 중...",
"syncSuccess": "원격 동기화가 완료되었습니다.",
"syncUnavailable": "원격 동기화를 시작할 수 없습니다.",
"serverUrl": "서버 URL",
"enterServerUrl": "서버 URL을 입력해 주세요.",
"mustIncludeProtocol": "서버 URL은 http:// 또는 https://로 시작해야 합니다.",
"connectionTestFailed": "해당 URL에서 Termix 서버에 연결할 수 없습니다.",
"allowInvalidCertificate": "유효하지 않은 인증서를 허용합니다",
"allowInvalidCertificateDesc": "자체 서명 인증서 또는 IP 주소 인증서를 사용하는 신뢰할 수 있는 자체 호스팅 서버에서만 사용하십시오.",
"savedServers": "저장된 서버",
"removeServer": "제거하다",
"continueButton": "계속하다",
"cancelButton": "취소",
"signInTitle": "{{url}}에 로그인하세요",
"originTitle": "연결 출처",
"originDescription": "SSH 연결의 기본 시작 위치를 선택합니다. 이 설정은 호스트별로 재정의할 수 있습니다.",
"originLocal": "이 장치(로컬 네트워크)",
"originRemote": "원격 서버",
"bannerReconnect": "다시 연결",
"bannerMessage": "원격 동기화에는 재인증이 필요합니다."
},
"versionCheck": {
"error": "버전 확인 오류",
"checkFailed": "업데이트 확인 실패",
"upToDate": "앱이 최신 상태입니다",
"currentVersion": "현재 버전 {{version}}을(를) 사용 중입니다.",
"updateAvailable": "업데이트 가능",
"updateLinkLabel": "업데이트가 있습니다. GitHub에서 릴리스 노트를 확인하세요.",
"newVersionAvailable": "새 버전이 출시되었습니다! 현재 {{current}}을(를) 사용 중이며, {{latest}} 버전이 제공됩니다.",
"betaVersion": "베타 버전",
"betaVersionDesc": "현재 {{current}}을(를) 사용 중이며, 이는 최신 안정 릴리스 {{latest}}보다 최신 버전입니다.",
"releasedOn": "{{date}}에 출시됨",
"downloadUpdate": "업데이트 다운로드",
"checking": "업데이트 확인 중...",
"checkUpdates": "업데이트 확인",
"checkingUpdates": "업데이트 확인 중...",
"updateRequired": "업데이트 필요"
"updateLinkLabel": "업데이트가 있습니다. GitHub에서 릴리스 노트를 확인하세요."
},
"common": {
"close": "닫기",
@@ -1565,8 +1487,6 @@
"resetCodeRateLimitedWithTime": "속도 제한: 너무 많은 인증 시도가 있었습니다. 다시 시도하기 전에 {{time}}초 동안 기다려 주세요.",
"authTokenSaveFailed": "인증 토큰 저장에 실패했습니다.",
"authTokenMissing": "서버에서 새 인증 토큰을 반환하지 않았습니다. 다시 로그인해 주세요.",
"authTokenRejected": "원격 서버에서 새 인증 토큰을 거부했습니다.",
"keyringUnavailable": "로그인은 되었지만 세션을 저장할 수 없습니다. 이 시스템에는 세션을 암호화하는 데 사용할 수 있는 키링이 없습니다. gnome-keyring 또는 KWallet과 같은 비밀 서비스(Secret Service)를 시작하고 다시 로그인하세요.",
"failedToLoadServer": "서버를 불러오지 못했습니다.",
"remoteServerRequired": "원격 서버가 필요합니다. 이 연결 유형을 사용하려면 설정에서 원격 서버를 연결하세요."
},
+1 -81
View File
@@ -232,86 +232,8 @@
"widgetTunnelDesc": "Ingebouwde SSH-tunnelmanager voor een geconfigureerde host",
"widgetNoHostSelected": "Geen host geconfigureerd"
},
"serverConfig": {
"title": "Serverconfiguratie",
"description": "Configureer de Termix-server-URL om verbinding te maken met uw backendservices.",
"serverUrl": "Server-URL",
"enterServerUrl": "Voer een server-URL in.",
"saveFailed": "Configuratie opslaan mislukt",
"saveError": "Fout bij het opslaan van de configuratie",
"saving": "Besparing...",
"saveConfig": "Configuratie opslaan",
"helpText": "Voer de URL in waar uw Termix-server draait (bijvoorbeeld http://localhost:30001 of https://uw-server.com)",
"changeServer": "Server wijzigen",
"mustIncludeProtocol": "De server-URL moet beginnen met http:// of https://",
"allowInvalidCertificate": "Ongeldig certificaat toestaan",
"allowInvalidCertificateDesc": "Uitsluitend te gebruiken voor vertrouwde, zelfgehoste servers met zelfondertekende certificaten of IP-adrescertificaten.",
"useEmbedded": "Lokale server gebruiken",
"embeddedDesc": "Start Termix met de ingebouwde lokale server (geen externe server nodig).",
"embeddedConnecting": "Verbinding maken met de lokale server...",
"embeddedNotReady": "De lokale server is nog niet gereed. Wacht even en probeer het later opnieuw.",
"localServer": "Lokale server",
"savedServers": "Opgeslagen servers",
"noSavedServers": "Geen opgeslagen servers",
"removeServer": "Verwijderen"
},
"migrationNotice": {
"title": "Termix Desktop draait nu zelfstandig.",
"body1": "Deze app maakte voorheen rechtstreeks verbinding met uw Termix-server. Hij is herwerkt om volledig zelfstandig te werken, waarbij hosts en inloggegevens lokaal worden opgeslagen, zodat hij ook offline blijft functioneren. Tweewegsynchronisatie met een zelfgehoste Termix-server is nu optioneel.",
"body2": "Uw bestaande hosts en inloggegevens bevinden zich nog steeds op {{url}}. Schakel externe synchronisatie in en maak opnieuw verbinding met die server om ze te herstellen en beide in de toekomst gesynchroniseerd te houden.",
"dismiss": "Nu niet",
"setUpSync": "Externe synchronisatie instellen"
},
"remoteSync": {
"title": "Synchronisatie op afstand",
"description": "U kunt deze desktop-app optioneel verbinden met een zelfgehoste Termix-server om uw hosts, inloggegevens en codefragmenten op verschillende apparaten te synchroniseren. De app werkt altijd volledig offline, ongeacht of u verbinding maakt of niet.",
"notConnected": "Niet verbonden",
"connected": "Aangesloten",
"connectedTo": "Verbonden met {{url}}",
"lastSynced": "Laatst gesynchroniseerd {{time}}",
"neverSynced": "Nooit gesynchroniseerd",
"syncError": "Synchronisatiefout: {{message}}",
"needsReauth": "Aanmelden verlopen",
"connectButton": "Verbinding maken met de server",
"disconnectButton": "Verbinding verbreken",
"syncNowButton": "Nu synchroniseren",
"syncing": "Synchroniseren...",
"syncSuccess": "Externe synchronisatie voltooid",
"syncUnavailable": "Externe synchronisatie kon niet worden gestart.",
"serverUrl": "Server-URL",
"enterServerUrl": "Voer een server-URL in.",
"mustIncludeProtocol": "De server-URL moet beginnen met http:// of https://",
"connectionTestFailed": "Er kon geen Termix-server worden bereikt op die URL.",
"allowInvalidCertificate": "Ongeldig certificaat toestaan",
"allowInvalidCertificateDesc": "Uitsluitend te gebruiken voor vertrouwde, zelfgehoste servers met zelfondertekende certificaten of IP-adrescertificaten.",
"savedServers": "Opgeslagen servers",
"removeServer": "Verwijderen",
"continueButton": "Doorgaan",
"cancelButton": "Annuleren",
"signInTitle": "Meld je aan bij {{url}}",
"originTitle": "Verbindingsoorsprong",
"originDescription": "Kies de standaardlocatie voor SSH-verbindingen. Deze instelling kan per host worden gewijzigd.",
"originLocal": "Dit apparaat (lokaal netwerk)",
"originRemote": "Externe server",
"bannerReconnect": "Opnieuw verbinding maken",
"bannerMessage": "Externe synchronisatie vereist herverificatie."
},
"versionCheck": {
"error": "Versiecontrolefout",
"checkFailed": "Controleren op updates is mislukt.",
"upToDate": "De app is up-to-date.",
"currentVersion": "Je gebruikt versie {{version}}",
"updateAvailable": "Update beschikbaar",
"updateLinkLabel": "Update beschikbaar — bekijk de release-opmerkingen op GitHub.",
"newVersionAvailable": "Er is een nieuwe versie beschikbaar! Je gebruikt momenteel {{current}}, maar {{latest}} is beschikbaar.",
"betaVersion": "Bètaversie",
"betaVersionDesc": "Je gebruikt versie {{current}}, die nieuwer is dan de laatste stabiele versie {{latest}}.",
"releasedOn": "Uitgebracht op {{date}}",
"downloadUpdate": "Download update",
"checking": "Controleren op updates...",
"checkUpdates": "Controleer op updates",
"checkingUpdates": "Controleren op updates...",
"updateRequired": "Update vereist"
"updateLinkLabel": "Update beschikbaar — bekijk de release-opmerkingen op GitHub."
},
"common": {
"close": "Dichtbij",
@@ -1565,8 +1487,6 @@
"resetCodeRateLimitedWithTime": "Snelheid beperkt: Te veel verificatiepogingen. Wacht {{time}} seconden voordat u het opnieuw probeert.",
"authTokenSaveFailed": "Het opslaan van het authenticatietoken is mislukt.",
"authTokenMissing": "De server heeft geen nieuw authenticatietoken teruggestuurd. Meld u alstublieft opnieuw aan.",
"authTokenRejected": "De externe server heeft het nieuwe authenticatietoken geweigerd.",
"keyringUnavailable": "U bent ingelogd, maar de sessie kon niet worden opgeslagen: dit systeem heeft geen sleutelbos beschikbaar om deze mee te versleutelen. Start een geheime service (zoals gnome-keyring of KWallet) en log opnieuw in.",
"failedToLoadServer": "Server laden mislukt",
"remoteServerRequired": "Externe server vereist. Verbind een externe server in de instellingen om dit verbindingstype te gebruiken."
},
+1 -81
View File
@@ -232,86 +232,8 @@
"widgetTunnelDesc": "Innebygd SSH-tunnelbehandler for en konfigurert vert",
"widgetNoHostSelected": "Ingen vert konfigurert"
},
"serverConfig": {
"title": "Serverkonfigurasjon",
"description": "Konfigurer URL-adressen til Termix-serveren for å koble til backend-tjenestene dine",
"serverUrl": "Server-URL",
"enterServerUrl": "Vennligst skriv inn en server-URL",
"saveFailed": "Kunne ikke lagre konfigurasjonen",
"saveError": "Feil ved lagring av konfigurasjonen",
"saving": "Lagrer...",
"saveConfig": "Lagre konfigurasjon",
"helpText": "Skriv inn URL-en der Termix-serveren din kjører (f.eks. http://localhost:30001 eller https://your-server.com)",
"changeServer": "Endre server",
"mustIncludeProtocol": "Server-URL-en må starte med http:// eller https://",
"allowInvalidCertificate": "Tillat ugyldig sertifikat",
"allowInvalidCertificateDesc": "Bruk kun for klarerte, selvhostede servere med selvsignerte sertifikater eller IP-adressesertifikater.",
"useEmbedded": "Bruk lokal server",
"embeddedDesc": "Kjør Termix med den innebygde lokale serveren (ingen ekstern server nødvendig)",
"embeddedConnecting": "Kobler til lokal server...",
"embeddedNotReady": "Den lokale serveren er ikke klar ennå. Vent litt og prøv på nytt.",
"localServer": "Lokal server",
"savedServers": "Lagrede servere",
"noSavedServers": "Ingen lagrede servere",
"removeServer": "Fjerne"
},
"migrationNotice": {
"title": "Termix Desktop kjører nå frittstående",
"body1": "Denne appen pleide å koble seg direkte til Termix-serveren din. Den har blitt omarbeidet til å kjøre helt alene, og lagrer verter og påloggingsinformasjon lokalt, slik at den fortsetter å fungere offline. Toveis synkronisering med en selvhostet Termix-server er nå valgfritt.",
"body2": "Dine eksisterende verter og påloggingsinformasjon er fortsatt på {{url}}. Slå på ekstern synkronisering og koble til den serveren på nytt for å få dem tilbake og holde begge synkroniserte fremover.",
"dismiss": "Ikke nå",
"setUpSync": "Konfigurer ekstern synkronisering"
},
"remoteSync": {
"title": "Fjernsynkronisering",
"description": "Du kan eventuelt koble denne skrivebordsappen til en selvhostet Termix-server for å synkronisere verter, påloggingsinformasjon og kodebiter på tvers av enheter. Appen fungerer alltid helt offline, enten du kobler til eller ikke.",
"notConnected": "Ikke tilkoblet",
"connected": "Tilkoblet",
"connectedTo": "Koblet til {{url}}",
"lastSynced": "Sist synkronisert {{time}}",
"neverSynced": "Aldri synkronisert",
"syncError": "Synkroniseringsfeil: {{message}}",
"needsReauth": "Påloggingen er utløpt",
"connectButton": "Koble til server",
"disconnectButton": "Frakople",
"syncNowButton": "Synkroniser nå",
"syncing": "Synkroniserer...",
"syncSuccess": "Ekstern synkronisering fullført",
"syncUnavailable": "Fjernsynkronisering kunne ikke startes",
"serverUrl": "Server-URL",
"enterServerUrl": "Vennligst skriv inn en server-URL",
"mustIncludeProtocol": "Server-URL-en må starte med http:// eller https://",
"connectionTestFailed": "Kunne ikke nå en Termix-server på den URL-adressen",
"allowInvalidCertificate": "Tillat ugyldig sertifikat",
"allowInvalidCertificateDesc": "Bruk kun for klarerte, selvhostede servere med selvsignerte sertifikater eller IP-adressesertifikater.",
"savedServers": "Lagrede servere",
"removeServer": "Fjerne",
"continueButton": "Fortsette",
"cancelButton": "Kansellere",
"signInTitle": "Logg inn på {{url}}",
"originTitle": "Tilkoblingens opprinnelse",
"originDescription": "Velg hvor SSH-tilkoblinger kommer fra som standard. Dette kan overstyres per vert.",
"originLocal": "Denne enheten (lokalt nettverk)",
"originRemote": "Ekstern server",
"bannerReconnect": "Koble til igjen",
"bannerMessage": "Ekstern synkronisering trenger ny autentisering"
},
"versionCheck": {
"error": "Versjonssjekkfeil",
"checkFailed": "Kunne ikke sjekke etter oppdateringer",
"upToDate": "Appen er oppdatert",
"currentVersion": "Du kjører versjon {{version}}",
"updateAvailable": "Oppdatering tilgjengelig",
"updateLinkLabel": "Oppdatering tilgjengelig – åpne utgivelsesnotatene på GitHub",
"newVersionAvailable": "En ny versjon er tilgjengelig! Du kjører {{current}}, men {{latest}} er tilgjengelig.",
"betaVersion": "Betaversjon",
"betaVersionDesc": "Du kjører {{current}}, som er nyere enn den siste stabile utgivelsen {{latest}}.",
"releasedOn": "Utgitt {{date}}",
"downloadUpdate": "Last ned oppdatering",
"checking": "Sjekker etter oppdateringer ...",
"checkUpdates": "Se etter oppdateringer",
"checkingUpdates": "Sjekker etter oppdateringer ...",
"updateRequired": "Oppdatering kreves"
"updateLinkLabel": "Oppdatering tilgjengelig – åpne utgivelsesnotatene på GitHub"
},
"common": {
"close": "Lukke",
@@ -1565,8 +1487,6 @@
"resetCodeRateLimitedWithTime": "Frekvens begrenset: For mange bekreftelsesforsøk. Vent {{time}} sekunder før du prøver på nytt.",
"authTokenSaveFailed": "Kunne ikke lagre autentiseringstokenet",
"authTokenMissing": "Serveren returnerte ikke et nytt autentiseringstoken. Vennligst logg inn på nytt.",
"authTokenRejected": "Den eksterne serveren avviste det nye autentiseringstokenet",
"keyringUnavailable": "Logget inn, men økten kunne ikke lagres: dette systemet har ingen nøkkelring tilgjengelig for å kryptere den med. Start en hemmelig tjeneste (som gnome-keyring eller KWallet) og logg inn på nytt.",
"failedToLoadServer": "Kunne ikke laste inn serveren",
"remoteServerRequired": "Ekstern server kreves. Koble til en ekstern server i Innstillinger for å bruke denne tilkoblingstypen."
},
+1 -81
View File
@@ -232,86 +232,8 @@
"widgetTunnelDesc": "Wbudowany menedżer tuneli SSH dla skonfigurowanego hosta",
"widgetNoHostSelected": "Brak skonfigurowanego hosta"
},
"serverConfig": {
"title": "Konfiguracja serwera",
"description": "Skonfiguruj adres URL serwera Termix, aby połączyć się z jego usługami",
"serverUrl": "Adres URL serwera",
"enterServerUrl": "Podaj adres URL serwera",
"saveFailed": "Nie udało się zapisać konfiguracji",
"saveError": "Błąd podczas zapisywania konfiguracji",
"saving": "Zapisywanie...",
"saveConfig": "Zapisz konfigurację",
"helpText": "Wprowadź adres URL, pod którym działa serwer Termix (np. http://localhost:30001 lub https://your-server.com)",
"changeServer": "Zmień serwer",
"mustIncludeProtocol": "Adres URL serwera musi zaczynać się od http:// lub https://",
"allowInvalidCertificate": "Zezwól na nieprawidłowy certyfikat",
"allowInvalidCertificateDesc": "Używaj tylko dla zaufanych serwerów własnych, z certyfikatami samopodpisanymi lub wystawionymi na adres IP.",
"useEmbedded": "Użyj serwera lokalnego",
"embeddedDesc": "Uruchom Termix za pomocą wbudowanego serwera lokalnego (serwer zdalny nie jest potrzebny)",
"embeddedConnecting": "Łączenie z serwerem lokalnym...",
"embeddedNotReady": "Serwer lokalny nie jest jeszcze gotowy. Poczekaj chwilę i spróbuj ponownie.",
"localServer": "Serwer lokalny",
"savedServers": "Zapisane serwery",
"noSavedServers": "Brak zapisanych serwerów",
"removeServer": "Usuń"
},
"migrationNotice": {
"title": "Termix Desktop działa teraz autonomicznie",
"body1": "Ta aplikacja łączyła się bezpośrednio z serwerem Termix. Została przeprojektowana tak, aby działać w pełni samodzielnie: przechowuje hosty i poświadczenia lokalnie, dzięki czemu działa też bez połączenia. Dwukierunkowa synchronizacja z własnym serwerem Termix jest teraz opcjonalna.",
"body2": "Dotychczasowe hosty i poświadczenia są nadal na {{url}}. Włącz synchronizację zdalną i połącz się ponownie z tym serwerem, aby je przywrócić i utrzymywać oba miejsca w synchronizacji.",
"dismiss": "Nie teraz",
"setUpSync": "Skonfiguruj synchronizację zdalną"
},
"remoteSync": {
"title": "Zdalna synchronizacja",
"description": "Opcjonalnie połącz tę aplikację desktopową z własnym serwerem Termix, aby synchronizować hosty, poświadczenia i snippety między urządzeniami. Aplikacja zawsze działa w pełni bez połączenia, niezależnie od tego, czy połączysz się z serwerem.",
"notConnected": "Nie połączono",
"connected": "Połączono",
"connectedTo": "Połączono z {{url}}",
"lastSynced": "Ostatnia synchronizacja {{time}}",
"neverSynced": "Nigdy nie synchronizowano",
"syncError": "Błąd synchronizacji: {{message}}",
"needsReauth": "Logowanie wygasło",
"connectButton": "Połącz się z serwerem",
"disconnectButton": "Odłącz",
"syncNowButton": "Synchronizuj teraz",
"syncing": "Synchronizacja...",
"syncSuccess": "Synchronizacja zdalna zakończona",
"syncUnavailable": "Nie można rozpocząć synchronizacji zdalnej",
"serverUrl": "Adres URL serwera",
"enterServerUrl": "Podaj adres URL serwera",
"mustIncludeProtocol": "Adres URL serwera musi zaczynać się od http:// lub https://",
"connectionTestFailed": "Nie można nawiązać połączenia z serwerem Termix pod tym adresem URL",
"allowInvalidCertificate": "Zezwól na nieprawidłowy certyfikat",
"allowInvalidCertificateDesc": "Używaj tylko dla zaufanych serwerów własnych, z certyfikatami samopodpisanymi lub wystawionymi na adres IP.",
"savedServers": "Zapisane serwery",
"removeServer": "Usuń",
"continueButton": "Kontynuuj",
"cancelButton": "Anuluj",
"signInTitle": "Zaloguj się do {{url}}",
"originTitle": "Źródło połączenia",
"originDescription": "Wybierz domyślne źródło połączeń SSH. Można to zmienić dla każdego hosta.",
"originLocal": "To urządzenie (sieć lokalna)",
"originRemote": "Serwer zdalny",
"bannerReconnect": "Połącz ponownie",
"bannerMessage": "Zdalna synchronizacja wymaga ponownego uwierzytelnienia"
},
"versionCheck": {
"error": "Błąd sprawdzania wersji",
"checkFailed": "Nie udało się sprawdzić dostępności aktualizacji",
"upToDate": "Aplikacja jest aktualna",
"currentVersion": "Używasz wersji {{version}}",
"updateAvailable": "Dostępna aktualizacja",
"updateLinkLabel": "Dostępna aktualizacja: otwórz informacje o wydaniu na GitHubie",
"newVersionAvailable": "Dostępna jest nowa wersja! Używasz wersji {{current}}, ale dostępna jest wersja {{latest}}.",
"betaVersion": "Wersja beta",
"betaVersionDesc": "Używasz wersji {{current}}, która jest nowsza od najnowszej stabilnej wersji {{latest}}.",
"releasedOn": "Wydano {{date}}",
"downloadUpdate": "Pobierz aktualizację",
"checking": "Sprawdzanie aktualizacji...",
"checkUpdates": "Sprawdź aktualizacje",
"checkingUpdates": "Sprawdzanie aktualizacji...",
"updateRequired": "Wymagana aktualizacja"
"updateLinkLabel": "Dostępna aktualizacja: otwórz informacje o wydaniu na GitHubie"
},
"common": {
"close": "Zamknij",
@@ -1565,8 +1487,6 @@
"resetCodeRateLimitedWithTime": "Przekroczono limit prób: zbyt wiele weryfikacji. Odczekaj {{time}} sekund przed kolejną próbą.",
"authTokenSaveFailed": "Nie udało się zapisać tokenu uwierzytelniającego",
"authTokenMissing": "Serwer nie zwrócił nowego tokenu uwierzytelniającego. Zaloguj się ponownie.",
"authTokenRejected": "Zdalny serwer odrzucił nowy token uwierzytelniający",
"keyringUnavailable": "Zalogowano, ale nie udało się zapisać sesji: ten system nie ma dostępnego pęku kluczy do jej zaszyfrowania. Uruchom usługę Secret Service (np. gnome-keyring lub KWallet) i zaloguj się ponownie.",
"failedToLoadServer": "Nie udało się załadować serwera",
"remoteServerRequired": "Wymagany jest serwer zdalny. Połącz się z serwerem zdalnym w Ustawieniach, aby korzystać z tego typu połączenia."
},
+1 -81
View File
@@ -232,86 +232,8 @@
"widgetTunnelDesc": "Gerenciador de túnel SSH integrado para um host configurado",
"widgetNoHostSelected": "Nenhum host configurado"
},
"serverConfig": {
"title": "Configuração do servidor",
"description": "Configure o URL do servidor Termix para conectar-se aos seus serviços de backend.",
"serverUrl": "URL do servidor",
"enterServerUrl": "Por favor, insira um URL de servidor.",
"saveFailed": "Falha ao salvar a configuração",
"saveError": "Erro ao salvar a configuração",
"saving": "Salvando...",
"saveConfig": "Salvar configuração",
"helpText": "Insira o URL onde seu servidor Termix está em execução (por exemplo, http://localhost:30001 ou https://seu-servidor.com)",
"changeServer": "Alterar servidor",
"mustIncludeProtocol": "O URL do servidor deve começar com http:// ou https://",
"allowInvalidCertificate": "Permitir certificado inválido",
"allowInvalidCertificateDesc": "Utilize somente em servidores autohospedados confiáveis com certificados autoassinados ou de endereço IP.",
"useEmbedded": "Usar servidor local",
"embeddedDesc": "Execute o Termix com o servidor local integrado (não é necessário um servidor remoto).",
"embeddedConnecting": "Conectando ao servidor local...",
"embeddedNotReady": "O servidor local ainda não está pronto. Aguarde um momento e tente novamente.",
"localServer": "Servidor local",
"savedServers": "Servidores salvos",
"noSavedServers": "Nenhum servidor salvo",
"removeServer": "Remover"
},
"migrationNotice": {
"title": "O Termix Desktop agora funciona de forma independente.",
"body1": "Este aplicativo costumava se conectar diretamente ao seu servidor Termix. Ele foi reformulado para funcionar de forma totalmente independente, armazenando hosts e credenciais localmente para que continue funcionando offline. A sincronização bidirecional com um servidor Termix auto-hospedado agora é opcional.",
"body2": "Seus hosts e credenciais atuais ainda estão em {{url}}. Ative a Sincronização Remota e reconecte-se a esse servidor para recuperá-los e mantê-los sincronizados daqui para frente.",
"dismiss": "Agora não",
"setUpSync": "Configurar sincronização remota"
},
"remoteSync": {
"title": "Sincronização remota",
"description": "Opcionalmente, conecte este aplicativo de desktop a um servidor Termix auto-hospedado para sincronizar seus hosts, credenciais e trechos de código entre dispositivos. O aplicativo funciona sempre totalmente offline, independentemente de estar conectado ou não.",
"notConnected": "Não conectado",
"connected": "Conectado",
"connectedTo": "Conectado a {{url}}",
"lastSynced": "Última sincronização {{time}}",
"neverSynced": "Nunca sincronizado",
"syncError": "Erro de sincronização: {{message}}",
"needsReauth": "Login expirou",
"connectButton": "Conectar ao servidor",
"disconnectButton": "Desconectar",
"syncNowButton": "Sincronizar agora",
"syncing": "Sincronizando...",
"syncSuccess": "Sincronização remota concluída",
"syncUnavailable": "Não foi possível iniciar a sincronização remota.",
"serverUrl": "URL do servidor",
"enterServerUrl": "Por favor, insira um URL de servidor.",
"mustIncludeProtocol": "O URL do servidor deve começar com http:// ou https://",
"connectionTestFailed": "Não foi possível acessar um servidor Termix nesse URL.",
"allowInvalidCertificate": "Permitir certificado inválido",
"allowInvalidCertificateDesc": "Utilize somente em servidores autohospedados confiáveis com certificados autoassinados ou de endereço IP.",
"savedServers": "Servidores salvos",
"removeServer": "Remover",
"continueButton": "Continuar",
"cancelButton": "Cancelar",
"signInTitle": "Faça login em {{url}}",
"originTitle": "Origem da conexão",
"originDescription": "Por padrão, escolha de onde as conexões SSH se originam. Essa configuração pode ser alterada para cada host individualmente.",
"originLocal": "Este dispositivo (rede local)",
"originRemote": "Servidor remoto",
"bannerReconnect": "Reconectar",
"bannerMessage": "A sincronização remota requer nova autenticação."
},
"versionCheck": {
"error": "Erro na verificação de versão",
"checkFailed": "Falha ao verificar se há atualizações",
"upToDate": "O aplicativo está atualizado.",
"currentVersion": "Você está executando a versão {{version}}",
"updateAvailable": "Atualização disponível",
"updateLinkLabel": "Atualização disponível — abra as notas de lançamento no GitHub.",
"newVersionAvailable": "Uma nova versão está disponível! Você está executando {{current}}, mas {{latest}} está disponível.",
"betaVersion": "Versão Beta",
"betaVersionDesc": "Você está executando {{current}}, que é mais recente do que a versão estável mais recente {{latest}}.",
"releasedOn": "Lançado em {{date}}",
"downloadUpdate": "Baixar atualização",
"checking": "Verificando atualizações...",
"checkUpdates": "Verifique se há atualizações",
"checkingUpdates": "Verificando atualizações...",
"updateRequired": "Atualização necessária"
"updateLinkLabel": "Atualização disponível — abra as notas de lançamento no GitHub."
},
"common": {
"close": "Fechar",
@@ -1565,8 +1487,6 @@
"resetCodeRateLimitedWithTime": "Taxa de tentativas limitada: Muitas tentativas de verificação. Aguarde {{time}} segundos antes de tentar novamente.",
"authTokenSaveFailed": "Falha ao salvar o token de autenticação",
"authTokenMissing": "O servidor não retornou um novo token de autenticação. Por favor, faça login novamente.",
"authTokenRejected": "O servidor remoto rejeitou o novo token de autenticação.",
"keyringUnavailable": "A sessão foi iniciada, mas não pôde ser salva: este sistema não possui um chaveiro disponível para criptografá-la. Inicie um Serviço Secreto (como o gnome-keyring ou o KWallet) e faça login novamente.",
"failedToLoadServer": "Falha ao carregar o servidor",
"remoteServerRequired": "É necessário um servidor remoto. Conecte um servidor remoto nas Configurações para usar este tipo de conexão."
},
+1 -81
View File
@@ -232,86 +232,8 @@
"widgetTunnelDesc": "Gestor de túneis SSH integrado para um host configurado",
"widgetNoHostSelected": "Nenhum host configurado"
},
"serverConfig": {
"title": "Configuração do Servidor",
"description": "Configure o URL do servidor Termix para se ligar aos seus serviços de backend",
"serverUrl": "URL do Servidor",
"enterServerUrl": "Por favor, insira um URL do servidor",
"saveFailed": "Falha ao guardar a configuração",
"saveError": "Erro ao guardar a configuração",
"saving": "A guardar...",
"saveConfig": "Guardar Configuração",
"helpText": "Insira o URL onde o seu servidor Termix está a ser executado (ex: http://localhost:30001 ou https://your-server.com)",
"changeServer": "Alterar Servidor",
"mustIncludeProtocol": "O URL do servidor deve começar com http:// ou https://",
"allowInvalidCertificate": "Permitir certificado inválido",
"allowInvalidCertificateDesc": "Usar apenas para servidores auto-hospedados de confiança com certificados auto-assinados ou de endereço IP.",
"useEmbedded": "Usar Servidor Local",
"embeddedDesc": "Executar o Termix com o servidor local incorporado (não é necessário servidor remoto)",
"embeddedConnecting": "A ligar ao servidor local...",
"embeddedNotReady": "O servidor local ainda não está pronto. Aguarde um momento e tente novamente.",
"localServer": "Servidor Local",
"savedServers": "Servidores Guardados",
"noSavedServers": "Nenhum servidor guardado",
"removeServer": "Remover"
},
"migrationNotice": {
"title": "O Termix Desktop funciona agora de forma independente.",
"body1": "Esta aplicação costumava ligar-se diretamente ao seu servidor Termix. Foi reformulado para funcionar de forma totalmente independente, armazenando hosts e credenciais localmente para que continue a funcionar offline. A sincronização bidirecional com um servidor Termix auto-hospedado é agora opcional.",
"body2": "Os seus hosts e credenciais atuais ainda estão em {{url}}. Ative a Sincronização Remota e volte a ligar-se a esse servidor para os recuperar e mantê-los sincronizados daqui para a frente.",
"dismiss": "Agora não",
"setUpSync": "Configurar sincronização remota"
},
"remoteSync": {
"title": "Sincronização remota",
"description": "Opcionalmente, ligue esta aplicação de desktop a um servidor Termix auto-hospedado para sincronizar os seus hosts, credenciais e pedaços de código entre dispositivos. A aplicação funciona sempre totalmente offline, independentemente de estar ligado ou não.",
"notConnected": "Não conectado",
"connected": "Conectado",
"connectedTo": "Ligado a {{url}}",
"lastSynced": "Última sincronização {{time}}",
"neverSynced": "Nunca sincronizado",
"syncError": "Erro de sincronização: {{message}}",
"needsReauth": "Login expirou",
"connectButton": "Ligar ao servidor",
"disconnectButton": "Desconectar",
"syncNowButton": "Sincronizar agora",
"syncing": "Sincronizando...",
"syncSuccess": "Sincronização remota concluída",
"syncUnavailable": "Não foi possível iniciar a sincronização remota.",
"serverUrl": "URL do servidor",
"enterServerUrl": "Por favor, introduza um URL de servidor.",
"mustIncludeProtocol": "O URL do servidor deve começar por http:// ou https://",
"connectionTestFailed": "Não foi possível aceder a um servidor Termix nesse URL.",
"allowInvalidCertificate": "Permitir certificado inválido",
"allowInvalidCertificateDesc": "Utilize apenas em servidores auto-hospedados fidedignos com certificados autoassinados ou de endereço IP.",
"savedServers": "Servidores salvos",
"removeServer": "Remover",
"continueButton": "Continuar",
"cancelButton": "Cancelar",
"signInTitle": "Faça login em {{url}}",
"originTitle": "Origem da ligação",
"originDescription": "Por predefinição, escolha de onde se originam as ligações SSH. Esta configuração pode ser alterada para cada host individualmente.",
"originLocal": "Este dispositivo (rede local)",
"originRemote": "Servidor remoto",
"bannerReconnect": "Reconectar",
"bannerMessage": "A sincronização remota requer nova autenticação."
},
"versionCheck": {
"error": "Erro na Verificação de Versão",
"checkFailed": "Falha ao verificar atualizações",
"upToDate": "A aplicação está atualizada",
"currentVersion": "Está a executar a versão {{version}}",
"updateAvailable": "Atualização disponível",
"updateLinkLabel": "Atualização disponível — abra as notas de lançamento no GitHub.",
"newVersionAvailable": "Está disponível uma nova versão! Está a executar {{current}}, mas a {{latest}} está disponível.",
"betaVersion": "Versão Beta",
"betaVersionDesc": "Está a executar a {{current}}, que é mais recente do que a última versão estável {{latest}}.",
"releasedOn": "Lançado a {{date}}",
"downloadUpdate": "Descarregar Atualização",
"checking": "A verificar atualizações...",
"checkUpdates": "Verificar Atualizações",
"checkingUpdates": "A verificar atualizações...",
"updateRequired": "Atualização necessária"
"updateLinkLabel": "Atualização disponível — abra as notas de lançamento no GitHub."
},
"common": {
"close": "Fechar",
@@ -1565,8 +1487,6 @@
"resetCodeRateLimitedWithTime": "Limite de tentativas: demasiadas tentativas de verificação. Por favor, aguarde {{time}} segundos antes de tentar novamente.",
"authTokenSaveFailed": "Falha ao guardar o token de autenticação",
"authTokenMissing": "O servidor não devolveu um novo token de autenticação. Por favor, inicie sessão novamente.",
"authTokenRejected": "O servidor remoto rejeitou o novo token de autenticação.",
"keyringUnavailable": "A sessão foi iniciada, mas não pôde ser guardada: este sistema não tem um porta-chaves disponível para a encriptar. Inicie um Serviço Secreto (como o gnome-keyring ou o KWallet) e inicie sessão novamente.",
"failedToLoadServer": "Falha ao carregar o servidor",
"remoteServerRequired": "É necessário um servidor remoto. Ligue um servidor remoto nas Definições para utilizar este tipo de ligação."
},
+1 -81
View File
@@ -232,86 +232,8 @@
"widgetTunnelDesc": "Manager de tunel SSH încorporat pentru o gazdă configurată",
"widgetNoHostSelected": "Nicio gazdă configurată"
},
"serverConfig": {
"title": "Configurarea serverului",
"description": "Configurați adresa URL a serverului Termix pentru a vă conecta la serviciile backend",
"serverUrl": "URL-ul serverului",
"enterServerUrl": "Introduceți adresa URL a serverului",
"saveFailed": "Nu s-a putut salva configurația",
"saveError": "Eroare la salvarea configurației",
"saving": "Economisire...",
"saveConfig": "Salvați configurația",
"helpText": "Introduceți adresa URL unde rulează serverul Termix (de exemplu, http://localhost:30001 sau https://serverul-dumneavoastră.com)",
"changeServer": "Schimbare server",
"mustIncludeProtocol": "URL-ul serverului trebuie să înceapă cu http:// sau https://",
"allowInvalidCertificate": "Permiteți certificatul nevalid",
"allowInvalidCertificateDesc": "A se utiliza numai pentru servere auto-găzduite de încredere, cu certificate autosemnate sau cu adresă IP.",
"useEmbedded": "Folosește serverul local",
"embeddedDesc": "Rulați Termix cu serverul local încorporat (nu este nevoie de un server la distanță)",
"embeddedConnecting": "Conectare la serverul local...",
"embeddedNotReady": "Serverul local nu este încă gata. Vă rugăm să așteptați un moment și să încercați din nou.",
"localServer": "Server local",
"savedServers": "Servere salvate",
"noSavedServers": "Niciun server salvat",
"removeServer": "Elimina"
},
"migrationNotice": {
"title": "Termix Desktop rulează acum independent",
"body1": "Această aplicație se conecta direct la serverul Termix. A fost reelaborată pentru a rula complet independent, stocând gazdele și acreditările local, astfel încât să funcționeze în continuare offline. Sincronizarea bidirecțională cu un server Termix auto-găzduit este acum opțională.",
"body2": "Gazdele și acreditările existente sunt încă pe {{url}}. Activați sincronizarea la distanță și reconectați-vă la serverul respectiv pentru a le reactiva și a le menține sincronizate de acum înainte.",
"dismiss": "Nu acum",
"setUpSync": "Configurați sincronizarea la distanță"
},
"remoteSync": {
"title": "Sincronizare la distanță",
"description": "Opțional, conectați această aplicație desktop la un server Termix auto-găzduit pentru a sincroniza gazdele, acreditările și fragmentele de date pe toate dispozitivele. Aplicația funcționează întotdeauna complet offline, indiferent dacă vă conectați sau nu.",
"notConnected": "Neconectat",
"connected": "Conectat",
"connectedTo": "Conectat la {{url}}",
"lastSynced": "Ultima sincronizare {{time}}",
"neverSynced": "Nu s-a sincronizat niciodată",
"syncError": "Eroare de sincronizare: {{message}}",
"needsReauth": "Conectarea a expirat",
"connectButton": "Conectare la server",
"disconnectButton": "Deconecta",
"syncNowButton": "Sincronizează acum",
"syncing": "Se sincronizează...",
"syncSuccess": "Sincronizarea la distanță a fost finalizată",
"syncUnavailable": "Sincronizarea la distanță nu a putut fi pornită",
"serverUrl": "URL-ul serverului",
"enterServerUrl": "Introduceți adresa URL a serverului",
"mustIncludeProtocol": "URL-ul serverului trebuie să înceapă cu http:// sau https://",
"connectionTestFailed": "Nu s-a putut accesa un server Termix la adresa URL respectivă.",
"allowInvalidCertificate": "Permiteți certificatul nevalid",
"allowInvalidCertificateDesc": "A se utiliza numai pentru servere auto-găzduite de încredere, cu certificate autosemnate sau cu adresă IP.",
"savedServers": "Servere salvate",
"removeServer": "Elimina",
"continueButton": "Continua",
"cancelButton": "Anula",
"signInTitle": "Conectați-vă la {{url}}",
"originTitle": "Originea conexiunii",
"originDescription": "Alegeți în mod implicit de unde provin conexiunile SSH. Această opțiune poate fi ignorată pentru fiecare gazdă.",
"originLocal": "Acest dispozitiv (rețea locală)",
"originRemote": "Server la distanță",
"bannerReconnect": "Reconectați-vă",
"bannerMessage": "Sincronizarea la distanță necesită reautentificare"
},
"versionCheck": {
"error": "Eroare la verificarea versiunii",
"checkFailed": "Nu s-a putut verifica dacă există actualizări",
"upToDate": "Aplicația este actualizată",
"currentVersion": "Folosești versiunea {{version}}",
"updateAvailable": "Actualizare disponibilă",
"updateLinkLabel": "Actualizare disponibilă — deschideți notele de lansare pe GitHub",
"newVersionAvailable": "O versiune nouă este disponibilă! Utilizați {{current}}, dar {{latest}} este disponibil.",
"betaVersion": "Versiune Beta",
"betaVersionDesc": "Rulezi {{current}}, care este mai nouă decât cea mai recentă versiune stabilă {{latest}}.",
"releasedOn": "Lansat pe {{date}}",
"downloadUpdate": "Descărcați actualizarea",
"checking": "Se verifică actualizările...",
"checkUpdates": "Verificați actualizările",
"checkingUpdates": "Se caută actualizări...",
"updateRequired": "Actualizare necesară"
"updateLinkLabel": "Actualizare disponibilă — deschideți notele de lansare pe GitHub"
},
"common": {
"close": "Aproape",
@@ -1565,8 +1487,6 @@
"resetCodeRateLimitedWithTime": "Rată limitată: Prea multe încercări de verificare. Vă rugăm să așteptați {{time}} secunde înainte de a încerca din nou.",
"authTokenSaveFailed": "Nu s-a putut salva jetonul de autentificare",
"authTokenMissing": "Serverul nu a returnat un nou token de autentificare. Vă rugăm să vă conectați din nou.",
"authTokenRejected": "Serverul la distanță a respins noul token de autentificare",
"keyringUnavailable": "Conectat, dar sesiunea nu a putut fi stocată: acest sistem nu are un keyring disponibil pentru criptare. Porniți un Serviciu Secret (cum ar fi gnome-keyring sau KWallet) și conectați-vă din nou.",
"failedToLoadServer": "Nu s-a putut încărca serverul",
"remoteServerRequired": "Este necesar un server la distanță. Conectați un server la distanță în Setări pentru a utiliza acest tip de conexiune."
},
+1 -81
View File
@@ -232,86 +232,8 @@
"widgetTunnelDesc": "Встроенный менеджер SSH-туннелей для настроенного хоста",
"widgetNoHostSelected": "Хост не настроен"
},
"serverConfig": {
"title": "Конфигурация сервера",
"description": "Настройте URL сервера Termix для подключения к вашим внутренним службам",
"serverUrl": "URL сервера",
"enterServerUrl": "Пожалуйста, введите URL сервера",
"saveFailed": "Не удалось сохранить конфигурацию",
"saveError": "Ошибка сохранения конфигурации",
"saving": "Сохранение...",
"saveConfig": "Сохранить конфигурацию",
"helpText": "Введите URL, по которому работает ваш сервер Termix (например, http://localhost:30001 или https://your-server.com)",
"changeServer": "Сменить сервер",
"mustIncludeProtocol": "URL сервера должен начинаться с http:// или https://",
"allowInvalidCertificate": "Разрешить недействительный сертификат",
"allowInvalidCertificateDesc": "Используйте только для доверенных самостоятельно развёрнутых серверов с самоподписанными сертификатами или сертификатами для IP-адресов.",
"useEmbedded": "Использовать локальный сервер",
"embeddedDesc": "Запускать Termix со встроенным локальным сервером (удалённый сервер не требуется)",
"embeddedConnecting": "Подключение к локальному серверу...",
"embeddedNotReady": "Локальный сервер ещё не готов. Пожалуйста, подождите немного и попробуйте снова.",
"localServer": "Локальный сервер",
"savedServers": "Сохранённые серверы",
"noSavedServers": "Нет сохранённых серверов",
"removeServer": "Удалить"
},
"migrationNotice": {
"title": "Теперь Termix Desktop работает в автономном режиме.",
"body1": "Раньше это приложение напрямую подключалось к вашему серверу Termix. Теперь оно переработано так, чтобы работать полностью автономно: данные о хостах и учётные данные хранятся локально, поэтому приложение продолжает работать даже без подключения к интернету. Двунаправленная синхронизация с вашим собственным сервером Termix теперь является необязательной.",
"body2": "Ваши существующие хосты и учетные данные по-прежнему находятся на {{url}}. Включите удаленную синхронизацию и переподключитесь к этому серверу, чтобы восстановить их и обеспечить синхронизацию в дальнейшем.",
"dismiss": "Не сейчас",
"setUpSync": "Настроить удалённую синхронизацию"
},
"remoteSync": {
"title": "Удалённая синхронизация",
"description": "При желании подключите это настольное приложение к собственному серверу Termix, чтобы синхронизировать хосты, учётные данные и фрагменты кода между устройствами. Приложение в любом случае полностью работает в автономном режиме, независимо от того, подключаете вы сервер или нет.",
"notConnected": "Не подключено",
"connected": "Подключено",
"connectedTo": "Подключено к {{url}}",
"lastSynced": "Последняя синхронизация {{time}}",
"neverSynced": "Никогда не синхронизировалось",
"syncError": "Ошибка синхронизации: {{message}}",
"needsReauth": "Срок действия авторизации истек.",
"connectButton": "Подключиться к серверу",
"disconnectButton": "Отключить",
"syncNowButton": "Синхронизировать сейчас",
"syncing": "Синхронизация...",
"syncSuccess": "Удалённая синхронизация завершена.",
"syncUnavailable": "Не удалось запустить удаленную синхронизацию.",
"serverUrl": "URL сервера",
"enterServerUrl": "Пожалуйста, введите URL-адрес сервера.",
"mustIncludeProtocol": "URL-адрес сервера должен начинаться с http:// или https://",
"connectionTestFailed": "Не удалось подключиться к серверу Termix по указанному URL-адресу.",
"allowInvalidCertificate": "Разрешить использование недействительного сертификата",
"allowInvalidCertificateDesc": "Используйте только для доверенных серверов, размещённых самостоятельно, с самоподписанными сертификатами или сертификатами для IP-адреса.",
"savedServers": "Сохраненные серверы",
"removeServer": "Удалить",
"continueButton": "Продолжить",
"cancelButton": "Отмена",
"signInTitle": "Войдите в {{url}}",
"originTitle": "Источник соединения",
"originDescription": "По умолчанию выбирается место, откуда исходят SSH-соединения. Это можно изменить для каждого хоста отдельно.",
"originLocal": "Это устройство (локальная сеть)",
"originRemote": "Удаленный сервер",
"bannerReconnect": "Переподключитесь",
"bannerMessage": "Для удалённой синхронизации требуется повторная аутентификация."
},
"versionCheck": {
"error": "Ошибка проверки версии",
"checkFailed": "Не удалось проверить обновления",
"upToDate": "Приложение актуально",
"currentVersion": "Вы используете версию {{version}}",
"updateAvailable": "Доступно обновление",
"updateLinkLabel": "Доступно обновление — откройте примечания к выпуску на GitHub.",
"newVersionAvailable": "Доступна новая версия! Вы используете {{current}}, но доступна версия {{latest}}.",
"betaVersion": "Бета-версия",
"betaVersionDesc": "Вы используете {{current}}, которая новее последней стабильной версии {{latest}}.",
"releasedOn": "Выпущено {{date}}",
"downloadUpdate": "Скачать обновление",
"checking": "Проверка обновлений...",
"checkUpdates": "Проверить обновления",
"checkingUpdates": "Проверка обновлений...",
"updateRequired": "Требуется обновление"
"updateLinkLabel": "Доступно обновление — откройте примечания к выпуску на GitHub."
},
"common": {
"close": "Закрыть",
@@ -1565,8 +1487,6 @@
"resetCodeRateLimitedWithTime": "Превышена частота запросов: слишком много попыток подтверждения. Подождите {{time}} секунд, прежде чем повторить попытку.",
"authTokenSaveFailed": "Не удалось сохранить токен аутентификации",
"authTokenMissing": "Сервер не вернул новый токен аутентификации. Пожалуйста, войдите в систему снова.",
"authTokenRejected": "Удаленный сервер отклонил новый токен аутентификации.",
"keyringUnavailable": "Вход выполнен, но сессия не сохранена: в системе отсутствует доступный брелок ключей для шифрования. Запустите службу секретов (например, gnome-keyring или KWallet) и войдите снова.",
"failedToLoadServer": "Не удалось загрузить сервер",
"remoteServerRequired": "Требуется удаленный сервер. Для использования этого типа подключения подключите удаленный сервер в настройках."
},
+1 -81
View File
@@ -232,86 +232,8 @@
"widgetTunnelDesc": "Уграђени SSH менаџер тунела за конфигурисани хост",
"widgetNoHostSelected": "Није конфигурисан хост"
},
"serverConfig": {
"title": "Конфигурација сервера",
"description": "Конфигуришите URL адресу Termix сервера да бисте се повезали са вашим бекенд сервисима",
"serverUrl": "URL сервера",
"enterServerUrl": "Молимо вас да унесете URL адресу сервера",
"saveFailed": "Чување конфигурације није успело",
"saveError": "Грешка при чувању конфигурације",
"saving": "Чување...",
"saveConfig": "Сачувај конфигурацију",
"helpText": "Унесите URL адресу на којој ради ваш Termix сервер (нпр. http://localhost:30001 или https://your-server.com)",
"changeServer": "Промени сервер",
"mustIncludeProtocol": "URL сервера мора да почиње са http:// или https://",
"allowInvalidCertificate": "Дозволи неважећи сертификат",
"allowInvalidCertificateDesc": "Користите само за поуздане самохостоване сервере са самопотписаним сертификатима или сертификатима за IP адресу.",
"useEmbedded": "Користи локални сервер",
"embeddedDesc": "Покрените Termix са уграђеним локалним сервером (није потребан удаљени сервер)",
"embeddedConnecting": "Повезивање са локалним сервером...",
"embeddedNotReady": "Локални сервер још није спреман. Молимо сачекајте тренутак и покушајте поново.",
"localServer": "Локални сервер",
"savedServers": "Сачувани сервери",
"noSavedServers": "Нема сачуваних сервера",
"removeServer": "Уклони"
},
"migrationNotice": {
"title": "Termix Desktop сада ради самостално",
"body1": "Ова апликација се раније директно повезивала са вашим Termix сервером. Прерађена је тако да ради потпуно самостално, чувајући хостове и акредитиве локално како би наставила да ради ван мреже. Двосмерна синхронизација са самостално хостованим Termix сервером је сада опционална.",
"body2": "Ваши постојећи хостови и акредитиви су и даље на {{url}}. Укључите даљинску синхронизацију и поново се повежите са тим сервером да бисте их вратили и одржали синхронизацију у будућности.",
"dismiss": "Не сада",
"setUpSync": "Подешавање даљинске синхронизације"
},
"remoteSync": {
"title": "Даљинска синхронизација",
"description": "Опционо, повежите ову десктоп апликацију са самостално хостованим Termix сервером да бисте синхронизовали своје хостове, акредитиве и исечке кода на свим уређајима. Апликација увек ради потпуно ван мреже, без обзира да ли се повежете или не.",
"notConnected": "Није повезано",
"connected": "Повезано",
"connectedTo": "Повезано са {{url}}",
"lastSynced": "Последња синхронизација {{time}}",
"neverSynced": "Никада није синхронизовано",
"syncError": "Грешка у синхронизацији: {{message}}",
"needsReauth": "Пријава је истекла",
"connectButton": "Повежи се са сервером",
"disconnectButton": "Прекини везу",
"syncNowButton": "Синхронизуј сада",
"syncing": "Синхронизација...",
"syncSuccess": "Даљинска синхронизација је завршена",
"syncUnavailable": "Даљинска синхронизација није могла да се покрене",
"serverUrl": "URL сервера",
"enterServerUrl": "Молимо вас да унесете URL адресу сервера",
"mustIncludeProtocol": "URL сервера мора да почиње са http:// или https://",
"connectionTestFailed": "Није могуће доћи до Termix сервера на тој URL адреси",
"allowInvalidCertificate": "Дозволи неважећи сертификат",
"allowInvalidCertificateDesc": "Користите само за поуздане самохостоване сервере са самопотписаним сертификатима или сертификатима за IP адресу.",
"savedServers": "Сачувани сервери",
"removeServer": "Уклони",
"continueButton": "Настави",
"cancelButton": "Откажи",
"signInTitle": "Пријавите се на {{url}}",
"originTitle": "Порекло везе",
"originDescription": "Изаберите одакле SSH везе потичу подразумевано. Ово се може заменити за сваки хост.",
"originLocal": "Овај уређај (локална мрежа)",
"originRemote": "Удаљени сервер",
"bannerReconnect": "Поново се повежите",
"bannerMessage": "Даљинска синхронизација захтева поновну аутентификацију"
},
"versionCheck": {
"error": "Грешка при провери верзије",
"checkFailed": "Провера ажурирања није успела",
"upToDate": "Апликација је ажурирана",
"currentVersion": "Користите верзију {{version}}",
"updateAvailable": "Ажурирање је доступно",
"updateLinkLabel": "Доступно ажурирање — отворите белешке о издању на GitHub-у",
"newVersionAvailable": "Нова верзија је доступна! Користите {{current}}, али је доступан {{latest}}.",
"betaVersion": "Бета верзија",
"betaVersionDesc": "Користите {{current}}, што је новије од најновије стабилне верзије {{latest}}.",
"releasedOn": "Објављено {{date}}",
"downloadUpdate": "Преузми ажурирање",
"checking": "Провера ажурирања...",
"checkUpdates": "Провери ажурирања",
"checkingUpdates": "Провера ажурирања...",
"updateRequired": "Потребно је ажурирање"
"updateLinkLabel": "Доступно ажурирање — отворите белешке о издању на GitHub-у"
},
"common": {
"close": "Затвори",
@@ -1565,8 +1487,6 @@
"resetCodeRateLimitedWithTime": "Ограничена брзина: Превише покушаја верификације. Молимо сачекајте {{time}} секунди пре него што покушате поново.",
"authTokenSaveFailed": "Чување токена за аутентификацију није успело",
"authTokenMissing": "Сервер није вратио нови токен за аутентификацију. Молимо вас да се поново пријавите.",
"authTokenRejected": "Удаљени сервер је одбио нови токен за аутентификацију",
"keyringUnavailable": "Пријављени сте, али сесија није могла бити сачувана: овај систем нема доступан привезак кључева за шифровање. Покрените тајну услугу (као што је gnome-keyring или KWallet) и поново се пријавите.",
"failedToLoadServer": "Учитавање сервера није успело",
"remoteServerRequired": "Потребан је удаљени сервер. Повежите се са удаљеним сервером у Подешавањима да бисте користили овај тип везе."
},
+1 -81
View File
@@ -232,86 +232,8 @@
"widgetTunnelDesc": "Inbäddad SSH-tunnelhanterare för en konfigurerad värd",
"widgetNoHostSelected": "Ingen värd konfigurerad"
},
"serverConfig": {
"title": "Serverkonfiguration",
"description": "Konfigurera Termix-serverns URL för att ansluta till dina backend-tjänster",
"serverUrl": "Server-URL",
"enterServerUrl": "Ange en server-URL",
"saveFailed": "Kunde inte spara konfigurationen",
"saveError": "Fel vid sparning av konfigurationen",
"saving": "Sparande...",
"saveConfig": "Spara konfiguration",
"helpText": "Ange URL:en där din Termix-server körs (t.ex. http://localhost:30001 eller https://your-server.com)",
"changeServer": "Ändra server",
"mustIncludeProtocol": "Serverns URL måste börja med http:// eller https://",
"allowInvalidCertificate": "Tillåt ogiltigt certifikat",
"allowInvalidCertificateDesc": "Använd endast för betrodda, självhostade servrar med självsignerade certifikat eller IP-adresscertifikat.",
"useEmbedded": "Använd lokal server",
"embeddedDesc": "Kör Termix med den inbyggda lokala servern (ingen fjärrserver behövs)",
"embeddedConnecting": "Ansluter till lokal server...",
"embeddedNotReady": "Den lokala servern är inte redo än. Vänta en stund och försök igen.",
"localServer": "Lokal server",
"savedServers": "Sparade servrar",
"noSavedServers": "Inga sparade servrar",
"removeServer": "Ta bort"
},
"migrationNotice": {
"title": "Termix Desktop körs nu fristående",
"body1": "Den här appen brukade ansluta direkt till din Termix-server. Den har omarbetats för att köras helt självständigt och lagrar värdar och inloggningsuppgifter lokalt så att den fortsätter att fungera offline. Tvåvägssynkronisering med en egen Termix-server är nu valfritt.",
"body2": "Dina befintliga värdar och inloggningsuppgifter finns fortfarande på {{url}}. Aktivera fjärrsynkronisering och återanslut till servern för att få tillbaka dem och hålla båda synkroniserade framöver.",
"dismiss": "Inte nu",
"setUpSync": "Konfigurera fjärrsynkronisering"
},
"remoteSync": {
"title": "Fjärrsynkronisering",
"description": "Anslut alternativt den här skrivbordsappen till en självhostad Termix-server för att synkronisera dina värdar, inloggningsuppgifter och kodavsnitt mellan enheter. Appen fungerar alltid helt offline oavsett om du ansluter eller inte.",
"notConnected": "Inte ansluten",
"connected": "Ansluten",
"connectedTo": "Ansluten till {{url}}",
"lastSynced": "Senast synkroniserad {{time}}",
"neverSynced": "Aldrig synkroniserad",
"syncError": "Synkroniseringsfel: {{message}}",
"needsReauth": "Inloggningen har upphört",
"connectButton": "Anslut till servern",
"disconnectButton": "Koppla från",
"syncNowButton": "Synkronisera nu",
"syncing": "Synkroniserar...",
"syncSuccess": "Fjärrsynkronisering slutförd",
"syncUnavailable": "Fjärrsynkronisering kunde inte startas",
"serverUrl": "Server-URL",
"enterServerUrl": "Ange en server-URL",
"mustIncludeProtocol": "Serverns URL måste börja med http:// eller https://",
"connectionTestFailed": "Kunde inte nå en Termix-server på den URL:en",
"allowInvalidCertificate": "Tillåt ogiltigt certifikat",
"allowInvalidCertificateDesc": "Använd endast för betrodda, självhostade servrar med självsignerade certifikat eller IP-adresscertifikat.",
"savedServers": "Sparade servrar",
"removeServer": "Ta bort",
"continueButton": "Fortsätta",
"cancelButton": "Avboka",
"signInTitle": "Logga in på {{url}}",
"originTitle": "Anslutningens ursprung",
"originDescription": "Välj var SSH-anslutningar kommer ifrån som standard. Detta kan åsidosättas per värd.",
"originLocal": "Den här enheten (lokalt nätverk)",
"originRemote": "Fjärrserver",
"bannerReconnect": "Återanslut",
"bannerMessage": "Fjärrsynkronisering behöver autentiseras på nytt"
},
"versionCheck": {
"error": "Versionskontrollfel",
"checkFailed": "Misslyckades med att söka efter uppdateringar",
"upToDate": "Appen är uppdaterad",
"currentVersion": "Du kör version {{version}}",
"updateAvailable": "Uppdatering tillgänglig",
"updateLinkLabel": "Uppdatering tillgänglig — öppna versionsinformationen på GitHub",
"newVersionAvailable": "En ny version är tillgänglig! Du kör {{current}}, men {{latest}} är tillgänglig.",
"betaVersion": "Betaversion",
"betaVersionDesc": "Du kör {{current}}, vilket är nyare än den senaste stabila versionen {{latest}}.",
"releasedOn": "Släpptes den {{date}}",
"downloadUpdate": "Ladda ner uppdatering",
"checking": "Söker efter uppdateringar...",
"checkUpdates": "Kontrollera efter uppdateringar",
"checkingUpdates": "Söker efter uppdateringar...",
"updateRequired": "Uppdatering krävs"
"updateLinkLabel": "Uppdatering tillgänglig — öppna versionsinformationen på GitHub"
},
"common": {
"close": "Nära",
@@ -1565,8 +1487,6 @@
"resetCodeRateLimitedWithTime": "Begränsad hastighet: För många verifieringsförsök. Vänta {{time}} sekunder innan du försöker igen.",
"authTokenSaveFailed": "Misslyckades med att spara autentiseringstoken",
"authTokenMissing": "Servern returnerade inte en ny autentiseringstoken. Vänligen logga in igen.",
"authTokenRejected": "Fjärrservern avvisade den nya autentiseringstoken",
"keyringUnavailable": "Inloggad, men sessionen kunde inte lagras: det här systemet har ingen nyckelring tillgänglig att kryptera den med. Starta en hemlig tjänst (som gnome-keyring eller KWallet) och logga in igen.",
"failedToLoadServer": "Misslyckades med att ladda servern",
"remoteServerRequired": "Fjärrserver krävs. Anslut en fjärrserver i Inställningar för att använda den här anslutningstypen."
},
+1 -81
View File
@@ -232,86 +232,8 @@
"widgetTunnelDesc": "ตัวจัดการอุโมงค์ SSH แบบฝังตัวสำหรับโฮสต์ที่กำหนดค่าไว้",
"widgetNoHostSelected": "ไม่มีการกำหนดค่าโฮสต์"
},
"serverConfig": {
"title": "การกำหนดค่าเซิร์ฟเวอร์",
"description": "กำหนดค่า URL ของเซิร์ฟเวอร์ Termix เพื่อเชื่อมต่อกับบริการแบ็กเอนด์ของคุณ",
"serverUrl": "URL ของเซิร์ฟเวอร์",
"enterServerUrl": "โปรดป้อน URL ของเซิร์ฟเวอร์",
"saveFailed": "ไม่สามารถบันทึกการตั้งค่าได้",
"saveError": "เกิดข้อผิดพลาดในการบันทึกการตั้งค่า",
"saving": "ประหยัด...",
"saveConfig": "บันทึกการตั้งค่า",
"helpText": "ป้อน URL ที่เซิร์ฟเวอร์ Termix ของคุณกำลังทำงานอยู่ (เช่น http://localhost:30001 หรือ https://your-server.com)",
"changeServer": "เปลี่ยนเซิร์ฟเวอร์",
"mustIncludeProtocol": "URL ของเซิร์ฟเวอร์ต้องขึ้นต้นด้วย http:// หรือ https://",
"allowInvalidCertificate": "อนุญาตให้ใช้ใบรับรองที่ไม่ถูกต้อง",
"allowInvalidCertificateDesc": "ใช้ได้เฉพาะกับเซิร์ฟเวอร์ที่ดูแลเองที่เชื่อถือได้ ซึ่งมีใบรับรองแบบลงนามเองหรือใบรับรองตามที่อยู่ IP เท่านั้น",
"useEmbedded": "ใช้เซิร์ฟเวอร์ภายในเครื่อง",
"embeddedDesc": "เรียกใช้งาน Termix ด้วยเซิร์ฟเวอร์ภายในเครื่อง (ไม่จำเป็นต้องใช้เซิร์ฟเวอร์ระยะไกล)",
"embeddedConnecting": "กำลังเชื่อมต่อกับเซิร์ฟเวอร์ภายใน...",
"embeddedNotReady": "เซิร์ฟเวอร์ในพื้นที่ยังไม่พร้อมใช้งาน โปรดรอสักครู่แล้วลองใหม่อีกครั้ง",
"localServer": "เซิร์ฟเวอร์ภายใน",
"savedServers": "เซิร์ฟเวอร์ที่บันทึกไว้",
"noSavedServers": "ไม่มีเซิร์ฟเวอร์ที่บันทึกไว้",
"removeServer": "ลบ"
},
"migrationNotice": {
"title": "ตอนนี้ Termix Desktop สามารถใช้งานแบบสแตนด์อโลนได้แล้ว",
"body1": "แอปนี้เดิมทีเชื่อมต่อโดยตรงกับเซิร์ฟเวอร์ Termix ของคุณ แต่ตอนนี้ได้รับการปรับปรุงใหม่ให้ทำงานได้ด้วยตัวเองอย่างสมบูรณ์ โดยจัดเก็บโฮสต์และข้อมูลรับรองไว้ในเครื่อง ทำให้ยังคงทำงานได้แม้ในขณะออฟไลน์ การซิงค์ข้อมูลแบบสองทางกับเซิร์ฟเวอร์ Termix ที่คุณโฮสต์เองนั้นเป็นตัวเลือกเสริมแล้ว",
"body2": "โฮสต์และข้อมูลรับรองที่มีอยู่ของคุณยังคงอยู่บน {{url}}เปิดใช้งานการซิงค์ระยะไกลและเชื่อมต่อกับเซิร์ฟเวอร์นั้นอีกครั้งเพื่อนำข้อมูลกลับมาและทำให้ทั้งสองซิงค์กันต่อไป",
"dismiss": "ไม่ใช่ตอนนี้",
"setUpSync": "ตั้งค่าการซิงค์ระยะไกล"
},
"remoteSync": {
"title": "การซิงค์ระยะไกล",
"description": "คุณสามารถเชื่อมต่อแอปพลิเคชันบนเดสก์ท็อปนี้กับเซิร์ฟเวอร์ Termix ที่คุณโฮสต์เองเพื่อซิงค์โฮสต์ ข้อมูลรับรอง และโค้ดตัวอย่างระหว่างอุปกรณ์ต่างๆ ได้ แอปพลิเคชันนี้ทำงานได้อย่างสมบูรณ์ในโหมดออฟไลน์เสมอ ไม่ว่าคุณจะเชื่อมต่อหรือไม่ก็ตาม",
"notConnected": "ไม่ได้เชื่อมต่อ",
"connected": "เชื่อมต่อแล้ว",
"connectedTo": "เชื่อมต่อกับ {{url}}",
"lastSynced": "ซิงค์ครั้งล่าสุด {{time}}",
"neverSynced": "ไม่เคยซิงค์",
"syncError": "ข้อผิดพลาดในการซิงค์: {{message}}",
"needsReauth": "การเข้าสู่ระบบหมดอายุแล้ว",
"connectButton": "เชื่อมต่อกับเซิร์ฟเวอร์",
"disconnectButton": "ตัดการเชื่อมต่อ",
"syncNowButton": "ซิงค์เดี๋ยวนี้",
"syncing": "กำลังซิงค์...",
"syncSuccess": "การซิงค์ระยะไกลเสร็จสมบูรณ์แล้ว",
"syncUnavailable": "ไม่สามารถเริ่มการซิงค์ระยะไกลได้",
"serverUrl": "URL ของเซิร์ฟเวอร์",
"enterServerUrl": "โปรดป้อน URL ของเซิร์ฟเวอร์",
"mustIncludeProtocol": "URL ของเซิร์ฟเวอร์ต้องขึ้นต้นด้วย http:// หรือ https://",
"connectionTestFailed": "ไม่สามารถเชื่อมต่อกับเซิร์ฟเวอร์ Termix ที่ URL นั้นได้",
"allowInvalidCertificate": "อนุญาตให้ใช้ใบรับรองที่ไม่ถูกต้อง",
"allowInvalidCertificateDesc": "ใช้ได้เฉพาะกับเซิร์ฟเวอร์ที่ดูแลเองที่เชื่อถือได้ ซึ่งมีใบรับรองแบบลงนามเองหรือใบรับรองตามที่อยู่ IP เท่านั้น",
"savedServers": "เซิร์ฟเวอร์ที่บันทึกไว้",
"removeServer": "ลบ",
"continueButton": "ดำเนินการต่อ",
"cancelButton": "ยกเลิก",
"signInTitle": "เข้าสู่ระบบที่ {{url}}",
"originTitle": "จุดเริ่มต้นของการเชื่อมต่อ",
"originDescription": "เลือกต้นทางของการเชื่อมต่อ SSH ตามค่าเริ่มต้น สามารถเปลี่ยนแปลงการตั้งค่านี้ได้สำหรับแต่ละโฮสต์",
"originLocal": "อุปกรณ์นี้ (เครือข่ายท้องถิ่น)",
"originRemote": "เซิร์ฟเวอร์ระยะไกล",
"bannerReconnect": "เชื่อมต่อใหม่",
"bannerMessage": "การซิงค์ระยะไกลจำเป็นต้องยืนยันตัวตนอีกครั้ง"
},
"versionCheck": {
"error": "ข้อผิดพลาดในการตรวจสอบเวอร์ชัน",
"checkFailed": "ไม่สามารถตรวจสอบการอัปเดตได้",
"upToDate": "แอปได้รับการอัปเดตแล้ว",
"currentVersion": "คุณกำลังใช้งานเวอร์ชัน {{version}}",
"updateAvailable": "มีการอัปเดตแล้ว",
"updateLinkLabel": "มีการอัปเดตแล้ว — ดูบันทึกการเปลี่ยนแปลงได้ที่ GitHub",
"newVersionAvailable": "มีเวอร์ชันใหม่ให้ใช้งานแล้ว! คุณกำลังใช้งาน {{current}}อยู่ แต่มี {{latest}} ให้ใช้งานแล้ว",
"betaVersion": "เวอร์ชันเบต้า",
"betaVersionDesc": "คุณกำลังใช้งาน {{current}}ซึ่งใหม่กว่าเวอร์ชันเสถียรล่าสุด {{latest}}",
"releasedOn": "เผยแพร่เมื่อ {{date}}",
"downloadUpdate": "ดาวน์โหลดการอัปเดต",
"checking": "กำลังตรวจสอบการอัปเดต...",
"checkUpdates": "ตรวจสอบการอัปเดต",
"checkingUpdates": "กำลังตรวจสอบการอัปเดต...",
"updateRequired": "ต้องอัปเดตข้อมูล"
"updateLinkLabel": "มีการอัปเดตแล้ว — ดูบันทึกการเปลี่ยนแปลงได้ที่ GitHub"
},
"common": {
"close": "ปิด",
@@ -1565,8 +1487,6 @@
"resetCodeRateLimitedWithTime": "จำกัดจำนวนครั้ง: มีการพยายามยืนยันมากเกินไป โปรดรอ {{time}} วินาทีก่อนลองอีกครั้ง",
"authTokenSaveFailed": "ไม่สามารถบันทึกโทเค็นการตรวจสอบสิทธิ์ได้",
"authTokenMissing": "เซิร์ฟเวอร์ไม่ได้ส่งโทเค็นการตรวจสอบสิทธิ์ใหม่กลับมา โปรดเข้าสู่ระบบอีกครั้ง",
"authTokenRejected": "เซิร์ฟเวอร์ระยะไกลปฏิเสธโทเค็นการตรวจสอบสิทธิ์ใหม่",
"keyringUnavailable": "เข้าสู่ระบบแล้ว แต่ไม่สามารถบันทึกเซสชันได้: ระบบนี้ไม่มีคีย์ริงสำหรับเข้ารหัส โปรดเริ่มใช้งานบริการเข้ารหัสลับ (เช่น gnome-keyring หรือ KWallet) แล้วเข้าสู่ระบบอีกครั้ง",
"failedToLoadServer": "ไม่สามารถโหลดเซิร์ฟเวอร์ได้",
"remoteServerRequired": "จำเป็นต้องใช้เซิร์ฟเวอร์ระยะไกล เชื่อมต่อเซิร์ฟเวอร์ระยะไกลในการตั้งค่าเพื่อใช้ประเภทการเชื่อมต่อนี้"
},
+1 -81
View File
@@ -232,86 +232,8 @@
"widgetTunnelDesc": "Yapılandırılmış bir sunucu için gömülü SSH tünel yöneticisi",
"widgetNoHostSelected": "Hiçbir sunucu yapılandırılmadı"
},
"serverConfig": {
"title": "Sunucu Yapılandırması",
"description": "Arka uç hizmetlerinize bağlanmak için Termix sunucu URL'sini yapılandırın",
"serverUrl": "Sunucu URL'si",
"enterServerUrl": "Lütfen bir sunucu URL'si girin",
"saveFailed": "Yapılandırma kaydedilemedi",
"saveError": "Yapılandırma kaydedilirken hata oluştu",
"saving": "Kaydediliyor...",
"saveConfig": "Yapılandırmayı Kaydet",
"helpText": "Termix sunucunuzun çalıştığı URL'yi girin (ör. http://localhost:30001 veya https://sunucunuz.com)",
"changeServer": "Sunucuyu Değiştir",
"mustIncludeProtocol": "Sunucu URL'si http:// veya https:// ile başlamalıdır",
"allowInvalidCertificate": "Geçersiz sertifikaya izin ver",
"allowInvalidCertificateDesc": "Yalnızca kendinden imzalı veya IP adresi sertifikaları olan güvenilir, kendi barındırdığınız sunucular için kullanın.",
"useEmbedded": "Yerel Sunucu Kullan",
"embeddedDesc": "Termix'i yerleşik yerel sunucu ile çalıştırın (uzak sunucuya gerek yok)",
"embeddedConnecting": "Yerel sunucuya bağlanılıyor...",
"embeddedNotReady": "Yerel sunucu henüz hazır değil. Lütfen biraz bekleyip tekrar deneyin.",
"localServer": "Yerel Sunucu",
"savedServers": "Kayıtlı Sunucular",
"noSavedServers": "Kayıtlı sunucu yok",
"removeServer": "Kaldır"
},
"migrationNotice": {
"title": "Termix Desktop artık bağımsız olarak çalışıyor.",
"body1": "Bu uygulama eskiden doğrudan Termix sunucunuza bağlanıyordu. Artık tamamen kendi başına çalışacak şekilde yeniden tasarlandı; sunucuları ve kimlik bilgilerini yerel olarak depolayarak çevrimdışı çalışmaya devam ediyor. Kendi sunucunuzda barındırdığınız Termix sunucusuyla çift yönlü senkronizasyon artık isteğe bağlıdır.",
"body2": "Mevcut sunucularınız ve kimlik bilgileriniz hala {{url}}üzerindedir. Uzaktan Senkronizasyonu açın ve bunları geri getirmek ve ileride ikisinin de senkronize kalmasını sağlamak için o sunucuya yeniden bağlanın.",
"dismiss": "Şimdi değil",
"setUpSync": "Uzaktan Senkronizasyonu Ayarla"
},
"remoteSync": {
"title": "Uzaktan Senkronizasyon",
"description": "İsteğe bağlı olarak, bu masaüstü uygulamasını kendi barındırdığınız bir Termix sunucusuna bağlayarak sunucularınızı, kimlik bilgilerinizi ve kod parçacıklarınızı cihazlar arasında senkronize edebilirsiniz. Uygulama, bağlantı kurup kurmamanızdan bağımsız olarak her zaman tamamen çevrimdışı çalışır.",
"notConnected": "Bağlı değil",
"connected": "Bağlı",
"connectedTo": "{{url}} ile bağlantılı",
"lastSynced": "Son senkronizasyon {{time}}",
"neverSynced": "Hiç senkronize edilmedi",
"syncError": "Senkronizasyon hatası: {{message}}",
"needsReauth": "Oturum açma süresi doldu",
"connectButton": "Sunucuya bağlan",
"disconnectButton": "Bağlantıyı kes",
"syncNowButton": "Şimdi Senkronize Et",
"syncing": "Senkronizasyon...",
"syncSuccess": "Uzaktan senkronizasyon tamamlandı.",
"syncUnavailable": "Uzaktan senkronizasyon başlatılamadı.",
"serverUrl": "Sunucu URL'si",
"enterServerUrl": "Lütfen bir sunucu URL'si girin.",
"mustIncludeProtocol": "Sunucu URL'si http:// veya https:// ile başlamalıdır.",
"connectionTestFailed": "Belirtilen URL'deki Termix sunucusuna ulaşılamadı.",
"allowInvalidCertificate": "Geçersiz sertifikaya izin ver",
"allowInvalidCertificateDesc": "Yalnızca kendinden imzalı veya IP adresi sertifikalarına sahip, güvenilir, kendi sunucunuzda barındırdığınız sunucular için kullanın.",
"savedServers": "Kaydedilen Sunucular",
"removeServer": "Kaldırmak",
"continueButton": "Devam etmek",
"cancelButton": "İptal etmek",
"signInTitle": "{{url}} adresine giriş yapın.",
"originTitle": "Bağlantı Kaynağı",
"originDescription": "SSH bağlantılarının varsayılan olarak nereden kaynaklanacağını seçin. Bu ayar her sunucu için geçersiz kılınabilir.",
"originLocal": "Bu cihaz (yerel ağ)",
"originRemote": "Uzak sunucu",
"bannerReconnect": "Yeniden bağlan",
"bannerMessage": "Uzaktan senkronizasyon için yeniden kimlik doğrulaması gerekiyor."
},
"versionCheck": {
"error": "Sürüm Denetleme Hatası",
"checkFailed": "Güncellemeler denetlenemedi",
"upToDate": "Uygulama güncel",
"currentVersion": "Sürüm {{version}} kullanıyorsunuz",
"updateAvailable": "Güncelleme Mevcut",
"updateLinkLabel": "Güncelleme mevcut — GitHub'daki sürüm notlarını açın.",
"newVersionAvailable": "Yeni bir sürüm mevcut! Şu anda {{current}} sürümünü kullanıyorsunuz, ancak {{latest}} sürümü mevcut.",
"betaVersion": "Beta Sürümü",
"betaVersionDesc": "Şu anda {{current}} sürümünü kullanıyorsunuz, bu sürüm en son kararlı sürüm {{latest}}'den daha yeni.",
"releasedOn": "{{date}} tarihinde yayımlandı",
"downloadUpdate": "Güncellemeyi İndir",
"checking": "Güncellemeler kontrol ediliyor...",
"checkUpdates": "Güncellemeleri Kontrol Et",
"checkingUpdates": "Güncellemeler kontrol ediliyor...",
"updateRequired": "Güncelleme Gerekli"
"updateLinkLabel": "Güncelleme mevcut — GitHub'daki sürüm notlarını açın."
},
"common": {
"close": "Kapat",
@@ -1565,8 +1487,6 @@
"resetCodeRateLimitedWithTime": "Hız sınırlaması: Çok fazla doğrulama denemesi. Lütfen tekrar denemeden önce {{time}} saniye bekleyin.",
"authTokenSaveFailed": "Kimlik doğrulama belirteci kaydedilemedi",
"authTokenMissing": "Sunucu yeni bir kimlik doğrulama belirteci döndürmedi. Lütfen tekrar giriş yapın.",
"authTokenRejected": "Uzak sunucu yeni kimlik doğrulama belirtecini reddetti.",
"keyringUnavailable": "Oturum açıldı, ancak oturum kaydedilemedi: bu sistemde şifrelemek için kullanılabilecek bir anahtar halkası bulunmuyor. Bir Gizli Servis (örneğin gnome-keyring veya KWallet) başlatın ve tekrar oturum açın.",
"failedToLoadServer": "Sunucu yüklenemedi",
"remoteServerRequired": "Uzak sunucu gereklidir. Bu bağlantı türünü kullanmak için Ayarlar'dan bir uzak sunucu bağlayın."
},
+1 -81
View File
@@ -232,86 +232,8 @@
"widgetTunnelDesc": "Вбудований менеджер тунелів SSH для налаштованого хоста",
"widgetNoHostSelected": "Хост не налаштовано"
},
"serverConfig": {
"title": "Конфігурація сервера",
"description": "Налаштуйте URL-адресу сервера Termix для підключення до ваших серверних служб",
"serverUrl": "URL-адреса сервера",
"enterServerUrl": "Будь ласка, введіть URL-адресу сервера",
"saveFailed": "Не вдалося зберегти конфігурацію",
"saveError": "Помилка збереження конфігурації",
"saving": "Збереження...",
"saveConfig": "Зберегти конфігурацію",
"helpText": "Введіть URL-адресу, на якій працює ваш сервер Termix (наприклад, http://localhost:30001 або https://your-server.com)",
"changeServer": "Змінити сервер",
"mustIncludeProtocol": "URL-адреса сервера має починатися з http:// або https://",
"allowInvalidCertificate": "Дозволити недійсний сертифікат",
"allowInvalidCertificateDesc": "Використовуйте лише для довірених серверів із власним хостингом та самопідписаними сертифікатами або сертифікатами IP-адреси.",
"useEmbedded": "Використовувати локальний сервер",
"embeddedDesc": "Запустіть Termix із вбудованим локальним сервером (віддалений сервер не потрібен)",
"embeddedConnecting": "Підключення до локального сервера...",
"embeddedNotReady": "Локальний сервер ще не готовий. Зачекайте хвилинку та спробуйте ще раз.",
"localServer": "Локальний сервер",
"savedServers": "Збережені сервери",
"noSavedServers": "Немає збережених серверів",
"removeServer": "Видалити"
},
"migrationNotice": {
"title": "Termix Desktop тепер працює автономно",
"body1": "Цей додаток раніше підключався безпосередньо до вашого сервера Termix. Його було перероблено, щоб він працював повністю самостійно, зберігаючи хости та облікові дані локально, щоб він міг працювати офлайн. Двостороння синхронізація з власним сервером Termix тепер необов'язкова.",
"body2": "Ваші існуючі хости та облікові дані все ще на {{url}}. Увімкніть віддалену синхронізацію та повторно підключіться до цього сервера, щоб відновити їх та підтримувати синхронізацію надалі.",
"dismiss": "Не зараз",
"setUpSync": "Налаштування віддаленої синхронізації"
},
"remoteSync": {
"title": "Віддалена синхронізація",
"description": "За бажанням, підключіть цей настільний додаток до окремого сервера Termix, щоб синхронізувати свої хости, облікові дані та фрагменти коду на всіх пристроях. Додаток завжди працює повністю офлайн, незалежно від того, чи підключено ви його до мережі.",
"notConnected": "Не підключено",
"connected": "Підключено",
"connectedTo": "Підключено до {{url}}",
"lastSynced": "Остання синхронізація {{time}}",
"neverSynced": "Ніколи не синхронізувалося",
"syncError": "Помилка синхронізації: {{message}}",
"needsReauth": "Термін дії входу минув",
"connectButton": "Підключення до сервера",
"disconnectButton": "Відключитися",
"syncNowButton": "Синхронізувати зараз",
"syncing": "Синхронізація...",
"syncSuccess": "Віддалену синхронізацію завершено",
"syncUnavailable": "Не вдалося розпочати віддалену синхронізацію",
"serverUrl": "URL-адреса сервера",
"enterServerUrl": "Будь ласка, введіть URL-адресу сервера",
"mustIncludeProtocol": "URL-адреса сервера має починатися з http:// або https://",
"connectionTestFailed": "Не вдалося зв'язатися із сервером Termix за цією URL-адресою",
"allowInvalidCertificate": "Дозволити недійсний сертифікат",
"allowInvalidCertificateDesc": "Використовуйте лише для довірених серверів із власним хостингом та самопідписаними сертифікатами або сертифікатами IP-адреси.",
"savedServers": "Збережені сервери",
"removeServer": "Видалити",
"continueButton": "Продовжити",
"cancelButton": "Скасувати",
"signInTitle": "Увійти до {{url}}",
"originTitle": "Джерело з'єднання",
"originDescription": "Виберіть, звідки за замовчуванням створюються SSH-з’єднання. Це можна змінити для кожного хоста окремо.",
"originLocal": "Цей пристрій (локальна мережа)",
"originRemote": "Віддалений сервер",
"bannerReconnect": "Знову підключитися",
"bannerMessage": "Віддалена синхронізація потребує повторної автентифікації"
},
"versionCheck": {
"error": "Помилка перевірки версії",
"checkFailed": "Не вдалося перевірити наявність оновлень",
"upToDate": "Додаток оновлено",
"currentVersion": "Ви використовуєте версію {{version}}",
"updateAvailable": "Доступне оновлення",
"updateLinkLabel": "Доступне оновлення — відкрийте нотатки до випуску на GitHub",
"newVersionAvailable": "Доступна нова версія! Ви використовуєте {{current}}, але {{latest}} доступний.",
"betaVersion": "Бета-версія",
"betaVersionDesc": "Ви використовуєте {{current}}, яка є новішою за останню стабільну версію {{latest}}.",
"releasedOn": "Випущено {{date}}",
"downloadUpdate": "Завантажити оновлення",
"checking": "Перевірка оновлень...",
"checkUpdates": "Перевірити наявність оновлень",
"checkingUpdates": "Перевірка оновлень...",
"updateRequired": "Потрібне оновлення"
"updateLinkLabel": "Доступне оновлення — відкрийте нотатки до випуску на GitHub"
},
"common": {
"close": "Закрити",
@@ -1565,8 +1487,6 @@
"resetCodeRateLimitedWithTime": "Обмеження швидкості: Забагато спроб перевірки. Будь ласка, зачекайте {{time}} секунд, перш ніж повторити спробу.",
"authTokenSaveFailed": "Не вдалося зберегти токен автентифікації",
"authTokenMissing": "Сервер не повернув новий токен автентифікації. Будь ласка, увійдіть ще раз.",
"authTokenRejected": "Віддалений сервер відхилив новий токен автентифікації",
"keyringUnavailable": "Ви ввійшли, але сеанс не вдалося зберегти: у цій системі немає доступного зв'язка ключів для його шифрування. Запустіть секретну службу (наприклад, gnome-keyring або KWallet) та знову ввійдіть.",
"failedToLoadServer": "Не вдалося завантажити сервер",
"remoteServerRequired": "Потрібен віддалений сервер. Підключіться до віддаленого сервера в налаштуваннях, щоб використовувати цей тип підключення."
},
+1 -81
View File
@@ -232,86 +232,8 @@
"widgetTunnelDesc": "Trình quản lý đường hầm SSH tích hợp cho máy chủ đã cấu hình",
"widgetNoHostSelected": "Chưa cấu hình máy chủ"
},
"serverConfig": {
"title": "Cấu hình Máy chủ",
"description": "Cấu hình URL máy chủ Termix để kết nối đến các dịch vụ backend của bạn",
"serverUrl": "URL máy chủ",
"enterServerUrl": "Vui lòng nhập URL máy chủ",
"saveFailed": "Lưu cấu hình thất bại",
"saveError": "Lỗi khi lưu cấu hình",
"saving": "Đang lưu...",
"saveConfig": "Lưu Cấu hình",
"helpText": "Nhập URL nơi máy chủ Termix của bạn đang chạy (ví dụ: http://localhost:30001 hoặc https://your-server.com)",
"changeServer": "Đổi Máy chủ",
"mustIncludeProtocol": "URL máy chủ phải bắt đầu bằng http:// hoặc https://",
"allowInvalidCertificate": "Cho phép chứng chỉ không hợp lệ",
"allowInvalidCertificateDesc": "Chỉ dùng cho các máy chủ tự lưu trữ đáng tin cậy có chứng chỉ tự ký hoặc chứng chỉ địa chỉ IP.",
"useEmbedded": "Dùng Máy chủ Cục bộ",
"embeddedDesc": "Chạy Termix với máy chủ cục bộ tích hợp (không cần máy chủ từ xa)",
"embeddedConnecting": "Đang kết nối đến máy chủ cục bộ...",
"embeddedNotReady": "Máy chủ cục bộ chưa sẵn sàng. Vui lòng đợi một lát và thử lại.",
"localServer": "Máy chủ Cục bộ",
"savedServers": "Máy chủ Đã lưu",
"noSavedServers": "Không có máy chủ nào được lưu",
"removeServer": "Xóa"
},
"migrationNotice": {
"title": "Termix Desktop hiện đã có thể chạy độc lập.",
"body1": "Ứng dụng này trước đây kết nối trực tiếp với máy chủ Termix của bạn. Giờ đây, nó đã được thiết kế lại để hoạt động hoàn toàn độc lập, lưu trữ máy chủ và thông tin đăng nhập cục bộ để tiếp tục hoạt động ngoại tuyến. Đồng bộ hóa hai chiều với máy chủ Termix tự lưu trữ hiện là tùy chọn.",
"body2": "Máy chủ và thông tin đăng nhập hiện tại của bạn vẫn nằm trên {{url}}. Hãy bật Đồng bộ hóa từ xa và kết nối lại với máy chủ đó để khôi phục chúng và giữ cho cả hai luôn được đồng bộ trong tương lai.",
"dismiss": "Không phải bây giờ",
"setUpSync": "Thiết lập đồng bộ hóa từ xa"
},
"remoteSync": {
"title": "Đồng bộ từ xa",
"description": "Bạn có thể tùy chọn kết nối ứng dụng máy tính để bàn này với máy chủ Termix tự lưu trữ để đồng bộ hóa máy chủ, thông tin đăng nhập và đoạn mã trên các thiết bị. Ứng dụng luôn hoạt động hoàn toàn ngoại tuyến cho dù bạn có kết nối mạng hay không.",
"notConnected": "Không kết nối",
"connected": "Đã kết nối",
"connectedTo": "Đã kết nối với {{url}}",
"lastSynced": "Lần đồng bộ cuối cùng {{time}}",
"neverSynced": "Chưa bao giờ đồng bộ",
"syncError": "Lỗi đồng bộ: {{message}}",
"needsReauth": "Đăng nhập đã hết hạn",
"connectButton": "Kết nối với máy chủ",
"disconnectButton": "Ngắt kết nối",
"syncNowButton": "Đồng bộ ngay",
"syncing": "Đang đồng bộ hóa...",
"syncSuccess": "Đồng bộ hóa từ xa đã hoàn tất",
"syncUnavailable": "Không thể khởi động đồng bộ hóa từ xa.",
"serverUrl": "URL máy chủ",
"enterServerUrl": "Vui lòng nhập URL máy chủ",
"mustIncludeProtocol": "URL máy chủ phải bắt đầu bằng http:// hoặc https://",
"connectionTestFailed": "Không thể kết nối đến máy chủ Termix tại địa chỉ URL đó.",
"allowInvalidCertificate": "Cho phép chứng chỉ không hợp lệ",
"allowInvalidCertificateDesc": "Chỉ sử dụng cho các máy chủ tự lưu trữ đáng tin cậy có chứng chỉ tự ký hoặc chứng chỉ địa chỉ IP.",
"savedServers": "Máy chủ đã lưu",
"removeServer": "Di dời",
"continueButton": "Tiếp tục",
"cancelButton": "Hủy bỏ",
"signInTitle": "Đăng nhập vào {{url}}",
"originTitle": "Nguồn gốc kết nối",
"originDescription": "Chọn nơi mặc định các kết nối SSH được tạo ra. Có thể thay đổi cài đặt này cho từng máy chủ.",
"originLocal": "Thiết bị này (mạng cục bộ)",
"originRemote": "Máy chủ từ xa",
"bannerReconnect": "Kết nối lại",
"bannerMessage": "Đồng bộ hóa từ xa cần xác thực lại."
},
"versionCheck": {
"error": "Lỗi Kiểm tra Phiên bản",
"checkFailed": "Kiểm tra cập nhật thất bại",
"upToDate": "Ứng dụng đã là phiên bản mới nhất",
"currentVersion": "Bạn đang chạy phiên bản {{version}}",
"updateAvailable": "Có bản cập nhật",
"updateLinkLabel": "Đã có bản cập nhật — xem ghi chú phát hành trên GitHub.",
"newVersionAvailable": "Đã có phiên bản mới! Bạn đang chạy {{current}}, nhưng đã có {{latest}}.",
"betaVersion": "Phiên bản beta",
"betaVersionDesc": "Bạn đang chạy {{current}}, mới hơn bản phát hành ổn định mới nhất {{latest}}.",
"releasedOn": "Phát hành vào {{date}}",
"downloadUpdate": "Tải xuống bản cập nhật",
"checking": "Đang kiểm tra cập nhật...",
"checkUpdates": "Kiểm tra cập nhật",
"checkingUpdates": "Đang kiểm tra cập nhật...",
"updateRequired": "Yêu cầu cập nhật"
"updateLinkLabel": "Đã có bản cập nhật — xem ghi chú phát hành trên GitHub."
},
"common": {
"close": "Đóng",
@@ -1565,8 +1487,6 @@
"resetCodeRateLimitedWithTime": "Hạn chế tốc độ: Quá nhiều lần xác minh. Vui lòng chờ {{time}} giây trước khi thử lại.",
"authTokenSaveFailed": "Lưu token xác thực thất bại",
"authTokenMissing": "Máy chủ không trả về mã xác thực mới. Vui lòng đăng nhập lại.",
"authTokenRejected": "Máy chủ từ xa đã từ chối mã thông báo xác thực mới.",
"keyringUnavailable": "Đã đăng nhập, nhưng phiên làm việc không thể được lưu lại: hệ thống này không có khóa mã hóa nào để sử dụng. Hãy khởi động một dịch vụ mã hóa bí mật (như gnome-keyring hoặc KWallet) và đăng nhập lại.",
"failedToLoadServer": "Tải máy chủ thất bại",
"remoteServerRequired": "Cần có máy chủ từ xa. Kết nối máy chủ từ xa trong Cài đặt để sử dụng loại kết nối này."
},
+1 -81
View File
@@ -232,86 +232,8 @@
"widgetTunnelDesc": "用于已配置主机的嵌入式 SSH 隧道管理器",
"widgetNoHostSelected": "未配置主机"
},
"serverConfig": {
"title": "服务器配置",
"description": "配置 Termix 服务器 URL 以连接到后端服务",
"serverUrl": "服务器 URL",
"enterServerUrl": "请输入服务器 URL",
"saveFailed": "保存配置失败",
"saveError": "保存配置出错",
"saving": "保存中...",
"saveConfig": "保存配置",
"helpText": "输入 Termix 服务器运行的 URL(例如 http://localhost:30001 或 https://your-server.com)",
"changeServer": "更改服务器",
"mustIncludeProtocol": "服务器 URL 必须以 http:// 或 https:// 开头",
"allowInvalidCertificate": "允许无效证书",
"allowInvalidCertificateDesc": "仅用于可信的自托管服务器,证书为自签名或 IP 地址证书。",
"useEmbedded": "使用本地服务器",
"embeddedDesc": "使用内置本地服务器运行 Termix(无需远程服务器)",
"embeddedConnecting": "正在连接本地服务器...",
"embeddedNotReady": "本地服务器尚未就绪,请稍候再试。",
"localServer": "本地服务器",
"savedServers": "已保存的服务器",
"noSavedServers": "无已保存的服务器",
"removeServer": "移除"
},
"migrationNotice": {
"title": "Termix Desktop 现在可以独立运行",
"body1": "此应用以前直接连接到您的 Termix 服务器。现在已重新设计,可完全独立运行,将主机和凭据存储在本地,因此即使离线也能正常工作。与自托管 Termix 服务器的双向同步现在是可选的。",
"body2": "您现有的主机和凭据仍然在 {{url}}上。启用远程同步并重新连接到该服务器,即可恢复它们并保持两者同步。",
"dismiss": "稍后再说",
"setUpSync": "设置远程同步"
},
"remoteSync": {
"title": "远程同步",
"description": "您可以选择将此桌面应用程序连接到自托管的 Termix 服务器,以便在不同设备间同步主机名、凭据和代码片段。无论是否连接,该应用程序始终可以完全离线运行。",
"notConnected": "未连接",
"connected": "已连接",
"connectedTo": "已连接至 {{url}}",
"lastSynced": "上次同步时间 {{time}}",
"neverSynced": "从未同步",
"syncError": "同步错误: {{message}}",
"needsReauth": "登录已过期",
"connectButton": "连接到服务器",
"disconnectButton": "断开连接",
"syncNowButton": "立即同步",
"syncing": "正在同步...",
"syncSuccess": "远程同步完成",
"syncUnavailable": "远程同步无法启动",
"serverUrl": "服务器 URL",
"enterServerUrl": "请输入服务器 URL",
"mustIncludeProtocol": "服务器 URL 必须以 http:// 或 https:// 开头",
"connectionTestFailed": "无法通过该 URL 连接到 Termix 服务器",
"allowInvalidCertificate": "允许无效证书",
"allowInvalidCertificateDesc": "仅适用于具有自签名证书或 IP 地址证书的受信任的自托管服务器。",
"savedServers": "已保存的服务器",
"removeServer": "删除",
"continueButton": "继续",
"cancelButton": "取消",
"signInTitle": "登录到 {{url}}",
"originTitle": "连接源",
"originDescription": "选择 SSH 连接的默认发起位置。此设置可针对每个主机进行单独设置。",
"originLocal": "此设备(本地网络)",
"originRemote": "远程服务器",
"bannerReconnect": "重新连接",
"bannerMessage": "远程同步需要重新验证"
},
"versionCheck": {
"error": "版本检查错误",
"checkFailed": "检查更新失败",
"upToDate": "应用已是最新",
"currentVersion": "您正在运行版本 {{version}}",
"updateAvailable": "有可用更新",
"updateLinkLabel": "有更新可用——请在 GitHub 上查看发布说明。",
"newVersionAvailable": "新版本可用!您当前版本为 {{current}},最新版本为 {{latest}}。",
"betaVersion": "测试版",
"betaVersionDesc": "您正在运行 {{current}},比最新稳定版 {{latest}} 更新。",
"releasedOn": "发布于 {{date}}",
"downloadUpdate": "下载更新",
"checking": "正在检查更新...",
"checkUpdates": "检查更新",
"checkingUpdates": "正在检查更新...",
"updateRequired": "需要更新"
"updateLinkLabel": "有更新可用——请在 GitHub 上查看发布说明。"
},
"common": {
"close": "关闭",
@@ -1565,8 +1487,6 @@
"resetCodeRateLimitedWithTime": "频率受限:验证尝试次数过多,请等待 {{time}} 秒后再试。",
"authTokenSaveFailed": "保存认证令牌失败",
"authTokenMissing": "服务器未返回新的身份验证令牌。请重新登录。",
"authTokenRejected": "远程服务器拒绝了新的身份验证令牌。",
"keyringUnavailable": "已登录,但会话无法保存:此系统没有可用于加密的密钥环。请启动密钥服务(例如 gnome-keyring 或 KWallet)并重新登录。",
"failedToLoadServer": "加载服务器失败",
"remoteServerRequired": "需要远程服务器。请在“设置”中连接远程服务器以使用此连接类型。"
},
+1 -81
View File
@@ -232,86 +232,8 @@
"widgetTunnelDesc": "用於已設定主機的內嵌 SSH 通道管理員",
"widgetNoHostSelected": "未設定主機"
},
"serverConfig": {
"title": "伺服器設定",
"description": "設定 Termix 伺服器 URL 以連線至您的後端服務",
"serverUrl": "伺服器 URL",
"enterServerUrl": "請輸入伺服器 URL",
"saveFailed": "儲存設定失敗",
"saveError": "儲存設定時發生錯誤",
"saving": "儲存中...",
"saveConfig": "儲存設定",
"helpText": "輸入您的 Termix 伺服器執行所在的 URL(例如 http://localhost:30001 或 https://your-server.com)",
"changeServer": "變更伺服器",
"mustIncludeProtocol": "伺服器 URL 必須以 http:// 或 https:// 開頭",
"allowInvalidCertificate": "允許無效的憑證",
"allowInvalidCertificateDesc": "僅用於使用自我簽署或 IP 位址憑證的受信任自託管伺服器。",
"useEmbedded": "使用本機伺服器",
"embeddedDesc": "以內建的本機伺服器執行 Termix(無需遠端伺服器)",
"embeddedConnecting": "正在連線至本機伺服器...",
"embeddedNotReady": "本機伺服器尚未就緒。請稍候片刻再試一次。",
"localServer": "本機伺服器",
"savedServers": "已儲存的伺服器",
"noSavedServers": "無已儲存的伺服器",
"removeServer": "移除"
},
"migrationNotice": {
"title": "Termix Desktop 現在可以獨立運行",
"body1": "此應用程式以前直接連接到您的 Termix 伺服器。現在已重新設計,可完全獨立運行,將主機和憑證儲存在本地,因此即使離線也能正常工作。與自託管 Termix 伺服器的雙向同步現在是可選的。",
"body2": "您現有的主機和憑證仍在 {{url}}上。啟用遠端同步並重新連接到該伺服器,即可恢復它們並保持兩者同步。",
"dismiss": "現在不要",
"setUpSync": "設定遠端同步"
},
"remoteSync": {
"title": "遠端同步",
"description": "您可以選擇將此桌面應用程式連接到自架的 Termix 伺服器,以便在不同裝置間同步主機名稱、憑證和程式碼片段。無論是否連接,該應用程式始終可以完全離線運行。",
"notConnected": "未連接",
"connected": "已連接",
"connectedTo": "已連接至 {{url}}",
"lastSynced": "上次同步時間 {{time}}",
"neverSynced": "從未同步",
"syncError": "同步錯誤: {{message}}",
"needsReauth": "登入已過期",
"connectButton": "連接到伺服器",
"disconnectButton": "斷開",
"syncNowButton": "立即同步",
"syncing": "正在同步...",
"syncSuccess": "遠端同步完成",
"syncUnavailable": "遠端同步無法啟動",
"serverUrl": "伺服器 URL",
"enterServerUrl": "請輸入伺服器網址",
"mustIncludeProtocol": "伺服器 URL 必須以 http:// 或 https:// 開頭。",
"connectionTestFailed": "無法透過該 URL 連線到 Termix 伺服器",
"allowInvalidCertificate": "允許無效證書",
"allowInvalidCertificateDesc": "僅適用於具有自簽名憑證或 IP 位址憑證的受信任的自託管伺服器。",
"savedServers": "已儲存的伺服器",
"removeServer": "消除",
"continueButton": "繼續",
"cancelButton": "取消",
"signInTitle": "登入 {{url}}",
"originTitle": "連接來源",
"originDescription": "選擇 SSH 連線的預設發起位置。此設定可針對每個主機進行單獨設定。",
"originLocal": "此設備(本地網路)",
"originRemote": "遠端伺服器",
"bannerReconnect": "重新連接",
"bannerMessage": "遠端同步需要重新驗證"
},
"versionCheck": {
"error": "版本檢查錯誤",
"checkFailed": "檢查更新失敗",
"upToDate": "應用程式已是最新版本",
"currentVersion": "您正在執行版本 {{version}}",
"updateAvailable": "有可用更新",
"updateLinkLabel": "有更新可用——請在 GitHub 上查看發布說明。",
"newVersionAvailable": "有新版本可用!您正在執行 {{current}},但 {{latest}} 已可用。",
"betaVersion": "Beta 版本",
"betaVersionDesc": "您正在執行 {{current}},此版本比最新的穩定版本 {{latest}} 還新。",
"releasedOn": "發佈於 {{date}}",
"downloadUpdate": "下載更新",
"checking": "正在檢查更新...",
"checkUpdates": "檢查更新",
"checkingUpdates": "正在檢查更新...",
"updateRequired": "需要更新"
"updateLinkLabel": "有更新可用——請在 GitHub 上查看發布說明。"
},
"common": {
"close": "關閉",
@@ -1565,8 +1487,6 @@
"resetCodeRateLimitedWithTime": "速率限制:驗證嘗試次數過多。請等待 {{time}} 秒後再試。",
"authTokenSaveFailed": "無法儲存驗證權杖",
"authTokenMissing": "伺服器未傳回新的身份驗證令牌。請重新登入。",
"authTokenRejected": "遠端伺服器拒絕了新的身份驗證令牌。",
"keyringUnavailable": "已登錄,但會話無法儲存:此系統沒有可用於加密的金鑰環。請啟動金鑰服務(例如 gnome-keyring 或 KWallet)並重新登入。",
"failedToLoadServer": "無法載入伺服器",
"remoteServerRequired": "需要遠端伺服器。請在「設定」中連接遠端伺服器以使用此連線類型。"
},
+22 -78
View File
@@ -98,10 +98,18 @@ export interface UserInfo {
password_hash?: string;
data_unlocked?: boolean;
show_donation_modal?: boolean;
/** On a desktop linked to a server, the account it is signed in to there. */
linked?: LinkedAccountInfo | null;
}
export interface RemoteSyncUserInfo extends UserInfo {
roles: UserRole[];
export interface LinkedAccountInfo {
serverUrl: string;
serverName: string | null;
username: string | null;
isAdmin: boolean;
roles: string[];
permissions: string[];
status: string;
}
interface UserCount {
@@ -577,43 +585,6 @@ interface AxiosErrorExtended extends AxiosError {
config?: AxiosRequestConfigExtended;
}
export interface ElectronUpdateCheckResult {
success: boolean;
status?: "up_to_date" | "requires_update" | "beta";
localVersion?: string;
remoteVersion?: string;
latest_release?: {
tag_name: string;
name: string;
published_at: string;
html_url: string;
body: string;
};
cached?: boolean;
cache_age?: number;
error?: string;
}
export async function checkElectronUpdate(): Promise<ElectronUpdateCheckResult> {
if (!isElectron())
return { success: false, error: "Not in Electron environment" };
try {
const result = (await (
window as Window &
typeof globalThis & {
IS_ELECTRON?: boolean;
electronAPI?: { invoke?: (channel: string) => Promise<unknown> };
}
).electronAPI?.invoke?.("check-electron-update")) as
ElectronUpdateCheckResult | undefined;
return result ?? { success: false, error: "Update check failed" };
} catch (error) {
console.error("Failed to check Electron update:", error);
return { success: false, error: "Update check failed" };
}
}
/** A URL on the main backend, resolved for web, dev proxy and Electron. */
export function getBackendUrl(path: string): string {
return getApiUrl(path, 30001);
@@ -624,10 +595,8 @@ function getApiUrl(path: string, defaultPort: number): string {
const electronMode = isElectron();
if (electronMode) {
// The desktop app always runs its embedded local backend as the
// source of truth. A configured remote sync server is a separate,
// narrow connection used only by the sync engine (see
// remote-sync-axios.ts), not by these shared instances.
// The desktop app always talks to its embedded backend. The server it
// may be linked to is reached through createRemoteOriginApiInstance.
return `http://localhost:${defaultPort}${path}`;
} else if (devMode) {
if (!import.meta.env.VITE_API_HOST) {
@@ -651,9 +620,8 @@ function getApiUrl(path: string, defaultPort: number): string {
// the backend that holds that host's live session is the connected remote
// server instead, so calls for that host must follow it there.
//
// These dynamically-baseURL'd instances resolve the remote server's URL and
// JWT fresh on every request (cheap, and correct even if the user
// connects/disconnects remote sync without an app reload).
// These instances look up the linked server and its session on every
// request, so linking or unlinking needs no reload.
export function createRemoteOriginApiInstance(path: string): AxiosInstance {
const instance = axios.create({
@@ -663,27 +631,19 @@ export function createRemoteOriginApiInstance(path: string): AxiosInstance {
instance.interceptors.request.use(
async (config: InternalAxiosRequestConfig) => {
const [remoteConfig, remoteJwt] = 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
>,
]);
const baseUrl = (remoteConfig?.serverUrl || "").replace(/\/$/, "");
config.baseURL = baseUrl
? `${baseUrl}${path}`
const { getLinkedSession } = await import("@/lib/linked-server");
const linked = await getLinkedSession();
config.baseURL = linked
? `${linked.serverUrl}${path}`
: "http://no-server-configured";
if (config.headers.set) {
config.headers.set("X-Electron-App", "true");
if (remoteJwt)
config.headers.set("Authorization", `Bearer ${remoteJwt}`);
if (linked)
config.headers.set("Authorization", `Bearer ${linked.token}`);
} else {
config.headers["X-Electron-App"] = "true";
if (remoteJwt) config.headers["Authorization"] = `Bearer ${remoteJwt}`;
if (linked) config.headers["Authorization"] = `Bearer ${linked.token}`;
}
return config;
@@ -695,11 +655,7 @@ export function createRemoteOriginApiInstance(path: string): AxiosInstance {
let remoteCoreApi: AxiosInstance | null = null;
/**
* The remote server's core routes, unprefixed.
*
* For core routes such as /sync/hosts and /host/status.
*/
/** The linked server's core routes, unprefixed. */
export function getRemoteCoreApi(): AxiosInstance {
if (!remoteCoreApi) {
remoteCoreApi = createRemoteOriginApiInstance("");
@@ -1053,18 +1009,6 @@ export async function getUserInfo(): Promise<UserInfo> {
}
}
export async function getRemoteSyncUserInfo(): Promise<RemoteSyncUserInfo | null> {
if (!isElectron()) return null;
try {
// ?? null so a missing preload bridge matches the declared return type
// rather than resolving to undefined.
return ((await window.electronAPI?.invoke?.("get-remote-sync-user-info")) ??
null) as RemoteSyncUserInfo | null;
} catch {
return null;
}
}
export async function dismissDonationModal(): Promise<void> {
try {
await authApi.post("/users/me/dismiss-donation-modal");
+8
View File
@@ -6,6 +6,8 @@ import { FeaturesStep } from "./steps/FeaturesStep";
import { WorkflowStep } from "./steps/WorkflowStep";
import { SecurityStep } from "./steps/SecurityStep";
import { DoneStep } from "./steps/DoneStep";
import { DesktopSyncStep } from "./steps/DesktopSyncStep";
import { isElectron } from "@/lib/electron";
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface OnboardingContext {}
@@ -34,6 +36,12 @@ export const ONBOARDING_STEPS: OnboardingStep[] = [
Component: WelcomeStep,
},
{ id: "preset", titleKey: "onboarding.presetTitle", Component: PresetStep },
{
id: "desktop-sync",
titleKey: "onboarding.desktopTitle",
Component: DesktopSyncStep,
isRelevant: () => isElectron(),
},
{
id: "appearance",
titleKey: "onboarding.appearanceTitle",
@@ -0,0 +1,79 @@
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>
);
}
+7 -17
View File
@@ -1,24 +1,14 @@
/** app.desktop: the desktop app's remote sync connection, for plugins. */
/** app.desktop: the server this desktop is linked to, for plugins. */
import { isElectron } from "@/lib/electron";
import { getLinkedSession } from "@/lib/linked-server";
import { SYNC_CHANGED_EVENT } from "@/lib/sync-events";
export async function remoteServerUrl(): Promise<string | null> {
if (!isElectron()) return null;
try {
const config = (await window.electronAPI?.invoke?.(
"get-remote-sync-config",
)) as { serverUrl?: string } | null | undefined;
return config?.serverUrl || null;
} catch {
return null;
}
return (await getLinkedSession())?.serverUrl ?? null;
}
export function onRemoteServerChange(listener: () => void): () => void {
const unsubscribe = window.electronAPI?.onRemoteSyncStatusChanged?.(() =>
listener(),
);
return () => {
if (typeof unsubscribe === "function") unsubscribe();
};
const handler = () => listener();
window.addEventListener(SYNC_CHANGED_EVENT, handler);
return () => window.removeEventListener(SYNC_CHANGED_EVENT, handler);
}
+1
View File
@@ -133,6 +133,7 @@ export {
hydrateLocalSharedHostAuth,
resolveRemoteHostId,
} from "@/lib/remote-server-api";
export { remoteServerUrl as linkedServerUrl } from "@/plugin-host/desktop";
export { useConnectionDefaults } from "@/contexts/ConnectionDefaultsContext";
// Dashboard reads. The calls a plugin makes on the user's behalf (recent
-336
View File
@@ -1,336 +0,0 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Server, RefreshCw, Loader2 } from "lucide-react";
import { Button } from "@/components/button.tsx";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/dialog.tsx";
import { RemoteSyncServerPicker } from "./RemoteSyncServerPicker.tsx";
import { ElectronLoginForm } from "@/auth/ElectronLoginForm.tsx";
import { invalidateServerStatusCache } from "@/lib/hosts-request-cache.ts";
import { toast } from "sonner";
interface RemoteSyncConfig {
serverUrl: string;
connectedAt: string;
lastSyncedAt?: string | null;
lastSyncStatus?: "ok" | "error" | "never";
lastSyncError?: string | null;
}
interface RemoteSyncStatus {
connected: boolean;
syncing: boolean;
lastSyncedAt: string | null;
lastError: string | null;
needsReauth: boolean;
}
type DesktopSettings = { defaultConnectionOrigin: "local" | "remote" };
interface RemoteSyncPanelProps {
initialServerUrl?: string;
reconnectRequested?: boolean;
onReconnectRequestHandled?: () => void;
}
export function RemoteSyncPanel({
initialServerUrl,
reconnectRequested = false,
onReconnectRequestHandled,
}: RemoteSyncPanelProps) {
const { t } = useTranslation();
const [config, setConfig] = useState<RemoteSyncConfig | null>(null);
const [status, setStatus] = useState<RemoteSyncStatus | null>(null);
const [desktopSettings, setDesktopSettings] = useState<DesktopSettings>({
defaultConnectionOrigin: "local",
});
const [step, setStep] = useState<"idle" | "picker" | "login">("idle");
const [pendingServerUrl, setPendingServerUrl] = useState("");
const [syncingNow, setSyncingNow] = useState(false);
const consumedInitialServerUrl = useRef(false);
const refresh = useCallback(async () => {
const [cfg, st, settings] = await Promise.all([
window.electronAPI?.invoke?.(
"get-remote-sync-config",
) as Promise<RemoteSyncConfig | null>,
window.electronAPI?.invoke?.(
"get-remote-sync-status",
) as Promise<RemoteSyncStatus | null>,
window.electronAPI?.invoke?.(
"get-desktop-settings",
) as Promise<DesktopSettings>,
]);
setConfig(cfg ?? null);
setStatus(st ?? null);
if (settings) setDesktopSettings(settings);
}, []);
useEffect(() => {
refresh();
const unsubscribe = window.electronAPI?.onRemoteSyncStatusChanged?.(
(nextStatus: RemoteSyncStatus) => setStatus(nextStatus),
);
return () => unsubscribe?.();
}, [refresh]);
useEffect(() => {
if (consumedInitialServerUrl.current) return;
if (!initialServerUrl || config?.serverUrl) return;
consumedInitialServerUrl.current = true;
setPendingServerUrl(initialServerUrl);
setStep("picker");
}, [initialServerUrl, config]);
useEffect(() => {
if (!reconnectRequested || !config?.serverUrl) return;
setPendingServerUrl(config.serverUrl);
setStep("login");
onReconnectRequestHandled?.();
}, [config?.serverUrl, onReconnectRequestHandled, reconnectRequested]);
const handleConnectClick = () => setStep("picker");
const handleReconnectClick = () => {
if (!config?.serverUrl) return;
setPendingServerUrl(config.serverUrl);
setStep("login");
};
const handleServerConfigured = (serverUrl: string) => {
setPendingServerUrl(serverUrl);
setStep("login");
};
const handleAuthSuccess = async () => {
setStep("idle");
await refresh();
};
const handleDisconnect = async () => {
await window.electronAPI?.invoke?.("clear-remote-sync-config");
await refresh();
};
const handleSyncNow = async () => {
setSyncingNow(true);
try {
const result = (await window.electronAPI?.invoke?.("remote-sync-now")) as
RemoteSyncStatus | null | undefined;
if (!result || result.needsReauth || result.lastError) {
toast.error(result?.lastError || t("remoteSync.syncUnavailable"));
} else {
toast.success(t("remoteSync.syncSuccess"));
}
} catch (error) {
toast.error(
error instanceof Error && error.message
? error.message
: t("remoteSync.syncUnavailable"),
);
} finally {
setSyncingNow(false);
await refresh();
}
};
const handleOriginChange = async (origin: "local" | "remote") => {
const next = { ...desktopSettings, defaultConnectionOrigin: origin };
setDesktopSettings(next);
await window.electronAPI?.invoke?.("save-desktop-settings", next);
invalidateServerStatusCache();
window.dispatchEvent(new CustomEvent("hosts:refresh"));
};
const isConnected = !!config?.serverUrl;
const connectedToText = config?.serverUrl
? t("remoteSync.connectedTo", { url: config.serverUrl })
: "";
const connectionSubtitleText = status?.needsReauth
? t("remoteSync.needsReauth")
: status?.lastError
? t("remoteSync.syncError", { message: status.lastError })
: status?.lastSyncedAt
? t("remoteSync.lastSynced", {
time: new Date(status.lastSyncedAt).toLocaleString(),
})
: t("remoteSync.neverSynced");
return (
<div className="flex flex-col gap-4">
<div className="border border-border bg-muted/10 p-4 flex flex-col gap-3">
<div className="flex items-center gap-2">
<Server className="size-4 text-accent-brand" />
<p className="font-bold text-sm">{t("remoteSync.title")}</p>
</div>
<p className="text-xs text-muted-foreground">
{t("remoteSync.description")}
</p>
<div className="flex flex-col gap-2 border-t border-border pt-3">
<div className="flex flex-col gap-0.5 min-w-0">
{isConnected ? (
<>
<span
className="text-xs font-medium break-all"
title={connectedToText}
>
{connectedToText}
</span>
<span
className="text-[10px] text-muted-foreground break-words"
title={connectionSubtitleText}
>
{connectionSubtitleText}
</span>
</>
) : (
<span className="text-xs text-muted-foreground">
{t("remoteSync.notConnected")}
</span>
)}
</div>
<div className="flex items-center gap-2 flex-wrap">
{isConnected ? (
<>
{status?.needsReauth && (
<Button
type="button"
size="sm"
className="text-[10px] h-7"
onClick={handleReconnectClick}
>
{t("remoteSync.bannerReconnect")}
</Button>
)}
<Button
type="button"
variant="outline"
size="sm"
className="text-[10px] h-7"
onClick={handleSyncNow}
disabled={syncingNow || status?.syncing}
>
{syncingNow || status?.syncing ? (
<Loader2 className="size-3 animate-spin" />
) : (
<RefreshCw className="size-3" />
)}
{t("remoteSync.syncNowButton")}
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="text-[10px] h-7"
onClick={handleDisconnect}
>
{t("remoteSync.disconnectButton")}
</Button>
</>
) : (
<Button
type="button"
variant="outline"
size="sm"
className="text-[10px] h-7"
onClick={handleConnectClick}
>
<Server className="size-3" />
{t("remoteSync.connectButton")}
</Button>
)}
</div>
</div>
</div>
<div className="border border-border bg-muted/10 p-4 flex flex-col gap-3">
<p className="font-bold text-sm">{t("remoteSync.originTitle")}</p>
<p className="text-xs text-muted-foreground">
{t("remoteSync.originDescription")}
</p>
<div className="flex border border-border overflow-hidden w-fit">
<button
type="button"
onClick={() => handleOriginChange("local")}
className={`px-3 py-1.5 text-xs font-bold uppercase tracking-widest transition-colors ${
desktopSettings.defaultConnectionOrigin === "local"
? "bg-accent-brand text-background"
: "text-muted-foreground hover:text-foreground hover:bg-muted"
}`}
>
{t("remoteSync.originLocal")}
</button>
<button
type="button"
onClick={() => handleOriginChange("remote")}
disabled={!isConnected}
className={`px-3 py-1.5 text-xs font-bold uppercase tracking-widest transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${
desktopSettings.defaultConnectionOrigin === "remote"
? "bg-accent-brand text-background"
: "text-muted-foreground hover:text-foreground hover:bg-muted"
}`}
>
{t("remoteSync.originRemote")}
</button>
</div>
</div>
<Dialog
open={step === "picker"}
onOpenChange={(open) => !open && setStep("idle")}
>
<DialogContent className="bg-card border border-border sm:max-w-md">
<DialogHeader>
<DialogTitle className="sr-only">
{t("remoteSync.title")}
</DialogTitle>
</DialogHeader>
<RemoteSyncServerPicker
onServerConfigured={handleServerConfigured}
onCancel={() => setStep("idle")}
initialServerUrl={initialServerUrl}
/>
</DialogContent>
</Dialog>
<Dialog
open={step === "login"}
onOpenChange={(open) => !open && setStep("idle")}
>
<DialogContent
className="bg-card border border-border sm:max-w-4xl h-[80vh] flex flex-col p-0 gap-0"
// The entire dialog body is a cross-document iframe (the remote
// server's own login page). Radix's default "outside interaction"
// dismiss can't attribute clicks inside a different document to
// this dialog's content tree, so without this it treats a click
// on the iframe's own login button as an outside click and closes
// the dialog instantly, before any auth round-trip even starts --
// this is what looked like the login button silently doing
// nothing. Escape and the explicit X button still close it.
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
>
<DialogHeader className="sr-only">
<DialogTitle>
{t("remoteSync.signInTitle", { url: pendingServerUrl })}
</DialogTitle>
</DialogHeader>
<div className="flex-1 min-h-0">
<ElectronLoginForm
serverUrl={pendingServerUrl}
targetPurpose="remoteSync"
onAuthSuccess={handleAuthSuccess}
onChangeServer={() => setStep("picker")}
/>
</div>
</DialogContent>
</Dialog>
</div>
);
}
-278
View File
@@ -1,278 +0,0 @@
import React, { useState, useEffect, useRef } from "react";
import { Button } from "@/components/button.tsx";
import { Input } from "@/components/input.tsx";
import { Label } from "@/components/label.tsx";
import { Alert, AlertTitle, AlertDescription } from "@/components/alert.tsx";
import { Switch } from "@/components/switch.tsx";
import { useTranslation } from "react-i18next";
import { ChevronDown, X } from "lucide-react";
import { toast } from "sonner";
const SAVED_URLS_KEY = "termix_saved_server_urls";
const MAX_SAVED_URLS = 5;
function getSavedUrls(): string[] {
try {
const raw = localStorage.getItem(SAVED_URLS_KEY);
if (!raw) return [];
return JSON.parse(raw);
} catch {
return [];
}
}
function addSavedUrl(url: string) {
const urls = getSavedUrls().filter((u) => u !== url);
urls.unshift(url);
localStorage.setItem(
SAVED_URLS_KEY,
JSON.stringify(urls.slice(0, MAX_SAVED_URLS)),
);
}
function removeSavedUrl(url: string) {
const urls = getSavedUrls().filter((u) => u !== url);
localStorage.setItem(SAVED_URLS_KEY, JSON.stringify(urls));
}
interface RemoteSyncServerPickerProps {
onServerConfigured: (serverUrl: string) => void;
onCancel: () => void;
initialServerUrl?: string;
}
export function RemoteSyncServerPicker({
onServerConfigured,
onCancel,
initialServerUrl,
}: RemoteSyncServerPickerProps) {
const { t } = useTranslation();
const [serverUrl, setServerUrl] = useState(initialServerUrl ?? "");
const [allowInvalidCertificate, setAllowInvalidCertificate] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [savedUrls, setSavedUrls] = useState<string[]>([]);
const [dropdownOpen, setDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setSavedUrls(getSavedUrls());
}, []);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (
dropdownRef.current &&
!dropdownRef.current.contains(e.target as Node)
) {
setDropdownOpen(false);
}
}
if (dropdownOpen) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [dropdownOpen]);
const handleSaveConfig = async () => {
if (!serverUrl.trim()) {
setError(t("remoteSync.enterServerUrl"));
return;
}
setLoading(true);
setError(null);
try {
const normalizedUrl = serverUrl.trim();
if (
!normalizedUrl.startsWith("http://") &&
!normalizedUrl.startsWith("https://")
) {
setError(t("remoteSync.mustIncludeProtocol"));
setLoading(false);
return;
}
const testResult = (await window.electronAPI?.invoke?.(
"test-server-connection",
normalizedUrl,
normalizedUrl.startsWith("https://") && allowInvalidCertificate,
)) as { success?: boolean; error?: string; warning?: string } | undefined;
if (!testResult?.success) {
setError(testResult?.error || t("remoteSync.connectionTestFailed"));
setLoading(false);
return;
}
if (testResult.warning) {
toast.warning(testResult.warning);
}
const result = await window.electronAPI?.invoke?.(
"save-remote-sync-config",
{
serverUrl: normalizedUrl,
allowInvalidCertificate:
normalizedUrl.startsWith("https://") && allowInvalidCertificate,
connectedAt: new Date().toISOString(),
},
);
if ((result as { success?: boolean })?.success) {
addSavedUrl(normalizedUrl);
setSavedUrls(getSavedUrls());
onServerConfigured(normalizedUrl);
} else {
setError(t("serverConfig.saveFailed"));
}
} catch {
setError(t("serverConfig.saveError"));
} finally {
setLoading(false);
}
};
const handleUrlChange = (value: string) => {
setServerUrl(value);
setError(null);
};
return (
<div className="flex flex-col gap-5 p-6">
<div className="flex flex-col gap-1">
<p className="font-bold">{t("remoteSync.title")}</p>
<p className="text-sm text-muted-foreground">
{t("remoteSync.description")}
</p>
</div>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="remote-sync-server-url">
{t("remoteSync.serverUrl")}
</Label>
<div className="relative" ref={dropdownRef}>
<Input
id="remote-sync-server-url"
type="text"
placeholder="https://your-server.com"
value={serverUrl}
onChange={(e) => handleUrlChange(e.target.value)}
disabled={loading}
className={savedUrls.length > 0 ? "pr-9" : ""}
onFocus={() => {
if (savedUrls.length > 0) setDropdownOpen(true);
}}
/>
{savedUrls.length > 0 && (
<button
type="button"
tabIndex={-1}
onClick={() => setDropdownOpen((o) => !o)}
disabled={loading}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
aria-label={t("remoteSync.savedServers")}
>
<ChevronDown className="size-4" />
</button>
)}
{dropdownOpen && savedUrls.length > 0 && (
<div className="absolute z-50 w-full top-full mt-1 border border-border bg-card shadow-md">
<p className="px-2.5 py-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground border-b border-border">
{t("remoteSync.savedServers")}
</p>
{savedUrls.map((url) => (
<div
key={url}
className="flex items-center justify-between group hover:bg-muted transition-colors"
>
<button
type="button"
className="flex-1 text-left px-2.5 py-2 text-sm font-mono truncate"
onClick={() => {
handleUrlChange(url);
setDropdownOpen(false);
}}
>
{url}
</button>
<button
type="button"
className="px-2 py-2 text-muted-foreground hover:text-destructive transition-colors shrink-0"
title={t("remoteSync.removeServer")}
onClick={(e) => {
e.stopPropagation();
removeSavedUrl(url);
const updated = getSavedUrls();
setSavedUrls(updated);
if (updated.length === 0) setDropdownOpen(false);
}}
>
<X className="size-3.5" />
</button>
</div>
))}
</div>
)}
</div>
</div>
{serverUrl.trim().startsWith("https://") && (
<div className="flex items-start justify-between gap-3 border border-border bg-muted/20 p-3">
<div className="flex flex-col gap-1">
<Label htmlFor="remote-sync-allow-invalid-certificate">
{t("remoteSync.allowInvalidCertificate")}
</Label>
<p className="text-xs text-muted-foreground">
{t("remoteSync.allowInvalidCertificateDesc")}
</p>
</div>
<Switch
id="remote-sync-allow-invalid-certificate"
checked={allowInvalidCertificate}
onCheckedChange={setAllowInvalidCertificate}
disabled={loading}
/>
</div>
)}
{error && (
<Alert variant="destructive">
<AlertTitle>{t("common.error")}</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="flex gap-2">
<Button
type="button"
variant="outline"
className="flex-1"
onClick={onCancel}
disabled={loading}
>
{t("remoteSync.cancelButton")}
</Button>
<Button
type="button"
className="flex-1 bg-accent-brand hover:bg-accent-brand/90 text-background font-bold"
onClick={handleSaveConfig}
disabled={loading || !serverUrl.trim()}
>
{loading ? (
<span className="flex items-center gap-2">
<div className="w-4 h-4 border-2 border-background border-t-transparent rounded-full animate-spin" />
{t("serverConfig.saving")}
</span>
) : (
t("remoteSync.continueButton")
)}
</Button>
</div>
</div>
</div>
);
}
-7
View File
@@ -1,7 +0,0 @@
export function shouldForceLocalPreferenceStorage(
isDesktop: boolean,
remoteSyncConnected: boolean | null,
storageMode: "local" | "cloud",
): boolean {
return isDesktop && remoteSyncConnected === false && storageMode === "cloud";
}
+419
View File
@@ -0,0 +1,419 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { AlertCircle, Loader2 } from "lucide-react";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { Switch } from "@/components/switch";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/dialog";
import { ElectronLoginForm } from "@/auth/ElectronLoginForm";
import {
completeLink,
getLinkPreview,
probeServer,
reloginLink,
type ProbeProblem,
type ServerTarget,
type SyncStatus,
} from "@/api/sync-api";
import { ProxySettingsFields } from "./ProxySettingsFields";
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 [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,
});
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";
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className={`bg-card border border-border rounded-none ${
wide ? "sm:max-w-4xl h-[80vh] flex flex-col p-0 gap-0" : "sm:max-w-lg"
}`}
// The sign-in step is a page from another origin in an iframe; clicks
// inside it look like outside clicks to the dialog.
onPointerDownOutside={(event) => wide && event.preventDefault()}
onInteractOutside={(event) => wide && event.preventDefault()}
>
<DialogHeader className={wide ? "sr-only" : undefined}>
<DialogTitle>
{relogin ? t("sync.wizard.reloginTitle") : t("sync.wizard.title")}
</DialogTitle>
<DialogDescription>
{step === "server" && t("sync.wizard.serverDescription")}
{step === "signin" &&
t("sync.wizard.signinDescription", { url: target.serverUrl })}
{step === "choose" && t("sync.wizard.chooseDescription")}
{step === "finishing" && t("sync.wizard.finishingDescription")}
</DialogDescription>
</DialogHeader>
{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>
</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
type="submit"
className="rounded-none"
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
type="button"
className="rounded-none"
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>
)}
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,147 @@
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>
);
}
+635
View File
@@ -0,0 +1,635 @@
import {
useCallback,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
AlertTriangle,
Cloud,
CloudOff,
Loader2,
RefreshCw,
Unlink,
} from "lucide-react";
import { Button } from "@/components/button";
import { Switch } from "@/components/switch";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/dialog";
import { useSyncStatus } from "@/hooks/use-sync-status";
import { refreshSyncStatus, setSyncStatus } from "@/lib/sync-status";
import { notifySyncChanged } from "@/lib/linked-server";
import { invalidateServerStatusCache } from "@/lib/hosts-request-cache";
import {
getSyncConflicts,
getSyncErrors,
retrySyncErrors,
settleSyncConflict,
syncNow,
unlinkServer,
updateSyncSettings,
type SyncBasicAuth,
type SyncConflictItem,
type SyncEntityInfo,
type SyncErrorItem,
type SyncProxyHeader,
type SyncStatus,
} from "@/api/sync-api";
import { LinkServerDialog } from "./LinkServerDialog";
import { ProxySettingsFields } from "./ProxySettingsFields";
type DesktopSettings = { defaultConnectionOrigin: "local" | "remote" };
function Section({
title,
description,
children,
}: {
title: string;
description?: string;
children: ReactNode;
}) {
return (
<div className="border border-border bg-muted/10 p-3 flex flex-col gap-3">
<div className="flex flex-col gap-0.5">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{title}
</span>
{description && (
<span className="text-[10px] text-muted-foreground leading-relaxed">
{description}
</span>
)}
</div>
{children}
</div>
);
}
function useRelativeTime() {
const { t } = useTranslation();
return (value: string | null | undefined) => {
if (!value) return t("sync.never");
const seconds = Math.max(
0,
Math.round((Date.now() - new Date(value).getTime()) / 1000),
);
if (seconds < 60) return t("sync.justNow");
if (seconds < 3600)
return t("sync.minutesAgo", { count: Math.round(seconds / 60) });
if (seconds < 86400)
return t("sync.hoursAgo", { count: Math.round(seconds / 3600) });
return new Date(value).toLocaleString();
};
}
function entityLabel(
t: (key: string, options?: Record<string, unknown>) => string,
entity: SyncEntityInfo,
): string {
return t(`sync.entities.${entity.type}`, { defaultValue: entity.type });
}
export function SyncPanel() {
const { t } = useTranslation();
const status = useSyncStatus();
const relativeTime = useRelativeTime();
const [linkOpen, setLinkOpen] = useState(false);
const [reloginOpen, setReloginOpen] = useState(false);
const [unlinkOpen, setUnlinkOpen] = useState(false);
const [keepData, setKeepData] = useState(true);
const [busy, setBusy] = useState<string | null>(null);
const [conflicts, setConflicts] = useState<SyncConflictItem[]>([]);
const [errors, setErrors] = useState<SyncErrorItem[]>([]);
const [desktopSettings, setDesktopSettings] = useState<DesktopSettings>({
defaultConnectionOrigin: "local",
});
const [proxyDraft, setProxyDraft] = useState<{
customHeaders: SyncProxyHeader[];
basicAuth: SyncBasicAuth | null;
allowInvalidCertificate: boolean;
} | null>(null);
const apply = useCallback((next: SyncStatus) => {
setSyncStatus(next);
notifySyncChanged();
}, []);
useEffect(() => {
window.electronAPI
?.invoke?.("get-desktop-settings")
.then((settings) => {
if (settings) setDesktopSettings(settings as DesktopSettings);
})
.catch(() => {});
}, []);
const conflictCount = status?.conflicts ?? 0;
const errorCount = status?.errors ?? 0;
useEffect(() => {
if (!status?.linked) return;
getSyncConflicts()
.then(setConflicts)
.catch(() => {});
}, [status?.linked, conflictCount, status?.lastSyncAt]);
useEffect(() => {
if (!status?.linked) return;
getSyncErrors()
.then(setErrors)
.catch(() => {});
}, [status?.linked, errorCount, status?.lastSyncAt]);
const run = async (key: string, action: () => Promise<void>) => {
setBusy(key);
try {
await action();
} catch {
toast.error(t("sync.actionFailed"));
} finally {
setBusy(null);
}
};
const groups = useMemo(() => {
const byOwner = new Map<string, SyncEntityInfo[]>();
for (const entity of status?.entities ?? []) {
const key = entity.owner === "core" ? "core" : entity.owner;
const list = byOwner.get(key) ?? [];
list.push(entity);
byOwner.set(key, list);
}
return [...byOwner.entries()].sort(([a], [b]) =>
a === "core" ? -1 : b === "core" ? 1 : a.localeCompare(b),
);
}, [status?.entities]);
if (!status) {
return (
<div className="flex items-center justify-center p-8">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
);
}
const linkDialog = (
<LinkServerDialog
open={linkOpen}
onOpenChange={setLinkOpen}
onLinked={apply}
/>
);
if (!status.linked) {
return (
<div className="flex flex-col gap-3 p-3">
<div className="border border-border bg-muted/10 p-4 flex flex-col gap-3">
<div className="flex items-center gap-2">
<CloudOff className="size-4 text-muted-foreground" />
<span className="text-sm font-bold">{t("sync.unlinkedTitle")}</span>
</div>
<p className="text-xs text-muted-foreground leading-relaxed">
{t("sync.unlinkedBody")}
</p>
<ul className="text-xs text-muted-foreground list-disc pl-4 flex flex-col gap-1">
<li>{t("sync.unlinkedPointHosts")}</li>
<li>{t("sync.unlinkedPointOffline")}</li>
<li>{t("sync.unlinkedPointAccount")}</li>
</ul>
<Button
className="self-start rounded-none"
onClick={() => setLinkOpen(true)}
>
<Cloud className="size-4" />
{t("sync.linkButton")}
</Button>
</div>
{linkDialog}
</div>
);
}
const state = status.status ?? "idle";
const stateText =
state === "syncing"
? t("sync.state.syncing")
: state === "offline"
? t("sync.state.offline")
: state === "error"
? t("sync.state.error")
: state === "signed_out"
? t("sync.state.signedOut")
: t("sync.state.synced", { time: relativeTime(status.lastSyncAt) });
const toggleType = (type: string, enabled: boolean) =>
run(`type:${type}`, async () => {
const disabled = new Set<string>(
(status.entities ?? [])
.filter((entity) => !entity.enabled)
.map((entity) => entity.type),
);
if (enabled) disabled.delete(type);
else disabled.add(type);
apply(await updateSyncSettings({ disabledTypes: [...disabled] }));
});
const handleOriginChange = async (origin: "local" | "remote") => {
const next = { ...desktopSettings, defaultConnectionOrigin: origin };
setDesktopSettings(next);
await window.electronAPI?.invoke?.("save-desktop-settings", next);
invalidateServerStatusCache();
window.dispatchEvent(new CustomEvent("hosts:refresh"));
};
const draft = proxyDraft ?? {
customHeaders: status.customHeaders ?? [],
basicAuth: status.basicAuth ?? null,
allowInvalidCertificate: !!status.allowInvalidCertificate,
};
return (
<div className="flex flex-col gap-3 p-3">
<div className="border border-border bg-muted/10 p-3 flex flex-col gap-3">
<div className="flex items-start gap-2">
<Cloud className="size-4 text-accent-brand mt-0.5 shrink-0" />
<div className="flex flex-col min-w-0">
<span className="text-sm font-bold truncate">
{status.serverName || t("sync.server")}
</span>
<span className="text-[10px] text-muted-foreground break-all">
{status.serverUrl}
</span>
</div>
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="flex flex-col">
<span className="text-[10px] uppercase tracking-widest text-muted-foreground font-semibold">
{t("sync.account")}
</span>
<span className="font-semibold truncate">
{status.account?.username || "—"}
</span>
</div>
<div className="flex flex-col">
<span className="text-[10px] uppercase tracking-widest text-muted-foreground font-semibold">
{t("sync.status")}
</span>
<span
className={`font-semibold flex items-center gap-1 ${
state === "idle" || state === "syncing"
? ""
: "text-destructive"
}`}
>
{state === "syncing" && (
<Loader2 className="size-3 animate-spin" />
)}
{stateText}
</span>
</div>
</div>
{status.lastError && state !== "idle" && state !== "syncing" && (
<p className="text-[10px] text-muted-foreground break-words">
{status.lastError}
</p>
)}
{!!status.pending && (
<p className="text-[10px] text-muted-foreground">
{t("sync.pending", { count: status.pending })}
</p>
)}
<div className="flex flex-wrap gap-2">
{state === "signed_out" ? (
<Button
size="sm"
className="h-7 text-[10px] rounded-none"
onClick={() => setReloginOpen(true)}
>
{t("sync.signInAgain")}
</Button>
) : (
<Button
size="sm"
variant="outline"
className="h-7 text-[10px] rounded-none"
disabled={busy === "sync" || state === "syncing"}
onClick={() =>
run("sync", async () => {
apply(await syncNow());
})
}
>
{busy === "sync" || state === "syncing" ? (
<Loader2 className="size-3 animate-spin" />
) : (
<RefreshCw className="size-3" />
)}
{t("sync.syncNow")}
</Button>
)}
<Button
size="sm"
variant="outline"
className="h-7 text-[10px] rounded-none"
onClick={() => {
setKeepData(true);
setUnlinkOpen(true);
}}
>
<Unlink className="size-3" />
{t("sync.unlink")}
</Button>
</div>
</div>
{conflicts.length > 0 && (
<Section
title={t("sync.conflictsTitle")}
description={t("sync.conflictsDescription")}
>
{conflicts.map((conflict) => (
<div
key={conflict.id}
className="flex items-center justify-between gap-2 border border-border p-2"
>
<div className="flex flex-col min-w-0">
<span className="text-xs font-semibold truncate">
{conflict.name}
</span>
<span className="text-[10px] text-muted-foreground">
{t(`sync.entities.${conflict.entityType}`, {
defaultValue: conflict.entityType,
})}
</span>
</div>
<div className="flex gap-1 shrink-0">
<Button
size="sm"
variant="outline"
className="h-6 text-[10px] rounded-none"
disabled={busy === `conflict:${conflict.id}`}
onClick={() =>
run(`conflict:${conflict.id}`, async () => {
await settleSyncConflict(conflict.id, "mine");
refreshSyncStatus();
})
}
>
{t("sync.keepMine")}
</Button>
<Button
size="sm"
variant="outline"
className="h-6 text-[10px] rounded-none"
disabled={busy === `conflict:${conflict.id}`}
onClick={() =>
run(`conflict:${conflict.id}`, async () => {
await settleSyncConflict(conflict.id, "server");
refreshSyncStatus();
})
}
>
{t("sync.keepServer")}
</Button>
</div>
</div>
))}
</Section>
)}
{errors.length > 0 && (
<Section
title={t("sync.errorsTitle")}
description={t("sync.errorsDescription")}
>
{errors.map((error) => (
<div
key={`${error.entityType}:${error.syncId}`}
className="flex items-start gap-2 border border-border p-2"
>
<AlertTriangle className="size-3.5 text-destructive shrink-0 mt-0.5" />
<div className="flex flex-col min-w-0">
<span className="text-xs font-semibold">
{t(`sync.entities.${error.entityType}`, {
defaultValue: error.entityType,
})}
</span>
<span className="text-[10px] text-muted-foreground break-words">
{error.reason === "permission"
? t("sync.errorPermission")
: error.reason}
</span>
</div>
</div>
))}
<Button
size="sm"
variant="outline"
className="self-start h-7 text-[10px] rounded-none"
disabled={busy === "retry"}
onClick={() =>
run("retry", async () => {
apply(await retrySyncErrors());
})
}
>
{t("sync.retry")}
</Button>
</Section>
)}
<Section
title={t("sync.whatSyncs")}
description={t("sync.whatSyncsDescription")}
>
{groups.map(([owner, entities]) => (
<div key={owner} className="flex flex-col gap-1.5">
<span className="text-[10px] font-semibold text-muted-foreground">
{owner === "core"
? t("sync.coreGroup")
: (entities[0]?.ownerName ?? owner)}
</span>
{entities.map((entity) => (
<div
key={entity.type}
className="flex items-center justify-between gap-3"
>
<div className="flex flex-col min-w-0">
<span className="text-xs">{entityLabel(t, entity)}</span>
{!entity.onServer && (
<span className="text-[10px] text-muted-foreground">
{t("sync.notOnServer")}
</span>
)}
{entity.onServer && !entity.onDevice && (
<span className="text-[10px] text-muted-foreground">
{t("sync.notOnDevice")}
</span>
)}
</div>
<Switch
checked={entity.enabled}
disabled={busy === `type:${entity.type}`}
onCheckedChange={(checked) =>
void toggleType(entity.type, checked)
}
/>
</div>
))}
</div>
))}
<p className="text-[10px] text-muted-foreground leading-relaxed">
{t("sync.localOnlyHint")}
</p>
</Section>
<Section
title={t("sync.originTitle")}
description={t("sync.originDescription")}
>
<div className="flex border border-border overflow-hidden w-fit">
{(["local", "remote"] as const).map((origin) => (
<button
key={origin}
type="button"
onClick={() => void handleOriginChange(origin)}
className={`px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest transition-colors ${
desktopSettings.defaultConnectionOrigin === origin
? "bg-accent-brand text-background"
: "text-muted-foreground hover:text-foreground hover:bg-muted"
}`}
>
{origin === "local"
? t("sync.originLocal")
: t("sync.originRemote")}
</button>
))}
</div>
</Section>
<Section
title={t("sync.proxy.title")}
description={t("sync.proxy.description")}
>
<ProxySettingsFields
customHeaders={draft.customHeaders}
basicAuth={draft.basicAuth}
onChange={(next) => setProxyDraft({ ...draft, ...next })}
/>
<div className="flex items-center justify-between gap-3">
<div className="flex flex-col gap-0.5">
<span className="text-xs">
{t("sync.proxy.allowInvalidCertificate")}
</span>
<span className="text-[10px] text-muted-foreground">
{t("sync.proxy.allowInvalidCertificateHint")}
</span>
</div>
<Switch
checked={draft.allowInvalidCertificate}
onCheckedChange={(checked) =>
setProxyDraft({ ...draft, allowInvalidCertificate: checked })
}
/>
</div>
{proxyDraft && (
<Button
size="sm"
className="self-start h-7 text-[10px] rounded-none"
disabled={busy === "proxy"}
onClick={() =>
run("proxy", async () => {
apply(await updateSyncSettings(proxyDraft));
setProxyDraft(null);
toast.success(t("sync.proxy.saved"));
})
}
>
{t("common.save")}
</Button>
)}
</Section>
<LinkServerDialog
open={reloginOpen}
onOpenChange={setReloginOpen}
onLinked={apply}
relogin={status.serverUrl ? { serverUrl: status.serverUrl } : undefined}
/>
<Dialog open={unlinkOpen} onOpenChange={setUnlinkOpen}>
<DialogContent className="bg-card border border-border rounded-none sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("sync.unlinkTitle")}</DialogTitle>
<DialogDescription>{t("sync.unlinkDescription")}</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-2">
{(
[
{
value: true,
title: t("sync.unlinkKeepTitle"),
body: t("sync.unlinkKeepBody"),
},
{
value: false,
title: t("sync.unlinkRemoveTitle"),
body: t("sync.unlinkRemoveBody"),
},
] as const
).map((option) => (
<button
key={String(option.value)}
type="button"
onClick={() => setKeepData(option.value)}
className={`text-left border p-3 flex flex-col gap-1 transition-colors ${
keepData === 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>
<DialogFooter>
<Button
variant="outline"
className="rounded-none"
onClick={() => setUnlinkOpen(false)}
>
{t("common.cancel")}
</Button>
<Button
variant="destructive"
className="rounded-none"
disabled={busy === "unlink"}
onClick={() =>
run("unlink", async () => {
apply(await unlinkServer(keepData));
setUnlinkOpen(false);
toast.success(t("sync.unlinked"));
})
}
>
{busy === "unlink" && <Loader2 className="size-4 animate-spin" />}
{t("sync.unlink")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{linkDialog}
</div>
);
}
+16 -11
View File
@@ -15,6 +15,7 @@ import { Skeleton } from "@/components/skeleton";
import { readRailPreference, setRailPreference } from "./rail-preferences";
import { useRailItems, type RailItemDef } from "./rail-items";
import { RailBadge } from "./RailBadge";
import { rem } from "@/lib/rem";
/** Core rail views; plugins add their own ids at runtime. */
export type CoreRailView =
@@ -102,7 +103,7 @@ function buildRailButtons(
const btnBase =
"relative flex items-center h-7 rounded shrink-0 transition-colors gap-2.5";
const btnStyle = { margin: "0 4px", padding: "0 8px" };
const btnStyle = { margin: `0 ${rem(4)}`, padding: `0 ${rem(8)}` };
export function AppRail({
railView,
@@ -240,7 +241,7 @@ export function AppRail({
return (
<div
className="hidden md:flex flex-col items-stretch bg-sidebar border-r border-border shrink-0 overflow-hidden pt-2 gap-1 transition-[width] duration-200 min-h-0"
style={{ width: railExpanded ? 160 : 40 }}
style={{ width: rem(railExpanded ? 160 : 40) }}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onContextMenu={(e) => {
@@ -273,7 +274,9 @@ export function AppRail({
<div
key={`sep-${i}`}
className="mx-auto h-px bg-border my-0.5 shrink-0 transition-[width] duration-200"
style={{ width: railExpanded ? "calc(100% - 16px)" : 20 }}
style={{
width: railExpanded ? `calc(100% - ${rem(16)})` : rem(20),
}}
/>
) : "tabType" in item ? (
<button
@@ -284,7 +287,7 @@ export function AppRail({
>
<span
className="relative shrink-0 flex items-center justify-center"
style={{ width: 16, height: 16 }}
style={{ width: rem(16), height: rem(16) }}
>
{item.icon}
{item.useBadge && (
@@ -343,7 +346,7 @@ export function AppRail({
>
<span
className="relative shrink-0 flex items-center justify-center"
style={{ width: 16, height: 16 }}
style={{ width: rem(16), height: rem(16) }}
>
{item.icon}
{item.useBadge && (
@@ -384,7 +387,7 @@ export function AppRail({
>
<span
className="shrink-0 flex items-center justify-center"
style={{ width: 16, height: 16 }}
style={{ width: rem(16), height: rem(16) }}
>
<Pin size={16} />
</span>
@@ -400,7 +403,9 @@ export function AppRail({
{showPinButton && (
<div
className="mx-auto h-px bg-border my-0.5 shrink-0 transition-[width] duration-200"
style={{ width: railExpanded ? "calc(100% - 16px)" : 20 }}
style={{
width: railExpanded ? `calc(100% - ${rem(16)})` : rem(20),
}}
/>
)}
{footerItems.map((item) => {
@@ -449,7 +454,7 @@ export function AppRail({
>
<span
className="relative shrink-0 flex items-center justify-center"
style={{ width: 16, height: 16 }}
style={{ width: rem(16), height: rem(16) }}
>
<Icon size={16} />
{item.useBadge && (
@@ -528,7 +533,7 @@ export function AppRail({
>
<span
className="relative shrink-0 flex items-center justify-center"
style={{ width: 16, height: 16 }}
style={{ width: rem(16), height: rem(16) }}
>
{item.icon}
</span>
@@ -549,7 +554,7 @@ export function AppRail({
>
<span
className="shrink-0 flex items-center justify-center"
style={{ width: 16, height: 16 }}
style={{ width: rem(16), height: rem(16) }}
>
<LogOut size={16} />
</span>
@@ -571,7 +576,7 @@ export function AppRail({
>
<div
className="rounded-full bg-accent-brand/20 border border-accent-brand/30 flex items-center justify-center font-bold text-accent-brand shrink-0"
style={{ width: 24, height: 24, fontSize: 11 }}
style={{ width: rem(24), height: rem(24), fontSize: rem(11) }}
>
{username.charAt(0).toUpperCase() || "U"}
</div>
+16
View File
@@ -19,12 +19,15 @@ import {
} from "@/components/folder-style";
import { normalizePath, splitPath } from "./FolderPathPicker";
import { getCredentials } from "@/main-axios";
import { useSyncStatus } from "@/hooks/use-sync-status";
import { FakeSwitch, SettingRow } from "@/components/section-card";
export type FolderMetadataValue = {
name: string;
color: string;
icon: string;
credentialId: number | null;
localOnly: boolean;
};
type CredentialOption = { id: string; name: string; username?: string };
@@ -45,6 +48,7 @@ export function FolderMetadataDialog({
color?: string;
icon?: string;
credentialId?: number | null;
localOnly?: boolean;
};
/** Every folder path that already exists, for duplicate-name validation. */
existingPaths?: string[];
@@ -58,6 +62,8 @@ export function FolderMetadataDialog({
const [color, setColor] = useState(DEFAULT_FOLDER_COLOR);
const [icon, setIcon] = useState(DEFAULT_FOLDER_ICON);
const [credentialId, setCredentialId] = useState<string>("");
const [localOnly, setLocalOnly] = useState(false);
const syncLinked = !!useSyncStatus()?.linked;
const [credentials, setCredentials] = useState<CredentialOption[]>([]);
const parentPath =
@@ -84,6 +90,7 @@ export function FolderMetadataDialog({
setCredentialId(
initial?.credentialId ? String(initial.credentialId) : "",
);
setLocalOnly(!!initial?.localOnly);
}
}, [open, initial]);
@@ -111,6 +118,7 @@ export function FolderMetadataDialog({
color,
icon,
credentialId: credentialId ? Number(credentialId) : null,
localOnly,
});
onOpenChange(false);
}
@@ -186,6 +194,14 @@ export function FolderMetadataDialog({
{t("hosts.folderCredentialHint")}
</p>
</div>
{syncLinked && (
<SettingRow
label={t("hosts.folderLocalOnly")}
description={t("hosts.folderLocalOnlyDesc")}
>
<FakeSwitch checked={localOnly} onChange={setLocalOnly} />
</SettingRow>
)}
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold">
{t("hosts.folderPreview")}
+13 -17
View File
@@ -45,7 +45,9 @@ export function HostAuthOverrideModal({
const ownerAuthShared =
overrideState?.ownerAuthShared ??
(protocol === "ssh" ? !!host.shareSshAuth : false);
const remoteShared = !!host.isShared && Number(host.id) < 0;
// A shared host's copy on a linked desktop: its override lives on the
// server, against the server's own credentials.
const sharedCopySyncId = host.sharedCopy ? (host.syncId ?? null) : null;
useEffect(() => {
if (!open) return;
@@ -53,18 +55,16 @@ export function HostAuthOverrideModal({
setLoading(true);
setLoadError(false);
const credentialsRequest = remoteShared
const credentialsRequest = sharedCopySyncId
? getConnectedRemoteApi().then((api) => {
if (!api) throw new Error("Remote server is not connected");
if (!api) throw new Error("The linked server is not reachable");
return api.get("/credentials").then((response) => response.data);
})
: getCredentials();
Promise.all([
credentialsRequest,
remoteShared
? getHostAuthOverride(Number(host.id), protocol, true)
: getHostAuthOverride(Number(host.id), protocol),
getHostAuthOverride(Number(host.id), protocol, sharedCopySyncId),
])
.then(([credentialResult, overrideResult]) => {
if (cancelled) return;
@@ -87,22 +87,18 @@ export function HostAuthOverrideModal({
return () => {
cancelled = true;
};
}, [host.id, open, protocol, remoteShared]);
}, [host.id, open, protocol, sharedCopySyncId]);
const handleSave = async () => {
setSaving(true);
try {
const credentialId = selectedId ? Number(selectedId) : null;
if (remoteShared) {
await setHostAuthOverride(
Number(host.id),
protocol,
credentialId,
true,
);
} else {
await setHostAuthOverride(Number(host.id), protocol, credentialId);
}
await setHostAuthOverride(
Number(host.id),
protocol,
credentialId,
sharedCopySyncId,
);
toast.success(
credentialId === null
? t(
+2
View File
@@ -167,6 +167,7 @@ export function createHostEditorForm(
socks5ProxyChain: (host?.socks5ProxyChain ?? []) as HostSocks5ProxyNode[],
connectionOrigin: (host?.connectionOrigin ?? null) as
"local" | "remote" | null,
localOnly: host?.localOnly ?? false,
forceKeyboardInteractive: host?.forceKeyboardInteractive ?? false,
inheritTerminalAppearance:
!host ||
@@ -415,6 +416,7 @@ export function buildHostEditorPayload(
socks5ProxyChain:
form.socks5ProxyMode === "chain" ? form.socks5ProxyChain : null,
connectionOrigin: form.connectionOrigin,
localOnly: form.localOnly,
enableSsh: protocols.enableSsh,
sshPort: Number(form.sshPort),
forceKeyboardInteractive: form.forceKeyboardInteractive,
+13
View File
@@ -11,6 +11,7 @@ import { FolderPathPicker } from "./FolderPathPicker";
import { HostParentPicker } from "./HostParentPicker";
import { getSSHFolders, isElectron } from "@/main-axios";
import { connectionOriginAppliesTo } from "./HostEditorData";
import { useSyncStatus } from "@/hooks/use-sync-status";
import type { HostEditorForm, HostProtocols } from "./HostEditorData";
import { Select2 } from "@/components/select2";
import { useHostProtocols } from "./host-protocols";
@@ -39,6 +40,7 @@ export function HostEditorGeneralTab({
simpleMode?: boolean;
}) {
const { t } = useTranslation();
const syncLinked = !!useSyncStatus()?.linked;
const pluginProtocols = useHostProtocols();
// Tracks which picker is shown, independent of whether a value is set yet
@@ -311,6 +313,17 @@ export function HostEditorGeneralTab({
onChange={(v) => setField("pin", v)}
/>
</SettingRow>
{syncLinked && (
<SettingRow
label={t("hosts.localOnly")}
description={t("hosts.localOnlyDesc")}
>
<FakeSwitch
checked={form.localOnly}
onChange={(v) => setField("localOnly", v)}
/>
</SettingRow>
)}
</div>
<div className="flex flex-col gap-3 border-t border-border pt-4 pb-0">
<div className="flex items-center justify-between">
+2
View File
@@ -78,6 +78,8 @@ export function sshHostToHost(h: SSHHostWithStatus): Host {
pin: h.pin ?? false,
sortOrder: h.sortOrder ?? null,
connectionOrigin: h.connectionOrigin ?? null,
localOnly: !!h.localOnly,
sharedCopy: !!h.sharedCopy,
enableSsh: h.enableSsh != null ? h.enableSsh : isSshHost,
sshPort:
h.sshPort ??
+83 -103
View File
@@ -4,7 +4,6 @@ import { useTranslation } from "react-i18next";
import { copyToClipboard } from "@/lib/clipboard";
import {
getUserInfo,
getRemoteSyncUserInfo,
getApiKeys,
createApiKey,
deleteApiKey,
@@ -20,11 +19,10 @@ import {
import { getDatabaseTransferUrl } from "@/lib/database-transfer-url";
import { readRailPreference, setRailPreference } from "./rail-preferences";
import { useHostSidebarPreferences } from "./tree/hooks/useHostSidebarPreferences";
import type { UserRole } from "@/main-axios";
import type { LinkedAccountInfo, UserRole } from "@/main-axios";
import type React from "react";
import { isElectron } from "@/lib/electron";
import { RemoteSyncPanel } from "@/settings/RemoteSyncPanel.tsx";
import { shouldForceLocalPreferenceStorage } from "@/settings/remote-sync-state";
import { shell } from "@/plugin-host/shell-bridge";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { VersionBadge } from "@/components/version-badge";
@@ -522,15 +520,9 @@ export function UserProfilePanel({
onLogout,
userPrefs,
onPrefsChange,
remoteSyncInitialServerUrl,
remoteSyncReconnectRequested,
onRemoteSyncReconnectHandled,
}: {
username?: string;
onLogout?: () => void;
remoteSyncInitialServerUrl?: string;
remoteSyncReconnectRequested?: boolean;
onRemoteSyncReconnectHandled?: () => void;
userPrefs?: {
reopenTabsOnLogin: boolean;
storageMode?: string | null;
@@ -629,51 +621,19 @@ export function UserProfilePanel({
}
}, [userPrefs?.storageMode]);
// Remote sync is not connected by default on the desktop app (it's an
// opt-in feature configured from this same panel). "cloud" storage mode
// and Termix ID both assume a real multi-device server account, so they
// stay hidden/forced-off until the user actually connects one.
const [isRemoteSyncConnected, setIsRemoteSyncConnected] = useState<
boolean | null
>(() => (isElectron() ? null : true));
// On the desktop app, the account it is linked to on a server, if any.
// "Cloud" storage only has somewhere to go once it is linked.
const [linkedAccount, setLinkedAccount] = useState<
LinkedAccountInfo | null | undefined
>(() => (isElectron() ? undefined : null));
const isLinked = !!linkedAccount;
useEffect(() => {
if (!isElectron()) return;
let cancelled = false;
const refreshSyncStatus = () => {
window.electronAPI
?.invoke?.("get-remote-sync-config")
.then((config) => {
if (!cancelled) {
setIsRemoteSyncConnected(
!!(config as { serverUrl?: string } | null)?.serverUrl,
);
}
})
.catch(() => {});
};
refreshSyncStatus();
const unsubscribe = window.electronAPI?.onRemoteSyncStatusChanged?.(() =>
refreshSyncStatus(),
);
return () => {
cancelled = true;
unsubscribe?.();
};
}, []);
useEffect(() => {
if (
shouldForceLocalPreferenceStorage(
isElectron(),
isRemoteSyncConnected,
storageMode,
)
) {
if (isElectron() && linkedAccount === null && storageMode === "cloud") {
setStorageMode("local");
onPrefsChange?.({ storageMode: "local" });
}
}, [isRemoteSyncConnected, storageMode, onPrefsChange]);
}, [linkedAccount, storageMode, onPrefsChange]);
// Settings toggles — all backed by localStorage
const [commandAutocomplete, setCommandAutocomplete] = useState(
@@ -738,16 +698,17 @@ export function UserProfilePanel({
useEffect(() => {
getUserInfo()
.then(async (localInfo) => {
setUserId(localInfo.userId);
setIsOidc(localInfo.is_oidc ?? false);
setIsDualAuth(localInfo.is_dual_auth ?? false);
const remoteInfo = await getRemoteSyncUserInfo();
const info = remoteInfo ?? localInfo;
setAccountUsername(info.username);
.then((info) => {
setUserId(info.userId);
setIsOidc(info.is_oidc ?? false);
setIsDualAuth(info.is_dual_auth ?? false);
const linked = info.linked ?? null;
setLinkedAccount(linked);
// A linked desktop is the account it is signed in to on the server.
setAccountUsername(linked?.username || info.username);
setAccountTotpEnabled(info.totp_enabled ?? false);
setUserRole(
info.is_admin
(linked ? linked.isAdmin : info.is_admin)
? t("newUi.sidebar.userProfile.roleAdministrator")
: t("newUi.sidebar.userProfile.roleUser"),
);
@@ -758,10 +719,17 @@ export function UserProfilePanel({
} else {
setAuthMethod(t("newUi.sidebar.userProfile.authMethodLocal"));
}
if (remoteInfo) {
setUserRoles(remoteInfo.roles ?? []);
if (linked) {
setUserRoles(
linked.roles.map((name, index) => ({
id: index,
roleId: index,
roleName: name,
roleDisplayName: name,
})) as unknown as UserRole[],
);
} else {
getUserRoles(localInfo.userId)
getUserRoles(info.userId)
.then(({ roles }) => setUserRoles(roles ?? []))
.catch(() => {});
}
@@ -1340,7 +1308,7 @@ export function UserProfilePanel({
{/* Storage mode toggle — only meaningful once a remote server is
connected; with no sync there's nowhere for "cloud" to sync to,
so this stays forced to local storage and hidden. */}
{(!isElectron() || isRemoteSyncConnected === true) && (
{(!isElectron() || isLinked) && (
<div className="border border-border bg-card px-3 py-2.5 flex flex-col gap-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("newUi.sidebar.userProfile.storageModeSwitch")}
@@ -1389,17 +1357,35 @@ export function UserProfilePanel({
onToggle={() => toggle("account")}
>
<div className="flex flex-col gap-0 pt-2">
{isElectron() ? (
{isElectron() && (
<div className="border border-accent-brand/40 bg-accent-brand/10 px-3 py-2.5 mb-2">
<div className="flex items-center gap-1.5 text-xs font-semibold text-accent-brand">
<ShieldCheck className="size-3.5" />
{t("newUi.sidebar.userProfile.desktopProfileTitle")}
{isLinked
? t("newUi.sidebar.userProfile.linkedProfileTitle", {
server:
linkedAccount?.serverName || linkedAccount?.serverUrl,
})
: t("newUi.sidebar.userProfile.desktopProfileTitle")}
</div>
<p className="text-[10px] text-muted-foreground leading-relaxed mt-1">
{t("newUi.sidebar.userProfile.desktopProfileDescription")}
{isLinked
? t("newUi.sidebar.userProfile.linkedProfileDescription")
: t("newUi.sidebar.userProfile.desktopProfileDescription")}
</p>
<Button
variant="outline"
size="sm"
className="mt-2 h-7 text-[10px] rounded-none"
onClick={() => shell.openRailView("sync")}
>
{isLinked
? t("newUi.sidebar.userProfile.manageSync")
: t("newUi.sidebar.userProfile.setUpSync")}
</Button>
</div>
) : (
)}
{(!isElectron() || isLinked) && (
<div className="grid grid-cols-2 gap-x-4 gap-y-0">
<div className="flex flex-col py-2">
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
@@ -1427,33 +1413,37 @@ export function UserProfilePanel({
))}
</div>
</div>
<div className="flex flex-col py-2">
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
{t("newUi.sidebar.userProfile.authMethodLabel")}
</span>
<span className="text-sm font-semibold mt-0.5">
{authMethod || "—"}
</span>
</div>
<div className="flex flex-col py-2">
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
{t("newUi.sidebar.userProfile.twoFaLabel")}
</span>
<span className="flex items-center gap-1 mt-0.5">
{accountTotpEnabled ? (
<>
<ShieldCheck className="size-3.5 text-accent-brand" />
<span className="text-sm font-semibold text-accent-brand">
{t("newUi.sidebar.userProfile.twoFaOn")}
</span>
</>
) : (
<span className="text-sm font-semibold text-muted-foreground">
{t("newUi.sidebar.userProfile.twoFaOff")}
{!isElectron() && (
<>
<div className="flex flex-col py-2">
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
{t("newUi.sidebar.userProfile.authMethodLabel")}
</span>
)}
</span>
</div>
<span className="text-sm font-semibold mt-0.5">
{authMethod || "—"}
</span>
</div>
<div className="flex flex-col py-2">
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
{t("newUi.sidebar.userProfile.twoFaLabel")}
</span>
<span className="flex items-center gap-1 mt-0.5">
{accountTotpEnabled ? (
<>
<ShieldCheck className="size-3.5 text-accent-brand" />
<span className="text-sm font-semibold text-accent-brand">
{t("newUi.sidebar.userProfile.twoFaOn")}
</span>
</>
) : (
<span className="text-sm font-semibold text-muted-foreground">
{t("newUi.sidebar.userProfile.twoFaOff")}
</span>
)}
</span>
</div>
</>
)}
</div>
)}
@@ -1519,16 +1509,6 @@ export function UserProfilePanel({
</div>
</div>
{isElectron() && (
<div className="border-t border-border pt-3 mt-3">
<RemoteSyncPanel
initialServerUrl={remoteSyncInitialServerUrl}
reconnectRequested={remoteSyncReconnectRequested}
onReconnectRequestHandled={onRemoteSyncReconnectHandled}
/>
</div>
)}
{!isElectron() && (
<div className="border-t border-border pt-3 mt-3">
<div className="flex items-center justify-between">
@@ -2011,7 +1991,7 @@ export function UserProfilePanel({
{/* The embedded desktop backend auto-authenticates its machine-local
profile, so server login controls would imply protection they do
not provide. Remote Sync owns its separate account UI above. */}
not provide. A linked account's sign-in is managed on its server. */}
<AccordionSection
hidden={isElectron()}
id="security"
+2
View File
@@ -27,6 +27,7 @@ export function buildHostTree(
icon?: string;
credentialId?: number | null;
sortOrder?: number | null;
localOnly?: boolean;
}
>,
): HostFolder {
@@ -48,6 +49,7 @@ export function buildHostTree(
icon: meta?.icon,
credentialId: meta?.credentialId ?? null,
sortOrder: meta?.sortOrder ?? null,
localOnly: !!meta?.localOnly,
children: [],
};
folderMap.set(accumulated, folder);
@@ -1,3 +1,4 @@
import { rem } from "@/lib/rem";
import { useTranslation } from "react-i18next";
import {
ChevronRight,
@@ -114,7 +115,7 @@ export function CredentialItem({
const canDrag = arrangeMode && !isTouchOnly;
const depthStyle =
depth > 0 ? ({ paddingLeft: depth * 12 } as const) : undefined;
depth > 0 ? ({ paddingLeft: rem(depth * 12) } as const) : undefined;
const trayButtonClass =
"flex items-center justify-center size-6.5 text-muted-foreground/60 hover:text-foreground hover:bg-muted transition-colors";
@@ -1,3 +1,4 @@
import { rem } from "@/lib/rem";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
@@ -64,7 +65,7 @@ export function CredentialFolderItem({
return (
<div
className="relative"
style={depth > 0 ? { paddingLeft: depth * 12 } : undefined}
style={depth > 0 ? { paddingLeft: rem(depth * 12) } : undefined}
>
<div
onDragOver={(e) => {
+10
View File
@@ -1,5 +1,6 @@
import {
Braces,
Cloud,
Hammer,
KeyRound,
LayoutPanelLeft,
@@ -12,6 +13,7 @@ import {
} from "lucide-react";
import { useMemo, useSyncExternalStore } from "react";
import { usePermissions } from "@/hooks/use-permissions";
import { useSyncAttentionCount } from "@/hooks/use-sync-status";
import { isElectron } from "@/lib/electron";
import { createRegistry } from "@/lib/registry";
@@ -116,6 +118,14 @@ export const RAIL_ITEMS: RailItemDef[] = [
labelKey: "nav.splitScreen",
separatorAfter: true,
},
{
id: "sync",
icon: Cloud,
labelKey: "nav.sync",
electronOnly: true,
placement: "footer",
useBadge: useSyncAttentionCount,
},
];
/**
@@ -1,4 +1,5 @@
/* eslint-disable react-refresh/only-export-components */
import { rem } from "@/lib/rem";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { ChevronRight, Check, GripVertical } from "lucide-react";
@@ -177,7 +178,7 @@ export function FolderItem({
return (
<div
className="relative"
style={depth > 0 ? { paddingLeft: depth * 12 } : undefined}
style={depth > 0 ? { paddingLeft: rem(depth * 12) } : undefined}
>
<div className="relative">
<button
+2 -1
View File
@@ -1,4 +1,5 @@
/* eslint-disable react-refresh/only-export-components */
import { rem } from "@/lib/rem";
import { enabledHostProtocols } from "@/sidebar/host-protocols";
import { useEffect, useRef, useState, type MouseEvent } from "react";
import { createPortal } from "react-dom";
@@ -368,7 +369,7 @@ export function HostItem({
if (query && !hostMatchesQuery(host, query)) return null;
const depthStyle =
depth > 0 ? ({ paddingLeft: depth * 12 } as const) : undefined;
depth > 0 ? ({ paddingLeft: rem(depth * 12) } as const) : undefined;
const trayButtonClass =
"flex items-center justify-center size-[22.75px] text-muted-foreground/60 hover:text-foreground hover:bg-muted transition-colors";
+4
View File
@@ -459,6 +459,7 @@ export function SidebarTree({
color: string;
icon: string;
credentialId: number | null;
localOnly: boolean;
}) {
const existing = folderDialog?.folder;
try {
@@ -476,6 +477,7 @@ export function SidebarTree({
value.color,
value.icon,
value.credentialId,
value.localOnly,
);
} else {
await updateFolderMetadata(
@@ -483,6 +485,7 @@ export function SidebarTree({
value.color,
value.icon,
value.credentialId,
value.localOnly,
);
}
window.dispatchEvent(new CustomEvent("termix:hosts-changed"));
@@ -1346,6 +1349,7 @@ export function SidebarTree({
color: folderDialog.folder.color,
icon: folderDialog.folder.icon,
credentialId: folderDialog.folder.credentialId,
localOnly: folderDialog.folder.localOnly,
}
: undefined
}
+12 -11
View File
@@ -23,6 +23,13 @@ vi.mock("@/lib/connection-origin", () => ({
resolveConnectionOrigin: resolveConnectionOriginMock,
}));
vi.mock("@/lib/linked-server", () => ({
getLinkedSession: async () => ({
serverUrl: "https://termix.example.test",
token: "t",
}),
}));
import {
getAllServerStatuses,
refreshServerPolling,
@@ -40,23 +47,17 @@ function hostsAndStatuses(hosts: unknown[]) {
beforeEach(() => {
vi.clearAllMocks();
window.electronAPI = {
invoke: vi.fn(async (channel: string) =>
channel === "get-remote-sync-config"
? { serverUrl: "https://termix.example.test" }
: null,
),
} as unknown as NonNullable<typeof window.electronAPI>;
// The server keys statuses by sync id; its own ids mean nothing here.
remoteCoreApiMock.get.mockResolvedValue({
data: { 2: { status: "reachable" } },
data: { "sync-2": { status: "reachable" }, "sync-9": { status: "x" } },
});
});
describe("status check origin routing", () => {
it("only asks the embedded backend to poll local-origin hosts", async () => {
hostsAndStatuses([
{ id: 1, connectionOrigin: "local" },
{ id: 2, connectionOrigin: "remote" },
{ id: 1, connectionOrigin: "local", syncId: "sync-1" },
{ id: 2, connectionOrigin: "remote", syncId: "sync-2" },
]);
resolveConnectionOriginMock.mockImplementation(async (host) =>
host.connectionOrigin === "local" ? "local" : "remote",
@@ -73,7 +74,7 @@ describe("status check origin routing", () => {
__silentRetry: true,
});
expect(remoteCoreApiMock.get).toHaveBeenCalledWith(
"/host/status",
"/sync/v2/host-status",
expect.objectContaining({ __silentRetry: true }),
);
});
@@ -1,66 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
const isElectronMock = vi.hoisted(() => vi.fn(() => true));
vi.mock("@/lib/electron", () => ({ isElectron: isElectronMock }));
vi.mock("@/lib/base-path", () => ({ getBasePath: () => "" }));
vi.mock("@/shell/TabContext", () => ({ clearTermixSessionStorage: vi.fn() }));
vi.mock("sonner", () => ({ toast: { error: vi.fn(), success: vi.fn() } }));
const { getRemoteSyncUserInfo } = await import("../../main-axios");
const invoke = vi.fn();
beforeEach(() => {
isElectronMock.mockReturnValue(true);
invoke.mockReset();
(window as unknown as { electronAPI?: unknown }).electronAPI = { invoke };
});
afterEach(() => {
delete (window as unknown as { electronAPI?: unknown }).electronAPI;
});
describe("getRemoteSyncUserInfo", () => {
it("returns the identity reported by the main process", async () => {
const identity = {
userId: "u-1",
username: "alice",
is_admin: true,
is_oidc: false,
totp_enabled: false,
roles: [{ roleId: 1, roleDisplayName: "Admin" }],
};
invoke.mockResolvedValueOnce(identity);
await expect(getRemoteSyncUserInfo()).resolves.toEqual(identity);
expect(invoke).toHaveBeenCalledWith("get-remote-sync-user-info");
});
it("returns null in the browser without calling the bridge", async () => {
isElectronMock.mockReturnValue(false);
await expect(getRemoteSyncUserInfo()).resolves.toBeNull();
expect(invoke).not.toHaveBeenCalled();
});
it("returns null when no remote sync identity is available", async () => {
// The main process answers null when the server is unconfigured, the JWT is
// missing, or it has expired.
invoke.mockResolvedValueOnce(null);
await expect(getRemoteSyncUserInfo()).resolves.toBeNull();
});
it("returns null instead of propagating a bridge failure", async () => {
invoke.mockRejectedValueOnce(new Error("IPC channel closed"));
await expect(getRemoteSyncUserInfo()).resolves.toBeNull();
});
it("returns null when the preload bridge is absent", async () => {
delete (window as unknown as { electronAPI?: unknown }).electronAPI;
await expect(getRemoteSyncUserInfo()).resolves.toBeNull();
});
});
@@ -1,132 +0,0 @@
import Module from "node:module";
import { createRequire } from "node:module";
import os from "node:os";
import { beforeEach, describe, expect, it } from "vitest";
// remote-sync.cjs is CommonJS and pulls in `electron` at load time, which does
// not exist outside the Electron runtime. vi.mock cannot reach a require() made
// through createRequire, so the stub is injected into the CJS loader itself.
const electronStub = {
app: { getPath: () => os.tmpdir() },
safeStorage: { isEncryptionAvailable: () => false },
ipcMain: { handle: () => {} },
};
const loader = Module as unknown as {
_load: (request: string, parent: unknown, isMain: boolean) => unknown;
};
const originalLoad = loader._load;
loader._load = (request, parent, isMain) =>
request === "electron" ? electronStub : originalLoad(request, parent, isMain);
const require = createRequire(import.meta.url);
type Engine = {
stop: () => void;
pullSide: (
baseUrl: string,
token: string,
entityType: string,
since: string | null,
) => Promise<unknown[]>;
pullTombstones: (
baseUrl: string,
token: string,
entityType: string,
since: string | null,
) => Promise<unknown[]>;
pushRow: (...args: unknown[]) => Promise<void>;
pushTombstone: (
baseUrl: string,
token: string,
entityType: string,
syncId: string,
) => Promise<void>;
syncEntity: (args: {
entityType: string;
remoteBaseUrl: string;
remoteJwt: string;
since: string | null;
}) => Promise<{ syncedAt: string }>;
};
const REMOTE = "https://server.example";
const CURSOR = "2026-07-29T10:24:44.501Z";
describe("remote sync tombstone application", () => {
let engine: Engine;
let pushed: Array<{ baseUrl: string; syncId: string }>;
let fullPulls: string[];
beforeEach(() => {
const { initRemoteSync } = require("../../../../electron/remote-sync.cjs");
engine = initRemoteSync(() => null) as Engine;
engine.stop();
pushed = [];
fullPulls = [];
engine.pushRow = async () => {};
engine.pushTombstone = async (baseUrl, _token, _entityType, syncId) => {
pushed.push({ baseUrl, syncId });
};
});
/**
* The row deleted on the remote is untouched locally, so it is absent from
* the incremental window on both sides -- the shape every ordinary deletion
* takes once the two sides have converged.
*/
function stubSides(localHolds: string[], remoteTombstoneIds: string[]) {
engine.pullSide = async (baseUrl, _token, _entityType, since) => {
if (since) return [];
fullPulls.push(baseUrl);
return baseUrl === REMOTE
? []
: localHolds.map((syncId) => ({ syncId, updatedAt: CURSOR }));
};
engine.pullTombstones = async (baseUrl) =>
baseUrl === REMOTE
? remoteTombstoneIds.map((syncId) => ({ syncId }))
: [];
}
it("applies a remote deletion to a local row that is outside the incremental window", async () => {
stubSides(["cred-1"], ["cred-1"]);
await engine.syncEntity({
entityType: "sshCredentials",
remoteBaseUrl: REMOTE,
remoteJwt: "remote-jwt",
since: CURSOR,
});
expect(pushed).toEqual([
{ baseUrl: "http://127.0.0.1:30001", syncId: "cred-1" },
]);
});
it("does not re-push a deletion once the row is gone, so tombstones cannot ping-pong", async () => {
stubSides([], ["cred-1"]);
await engine.syncEntity({
entityType: "sshCredentials",
remoteBaseUrl: REMOTE,
remoteJwt: "remote-jwt",
since: CURSOR,
});
expect(pushed).toEqual([]);
});
it("does not pay for a presence check when nothing was deleted", async () => {
stubSides(["cred-1"], []);
await engine.syncEntity({
entityType: "sshCredentials",
remoteBaseUrl: REMOTE,
remoteJwt: "remote-jwt",
since: CURSOR,
});
expect(pushed).toEqual([]);
expect(fullPulls).toEqual([]);
});
});
-14
View File
@@ -1,14 +0,0 @@
import { createRequire } from "node:module";
import { describe, expect, it } from "vitest";
const require = createRequire(import.meta.url);
const { SYNCED_ENTITY_TYPES } =
require("../../../../electron/remote-sync-entities.cjs") as {
SYNCED_ENTITY_TYPES: readonly string[];
};
describe("desktop remote sync entities", () => {
it("retains user preferences in the current sync protocol", () => {
expect(SYNCED_ENTITY_TYPES).toContain("userPreferences");
});
});
+7 -9
View File
@@ -1,4 +1,7 @@
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
const getLinkedSession = vi.hoisted(() => vi.fn(async () => null));
vi.mock("@/lib/linked-server", () => ({ getLinkedSession }));
import {
buildOriginWsUrl,
resolveConnectionOrigin,
@@ -166,15 +169,10 @@ describe("buildOriginWsUrl", () => {
});
it("does not duplicate the Guacamole token on remote connections", async () => {
win.electronAPI = {
invoke: async (channel: string) => {
if (channel === "get-remote-sync-config") {
return { serverUrl: "https://termix.example" };
}
if (channel === "get-remote-sync-jwt") return "remote-jwt";
return null;
},
};
getLinkedSession.mockResolvedValueOnce({
serverUrl: "https://termix.example",
token: "remote-jwt",
} as never);
const target = await buildOriginWsUrl({
origin: "remote",
+52
View File
@@ -0,0 +1,52 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const isElectron = vi.hoisted(() => vi.fn(() => true));
const get = vi.hoisted(() => vi.fn());
vi.mock("@/lib/electron", () => ({ isElectron }));
vi.mock("@/main-axios", () => ({ authApi: { get } }));
const { getLinkedSession, notifySyncChanged } =
await import("@/lib/linked-server");
beforeEach(() => {
notifySyncChanged();
get.mockReset();
isElectron.mockReturnValue(true);
});
describe("linked session", () => {
it("reads the session from the embedded backend and caches it", async () => {
get.mockResolvedValue({
data: { serverUrl: "https://termix.example/", token: "t" },
});
await expect(getLinkedSession()).resolves.toEqual({
serverUrl: "https://termix.example",
token: "t",
});
await getLinkedSession();
expect(get).toHaveBeenCalledTimes(1);
expect(get).toHaveBeenCalledWith("/sync/link/session");
});
it("reads it again after a sync change", async () => {
get.mockResolvedValue({ data: { serverUrl: null, token: null } });
await expect(getLinkedSession()).resolves.toBeNull();
notifySyncChanged();
get.mockResolvedValue({ data: { serverUrl: "https://x", token: "t" } });
await expect(getLinkedSession()).resolves.toEqual({
serverUrl: "https://x",
token: "t",
});
});
it("is null on the web and when the backend cannot answer", async () => {
isElectron.mockReturnValue(false);
await expect(getLinkedSession()).resolves.toBeNull();
expect(get).not.toHaveBeenCalled();
isElectron.mockReturnValue(true);
get.mockRejectedValue(new Error("down"));
await expect(getLinkedSession()).resolves.toBeNull();
});
});
+20
View File
@@ -0,0 +1,20 @@
import { afterEach, describe, expect, it } from "vitest";
import { rem, remScale } from "@/lib/rem";
afterEach(() => {
document.documentElement.style.fontSize = "";
});
describe("rem", () => {
it("states a Normal-size pixel length in rem", () => {
expect(rem(14)).toBe("1rem");
expect(rem(160)).toBe(`${160 / 14}rem`);
});
it("reports how much the interface size scales a pixel", () => {
document.documentElement.style.fontSize = "14px";
expect(remScale()).toBe(1);
document.documentElement.style.fontSize = "17.5px";
expect(remScale()).toBe(1.25);
});
});
+24 -83
View File
@@ -1,112 +1,53 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { SSHHost } from "@/types/index";
const isElectron = vi.hoisted(() => vi.fn());
const getLinkedSession = vi.hoisted(() => vi.fn());
const remoteApi = vi.hoisted(() => ({ get: vi.fn() }));
vi.mock("@/lib/electron", () => ({ isElectron }));
vi.mock("@/lib/linked-server", () => ({ getLinkedSession }));
vi.mock("@/main-axios", () => ({ getRemoteCoreApi: () => remoteApi }));
import {
getConnectedRemoteApi,
hydrateLocalSharedHostAuth,
markRemoteSharedHosts,
resolveRemoteHostId,
} from "@/lib/remote-server-api";
describe("remote server API", () => {
const invoke = vi.fn();
const LINKED = { serverUrl: "https://termix.example", token: "t" };
describe("remote server API", () => {
beforeEach(() => {
isElectron.mockReset();
getLinkedSession.mockReset();
remoteApi.get.mockReset();
invoke.mockReset();
Object.defineProperty(window, "electronAPI", {
configurable: true,
value: { invoke },
});
});
it("is available only for a configured Electron sync server", async () => {
isElectron.mockReturnValue(false);
it("is available only while this desktop is linked", async () => {
getLinkedSession.mockResolvedValueOnce(null);
await expect(getConnectedRemoteApi()).resolves.toBeNull();
isElectron.mockReturnValue(true);
invoke.mockResolvedValueOnce({ serverUrl: "https://termix.example" });
getLinkedSession.mockResolvedValueOnce(LINKED);
await expect(getConnectedRemoteApi()).resolves.toBe(remoteApi);
});
it("maps a local host to the remote numeric id by syncId", async () => {
isElectron.mockReturnValue(true);
invoke.mockResolvedValue({ serverUrl: "https://termix.example" });
remoteApi.get.mockResolvedValue({
data: { rows: [{ id: 41, syncId: "host-a" }] },
});
it("asks the linked server for its id of a host by sync id", async () => {
getLinkedSession.mockResolvedValue(LINKED);
remoteApi.get.mockResolvedValueOnce({ data: { id: 41, name: "web" } });
await expect(resolveRemoteHostId("host-a")).resolves.toBe(41);
await expect(resolveRemoteHostId("host a")).resolves.toBe(41);
expect(remoteApi.get).toHaveBeenCalledWith("/sync/v2/hosts/host%20a");
});
it("answers null for an unknown host, no sync id, or no link", async () => {
getLinkedSession.mockResolvedValue(LINKED);
remoteApi.get.mockRejectedValueOnce(new Error("404"));
await expect(resolveRemoteHostId("missing")).resolves.toBeNull();
expect(remoteApi.get).toHaveBeenCalledWith("/sync/hosts");
await expect(resolveRemoteHostId(null)).resolves.toBeNull();
getLinkedSession.mockResolvedValue(null);
await expect(resolveRemoteHostId("host-a")).resolves.toBeNull();
});
it("keeps only shared remote hosts and gives them collision-free ids", () => {
const rows = markRemoteSharedHosts([
{ id: 4, isShared: false },
{
id: 9,
isShared: true,
syncId: "shared-host",
connectionOrigin: "local",
},
] as SSHHost[]);
expect(rows).toEqual([
expect.objectContaining({
id: -9,
syncId: "shared-host",
connectionOrigin: "local",
}),
]);
});
it("hydrates a remote shared host for a local connection without persisting remote ids", async () => {
isElectron.mockReturnValue(true);
invoke.mockResolvedValue({ serverUrl: "https://termix.example" });
remoteApi.get.mockResolvedValue({
data: {
username: "recipient",
authType: "key",
key: "PRIVATE KEY",
keyPassword: "passphrase",
keyType: "ed25519",
},
});
const result = await hydrateLocalSharedHostAuth({
id: -9,
isShared: true,
syncId: "shared-host",
credentialId: 77,
username: "owner",
authType: "key",
});
expect(remoteApi.get).toHaveBeenCalledWith(
"/host/db/host/9/local-connection-auth",
);
expect(result).toEqual(
expect.objectContaining({
id: -9,
syncId: null,
credentialId: undefined,
username: "recipient",
key: "PRIVATE KEY",
keyPassword: "passphrase",
}),
);
});
it("leaves local and owned hosts untouched", async () => {
const host = { id: 9, isShared: false, username: "root" };
it("leaves hosts as they are, since shared copies carry their own auth", async () => {
const host = { id: 9, isShared: true, username: "root" };
await expect(hydrateLocalSharedHostAuth(host)).resolves.toBe(host);
expect(remoteApi.get).not.toHaveBeenCalled();
});
@@ -1,41 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const isElectron = vi.hoisted(() => vi.fn());
vi.mock("@/lib/electron", () => ({ isElectron }));
import { requestRemoteSync } from "@/lib/remote-sync-trigger";
describe("requestRemoteSync", () => {
const invoke = vi.fn();
beforeEach(() => {
isElectron.mockReset();
invoke.mockReset();
Object.defineProperty(window, "electronAPI", {
configurable: true,
value: { invoke },
});
});
it("starts a sync when the desktop is connected to a server", async () => {
isElectron.mockReturnValue(true);
invoke.mockResolvedValueOnce({ serverUrl: "https://termix.example" });
await requestRemoteSync();
expect(invoke).toHaveBeenNthCalledWith(1, "get-remote-sync-config");
expect(invoke).toHaveBeenNthCalledWith(2, "remote-sync-now");
});
it("does nothing outside Electron or without a configured server", async () => {
isElectron.mockReturnValue(false);
await requestRemoteSync();
expect(invoke).not.toHaveBeenCalled();
isElectron.mockReturnValue(true);
invoke.mockResolvedValueOnce(null);
await requestRemoteSync();
expect(invoke).toHaveBeenCalledOnce();
});
});
+55
View File
@@ -0,0 +1,55 @@
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();
});
});
+34
View File
@@ -0,0 +1,34 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { applyFontSize } from "@/lib/theme";
type ElectronWindow = { electronAPI?: unknown };
afterEach(() => {
delete (window as unknown as ElectronWindow).electronAPI;
document.documentElement.className = "";
localStorage.clear();
});
describe("interface size", () => {
it("sets the root size class and remembers it", () => {
applyFontSize("lg");
expect(document.documentElement.classList.contains("fs-lg")).toBe(true);
applyFontSize("sm");
expect(document.documentElement.className).toBe("fs-sm");
expect(localStorage.getItem("termix-font-size")).toBe("sm");
});
it("takes back the window zoom an earlier desktop build set", () => {
const setZoomFactor = vi.fn();
(window as unknown as ElectronWindow).electronAPI = { setZoomFactor };
applyFontSize("xl");
expect(setZoomFactor).toHaveBeenCalledWith(1);
expect(document.documentElement.classList.contains("fs-xl")).toBe(true);
});
it("falls back to Normal for an unknown size", () => {
applyFontSize("huge" as never);
expect(document.documentElement.classList.contains("fs-md")).toBe(true);
expect(localStorage.getItem("termix-font-size")).toBe("md");
});
});
@@ -1,8 +1,11 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { ONBOARDING_STEPS, relevantSteps } from "@/onboarding/onboarding-steps";
import { UI_AREA_KEYS, PRESETS } from "@/types/ui-preferences";
import en from "@/locales/en.json";
const isElectron = vi.hoisted(() => vi.fn(() => false));
vi.mock("@/lib/electron", () => ({ isElectron }));
function lookup(key: string): unknown {
return key
.split(".")
@@ -34,6 +37,14 @@ describe("ONBOARDING_STEPS", () => {
expect(ids).not.toContain("first-host");
});
it("asks how the desktop app is used only on the desktop", () => {
isElectron.mockReturnValue(false);
expect(relevantSteps({}).map((s) => s.id)).not.toContain("desktop-sync");
isElectron.mockReturnValue(true);
expect(relevantSteps({}).map((s) => s.id)).toContain("desktop-sync");
isElectron.mockReturnValue(false);
});
it("ends on the done step", () => {
const ids = relevantSteps({}).map((s) => s.id);
expect(ids[ids.length - 1]).toBe("done");
@@ -1,87 +0,0 @@
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { toast, invoke } = vi.hoisted(() => ({
toast: { error: vi.fn(), success: vi.fn() },
invoke: vi.fn(),
}));
vi.mock("sonner", () => ({ toast }));
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
vi.mock("@/auth/ElectronLoginForm.tsx", () => ({
ElectronLoginForm: ({ serverUrl }: { serverUrl: string }) => (
<div data-testid="remote-login">{serverUrl}</div>
),
}));
import { RemoteSyncPanel } from "../../settings/RemoteSyncPanel";
const config = {
serverUrl: "http://192.168.3.175:6060",
connectedAt: "2026-08-27T00:00:00.000Z",
};
const status = {
connected: true,
syncing: false,
lastSyncedAt: null,
lastError: "Remote session expired",
needsReauth: true,
};
beforeEach(() => {
invoke.mockImplementation((channel: string) => {
if (channel === "get-remote-sync-config") return Promise.resolve(config);
if (channel === "get-remote-sync-status") return Promise.resolve(status);
if (channel === "get-desktop-settings") {
return Promise.resolve({ defaultConnectionOrigin: "local" });
}
if (channel === "remote-sync-now") return Promise.resolve(status);
return Promise.resolve(null);
});
Object.defineProperty(window, "electronAPI", {
configurable: true,
value: { invoke, onRemoteSyncStatusChanged: vi.fn() },
});
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe("RemoteSyncPanel", () => {
it("opens the configured server login when the banner requests reconnect", async () => {
const onHandled = vi.fn();
render(
<RemoteSyncPanel
reconnectRequested
onReconnectRequestHandled={onHandled}
/>,
);
expect((await screen.findByTestId("remote-login")).textContent).toBe(
config.serverUrl,
);
expect(onHandled).toHaveBeenCalledOnce();
});
it("shows the sync failure returned by the main process", async () => {
render(<RemoteSyncPanel />);
const syncButton = await screen.findByRole("button", {
name: /remoteSync.syncNowButton/,
});
fireEvent.click(syncButton);
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith("Remote session expired");
});
});
});
@@ -1,16 +0,0 @@
import { describe, expect, it } from "vitest";
import { shouldForceLocalPreferenceStorage } from "../../settings/remote-sync-state";
describe("remote sync preference storage state", () => {
it("does not overwrite cloud mode while desktop sync config is loading", () => {
expect(shouldForceLocalPreferenceStorage(true, null, "cloud")).toBe(false);
});
it("forces local mode only after desktop sync is confirmed unconfigured", () => {
expect(shouldForceLocalPreferenceStorage(true, false, "cloud")).toBe(true);
expect(shouldForceLocalPreferenceStorage(true, true, "cloud")).toBe(false);
expect(shouldForceLocalPreferenceStorage(false, false, "cloud")).toBe(
false,
);
});
});
@@ -0,0 +1,127 @@
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { SyncStatus } from "@/api/sync-api";
const status = vi.hoisted(() => ({ value: null as SyncStatus | null }));
const api = vi.hoisted(() => ({
getSyncConflicts: vi.fn(async () => []),
getSyncErrors: vi.fn(async () => []),
updateSyncSettings: vi.fn(),
syncNow: vi.fn(),
unlinkServer: vi.fn(),
retrySyncErrors: vi.fn(),
settleSyncConflict: vi.fn(),
probeServer: vi.fn(),
getLinkPreview: vi.fn(),
completeLink: vi.fn(),
reloginLink: vi.fn(),
}));
vi.mock("@/hooks/use-sync-status", () => ({
useSyncStatus: () => status.value,
}));
vi.mock("@/lib/sync-status", () => ({
setSyncStatus: vi.fn(),
refreshSyncStatus: vi.fn(),
}));
vi.mock("@/lib/linked-server", () => ({ notifySyncChanged: vi.fn() }));
vi.mock("@/lib/hosts-request-cache", () => ({
invalidateServerStatusCache: vi.fn(),
}));
vi.mock("@/api/sync-api", () => api);
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 { SyncPanel } = await import("@/settings/sync/SyncPanel");
beforeEach(() => {
(window as unknown as { electronAPI: unknown }).electronAPI = {
invoke: vi.fn(async () => null),
};
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
const LINKED: SyncStatus = {
linked: true,
serverUrl: "https://termix.example",
serverName: "Home",
account: { username: "luke" },
status: "idle",
lastSyncAt: new Date().toISOString(),
pending: 0,
conflicts: 0,
errors: 0,
entities: [
{
type: "hosts",
owner: "core",
ownerName: null,
readOnly: false,
onServer: true,
onDevice: true,
enabled: true,
},
{
type: "snippets",
owner: "snippets",
ownerName: "Snippets",
readOnly: false,
onServer: true,
onDevice: true,
enabled: false,
},
],
};
describe("SyncPanel", () => {
it("explains this-device-only use and offers to link", () => {
status.value = { linked: false, entities: [] };
render(<SyncPanel />);
expect(screen.getByText("sync.unlinkedTitle")).toBeTruthy();
fireEvent.click(screen.getByText("sync.linkButton"));
expect(screen.getByText("sync.wizard.title")).toBeTruthy();
});
it("shows the account, server and what syncs, grouped by feature", () => {
status.value = LINKED;
render(<SyncPanel />);
expect(screen.getByText("Home")).toBeTruthy();
expect(screen.getByText("luke")).toBeTruthy();
expect(screen.getByText("sync.coreGroup")).toBeTruthy();
expect(screen.getByText("Snippets")).toBeTruthy();
});
it("switches a kind of data off, keeping the others as they are", async () => {
status.value = LINKED;
api.updateSyncSettings.mockResolvedValue(LINKED);
render(<SyncPanel />);
const switches = screen.getAllByRole("switch");
fireEvent.click(switches[0]);
await waitFor(() =>
expect(api.updateSyncSettings).toHaveBeenCalledWith({
disabledTypes: expect.arrayContaining(["snippets", "hosts"]),
}),
);
});
it("asks to sign in again when the server ended the session", () => {
status.value = { ...LINKED, status: "signed_out" };
render(<SyncPanel />);
expect(screen.getByText("sync.state.signedOut")).toBeTruthy();
expect(screen.getByText("sync.signInAgain")).toBeTruthy();
expect(screen.queryByText("sync.syncNow")).toBeNull();
});
});
Loaded 100 of 242 files, more files were not shown because too many files have changed in this diff. Show more