* feat: AI translations at release, 7 new languages and regional fallback

* fix: proper plural forms for count strings and full translations

* fix: plural forms for selected, hidden and active counts

* fix: synced plugin updates failing on desktop and server-only plugins shown as stopped

* fix: restore rounded corners on app icons and favicon

* feat: desktop app auto updates with automatic, ask first or never modes

* feat: community and custom plugin registries

* feat: plugin guide for AI tools in the SDK with a lint check

* fix: keep the terminal WebSocket open past nginx's 60s default (#1588)

* chore: update changelog

* feat: show plugin download counts only and stop CI builds inflating them

* feat: hide installed plugins in the browse tab by default

* chore: pin bundled plugins to latest releases

* chore: update translations

* fix: focusing a saved password field and saving no longer erases it

* fix: tell desktop users when their 2.8 data needs 2.9 first

* fix: point windows update info at the x64 installer

* fix: keep desktop client tunnel sockets alive behind proxies

* chore: pin bundled plugins to latest releases

* fix: cover client tunnel keepalive in the c2s runtime tests

* fix: typing after opening a host no longer goes to the host search

* fix: accept tab and = separators in SSH config import (#1598)

* chore: update changelog

* chore: drop plugin versions from core changelog

* chore: update translations

* chore: lint, format, and bump version to 26.10.1

---------

Co-authored-by: Darnel Hunter <68697450+innotelinc@users.noreply.github.com>
Co-authored-by: Iain <Iain1970@protonmail.com>
This commit is contained in:
Luke Gustafson
2026-10-12 11:35:48 -05:00
committed by GitHub
co-authored by Darnel Hunter Iain
parent 6873bc92bb
commit 62ac931468
174 changed files with 94334 additions and 39995 deletions
+30
View File
@@ -3,9 +3,11 @@ import { describe, expect, it, beforeEach } from "vitest";
import {
changeAppLanguage,
consumeLoginLanguage,
LOCALE_FILES,
normalizeLanguageCode,
rememberLoginLanguage,
} from "../../i18n/i18n";
import { LANGUAGES, TRANSLATED_LANGUAGES } from "../../i18n/languages";
describe("i18n language handling", () => {
beforeEach(() => {
@@ -20,6 +22,34 @@ describe("i18n language handling", () => {
expect(normalizeLanguageCode("unknown")).toBe("en");
});
it("maps regional variants to the closest shipped language", () => {
expect(normalizeLanguageCode("es-MX")).toBe("es-ES");
expect(normalizeLanguageCode("es")).toBe("es-ES");
expect(normalizeLanguageCode("sv")).toBe("sv-SE");
expect(normalizeLanguageCode("pt")).toBe("pt-BR");
expect(normalizeLanguageCode("pt-AO")).toBe("pt-BR");
expect(normalizeLanguageCode("zh")).toBe("zh-CN");
expect(normalizeLanguageCode("zh-HK")).toBe("zh-TW");
expect(normalizeLanguageCode("zh-Hant-TW")).toBe("zh-TW");
expect(normalizeLanguageCode("nb-NO")).toBe("no");
expect(normalizeLanguageCode("de-AT")).toBe("de");
expect(normalizeLanguageCode("sk-SK")).toBe("sk");
});
it("ships a translated file for every listed language", () => {
const files = new Set(
Object.keys(import.meta.glob("../../locales/translated/*.json")).map(
(path) => path.split("/").pop()?.replace(".json", ""),
),
);
for (const language of TRANSLATED_LANGUAGES) {
expect(LOCALE_FILES[language.code]).toBe(language.file);
expect(files.has(language.file), language.file).toBe(true);
}
expect(LANGUAGES[0]).toEqual({ code: "en", label: "English" });
expect(LANGUAGES).toHaveLength(TRANSLATED_LANGUAGES.length + 1);
});
it("stores the normalized language after a successful switch", async () => {
await expect(changeAppLanguage("zh_CN")).resolves.toBe("zh-CN");
expect(localStorage.getItem("i18nextLng")).toBe("zh-CN");
+48
View File
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { updateToastFor, type DesktopUpdateState } from "@/lib/desktop-updates";
function state(patch: Partial<DesktopUpdateState>): DesktopUpdateState {
return {
kind: "auto",
mode: "prompt",
currentVersion: "26.10.1",
status: "available",
version: "26.10.2",
releaseUrl: null,
progress: null,
error: null,
...patch,
};
}
describe("updateToastFor", () => {
it("asks before downloading in ask first mode", () => {
expect(updateToastFor(state({}))).toBe("prompt");
});
it("stays quiet while automatic mode downloads", () => {
expect(updateToastFor(state({ mode: "automatic" }))).toBeNull();
expect(
updateToastFor(state({ mode: "automatic", status: "downloading" })),
).toBeNull();
});
it("offers a restart once downloaded", () => {
expect(updateToastFor(state({ status: "downloaded" }))).toBe("ready");
expect(
updateToastFor(state({ mode: "automatic", status: "downloaded" })),
).toBe("ready");
});
it("links the release for installs that can not update", () => {
expect(updateToastFor(state({ kind: "manual" }))).toBe("manual");
});
it("never toasts when updates are off", () => {
expect(updateToastFor(state({ mode: "never" }))).toBeNull();
expect(
updateToastFor(state({ mode: "never", status: "downloaded" })),
).toBeNull();
expect(updateToastFor(null)).toBeNull();
});
});
@@ -38,6 +38,20 @@ describe("getEmbeddedServerFailure", () => {
});
});
it("surfaces a database too old to open", async () => {
setElectronAPI({
isElectron: true,
getEmbeddedServerStatus: vi.fn().mockResolvedValue({
running: false,
failure: { reason: "old-database", port: null },
}),
});
await expect(getEmbeddedServerFailure()).resolves.toEqual({
reason: "old-database",
port: null,
});
});
it("surfaces a generic crash", async () => {
setElectronAPI({
isElectron: true,
@@ -93,6 +93,22 @@ describe("CORE_STEPS", () => {
]);
});
it("asks how the desktop app updates when it has an updater", () => {
const win = window as unknown as { electronAPI?: unknown };
win.electronAPI = { isElectron: true, updates: {} };
try {
expect(ids(ctx({ isDesktop: true }))).toEqual([
"desktop-sync",
"desktop-updates",
"preset",
"appearance",
]);
expect(ids(ctx())).not.toContain("desktop-updates");
} finally {
delete win.electronAPI;
}
});
it("offers account security only when there is something to set up", () => {
expect(ids(ctx({ canEnrollSecondFactor: true }))).toEqual([
"preset",
@@ -28,8 +28,7 @@ const [plugin] = mergePlugins(
pinnedVersion: null,
autoUpdate: false,
bundled: false,
installCount: null,
installCountSource: null,
downloads: null,
},
],
);
+19 -4
View File
@@ -22,8 +22,7 @@ const plugin = {
capabilities: [],
contributes: {},
versions: [],
installCount: null,
installCountSource: null,
downloads: null,
};
vi.mock("@/plugins/use-plugins-manager", () => ({
@@ -40,6 +39,7 @@ vi.mock("@/plugins/use-plugins-manager", () => ({
confirmConsent: vi.fn(),
developerMode: false,
signedOnly: false,
registries: [],
}),
}));
@@ -60,17 +60,32 @@ beforeEach(() => localStorage.clear());
afterEach(cleanup);
describe("PluginsTab", () => {
it("tells people community plugins are coming and links the guide", async () => {
it("points to the Registries tab and links the submit guide", async () => {
await renderTab();
fireEvent.click(screen.getByText(en.plugins.manager.tabs.browse));
expect(
screen.getByText(en.plugins.manager.communitySoon, { exact: false }),
screen.getByText(en.plugins.manager.registries.browseHint, {
exact: false,
}),
).toBeTruthy();
const link = screen.getByText(en.plugins.manager.communitySubmit);
expect(link.getAttribute("href")).toBe(docsUrl("communityRegistry"));
});
it("hides installed plugins in Browse until shown", async () => {
await renderTab();
fireEvent.click(screen.getByText(en.plugins.manager.tabs.browse));
expect(screen.queryByText("Demo Plugin")).toBeNull();
expect(screen.getByText(en.plugins.manager.allInstalled)).toBeTruthy();
fireEvent.click(screen.getByRole("switch"));
expect(screen.getByText("Demo Plugin")).toBeTruthy();
expect(localStorage.getItem("pluginsTab.browseInstalled")).toBe("show");
});
it("switches to a table and remembers it", async () => {
await renderTab();
expect(screen.queryByRole("table")).toBeNull();
@@ -0,0 +1,135 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import i18next from "i18next";
import { I18nextProvider, initReactI18next } from "react-i18next";
import en from "@/locales/en.json";
import { RegistriesSection } from "@/plugins/RegistriesSection";
import type { PluginsManager } from "@/plugins/use-plugins-manager";
const api = vi.hoisted(() => ({
getRegistries: vi.fn(),
previewRegistry: vi.fn(),
addRegistry: vi.fn(),
setRegistryEnabled: vi.fn(),
removeRegistry: vi.fn(),
}));
vi.mock("@/api/plugins-api", () => api);
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
const manager = {
registries: [],
managedByServer: false,
refresh: vi.fn(async () => {}),
} as unknown as PluginsManager;
function state(communityEnabled = false) {
return {
managedByServer: false,
official: { id: "official", url: "https://example.com/official.json" },
community: {
id: "community",
url: "https://example.com/community.json",
enabled: communityEnabled,
},
custom: [],
};
}
async function renderSection() {
const i18n = i18next.createInstance();
await i18n.use(initReactI18next).init({
lng: "en",
resources: { en: { translation: en } },
});
return render(
<I18nextProvider i18n={i18n}>
<RegistriesSection manager={manager} />
</I18nextProvider>,
);
}
beforeEach(() => {
for (const fn of Object.values(api)) fn.mockReset();
api.getRegistries.mockResolvedValue(state());
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe("RegistriesSection", () => {
it("asks before turning community plugins on", async () => {
const confirm = vi.spyOn(window, "confirm").mockReturnValue(false);
await renderSection();
const toggle = await screen.findByRole("switch");
fireEvent.click(toggle);
await waitFor(() => expect(confirm).toHaveBeenCalled());
expect(confirm.mock.calls[0][0]).toContain(
en.plugins.manager.registries.communityConfirmBody,
);
expect(api.setRegistryEnabled).not.toHaveBeenCalled();
confirm.mockReturnValue(true);
api.getRegistries.mockResolvedValue(state(true));
fireEvent.click(toggle);
await waitFor(() =>
expect(api.setRegistryEnabled).toHaveBeenCalledWith("community", true),
);
});
it("does not ask when turning community plugins off", async () => {
const confirm = vi.spyOn(window, "confirm");
api.getRegistries.mockResolvedValue(state(true));
await renderSection();
fireEvent.click(await screen.findByRole("switch"));
await waitFor(() =>
expect(api.setRegistryEnabled).toHaveBeenCalledWith("community", false),
);
expect(confirm).not.toHaveBeenCalled();
});
it("checks a registry before adding it with the key it showed", async () => {
api.previewRegistry.mockResolvedValue({
id: "acme",
name: "Acme Plugins",
url: "https://acme.example/index.json",
keyId: "abcd1234abcd1234",
plugins: 3,
});
api.addRegistry.mockResolvedValue({
id: "acme",
name: "Acme Plugins",
url: "https://acme.example/index.json",
keyId: "abcd1234abcd1234",
enabled: true,
addedAt: "",
});
await renderSection();
fireEvent.click(await screen.findByText(en.plugins.manager.registries.add));
fireEvent.change(
screen.getByLabelText(en.plugins.manager.registries.urlLabel),
{ target: { value: "https://acme.example/index.json" } },
);
fireEvent.click(screen.getByText(en.plugins.manager.registries.check));
expect(await screen.findByText("Acme Plugins")).toBeTruthy();
expect(screen.getByText("Key abcd1234abcd1234")).toBeTruthy();
expect(api.addRegistry).not.toHaveBeenCalled();
fireEvent.click(screen.getByText(en.plugins.manager.registries.addConfirm));
await waitFor(() =>
expect(api.addRegistry).toHaveBeenCalledWith(
"https://acme.example/index.json",
"abcd1234abcd1234",
),
);
});
});
+15 -5
View File
@@ -61,8 +61,7 @@ function entry(
pinnedVersion: null,
autoUpdate: false,
bundled: true,
installCount: null,
installCountSource: null,
downloads: null,
...overrides,
};
}
@@ -125,6 +124,14 @@ describe("mergePlugins", () => {
);
});
it("offers no update for a plugin a linked desktop leaves to its server", () => {
const [row] = mergePlugins(
[summary({ enabled: false, state: "disabled", runsOnServer: true })],
[entry()],
);
expect(row).toMatchObject({ status: "server", updateAvailable: false });
});
it("keeps installed plugins when the registry is unreachable", () => {
const rows = mergePlugins([summary()], null);
expect(rows).toHaveLength(1);
@@ -140,6 +147,9 @@ describe("pluginStatus", () => {
"disabled",
);
expect(pluginStatus(summary())).toBe("running");
expect(pluginStatus(summary({ state: "failed", runsOnServer: true }))).toBe(
"server",
);
});
});
@@ -224,9 +234,9 @@ describe("popularity", () => {
const rows = mergePlugins(
[],
[
entry({ id: "a", name: "A", installed: false, installCount: 5 }),
entry({ id: "b", name: "B", installed: false, installCount: null }),
entry({ id: "c", name: "C", installed: false, installCount: 90 }),
entry({ id: "a", name: "A", installed: false, downloads: 5 }),
entry({ id: "b", name: "B", installed: false, downloads: null }),
entry({ id: "c", name: "C", installed: false, downloads: 90 }),
],
);
expect(sortPlugins(rows, "popular").map((r) => r.id)).toEqual([
@@ -0,0 +1,99 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
act,
cleanup,
fireEvent,
render,
screen,
} from "@testing-library/react";
import { DesktopUpdatesCard } from "@/settings/DesktopUpdatesCard";
import type { DesktopUpdateState } from "@/lib/desktop-updates";
type TestWindow = { electronAPI?: unknown };
function state(patch: Partial<DesktopUpdateState> = {}): DesktopUpdateState {
return {
kind: "auto",
mode: "prompt",
currentVersion: "26.10.1",
status: "idle",
version: null,
releaseUrl: null,
progress: null,
error: null,
...patch,
};
}
function bridge(initial: DesktopUpdateState) {
return {
getState: vi.fn(async () => initial),
check: vi.fn(async () => initial),
download: vi.fn(async () => initial),
install: vi.fn(async () => true),
setMode: vi.fn(async () => initial),
onState: vi.fn(() => () => {}),
};
}
async function renderWith(initial: DesktopUpdateState) {
const api = bridge(initial);
(window as TestWindow).electronAPI = { isElectron: true, updates: api };
await act(async () => {
render(<DesktopUpdatesCard />);
});
return api;
}
afterEach(() => {
cleanup();
delete (window as TestWindow).electronAPI;
});
describe("DesktopUpdatesCard", () => {
it("renders nothing in the browser", () => {
const { container } = render(<DesktopUpdatesCard />);
expect(container.innerHTML).toBe("");
});
it("checks on demand", async () => {
const api = await renderWith(state());
fireEvent.click(screen.getByText("desktopUpdates.checkNow"));
expect(api.check).toHaveBeenCalled();
});
it("offers the download in ask first mode", async () => {
const api = await renderWith(
state({ status: "available", version: "26.10.2" }),
);
fireEvent.click(screen.getByText("desktopUpdates.download"));
expect(api.download).toHaveBeenCalled();
});
it("restarts into a downloaded update", async () => {
const api = await renderWith(
state({ status: "downloaded", version: "26.10.2" }),
);
fireEvent.click(screen.getByText("desktopUpdates.restart"));
expect(api.install).toHaveBeenCalled();
});
it("links the release for installs that can not update", async () => {
await renderWith(
state({
kind: "manual",
status: "available",
version: "26.10.2",
releaseUrl: "https://example.com/r",
}),
);
expect(
screen.getByText("desktopUpdates.download").closest("a"),
).toHaveProperty("href", "https://example.com/r");
});
it("hides on dev builds", async () => {
await renderWith(state({ kind: "disabled" }));
expect(screen.queryByText("desktopUpdates.title")).toBeNull();
});
});
@@ -33,6 +33,16 @@ describe("settings pages", () => {
expect(admin).toContain("security");
});
it("shows Updates to everyone on desktop but only admins in the browser", () => {
const ids = (isAdmin: boolean, isElectron: boolean) =>
visibleSettingsPages(CORE_SETTINGS_PAGES, { isAdmin, isElectron }).map(
(page) => page.id,
);
expect(ids(false, true)).toContain("updates");
expect(ids(false, false)).not.toContain("updates");
expect(ids(true, false)).toContain("updates");
});
it("finds a page by a label shown on it, not only its name", () => {
const hits = searchSettingsPages(CORE_SETTINGS_PAGES, "theme", translate);
expect(hits.some((hit) => hit.page.id === "appearance")).toBe(true);
@@ -23,7 +23,9 @@ vi.mock("@/sidebar/tree/FolderItem/FolderItem", () => ({
folderHostCount: () => 1,
}));
vi.mock("@/sidebar/tree/HostItem/HostItem", () => ({
HostItem: () => <div>Host</div>,
HostItem: ({ onOpenTab }: { onOpenTab: (type: string) => void }) => (
<button onClick={() => onOpenTab("terminal")}>Host</button>
),
}));
const observers: Array<{
@@ -161,13 +163,45 @@ it("finds a host inside a closed folder and opens the keyboard-selected match",
const search = view.getByLabelText("hosts.speedSearch");
expect(document.activeElement).toBe(search);
expect(view.container.querySelectorAll("[data-index]")).toHaveLength(3);
fireEvent.keyDown(search, { key: "ArrowDown" });
fireEvent.keyDown(search, { key: "Enter" });
expect(onOpenTab).toHaveBeenCalledWith(beta, "terminal", {
forceNewTab: false,
});
fireEvent.keyDown(search, { key: "Escape" });
expect(view.queryByLabelText("hosts.speedSearch")).toBeNull();
expect(view.container.querySelectorAll("[data-index]")).toHaveLength(1);
expect(document.activeElement).toBe(list);
fireEvent.keyDown(list, { key: "a" });
const again = view.getByLabelText("hosts.speedSearch");
fireEvent.keyDown(again, { key: "ArrowDown" });
fireEvent.keyDown(again, { key: "Enter" });
expect(onOpenTab).toHaveBeenCalledWith(beta, "terminal", {
forceNewTab: false,
});
// The opened session takes over the keyboard.
expect(view.queryByLabelText("hosts.speedSearch")).toBeNull();
expect(view.container.contains(document.activeElement)).toBe(false);
});
it("lets go of focus when a host opens, so typing reaches the session", () => {
const host = {
id: "1",
name: "alpha",
ip: "192.0.2.1",
username: "u",
} as Host;
const onOpenTab = vi.fn();
const view = render(
<SidebarTree
children={[{ name: "group", path: "group", children: [host] }]}
onOpenTab={onOpenTab}
onEditHost={vi.fn()}
selectionMode={false}
onToggleSelectionMode={vi.fn()}
/>,
);
const row = view.getByText("Host");
row.focus();
fireEvent.click(row);
expect(onOpenTab).toHaveBeenCalledWith(host, "terminal", undefined);
expect(document.activeElement).not.toBe(row);
fireEvent.keyDown(document.body, { key: "a" });
expect(view.queryByLabelText("hosts.speedSearch")).toBeNull();
});
+5 -1
View File
@@ -38,8 +38,12 @@ describe("onboarding seen map", () => {
});
it("legacy seen covers every core step at launch", () => {
// Steps added later stay out so upgraded users still see them.
const addedLater = ["desktop-updates"];
expect(Object.keys(LEGACY_SEEN).sort()).toEqual(
Object.keys(CORE_ONBOARDING_STEPS).sort(),
Object.keys(CORE_ONBOARDING_STEPS)
.filter((key) => !addedLater.includes(key))
.sort(),
);
});
});