mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 22:55:24 +00:00
v26.10.1 (#1599)
* 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:
174 files changed
+94334
-39995
No files matched your search
@@ -542,7 +542,6 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
plugin_id TEXT NOT NULL,
|
||||
registry_id TEXT NOT NULL,
|
||||
count INTEGER NOT NULL DEFAULT 0,
|
||||
source TEXT NOT NULL DEFAULT 'aggregate-telemetry',
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (plugin_id, registry_id)
|
||||
);
|
||||
|
||||
@@ -80,6 +80,8 @@ export const RETIRED_COLUMNS: Record<string, string[]> = {
|
||||
"donation_modal_dismissed",
|
||||
],
|
||||
sessions: ["oidc_sub", "oidc_sid", "sso_provider_id"],
|
||||
// 26.10.1 shows downloads only.
|
||||
plugin_install_counts: ["source"],
|
||||
user_preferences: [
|
||||
"command_autocomplete",
|
||||
"terminal_defaults",
|
||||
|
||||
@@ -1055,10 +1055,9 @@ export const pluginRegistries = mysqlTable("plugin_registries", {
|
||||
});
|
||||
|
||||
/**
|
||||
* Install counts populated by a background job (GitHub release download
|
||||
* counts, aggregated telemetry, or a manual override) rather than by the
|
||||
* install/uninstall actions themselves, kept separate from `plugins` so
|
||||
* that job can overwrite counts without touching install state.
|
||||
* Download counts from each registry's stats.json, written by a background
|
||||
* job rather than by installs, kept separate from `plugins` so that job can
|
||||
* overwrite counts without touching install state.
|
||||
*/
|
||||
export const pluginInstallCounts = mysqlTable(
|
||||
"plugin_install_counts",
|
||||
@@ -1067,7 +1066,6 @@ export const pluginInstallCounts = mysqlTable(
|
||||
pluginId: varchar("plugin_id", { length: 255 }).notNull(),
|
||||
registryId: varchar("registry_id", { length: 255 }).notNull(),
|
||||
count: int("count").notNull().default(0),
|
||||
source: text("source").notNull().default("aggregate-telemetry"),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
|
||||
@@ -1055,10 +1055,9 @@ export const pluginRegistries = pgTable("plugin_registries", {
|
||||
});
|
||||
|
||||
/**
|
||||
* Install counts populated by a background job (GitHub release download
|
||||
* counts, aggregated telemetry, or a manual override) rather than by the
|
||||
* install/uninstall actions themselves, kept separate from `plugins` so
|
||||
* that job can overwrite counts without touching install state.
|
||||
* Download counts from each registry's stats.json, written by a background
|
||||
* job rather than by installs, kept separate from `plugins` so that job can
|
||||
* overwrite counts without touching install state.
|
||||
*/
|
||||
export const pluginInstallCounts = pgTable(
|
||||
"plugin_install_counts",
|
||||
@@ -1067,7 +1066,6 @@ export const pluginInstallCounts = pgTable(
|
||||
pluginId: varchar("plugin_id", { length: 255 }).notNull(),
|
||||
registryId: varchar("registry_id", { length: 255 }).notNull(),
|
||||
count: integer("count").notNull().default(0),
|
||||
source: text("source").notNull().default("aggregate-telemetry"),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
|
||||
@@ -1051,10 +1051,9 @@ export const pluginRegistries = sqliteTable("plugin_registries", {
|
||||
});
|
||||
|
||||
/**
|
||||
* Install counts populated by a background job (GitHub release download
|
||||
* counts, aggregated telemetry, or a manual override) rather than by the
|
||||
* install/uninstall actions themselves, kept separate from `plugins` so
|
||||
* that job can overwrite counts without touching install state.
|
||||
* Download counts from each registry's stats.json, written by a background
|
||||
* job rather than by installs, kept separate from `plugins` so that job can
|
||||
* overwrite counts without touching install state.
|
||||
*/
|
||||
export const pluginInstallCounts = sqliteTable(
|
||||
"plugin_install_counts",
|
||||
@@ -1063,7 +1062,6 @@ export const pluginInstallCounts = sqliteTable(
|
||||
pluginId: text("plugin_id").notNull(),
|
||||
registryId: text("registry_id").notNull(),
|
||||
count: integer("count").notNull().default(0),
|
||||
source: text("source").notNull().default("aggregate-telemetry"),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
|
||||
@@ -7,10 +7,9 @@ export type PluginInstallCountRecord = typeof pluginInstallCounts.$inferSelect;
|
||||
export interface PluginInstallCountInput {
|
||||
pluginId: string;
|
||||
count: number;
|
||||
source: string;
|
||||
}
|
||||
|
||||
/** Popularity numbers for the store, one row per plugin and registry. */
|
||||
/** Download counts for the store, one row per plugin and registry. */
|
||||
export class PluginInstallCountRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
@@ -38,13 +37,11 @@ export class PluginInstallCountRepository {
|
||||
let written = 0;
|
||||
for (const entry of entries) {
|
||||
const row = existing.get(entry.pluginId);
|
||||
if (row && row.count === entry.count && row.source === entry.source) {
|
||||
continue;
|
||||
}
|
||||
if (row && row.count === entry.count) continue;
|
||||
if (row) {
|
||||
await this.context.drizzle
|
||||
.update(pluginInstallCounts)
|
||||
.set({ count: entry.count, source: entry.source, updatedAt: now })
|
||||
.set({ count: entry.count, updatedAt: now })
|
||||
.where(
|
||||
and(
|
||||
eq(pluginInstallCounts.pluginId, entry.pluginId),
|
||||
@@ -56,7 +53,6 @@ export class PluginInstallCountRepository {
|
||||
pluginId: entry.pluginId,
|
||||
registryId,
|
||||
count: entry.count,
|
||||
source: entry.source,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -91,11 +91,11 @@ export function parseSSHConfig(content: string): SSHConfigHost[] {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith("#")) continue;
|
||||
|
||||
const spaceIdx = line.indexOf(" ");
|
||||
if (spaceIdx === -1) continue;
|
||||
const match = /^(\S+?)(?:\s*=\s*|\s+)(.+)$/.exec(line);
|
||||
if (!match) continue;
|
||||
|
||||
const key = line.slice(0, spaceIdx).toLowerCase();
|
||||
const value = line.slice(spaceIdx + 1).trim();
|
||||
const key = match[1].toLowerCase();
|
||||
const value = match[2];
|
||||
|
||||
if (key === "host") {
|
||||
if (current && current.hostname) results.push(current);
|
||||
|
||||
@@ -17,8 +17,13 @@ import { describePluginFrontend } from "../../plugins/assets.js";
|
||||
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
|
||||
import { getPluginPublicHttpRoutes } from "../../plugins/http.js";
|
||||
import { getPluginPublicWsRoutes } from "../../plugins/ws.js";
|
||||
import { MAX_UPLOAD_BYTES, PluginManageError } from "../../plugins/manage.js";
|
||||
import {
|
||||
isLinkedDesktop,
|
||||
MAX_UPLOAD_BYTES,
|
||||
PluginManageError,
|
||||
} from "../../plugins/manage.js";
|
||||
import { parsePluginChannel } from "../../plugins/registry-index.js";
|
||||
import { RegistryError } from "../../plugins/registries.js";
|
||||
import {
|
||||
pluginDocsUrl,
|
||||
pluginEnvVars,
|
||||
@@ -236,6 +241,9 @@ export async function listPreLoginPlugins(): Promise<
|
||||
* it, and `locales` lists the languages it ships ("en" and xx_YY).
|
||||
* `dependencies` and `optionalDependencies` let the loader activate
|
||||
* frontends in the same order the server does.
|
||||
*
|
||||
* On a desktop linked to a server, `runsOnServer` marks plugins that
|
||||
* only run on that server and stay off here on purpose.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* responses:
|
||||
@@ -253,6 +261,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
userId,
|
||||
"admin.plugins.manage",
|
||||
);
|
||||
const linked = await isLinkedDesktop();
|
||||
|
||||
// One query for every plugin rather than one per plugin.
|
||||
const grantsByPlugin = new Map<string, string[]>();
|
||||
@@ -285,6 +294,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
let features: string[] = [];
|
||||
let docs: string | undefined;
|
||||
let env: PluginEnvVar[] = [];
|
||||
let desktopMode: unknown = loaded?.manifest.desktop;
|
||||
try {
|
||||
const manifest = JSON.parse(record.manifestJson) as {
|
||||
contributes?: unknown;
|
||||
@@ -299,7 +309,9 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
env?: unknown;
|
||||
dependencies?: Record<string, string>;
|
||||
optionalDependencies?: Record<string, string>;
|
||||
desktop?: unknown;
|
||||
};
|
||||
desktopMode = loaded?.manifest.desktop ?? manifest?.desktop;
|
||||
contributes = manifest?.contributes ?? null;
|
||||
capabilities = Array.isArray(manifest?.capabilities)
|
||||
? (manifest.capabilities as string[])
|
||||
@@ -332,6 +344,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
version: record.version,
|
||||
enabled: record.state === "enabled",
|
||||
state: loaded?.state ?? record.state,
|
||||
runsOnServer: linked && desktopMode === "server",
|
||||
contributes,
|
||||
icon,
|
||||
dependencies,
|
||||
@@ -392,6 +405,7 @@ async function auditPluginAction(
|
||||
action: string,
|
||||
pluginId: string,
|
||||
details: Record<string, unknown> = {},
|
||||
resourceType = "plugin",
|
||||
): Promise<void> {
|
||||
const userId = (req as AuthenticatedRequest).userId as string;
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
@@ -400,7 +414,7 @@ async function auditPluginAction(
|
||||
userId,
|
||||
username: await getAuditUsername(userId),
|
||||
action,
|
||||
resourceType: "plugin",
|
||||
resourceType,
|
||||
resourceId: pluginId,
|
||||
resourceName: pluginId,
|
||||
details: JSON.stringify(details),
|
||||
@@ -419,7 +433,7 @@ function sendManageError(
|
||||
fallback: string,
|
||||
operation: string,
|
||||
): void {
|
||||
if (error instanceof PluginManageError) {
|
||||
if (error instanceof PluginManageError || error instanceof RegistryError) {
|
||||
res.status(error.status).json({
|
||||
error: error.message,
|
||||
code: error.code,
|
||||
@@ -570,6 +584,304 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugins/registries:
|
||||
* get:
|
||||
* summary: List the plugin registries
|
||||
* description: The official registry, which is always on, the community registry and whether it is on, and every custom registry an admin added with its pinned key fingerprint.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The registries.
|
||||
* 403:
|
||||
* description: The caller lacks admin.plugins.manage.
|
||||
*/
|
||||
router.get(
|
||||
"/registries",
|
||||
authenticateJWT,
|
||||
requireManagePlugins,
|
||||
async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const { readRegistriesConfig } =
|
||||
await import("../../plugins/registries.js");
|
||||
const { officialSource, communitySource } =
|
||||
await import("../../plugins/registry-index.js");
|
||||
const config = await readRegistriesConfig();
|
||||
res.json({
|
||||
managedByServer: await isLinkedDesktop(),
|
||||
official: { id: "official", url: officialSource().url },
|
||||
community: {
|
||||
id: "community",
|
||||
url: communitySource().url,
|
||||
enabled: config.community.enabled,
|
||||
},
|
||||
custom: config.custom.map((entry) => ({
|
||||
id: entry.id,
|
||||
name: entry.name,
|
||||
url: entry.url,
|
||||
keyId: entry.keyId,
|
||||
enabled: entry.enabled,
|
||||
addedAt: entry.addedAt,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
sendManageError(
|
||||
res,
|
||||
error,
|
||||
"Failed to read plugin registries",
|
||||
"plugin_registry",
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugins/registries/preview:
|
||||
* post:
|
||||
* summary: Read a registry index before adding it
|
||||
* description: Fetches the index at url and returns its name, the id it would get, how many plugins it lists and the fingerprint of the key it signs with. Nothing is saved. The index must list a publicKey.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - url
|
||||
* properties:
|
||||
* url:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: What the registry would be added as.
|
||||
* 400:
|
||||
* description: Not an https URL, or the index has no publicKey.
|
||||
* 403:
|
||||
* description: The caller lacks admin.plugins.manage.
|
||||
* 409:
|
||||
* description: The registry is already added.
|
||||
* 502:
|
||||
* description: The index could not be read.
|
||||
*/
|
||||
router.post(
|
||||
"/registries/preview",
|
||||
authenticateJWT,
|
||||
requireManagePlugins,
|
||||
async (req: Request, res: Response) => {
|
||||
if (await refuseOnLinkedDesktop(res)) return;
|
||||
try {
|
||||
const { previewRegistry } = await import("../../plugins/registries.js");
|
||||
res.json(await previewRegistry(req.body?.url));
|
||||
} catch (error) {
|
||||
sendManageError(
|
||||
res,
|
||||
error,
|
||||
"Failed to read the registry",
|
||||
"plugin_registry",
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugins/registries:
|
||||
* post:
|
||||
* summary: Add a custom plugin registry
|
||||
* description: Reads the index again and pins the key it names. keyId must be the fingerprint the preview showed, otherwise the answer is 409 KEY_MISMATCH and nothing is saved. Every plugin installed from it must be signed by that key.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - url
|
||||
* - keyId
|
||||
* properties:
|
||||
* url:
|
||||
* type: string
|
||||
* keyId:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The added registry.
|
||||
* 400:
|
||||
* description: Not an https URL, no publicKey, or too many registries.
|
||||
* 403:
|
||||
* description: The caller lacks admin.plugins.manage.
|
||||
* 409:
|
||||
* description: Already added, or the key changed since the preview.
|
||||
* 502:
|
||||
* description: The index could not be read.
|
||||
*/
|
||||
router.post(
|
||||
"/registries",
|
||||
authenticateJWT,
|
||||
requireManagePlugins,
|
||||
async (req: Request, res: Response) => {
|
||||
if (await refuseOnLinkedDesktop(res)) return;
|
||||
try {
|
||||
const { addRegistry } = await import("../../plugins/registries.js");
|
||||
const entry = await addRegistry(req.body?.url, req.body?.keyId);
|
||||
await auditPluginAction(
|
||||
req,
|
||||
"add_plugin_registry",
|
||||
entry.id,
|
||||
{ url: entry.url, keyId: entry.keyId },
|
||||
"plugin_registry",
|
||||
);
|
||||
res.json({
|
||||
id: entry.id,
|
||||
name: entry.name,
|
||||
url: entry.url,
|
||||
keyId: entry.keyId,
|
||||
enabled: entry.enabled,
|
||||
addedAt: entry.addedAt,
|
||||
});
|
||||
} catch (error) {
|
||||
sendManageError(
|
||||
res,
|
||||
error,
|
||||
"Failed to add the registry",
|
||||
"plugin_registry",
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugins/registries/{id}:
|
||||
* patch:
|
||||
* summary: Turn a plugin registry on or off
|
||||
* description: Works for community and custom registries. Plugins already installed from a registry that is off keep running but get no updates.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - enabled
|
||||
* properties:
|
||||
* enabled:
|
||||
* type: boolean
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The registry was changed.
|
||||
* 400:
|
||||
* description: Invalid body, or the official registry.
|
||||
* 403:
|
||||
* description: The caller lacks admin.plugins.manage.
|
||||
* 404:
|
||||
* description: No such registry.
|
||||
*/
|
||||
router.patch(
|
||||
"/registries/:id",
|
||||
authenticateJWT,
|
||||
requireManagePlugins,
|
||||
async (req: Request, res: Response) => {
|
||||
const id = String(req.params.id);
|
||||
const enabled = req.body?.enabled;
|
||||
if (typeof enabled !== "boolean") {
|
||||
res.status(400).json({ error: "enabled must be a boolean" });
|
||||
return;
|
||||
}
|
||||
if (await refuseOnLinkedDesktop(res)) return;
|
||||
try {
|
||||
const { setRegistryEnabled } =
|
||||
await import("../../plugins/registries.js");
|
||||
await setRegistryEnabled(id, enabled);
|
||||
await auditPluginAction(
|
||||
req,
|
||||
enabled ? "enable_plugin_registry" : "disable_plugin_registry",
|
||||
id,
|
||||
{},
|
||||
"plugin_registry",
|
||||
);
|
||||
res.json({ id, enabled });
|
||||
} catch (error) {
|
||||
sendManageError(
|
||||
res,
|
||||
error,
|
||||
"Failed to change the registry",
|
||||
"plugin_registry",
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugins/registries/{id}:
|
||||
* delete:
|
||||
* summary: Remove a custom plugin registry
|
||||
* description: Refused with 409 REGISTRY_IN_USE while plugins installed from it remain, since its key has to stay trusted for them to load.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The registry was removed.
|
||||
* 400:
|
||||
* description: Official and community can only be turned off.
|
||||
* 403:
|
||||
* description: The caller lacks admin.plugins.manage.
|
||||
* 404:
|
||||
* description: No such registry.
|
||||
* 409:
|
||||
* description: Plugins from it are still installed.
|
||||
*/
|
||||
router.delete(
|
||||
"/registries/:id",
|
||||
authenticateJWT,
|
||||
requireManagePlugins,
|
||||
async (req: Request, res: Response) => {
|
||||
const id = String(req.params.id);
|
||||
if (await refuseOnLinkedDesktop(res)) return;
|
||||
try {
|
||||
const { removeRegistry } = await import("../../plugins/registries.js");
|
||||
await removeRegistry(id);
|
||||
await auditPluginAction(
|
||||
req,
|
||||
"remove_plugin_registry",
|
||||
id,
|
||||
{},
|
||||
"plugin_registry",
|
||||
);
|
||||
res.json({ id });
|
||||
} catch (error) {
|
||||
sendManageError(
|
||||
res,
|
||||
error,
|
||||
"Failed to remove the registry",
|
||||
"plugin_registry",
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugins/{id}/state:
|
||||
@@ -1597,10 +1909,11 @@ function readVersion(body: unknown): string | undefined | null {
|
||||
* @openapi
|
||||
* /plugins/registry:
|
||||
* get:
|
||||
* summary: List the plugins in the official registry
|
||||
* summary: List the plugins in every registry that is on
|
||||
* description: >
|
||||
* Fetches the official registry index (cached for 15 minutes unless
|
||||
* refresh is set) and merges it with what is installed here: the
|
||||
* Fetches the index of every registry that is on (each cached for 15
|
||||
* minutes unless refresh is set), with each one's status under
|
||||
* registries, and merges them with what is installed here: the
|
||||
* installed version, the newest release this build can run, whether an
|
||||
* update asks for new capabilities, and the pin and auto-update choices.
|
||||
* tags:
|
||||
@@ -1741,10 +2054,10 @@ router.post(
|
||||
* @openapi
|
||||
* /plugins/{id}/install:
|
||||
* post:
|
||||
* summary: Install a plugin from the official registry
|
||||
* summary: Install a plugin from a registry
|
||||
* description: >
|
||||
* Downloads the release, checks its sha256 and signature against a key
|
||||
* this build trusts, loads it, grants the capabilities it declares and
|
||||
* Downloads the release, checks its sha256 and signature against the keys of
|
||||
* the registry it comes from, loads it, grants the capabilities it declares and
|
||||
* starts it along with any installed dependency that is off. Without a
|
||||
* version the newest compatible release is used; naming an older one
|
||||
* pins the plugin there. A bundled plugin at its shipped version needs
|
||||
@@ -1774,6 +2087,9 @@ router.post(
|
||||
* type: string
|
||||
* enum: [stable, beta]
|
||||
* description: The channel to follow. beta installs the newest beta when it is newer than stable.
|
||||
* registryId:
|
||||
* type: string
|
||||
* description: The registry to install from, official when unset. It must be turned on, and only the official registry can offer an official plugin id.
|
||||
* capabilities:
|
||||
* type: array
|
||||
* items:
|
||||
@@ -1786,9 +2102,9 @@ router.post(
|
||||
* 403:
|
||||
* description: The caller lacks admin.plugins.manage.
|
||||
* 404:
|
||||
* description: Not in the official registry.
|
||||
* description: Not in that registry.
|
||||
* 409:
|
||||
* description: Already installed, incompatible, the consent does not match, or managed by a linked server.
|
||||
* description: Already installed, incompatible, the consent does not match, the registry is off, the id belongs to an official plugin, or managed by a linked server.
|
||||
* 502:
|
||||
* description: The registry could not be reached.
|
||||
*/
|
||||
@@ -1804,15 +2120,19 @@ router.post(
|
||||
req.body?.channel === undefined
|
||||
? "stable"
|
||||
: parsePluginChannel(req.body.channel);
|
||||
const registryId =
|
||||
req.body?.registryId === undefined ? undefined : req.body.registryId;
|
||||
if (
|
||||
!ID_PATTERN.test(pluginId) ||
|
||||
version === null ||
|
||||
capabilities === null ||
|
||||
channel === null
|
||||
channel === null ||
|
||||
(registryId !== undefined &&
|
||||
(typeof registryId !== "string" || !ID_PATTERN.test(registryId)))
|
||||
) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ error: "Invalid plugin id, version, channel or capabilities" });
|
||||
res.status(400).json({
|
||||
error: "Invalid plugin id, version, channel, registry or capabilities",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (await refuseOnLinkedDesktop(res)) return;
|
||||
@@ -1822,11 +2142,13 @@ router.post(
|
||||
const result = await installPlugin(pluginId, {
|
||||
version,
|
||||
channel,
|
||||
registryId,
|
||||
userId,
|
||||
capabilities,
|
||||
});
|
||||
await auditPluginAction(req, "install_plugin", pluginId, {
|
||||
version: result.version,
|
||||
registryId: registryId ?? "official",
|
||||
capabilities,
|
||||
});
|
||||
res.json(result);
|
||||
@@ -1847,7 +2169,7 @@ router.post(
|
||||
* post:
|
||||
* summary: Update or change the installed version of a plugin
|
||||
* description: >
|
||||
* Swaps in another release from the official registry, newer or older.
|
||||
* Swaps in another release from the registry the plugin came from, newer or older.
|
||||
* If the release declares capabilities the installed one does not, the
|
||||
* request must set acceptCapabilities and list exactly those added
|
||||
* capabilities in capabilities, otherwise it answers 409 with code
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* The registry timer: refreshes the store's install counts and applies
|
||||
* The registry timer: refreshes the store's download counts and applies
|
||||
* plugin updates for plugins with auto-update on. An update that asks for a
|
||||
* new capability is never applied here; admins get an alert and review it in
|
||||
* the Plugins tab. Nothing is ever installed from here.
|
||||
@@ -13,8 +13,8 @@ const CHECK_EVERY_MS = 6 * 60 * 60_000;
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
|
||||
export async function runPluginAutoUpdate(): Promise<void> {
|
||||
const { syncInstallCounts } = await import("./install-counts.js");
|
||||
await syncInstallCounts();
|
||||
const { syncDownloadCounts } = await import("./download-counts.js");
|
||||
await syncDownloadCounts();
|
||||
|
||||
const { isLinkedDesktop, updateAllPlugins } = await import("./manage.js");
|
||||
if (await isLinkedDesktop()) return;
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Download counts for the plugin store, copied from the registry's
|
||||
* stats.json into plugin_install_counts.
|
||||
*
|
||||
* A count is how often the plugin's .tmxplug release files were downloaded.
|
||||
* An instance that updates three times counts three times, and copies bundled
|
||||
* into Docker images or the desktop app never count. Treat it as a ranking,
|
||||
* never as a user count.
|
||||
*/
|
||||
|
||||
import { pluginLogger } from "../utils/logger.js";
|
||||
import {
|
||||
OFFICIAL_REGISTRY_ID,
|
||||
fetchRegistryStats,
|
||||
officialSource,
|
||||
type RegistrySource,
|
||||
} from "./registry-index.js";
|
||||
|
||||
async function repository() {
|
||||
const { createCurrentPluginInstallCountRepository } =
|
||||
await import("../database/repositories/factory.js");
|
||||
return createCurrentPluginInstallCountRepository();
|
||||
}
|
||||
|
||||
const STALE_MS = 6 * 60 * 60_000;
|
||||
const FORCED_MS = 60_000;
|
||||
const WAIT_MS = 3_000;
|
||||
|
||||
let lastSyncAt = 0;
|
||||
let running: Promise<number> | null = null;
|
||||
|
||||
/**
|
||||
* Syncs when the counts are older than the registry timer's interval, or
|
||||
* older than a minute with force (the Refresh button). Waits a few seconds
|
||||
* at most so a slow registry never holds up the store; a sync that runs
|
||||
* longer still finishes in the background.
|
||||
*/
|
||||
export async function refreshDownloadCounts(force = false): Promise<void> {
|
||||
const age = Date.now() - lastSyncAt;
|
||||
if (!running && age >= (force ? FORCED_MS : STALE_MS)) {
|
||||
running = syncDownloadCounts().finally(() => {
|
||||
running = null;
|
||||
});
|
||||
}
|
||||
if (!running) return;
|
||||
let timeout: NodeJS.Timeout | undefined;
|
||||
await Promise.race([
|
||||
running,
|
||||
new Promise((resolve) => {
|
||||
timeout = setTimeout(resolve, WAIT_MS);
|
||||
}),
|
||||
]);
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
/** Test seam. */
|
||||
export function resetDownloadCountRefresh(): void {
|
||||
lastSyncAt = 0;
|
||||
running = null;
|
||||
}
|
||||
|
||||
/** Returns how many rows changed. Never throws; a failed sync keeps the old numbers. */
|
||||
export async function syncDownloadCounts(): Promise<number> {
|
||||
lastSyncAt = Date.now();
|
||||
let changed = 0;
|
||||
let sources: RegistrySource[] = [officialSource()];
|
||||
try {
|
||||
const { listSources } = await import("./registries.js");
|
||||
sources = await listSources();
|
||||
} catch {
|
||||
// Official only when the registry choices cannot be read.
|
||||
}
|
||||
for (const source of sources) {
|
||||
try {
|
||||
const stats = await fetchRegistryStats(source);
|
||||
if (!stats) continue;
|
||||
const entries = [...stats.plugins].map(([pluginId, downloads]) => ({
|
||||
pluginId,
|
||||
count: downloads,
|
||||
}));
|
||||
changed += await (await repository()).upsertMany(source.id, entries);
|
||||
} catch (error) {
|
||||
pluginLogger.warn(
|
||||
`Could not sync plugin download counts from ${source.id}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
{ operation: "plugin_registry" },
|
||||
);
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
export async function readDownloadCounts(
|
||||
registryId: string = OFFICIAL_REGISTRY_ID,
|
||||
): Promise<Map<string, number>> {
|
||||
try {
|
||||
const rows = await (await repository()).listByRegistry(registryId);
|
||||
return new Map(rows.map((row) => [row.pluginId, row.count]));
|
||||
} catch {
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import module from "node:module";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { getPluginsDir } from "./paths.js";
|
||||
|
||||
/**
|
||||
* A plugin build leaves host packages (drizzle-orm, express, ssh2, ...) for
|
||||
* core to supply, and Node finds them by walking up from the importing file.
|
||||
* That works for bundled plugins inside the app, but a .tmxplug unpacked into
|
||||
* a data dir outside it (the desktop app keeps it in AppData) finds nothing.
|
||||
* Those imports fall back to core's own copy, the one bundled plugins get.
|
||||
*/
|
||||
|
||||
let installed = false;
|
||||
|
||||
export function isBareSpecifier(specifier: string): boolean {
|
||||
if (specifier.startsWith(".") || specifier.startsWith("/")) return false;
|
||||
if (specifier.startsWith("#")) return false;
|
||||
// file:, node:, data:, and a Windows drive letter
|
||||
return !/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(specifier);
|
||||
}
|
||||
|
||||
/** Whether an import from parentURL may fall back to core's packages. */
|
||||
export function isUserPluginFile(
|
||||
parentURL: string | undefined,
|
||||
pluginsDir: string,
|
||||
): boolean {
|
||||
if (!parentURL?.startsWith("file:")) return false;
|
||||
let file: string;
|
||||
try {
|
||||
file = fileURLToPath(new URL(parentURL));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const relative = path.relative(path.resolve(pluginsDir), file);
|
||||
return (
|
||||
relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative)
|
||||
);
|
||||
}
|
||||
|
||||
export function installHostModuleFallback(): void {
|
||||
if (installed) return;
|
||||
installed = true;
|
||||
// Node 22.15+. Older runtimes keep plain resolution.
|
||||
if (typeof module.registerHooks !== "function") return;
|
||||
module.registerHooks({
|
||||
resolve(specifier, context, nextResolve) {
|
||||
try {
|
||||
return nextResolve(specifier, context);
|
||||
} catch (error) {
|
||||
if (
|
||||
(error as { code?: string })?.code !== "ERR_MODULE_NOT_FOUND" ||
|
||||
!isBareSpecifier(specifier) ||
|
||||
!isUserPluginFile(context.parentURL, getPluginsDir())
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
return nextResolve(specifier, {
|
||||
...context,
|
||||
parentURL: import.meta.url,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
/**
|
||||
* Popularity numbers for the plugin store, copied from the registry's
|
||||
* stats.json into plugin_install_counts.
|
||||
*
|
||||
* Two sources, and the store prefers the first:
|
||||
* - "aggregate-telemetry": instances that reported the plugin enabled in the
|
||||
* last 7 days through the Usage Statistics plugin. It undercounts by every
|
||||
* instance that turned the report off or never installed that plugin.
|
||||
* - "github-releases": downloads of the plugin's .tmxplug release assets.
|
||||
* This counts downloads, not active installs: an instance that updates
|
||||
* three times counts three times, and Docker images that bundle the plugin
|
||||
* count once per build, not once per install.
|
||||
*
|
||||
* Treat both as a rough ranking, never as a user count.
|
||||
*/
|
||||
|
||||
import { pluginLogger } from "../utils/logger.js";
|
||||
import { OFFICIAL_REGISTRY_ID, fetchRegistryStats } from "./registry-index.js";
|
||||
|
||||
export const TELEMETRY_SOURCE = "aggregate-telemetry";
|
||||
export const DOWNLOADS_SOURCE = "github-releases";
|
||||
|
||||
export interface InstallCount {
|
||||
count: number;
|
||||
source: string;
|
||||
}
|
||||
|
||||
async function repository() {
|
||||
const { createCurrentPluginInstallCountRepository } =
|
||||
await import("../database/repositories/factory.js");
|
||||
return createCurrentPluginInstallCountRepository();
|
||||
}
|
||||
|
||||
/** Picks the number to show: active installs when known, downloads otherwise. */
|
||||
export function preferredCount(entry: {
|
||||
downloads: number;
|
||||
activeInstalls: number | null;
|
||||
}): InstallCount {
|
||||
return entry.activeInstalls !== null && entry.activeInstalls > 0
|
||||
? { count: entry.activeInstalls, source: TELEMETRY_SOURCE }
|
||||
: { count: entry.downloads, source: DOWNLOADS_SOURCE };
|
||||
}
|
||||
|
||||
/** Returns how many rows changed. Never throws; a failed sync keeps the old numbers. */
|
||||
export async function syncInstallCounts(): Promise<number> {
|
||||
try {
|
||||
const stats = await fetchRegistryStats();
|
||||
if (!stats) return 0;
|
||||
const entries = [...stats.plugins].map(([pluginId, entry]) => ({
|
||||
pluginId,
|
||||
...preferredCount(entry),
|
||||
}));
|
||||
return await (await repository()).upsertMany(OFFICIAL_REGISTRY_ID, entries);
|
||||
} catch (error) {
|
||||
pluginLogger.warn(
|
||||
`Could not sync plugin install counts: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
{ operation: "plugin_registry" },
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readInstallCounts(
|
||||
registryId: string = OFFICIAL_REGISTRY_ID,
|
||||
): Promise<Map<string, InstallCount>> {
|
||||
try {
|
||||
const rows = await (await repository()).listByRegistry(registryId);
|
||||
return new Map(
|
||||
rows.map((row) => [
|
||||
row.pluginId,
|
||||
{ count: row.count, source: row.source },
|
||||
]),
|
||||
);
|
||||
} catch {
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,11 @@ import {
|
||||
getPluginsDir,
|
||||
getUnpackedPluginsDir,
|
||||
} from "./paths.js";
|
||||
import { requireSignedPlugins, verifyPluginArtifact } from "./trust.js";
|
||||
import {
|
||||
requireSignedPlugins,
|
||||
verifyAnyTrusted,
|
||||
type KeyTier,
|
||||
} from "./trust.js";
|
||||
import { moveWithRetry } from "./fs-retry.js";
|
||||
import {
|
||||
createPluginContext,
|
||||
@@ -40,6 +44,7 @@ import {
|
||||
import { resolveRequirements } from "./service-registry.js";
|
||||
import { resolveSecretRequirements } from "./secret-registry.js";
|
||||
import { recordConflict } from "./conflicts.js";
|
||||
import { installHostModuleFallback } from "./host-modules.js";
|
||||
import { tablePrefix } from "@termix-ssh/plugin-sdk/db";
|
||||
|
||||
export type PluginState =
|
||||
@@ -76,6 +81,8 @@ export interface LoadedPlugin {
|
||||
artifact?: string;
|
||||
/** Key id that signed the artifact, when a trusted key signed it. */
|
||||
signedBy?: string;
|
||||
/** Which set of keys that key belongs to. */
|
||||
signedTier?: KeyTier;
|
||||
}
|
||||
|
||||
export interface PluginLoaderOptions {
|
||||
@@ -344,10 +351,11 @@ export class PluginLoader {
|
||||
const buffer = await fs.promises.readFile(file);
|
||||
const sigFile = `${file}.sig`;
|
||||
let signedBy: string | undefined;
|
||||
let signedTier: KeyTier | undefined;
|
||||
|
||||
if (fs.existsSync(sigFile)) {
|
||||
const sha256 = crypto.createHash("sha256").update(buffer).digest("hex");
|
||||
const result = verifyPluginArtifact(
|
||||
const result = verifyAnyTrusted(
|
||||
buffer,
|
||||
sha256,
|
||||
await fs.promises.readFile(sigFile, "utf8"),
|
||||
@@ -356,6 +364,7 @@ export class PluginLoader {
|
||||
throw new Error(`signature check failed: ${result.reason}`);
|
||||
}
|
||||
signedBy = result.keyId;
|
||||
signedTier = result.tier;
|
||||
} else if (requireSignedPlugins()) {
|
||||
throw new Error(
|
||||
`TERMIX_REQUIRE_SIGNED_PLUGINS is on and ${path.basename(file)} has no .sig next to it`,
|
||||
@@ -401,7 +410,7 @@ export class PluginLoader {
|
||||
assertBundledUpdate(
|
||||
id,
|
||||
raw?.version,
|
||||
signedBy,
|
||||
signedTier === "official" ? signedBy : undefined,
|
||||
this.bundledVersions.get(id) ?? replaced?.manifest.version,
|
||||
this.pinned.get(id),
|
||||
);
|
||||
@@ -426,6 +435,7 @@ export class PluginLoader {
|
||||
const plugin = await this.load(target, isUpdate ? "bundled" : "user");
|
||||
plugin.artifact = file;
|
||||
plugin.signedBy = signedBy;
|
||||
plugin.signedTier = signedTier;
|
||||
if (replaced) {
|
||||
pluginLogger.info(
|
||||
`Plugin ${id} updated from ${replaced.manifest.version} to ${plugin.manifest.version}`,
|
||||
@@ -567,6 +577,7 @@ export class PluginLoader {
|
||||
)}`;
|
||||
|
||||
try {
|
||||
installHostModuleFallback();
|
||||
const imported = (await import(entry)) as {
|
||||
activate?: PluginModule["activate"];
|
||||
deactivate?: PluginModule["deactivate"];
|
||||
@@ -615,7 +626,8 @@ export class PluginLoader {
|
||||
// A release signed by a trusted key is official whether it shipped in
|
||||
// the image or was downloaded, so it may adopt its 2.8 tables too.
|
||||
const applied = await migratePlugin(plugin.id, plugin.dir, {
|
||||
bundled: plugin.source === "bundled" || Boolean(plugin.signedBy),
|
||||
bundled:
|
||||
plugin.source === "bundled" || plugin.signedTier === "official",
|
||||
});
|
||||
// A new table may be where core data is waiting to move.
|
||||
if (applied.length > 0) {
|
||||
@@ -847,7 +859,7 @@ export function assertBundledUpdate(
|
||||
): void {
|
||||
if (!signedBy) {
|
||||
throw new Error(
|
||||
`a bundled plugin already uses the id "${id}"; only a signed update can replace it`,
|
||||
`a bundled plugin already uses the id "${id}"; only an official signed update can replace it`,
|
||||
);
|
||||
}
|
||||
if (typeof version !== "string" || !semver.valid(version)) {
|
||||
|
||||
+203
-31
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* Installing, updating, uninstalling and switching plugins at runtime.
|
||||
*
|
||||
* The official registry is the one real source. With developer mode on, an
|
||||
* admin can also install a .tmxplug from a file; that skips the download,
|
||||
* Plugins come from the official registry, plus the community and custom
|
||||
* registries an admin turned on (see registries.ts). With developer mode on,
|
||||
* an admin can also install a .tmxplug from a file; that skips the download,
|
||||
* sha256 and signature checks and stays marked unverified. Bundled plugins ship in the
|
||||
* image and can still be uninstalled: their files are deleted and the id is
|
||||
* recorded in the settings table, so the shipped copy is ignored from then
|
||||
@@ -38,14 +39,27 @@ import {
|
||||
getRegistryStatus,
|
||||
isApiCompatible,
|
||||
latestOnChannel,
|
||||
officialSource,
|
||||
parsePluginChannel,
|
||||
type PluginChannel,
|
||||
type RegistryIndex,
|
||||
type RegistryPlugin,
|
||||
type RegistrySource,
|
||||
type RegistryTier,
|
||||
type RegistryVersion,
|
||||
} from "./registry-index.js";
|
||||
import {
|
||||
findSource,
|
||||
listSources,
|
||||
loadCustomRegistryKeys,
|
||||
pinnedKeyOf,
|
||||
} from "./registries.js";
|
||||
import { invalidatePluginPermissionCache } from "./permissions.js";
|
||||
import { moveWithRetry } from "./fs-retry.js";
|
||||
import { readInstallCounts } from "./install-counts.js";
|
||||
import {
|
||||
readDownloadCounts,
|
||||
refreshDownloadCounts,
|
||||
} from "./download-counts.js";
|
||||
import { readArtifactManifest, sameCapabilities } from "./artifact-manifest.js";
|
||||
import {
|
||||
parseManifest,
|
||||
@@ -127,6 +141,7 @@ async function readPins(): Promise<Map<string, string>> {
|
||||
export async function applyStoredPluginChoices(
|
||||
loader: PluginLoader,
|
||||
): Promise<void> {
|
||||
await loadCustomRegistryKeys();
|
||||
try {
|
||||
loader.setUninstalled(await readUninstalled());
|
||||
loader.setPinned(await readPins());
|
||||
@@ -208,12 +223,28 @@ function addedCapabilities(
|
||||
return next.filter((capability) => !have.has(capability));
|
||||
}
|
||||
|
||||
export interface RegistryListingSource {
|
||||
id: string;
|
||||
name: string;
|
||||
tier: RegistryTier;
|
||||
url: string;
|
||||
lastCheckedAt: string | null;
|
||||
error: string | null;
|
||||
plugins: number;
|
||||
}
|
||||
|
||||
export interface RegistryListing {
|
||||
/** The official registry, kept for older clients. */
|
||||
registry: ReturnType<typeof getRegistryStatus> & { id: string };
|
||||
/** Every registry read for this listing, official first. */
|
||||
registries: RegistryListingSource[];
|
||||
/** A linked desktop runs what its server runs, so nothing here can change. */
|
||||
managedByServer: boolean;
|
||||
plugins: Array<{
|
||||
id: string;
|
||||
registryId: string;
|
||||
registryName: string;
|
||||
tier: RegistryTier;
|
||||
name: string;
|
||||
description: string;
|
||||
author: string;
|
||||
@@ -245,28 +276,118 @@ export interface RegistryListing {
|
||||
pinnedVersion: string | null;
|
||||
autoUpdate: boolean;
|
||||
bundled: boolean;
|
||||
/** See install-counts.ts for what this number does and does not mean. */
|
||||
installCount: number | null;
|
||||
installCountSource: string | null;
|
||||
/** See download-counts.ts for what this number does and does not mean. */
|
||||
downloads: number | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
/** A custom registry whose index now names another key than the pinned one. */
|
||||
async function keyChanged(
|
||||
source: RegistrySource,
|
||||
index: RegistryIndex,
|
||||
): Promise<boolean> {
|
||||
if (source.tier !== "custom" || !index.publicKey) return false;
|
||||
return index.publicKey !== (await pinnedKeyOf(source.id));
|
||||
}
|
||||
|
||||
const KEY_CHANGED_MESSAGE =
|
||||
"the registry now names a different signing key; add it again to trust the new key";
|
||||
|
||||
/**
|
||||
* Ids only the official registry may offer: what ships in the image and what
|
||||
* the official index lists. Other registries can never take them over.
|
||||
*/
|
||||
function reservedIds(official: RegistryIndex | null): Set<string> {
|
||||
const ids = new Set(getPluginRuntime().loader.bundledIds());
|
||||
for (const plugin of official?.plugins ?? []) ids.add(plugin.id);
|
||||
return ids;
|
||||
}
|
||||
|
||||
export async function listRegistry(
|
||||
options: { force?: boolean } = {},
|
||||
): Promise<RegistryListing> {
|
||||
const index = await fetchRegistryIndex(options);
|
||||
const sources = await listSources();
|
||||
const results = await Promise.allSettled(
|
||||
sources.map((source) => fetchRegistryIndex(source, options)),
|
||||
);
|
||||
const official = results[0];
|
||||
if (official.status === "rejected") throw official.reason;
|
||||
|
||||
const { createCurrentPluginRepository } = await repos();
|
||||
const records = new Map(
|
||||
(await createCurrentPluginRepository().listAll()).map((r) => [r.id, r]),
|
||||
);
|
||||
const counts = await readInstallCounts();
|
||||
await refreshDownloadCounts(options.force);
|
||||
const counts = new Map(
|
||||
await Promise.all(
|
||||
sources
|
||||
.filter((source) => source.tier !== "custom")
|
||||
.map(
|
||||
async (source) =>
|
||||
[source.id, await readDownloadCounts(source.id)] as const,
|
||||
),
|
||||
),
|
||||
);
|
||||
const { loader } = getPluginRuntime();
|
||||
const shipped = loader.bundledIds();
|
||||
const reserved = reservedIds(official.value);
|
||||
|
||||
const registries: RegistryListingSource[] = [];
|
||||
// Per id, every registry that lists it, in source order.
|
||||
const offers = new Map<
|
||||
string,
|
||||
Array<{ source: RegistrySource; plugin: RegistryPlugin }>
|
||||
>();
|
||||
for (let i = 0; i < sources.length; i++) {
|
||||
const source = sources[i];
|
||||
const result = results[i];
|
||||
const status = getRegistryStatus(source);
|
||||
let error = status.error;
|
||||
let listed = 0;
|
||||
if (result.status === "fulfilled") {
|
||||
if (await keyChanged(source, result.value)) {
|
||||
error = KEY_CHANGED_MESSAGE;
|
||||
} else {
|
||||
for (const plugin of result.value.plugins) {
|
||||
if (source.tier !== "official" && reserved.has(plugin.id)) continue;
|
||||
const list = offers.get(plugin.id) ?? [];
|
||||
list.push({ source, plugin });
|
||||
offers.set(plugin.id, list);
|
||||
listed++;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
error =
|
||||
result.reason instanceof Error
|
||||
? result.reason.message
|
||||
: String(result.reason);
|
||||
}
|
||||
registries.push({
|
||||
id: source.id,
|
||||
name:
|
||||
result.status === "fulfilled" && result.value.name
|
||||
? result.value.name
|
||||
: source.name,
|
||||
tier: source.tier,
|
||||
url: source.url,
|
||||
lastCheckedAt: status.lastCheckedAt,
|
||||
error,
|
||||
plugins: listed,
|
||||
});
|
||||
}
|
||||
|
||||
const chosen: Array<{ source: RegistrySource; plugin: RegistryPlugin }> = [];
|
||||
for (const [id, list] of offers) {
|
||||
// An installed plugin is always shown from the registry it came from.
|
||||
const from = records.get(id)?.registryId;
|
||||
chosen.push(list.find((offer) => offer.source.id === from) ?? list[0]);
|
||||
}
|
||||
|
||||
return {
|
||||
registry: { id: OFFICIAL_REGISTRY_ID, ...getRegistryStatus() },
|
||||
registries,
|
||||
managedByServer: await isLinkedDesktop(),
|
||||
plugins: index.plugins.map((plugin) => {
|
||||
plugins: chosen.map(({ source, plugin }) => {
|
||||
const record = records.get(plugin.id);
|
||||
const live = loader.get(plugin.id);
|
||||
const channel = channelOf(record);
|
||||
@@ -283,6 +404,9 @@ export async function listRegistry(
|
||||
);
|
||||
return {
|
||||
id: plugin.id,
|
||||
registryId: source.id,
|
||||
registryName: registries.find((entry) => entry.id === source.id)!.name,
|
||||
tier: source.tier,
|
||||
name: plugin.name,
|
||||
description: plugin.description,
|
||||
author: plugin.author,
|
||||
@@ -318,8 +442,7 @@ export async function listRegistry(
|
||||
pinnedVersion: record?.pinnedVersion ?? null,
|
||||
autoUpdate: Boolean(record?.autoUpdate),
|
||||
bundled: shipped.has(plugin.id),
|
||||
installCount: counts.get(plugin.id)?.count ?? null,
|
||||
installCountSource: counts.get(plugin.id)?.source ?? null,
|
||||
downloads: counts.get(source.id)?.get(plugin.id) ?? null,
|
||||
};
|
||||
}),
|
||||
};
|
||||
@@ -362,7 +485,8 @@ async function grantDeclared(
|
||||
invalidatePluginPermissionCache(plugin.id);
|
||||
}
|
||||
|
||||
type SwapSource = { release: RegistryVersion } | { upload: string };
|
||||
type SwapSource =
|
||||
{ release: RegistryVersion; source: RegistrySource } | { upload: string };
|
||||
|
||||
/**
|
||||
* Puts a registry release, or an uploaded file, in place of whatever copy of
|
||||
@@ -377,6 +501,7 @@ async function swapIn(
|
||||
const bundledVersion = loader.bundledVersion(pluginId);
|
||||
const useBundled =
|
||||
"release" in source &&
|
||||
source.source.tier === "official" &&
|
||||
bundledVersion !== undefined &&
|
||||
bundledVersion === source.release.version &&
|
||||
isBundledOnDisk(pluginId);
|
||||
@@ -385,7 +510,7 @@ async function swapIn(
|
||||
const staged = useBundled
|
||||
? null
|
||||
: "release" in source
|
||||
? await downloadRelease(pluginId, source.release)
|
||||
? await downloadRelease(pluginId, source.release, source.source.keys)
|
||||
: { file: source.upload, signatureFile: `${source.upload}.sig` };
|
||||
|
||||
const finalFile = path.join(getPluginsDir(), `${pluginId}.tmxplug`);
|
||||
@@ -654,6 +779,8 @@ export interface InstallOptions {
|
||||
version?: string;
|
||||
/** Install only: the channel to follow from the start. */
|
||||
channel?: PluginChannel;
|
||||
/** Install only: the registry to install from. Official when unset. */
|
||||
registryId?: string;
|
||||
userId: string | null;
|
||||
acceptCapabilities?: boolean;
|
||||
/**
|
||||
@@ -684,33 +811,68 @@ function assertConsent(
|
||||
}
|
||||
}
|
||||
|
||||
function unreachable(error: unknown): PluginManageError {
|
||||
return new PluginManageError(
|
||||
`Could not reach the plugin registry: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
502,
|
||||
"REGISTRY_UNREACHABLE",
|
||||
);
|
||||
}
|
||||
|
||||
async function requireRelease(
|
||||
pluginId: string,
|
||||
version: string | undefined,
|
||||
channel: PluginChannel,
|
||||
registryId: string = OFFICIAL_REGISTRY_ID,
|
||||
): Promise<{
|
||||
index: RegistryIndex;
|
||||
source: RegistrySource;
|
||||
release: RegistryVersion;
|
||||
latest: RegistryVersion | null;
|
||||
}> {
|
||||
const source = await findSource(registryId);
|
||||
if (!source) {
|
||||
throw new PluginManageError(
|
||||
`The registry "${registryId}" is turned off or was removed`,
|
||||
409,
|
||||
"REGISTRY_OFF",
|
||||
);
|
||||
}
|
||||
let index: RegistryIndex;
|
||||
try {
|
||||
index = await fetchRegistryIndex();
|
||||
index = await fetchRegistryIndex(source);
|
||||
} catch (error) {
|
||||
throw unreachable(error);
|
||||
}
|
||||
if (await keyChanged(source, index)) {
|
||||
throw new PluginManageError(
|
||||
`Could not reach the plugin registry: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
502,
|
||||
"REGISTRY_UNREACHABLE",
|
||||
`${source.name}: ${KEY_CHANGED_MESSAGE}`,
|
||||
409,
|
||||
"KEY_CHANGED",
|
||||
);
|
||||
}
|
||||
if (source.tier !== "official") {
|
||||
let official: RegistryIndex;
|
||||
try {
|
||||
official = await fetchRegistryIndex(officialSource());
|
||||
} catch (error) {
|
||||
throw unreachable(error);
|
||||
}
|
||||
if (reservedIds(official).has(pluginId)) {
|
||||
throw new PluginManageError(
|
||||
`${pluginId} is an official plugin and can only come from the official registry`,
|
||||
409,
|
||||
"RESERVED_ID",
|
||||
);
|
||||
}
|
||||
}
|
||||
const found = findRelease(index, pluginId, version, channel);
|
||||
if (!found) {
|
||||
throw new PluginManageError(
|
||||
version
|
||||
? `${pluginId} ${version} is not in the official registry`
|
||||
: `${pluginId} is not in the official registry`,
|
||||
? `${pluginId} ${version} is not in ${source.name}`
|
||||
: `${pluginId} is not in ${source.name}`,
|
||||
404,
|
||||
"NOT_IN_REGISTRY",
|
||||
);
|
||||
@@ -723,12 +885,20 @@ async function requireRelease(
|
||||
);
|
||||
}
|
||||
return {
|
||||
index,
|
||||
source,
|
||||
release: found.release,
|
||||
latest: latestOnChannel(found.plugin, channel),
|
||||
};
|
||||
}
|
||||
|
||||
/** What a registry install is recorded as. */
|
||||
function tierFor(plugin: LoadedPlugin, source: RegistrySource): string {
|
||||
if (source.tier === "official" && plugin.source === "bundled") {
|
||||
return "bundled";
|
||||
}
|
||||
return source.tier;
|
||||
}
|
||||
|
||||
/** The version to hold at: an explicit pick that is not the newest. */
|
||||
function pinFor(
|
||||
release: RegistryVersion,
|
||||
@@ -763,10 +933,11 @@ export function installPlugin(
|
||||
}
|
||||
|
||||
const channel = options.channel ?? "stable";
|
||||
const { release, latest } = await requireRelease(
|
||||
const { source, release, latest } = await requireRelease(
|
||||
pluginId,
|
||||
options.version,
|
||||
channel,
|
||||
options.registryId,
|
||||
);
|
||||
assertConsent(options.capabilities, release.capabilities);
|
||||
const pin = pinFor(release, latest, options.version);
|
||||
@@ -774,7 +945,7 @@ export function installPlugin(
|
||||
|
||||
let plugin: LoadedPlugin;
|
||||
try {
|
||||
plugin = await swapIn(pluginId, { release });
|
||||
plugin = await swapIn(pluginId, { release, source });
|
||||
} catch (error) {
|
||||
await applyPin(pluginId, null);
|
||||
throw new PluginManageError(
|
||||
@@ -785,8 +956,8 @@ export function installPlugin(
|
||||
}
|
||||
|
||||
await repository.update(pluginId, {
|
||||
registryId: OFFICIAL_REGISTRY_ID,
|
||||
tier: plugin.source === "bundled" ? "bundled" : "official",
|
||||
registryId: source.id,
|
||||
tier: tierFor(plugin, source),
|
||||
pinnedVersion: pin,
|
||||
channel,
|
||||
});
|
||||
@@ -832,10 +1003,11 @@ async function updateUnlocked(
|
||||
throw new PluginManageError("Plugin not found", 404, "NOT_FOUND");
|
||||
}
|
||||
|
||||
const { release, latest } = await requireRelease(
|
||||
const { source, release, latest } = await requireRelease(
|
||||
pluginId,
|
||||
options.version,
|
||||
channelOf(record),
|
||||
record.registryId ?? OFFICIAL_REGISTRY_ID,
|
||||
);
|
||||
if (release.version === live.manifest.version) {
|
||||
throw new PluginManageError(
|
||||
@@ -867,7 +1039,7 @@ async function updateUnlocked(
|
||||
|
||||
let plugin: LoadedPlugin;
|
||||
try {
|
||||
plugin = await swapIn(pluginId, { release });
|
||||
plugin = await swapIn(pluginId, { release, source });
|
||||
} catch (error) {
|
||||
await applyPin(pluginId, record.pinnedVersion ?? null);
|
||||
if (wasEnabled && loader.get(pluginId)) await startAndRecord(pluginId);
|
||||
@@ -879,8 +1051,8 @@ async function updateUnlocked(
|
||||
}
|
||||
|
||||
await repository.update(pluginId, {
|
||||
registryId: OFFICIAL_REGISTRY_ID,
|
||||
tier: plugin.source === "bundled" ? "bundled" : "official",
|
||||
registryId: source.id,
|
||||
tier: tierFor(plugin, source),
|
||||
pinnedVersion: pin,
|
||||
});
|
||||
await grantDeclared(plugin, options.userId);
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
/**
|
||||
* Which registries this server reads plugins from.
|
||||
*
|
||||
* Official is always on. Community is off until an admin turns it on. Custom
|
||||
* registries are added by URL: their index names the key they sign with, the
|
||||
* admin confirms its fingerprint, and that key is pinned. A later key change
|
||||
* in the index is never followed, the admin has to add the registry again.
|
||||
*
|
||||
* Stored as one JSON value in the settings table.
|
||||
*/
|
||||
|
||||
import { pluginLogger } from "../utils/logger.js";
|
||||
import {
|
||||
OFFICIAL_REGISTRY_ID,
|
||||
COMMUNITY_REGISTRY_ID,
|
||||
communitySource,
|
||||
fetchIndexAt,
|
||||
officialSource,
|
||||
type RegistrySource,
|
||||
} from "./registry-index.js";
|
||||
import {
|
||||
isPluginPublicKey,
|
||||
pluginKeyId,
|
||||
setCustomPluginKeys,
|
||||
type TrustedPluginKey,
|
||||
} from "./trust.js";
|
||||
|
||||
const CONFIG_KEY = "plugin_registries";
|
||||
/** Keys a linked desktop copied from its server, so mirrored plugins load. */
|
||||
const SYNCED_KEYS_KEY = "plugin_registry_synced_keys";
|
||||
const ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
||||
const MAX_ID_LENGTH = 40;
|
||||
const MAX_NAME_LENGTH = 80;
|
||||
const MAX_CUSTOM = 20;
|
||||
|
||||
export interface CustomRegistry {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
publicKey: string;
|
||||
keyId: string;
|
||||
enabled: boolean;
|
||||
addedAt: string;
|
||||
}
|
||||
|
||||
export interface RegistriesConfig {
|
||||
community: { enabled: boolean };
|
||||
custom: CustomRegistry[];
|
||||
}
|
||||
|
||||
export class RegistryError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number,
|
||||
readonly code: string,
|
||||
readonly details?: Record<string, unknown>,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function settings() {
|
||||
const { createCurrentSettingsRepository } =
|
||||
await import("../database/repositories/factory.js");
|
||||
return createCurrentSettingsRepository();
|
||||
}
|
||||
|
||||
function parseCustom(raw: unknown): CustomRegistry | null {
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
const entry = raw as Record<string, unknown>;
|
||||
if (
|
||||
typeof entry.id !== "string" ||
|
||||
!ID_PATTERN.test(entry.id) ||
|
||||
typeof entry.url !== "string" ||
|
||||
!isPluginPublicKey(entry.publicKey)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: entry.id,
|
||||
name: typeof entry.name === "string" ? entry.name : entry.id,
|
||||
url: entry.url,
|
||||
publicKey: entry.publicKey,
|
||||
keyId: pluginKeyId(entry.publicKey),
|
||||
enabled: entry.enabled !== false,
|
||||
addedAt: typeof entry.addedAt === "string" ? entry.addedAt : "",
|
||||
};
|
||||
}
|
||||
|
||||
export function parseRegistriesConfig(raw: string | null): RegistriesConfig {
|
||||
const config: RegistriesConfig = {
|
||||
community: { enabled: false },
|
||||
custom: [],
|
||||
};
|
||||
if (!raw) return config;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
const community = parsed?.community as Record<string, unknown> | undefined;
|
||||
config.community.enabled = community?.enabled === true;
|
||||
if (Array.isArray(parsed?.custom)) {
|
||||
config.custom = parsed.custom
|
||||
.map(parseCustom)
|
||||
.filter((entry): entry is CustomRegistry => entry !== null);
|
||||
}
|
||||
} catch {
|
||||
// A broken value falls back to official only.
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
export async function readRegistriesConfig(): Promise<RegistriesConfig> {
|
||||
return parseRegistriesConfig(await (await settings()).get(CONFIG_KEY));
|
||||
}
|
||||
|
||||
async function writeRegistriesConfig(config: RegistriesConfig): Promise<void> {
|
||||
await (await settings()).set(CONFIG_KEY, JSON.stringify(config));
|
||||
await applyCustomKeys(config);
|
||||
}
|
||||
|
||||
function toKey(publicKey: string): TrustedPluginKey {
|
||||
return { id: pluginKeyId(publicKey), publicKey, addedIn: "admin" };
|
||||
}
|
||||
|
||||
async function readSyncedKeys(): Promise<string[]> {
|
||||
const raw = await (await settings()).get(SYNCED_KEYS_KEY);
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed.filter(isPluginPublicKey) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trusts the key of every custom registry, on or off: a plugin installed
|
||||
* from a registry that was later turned off still has to load.
|
||||
*/
|
||||
async function applyCustomKeys(config: RegistriesConfig): Promise<void> {
|
||||
const keys = new Map<string, TrustedPluginKey>();
|
||||
for (const entry of config.custom) {
|
||||
keys.set(entry.keyId, toKey(entry.publicKey));
|
||||
}
|
||||
for (const publicKey of await readSyncedKeys()) {
|
||||
keys.set(pluginKeyId(publicKey), toKey(publicKey));
|
||||
}
|
||||
setCustomPluginKeys([...keys.values()]);
|
||||
}
|
||||
|
||||
/** Called before the boot scan so plugins from custom registries load. */
|
||||
export async function loadCustomRegistryKeys(): Promise<void> {
|
||||
try {
|
||||
await applyCustomKeys(await readRegistriesConfig());
|
||||
} catch (error) {
|
||||
pluginLogger.warn(
|
||||
`Could not read plugin registries: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
{ operation: "plugin_init" },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** A linked desktop trusts the keys of its server's custom registries. */
|
||||
export async function setSyncedRegistryKeys(
|
||||
publicKeys: readonly unknown[],
|
||||
): Promise<void> {
|
||||
const valid = [...new Set(publicKeys.filter(isPluginPublicKey))].sort();
|
||||
const current = (await readSyncedKeys()).sort();
|
||||
if (valid.join() !== current.join()) {
|
||||
await (await settings()).set(SYNCED_KEYS_KEY, JSON.stringify(valid));
|
||||
}
|
||||
await applyCustomKeys(await readRegistriesConfig());
|
||||
}
|
||||
|
||||
export function customSource(entry: CustomRegistry): RegistrySource {
|
||||
return {
|
||||
id: entry.id,
|
||||
name: entry.name,
|
||||
url: entry.url,
|
||||
tier: "custom",
|
||||
keys: [toKey(entry.publicKey)],
|
||||
};
|
||||
}
|
||||
|
||||
/** Every registry plugins can come from right now, official first. */
|
||||
export async function listSources(): Promise<RegistrySource[]> {
|
||||
const config = await readRegistriesConfig();
|
||||
const sources = [officialSource()];
|
||||
if (config.community.enabled) sources.push(communitySource());
|
||||
for (const entry of config.custom) {
|
||||
if (entry.enabled) sources.push(customSource(entry));
|
||||
}
|
||||
return sources;
|
||||
}
|
||||
|
||||
export async function findSource(id: string): Promise<RegistrySource | null> {
|
||||
return (await listSources()).find((source) => source.id === id) ?? null;
|
||||
}
|
||||
|
||||
/** The pinned key of a custom registry, for spotting a changed index key. */
|
||||
export async function pinnedKeyOf(id: string): Promise<string | null> {
|
||||
const config = await readRegistriesConfig();
|
||||
return config.custom.find((entry) => entry.id === id)?.publicKey ?? null;
|
||||
}
|
||||
|
||||
export interface RegistryPreview {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
keyId: string;
|
||||
plugins: number;
|
||||
}
|
||||
|
||||
function normalizeUrl(input: unknown): string {
|
||||
if (typeof input !== "string" || !input.trim()) {
|
||||
throw new RegistryError("Enter the index URL", 400, "INVALID_URL");
|
||||
}
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(input.trim());
|
||||
} catch {
|
||||
throw new RegistryError("That is not a valid URL", 400, "INVALID_URL");
|
||||
}
|
||||
if (url.protocol !== "https:") {
|
||||
throw new RegistryError("The index must use https", 400, "INVALID_URL");
|
||||
}
|
||||
url.hash = "";
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function baseId(raw: string): string {
|
||||
const cleaned = raw
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, MAX_ID_LENGTH);
|
||||
return ID_PATTERN.test(cleaned) ? cleaned : "custom";
|
||||
}
|
||||
|
||||
function uniqueId(raw: string, config: RegistriesConfig): string {
|
||||
const taken = new Set<string>([
|
||||
OFFICIAL_REGISTRY_ID,
|
||||
COMMUNITY_REGISTRY_ID,
|
||||
...config.custom.map((entry) => entry.id),
|
||||
]);
|
||||
const base = baseId(raw);
|
||||
if (!taken.has(base)) return base;
|
||||
for (let n = 2; ; n++) {
|
||||
const candidate = `${base}-${n}`;
|
||||
if (!taken.has(candidate)) return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
async function inspect(
|
||||
input: unknown,
|
||||
config: RegistriesConfig,
|
||||
): Promise<RegistryPreview & { publicKey: string }> {
|
||||
const url = normalizeUrl(input);
|
||||
if (
|
||||
url === officialSource().url ||
|
||||
url === communitySource().url ||
|
||||
config.custom.some((entry) => entry.url === url)
|
||||
) {
|
||||
throw new RegistryError(
|
||||
"This registry is already added",
|
||||
409,
|
||||
"ALREADY_ADDED",
|
||||
);
|
||||
}
|
||||
let index;
|
||||
try {
|
||||
index = await fetchIndexAt(url);
|
||||
} catch (error) {
|
||||
throw new RegistryError(
|
||||
`Could not read the registry: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
502,
|
||||
"REGISTRY_UNREACHABLE",
|
||||
);
|
||||
}
|
||||
if (!index.publicKey) {
|
||||
throw new RegistryError(
|
||||
"The index has no publicKey, so its plugins cannot be checked",
|
||||
400,
|
||||
"NO_PUBLIC_KEY",
|
||||
);
|
||||
}
|
||||
return {
|
||||
id: uniqueId(index.registry || "custom", config),
|
||||
name: (index.name || index.registry || url).slice(0, MAX_NAME_LENGTH),
|
||||
url,
|
||||
keyId: pluginKeyId(index.publicKey),
|
||||
publicKey: index.publicKey,
|
||||
plugins: index.plugins.length,
|
||||
};
|
||||
}
|
||||
|
||||
/** Reads an index so the admin can check its name and key before adding. */
|
||||
export async function previewRegistry(url: unknown): Promise<RegistryPreview> {
|
||||
const { publicKey: _publicKey, ...preview } = await inspect(
|
||||
url,
|
||||
await readRegistriesConfig(),
|
||||
);
|
||||
return preview;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a custom registry. `keyId` is the fingerprint the admin saw in the
|
||||
* preview; the index is read again and must still name that key.
|
||||
*/
|
||||
export async function addRegistry(
|
||||
url: unknown,
|
||||
keyId: unknown,
|
||||
): Promise<CustomRegistry> {
|
||||
const config = await readRegistriesConfig();
|
||||
if (config.custom.length >= MAX_CUSTOM) {
|
||||
throw new RegistryError(
|
||||
`At most ${MAX_CUSTOM} custom registries can be added`,
|
||||
400,
|
||||
"TOO_MANY",
|
||||
);
|
||||
}
|
||||
const preview = await inspect(url, config);
|
||||
if (typeof keyId !== "string" || keyId !== preview.keyId) {
|
||||
throw new RegistryError(
|
||||
"The registry key changed since the preview. Check it again",
|
||||
409,
|
||||
"KEY_MISMATCH",
|
||||
{ keyId: preview.keyId },
|
||||
);
|
||||
}
|
||||
const entry: CustomRegistry = {
|
||||
id: preview.id,
|
||||
name: preview.name,
|
||||
url: preview.url,
|
||||
publicKey: preview.publicKey,
|
||||
keyId: preview.keyId,
|
||||
enabled: true,
|
||||
addedAt: new Date().toISOString(),
|
||||
};
|
||||
config.custom.push(entry);
|
||||
await writeRegistriesConfig(config);
|
||||
pluginLogger.info(`Added plugin registry ${entry.id} (${entry.url})`, {
|
||||
operation: "plugin_registry",
|
||||
});
|
||||
return entry;
|
||||
}
|
||||
|
||||
export async function setRegistryEnabled(
|
||||
id: string,
|
||||
enabled: boolean,
|
||||
): Promise<void> {
|
||||
const config = await readRegistriesConfig();
|
||||
if (id === OFFICIAL_REGISTRY_ID) {
|
||||
throw new RegistryError(
|
||||
"The official registry is always on",
|
||||
400,
|
||||
"OFFICIAL",
|
||||
);
|
||||
}
|
||||
if (id === COMMUNITY_REGISTRY_ID) {
|
||||
config.community.enabled = enabled;
|
||||
} else {
|
||||
const entry = config.custom.find((candidate) => candidate.id === id);
|
||||
if (!entry) {
|
||||
throw new RegistryError("Registry not found", 404, "NOT_FOUND");
|
||||
}
|
||||
entry.enabled = enabled;
|
||||
}
|
||||
await writeRegistriesConfig(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a custom registry. Refused while plugins from it are installed:
|
||||
* its key has to stay trusted for them to load.
|
||||
*/
|
||||
export async function removeRegistry(id: string): Promise<void> {
|
||||
if (id === OFFICIAL_REGISTRY_ID || id === COMMUNITY_REGISTRY_ID) {
|
||||
throw new RegistryError(
|
||||
"Built in registries can only be turned off",
|
||||
400,
|
||||
"BUILT_IN",
|
||||
);
|
||||
}
|
||||
const config = await readRegistriesConfig();
|
||||
if (!config.custom.some((candidate) => candidate.id === id)) {
|
||||
throw new RegistryError("Registry not found", 404, "NOT_FOUND");
|
||||
}
|
||||
const { createCurrentPluginRepository } =
|
||||
await import("../database/repositories/factory.js");
|
||||
const using = (await createCurrentPluginRepository().listAll())
|
||||
.filter((record) => record.registryId === id)
|
||||
.map((record) => record.id);
|
||||
if (using.length > 0) {
|
||||
throw new RegistryError(
|
||||
"Uninstall the plugins from this registry first",
|
||||
409,
|
||||
"REGISTRY_IN_USE",
|
||||
{ plugins: using },
|
||||
);
|
||||
}
|
||||
config.custom = config.custom.filter((candidate) => candidate.id !== id);
|
||||
await writeRegistriesConfig(config);
|
||||
pluginLogger.info(`Removed plugin registry ${id}`, {
|
||||
operation: "plugin_registry",
|
||||
});
|
||||
}
|
||||
|
||||
/** Public keys of the custom registries, for a linked desktop. */
|
||||
export async function customRegistryKeys(): Promise<string[]> {
|
||||
return (await readRegistriesConfig()).custom.map((entry) => entry.publicKey);
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
/**
|
||||
* The official plugin registry: fetching its index and downloading a release.
|
||||
* Plugin registries: fetching an index and downloading a release.
|
||||
*
|
||||
* The index is a plain JSON file in Termix-SSH/Termix-Registry. Nothing in it
|
||||
* is trusted on its own: every download is checked against the sha256 and
|
||||
* Ed25519 signature listed for it, and the signature must come from a key
|
||||
* pinned in trust.ts. A tampered index can at most point at a file that then
|
||||
* fails the check.
|
||||
* An index is a plain JSON file. Official and community live in
|
||||
* Termix-SSH/Termix-Registry, custom ones anywhere an admin points to.
|
||||
* Nothing in an index is trusted on its own: every download is checked
|
||||
* against the sha256 and Ed25519 signature listed for it, and the signature
|
||||
* must come from that registry's keys (built in for official and community,
|
||||
* pinned when an admin added a custom one). A tampered index can at most
|
||||
* point at a file that then fails the check.
|
||||
*/
|
||||
|
||||
import crypto from "node:crypto";
|
||||
@@ -20,13 +22,33 @@ import {
|
||||
} from "@termix-ssh/plugin-sdk/manifest";
|
||||
import { safeOutboundFetch } from "../utils/safe-outbound-fetch.js";
|
||||
import { pluginLogger } from "../utils/logger.js";
|
||||
import { verifyPluginArtifact } from "./trust.js";
|
||||
import {
|
||||
COMMUNITY_PLUGIN_KEYS,
|
||||
TRUSTED_PLUGIN_KEYS,
|
||||
isPluginPublicKey,
|
||||
verifyPluginArtifact,
|
||||
type TrustedPluginKey,
|
||||
} from "./trust.js";
|
||||
import { readArtifactManifest, sameCapabilities } from "./artifact-manifest.js";
|
||||
import { getPluginsDir } from "./paths.js";
|
||||
|
||||
export const OFFICIAL_REGISTRY_ID = "official";
|
||||
export const OFFICIAL_REGISTRY_URL =
|
||||
"https://raw.githubusercontent.com/Termix-SSH/Termix-Registry/main/official/index.json";
|
||||
export const COMMUNITY_REGISTRY_ID = "community";
|
||||
export const COMMUNITY_REGISTRY_URL =
|
||||
"https://raw.githubusercontent.com/Termix-SSH/Termix-Registry/main/community/index.json";
|
||||
|
||||
export type RegistryTier = "official" | "community" | "custom";
|
||||
|
||||
/** Where an index lives and which keys may sign what it lists. */
|
||||
export interface RegistrySource {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
tier: RegistryTier;
|
||||
keys: readonly TrustedPluginKey[];
|
||||
}
|
||||
|
||||
const CACHE_MS = 15 * 60_000;
|
||||
const MAX_INDEX_BYTES = 5 * 1024 * 1024;
|
||||
@@ -83,6 +105,8 @@ export interface RegistryIndex {
|
||||
registry: string;
|
||||
name: string;
|
||||
updatedAt?: string;
|
||||
/** The key the registry signs with, base64. Only read when adding one. */
|
||||
publicKey?: string;
|
||||
plugins: RegistryPlugin[];
|
||||
}
|
||||
|
||||
@@ -90,6 +114,26 @@ export function getRegistryUrl(): string {
|
||||
return process.env.TERMIX_PLUGIN_REGISTRY_URL || OFFICIAL_REGISTRY_URL;
|
||||
}
|
||||
|
||||
export function officialSource(): RegistrySource {
|
||||
return {
|
||||
id: OFFICIAL_REGISTRY_ID,
|
||||
name: "Termix Official Plugins",
|
||||
url: getRegistryUrl(),
|
||||
tier: "official",
|
||||
keys: TRUSTED_PLUGIN_KEYS,
|
||||
};
|
||||
}
|
||||
|
||||
export function communitySource(): RegistrySource {
|
||||
return {
|
||||
id: COMMUNITY_REGISTRY_ID,
|
||||
name: "Termix Community Plugins",
|
||||
url: process.env.TERMIX_COMMUNITY_REGISTRY_URL || COMMUNITY_REGISTRY_URL,
|
||||
tier: "community",
|
||||
keys: COMMUNITY_PLUGIN_KEYS,
|
||||
};
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | undefined {
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
@@ -174,6 +218,7 @@ export function parseRegistryIndex(raw: unknown): RegistryIndex {
|
||||
registry: asString(index.registry) ?? OFFICIAL_REGISTRY_ID,
|
||||
name: asString(index.name) ?? "",
|
||||
updatedAt: asString(index.updatedAt),
|
||||
publicKey: isPluginPublicKey(index.publicKey) ? index.publicKey : undefined,
|
||||
plugins,
|
||||
};
|
||||
}
|
||||
@@ -228,8 +273,18 @@ async function readLimited(response: Response, limit: number): Promise<Buffer> {
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
let cache: { index: RegistryIndex; at: number; hash: string } | null = null;
|
||||
let inflight: Promise<RegistryIndex> | null = null;
|
||||
interface CacheEntry {
|
||||
index: RegistryIndex;
|
||||
at: number;
|
||||
}
|
||||
|
||||
const caches = new Map<string, CacheEntry>();
|
||||
const inflight = new Map<string, Promise<RegistryIndex>>();
|
||||
const lastErrors = new Map<string, string>();
|
||||
|
||||
function cacheKey(source: RegistrySource): string {
|
||||
return `${source.id} ${source.url}`;
|
||||
}
|
||||
|
||||
export interface RegistryStatus {
|
||||
url: string;
|
||||
@@ -237,63 +292,83 @@ export interface RegistryStatus {
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
let lastError: string | null = null;
|
||||
|
||||
export function getRegistryStatus(): RegistryStatus {
|
||||
export function getRegistryStatus(
|
||||
source: RegistrySource = officialSource(),
|
||||
): RegistryStatus {
|
||||
const cache = caches.get(cacheKey(source));
|
||||
return {
|
||||
url: getRegistryUrl(),
|
||||
url: source.url,
|
||||
lastCheckedAt: cache ? new Date(cache.at).toISOString() : null,
|
||||
error: lastError,
|
||||
error: lastErrors.get(cacheKey(source)) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Fetches and parses an index with no caching, for previewing a new one. */
|
||||
export async function fetchIndexAt(url: string): Promise<RegistryIndex> {
|
||||
const response = await fetchFollowing(url);
|
||||
const body = await readLimited(response, MAX_INDEX_BYTES);
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = JSON.parse(body.toString("utf8"));
|
||||
} catch {
|
||||
throw new Error("the registry index is not valid JSON");
|
||||
}
|
||||
return parseRegistryIndex(raw);
|
||||
}
|
||||
|
||||
export async function fetchRegistryIndex(
|
||||
source: RegistrySource = officialSource(),
|
||||
options: { force?: boolean } = {},
|
||||
): Promise<RegistryIndex> {
|
||||
const key = cacheKey(source);
|
||||
const cache = caches.get(key);
|
||||
if (!options.force && cache && Date.now() - cache.at < CACHE_MS) {
|
||||
return cache.index;
|
||||
}
|
||||
if (inflight) return inflight;
|
||||
const running = inflight.get(key);
|
||||
if (running) return running;
|
||||
|
||||
inflight = (async () => {
|
||||
const request = (async () => {
|
||||
try {
|
||||
const response = await fetchFollowing(getRegistryUrl());
|
||||
const body = await readLimited(response, MAX_INDEX_BYTES);
|
||||
const index = parseRegistryIndex(JSON.parse(body.toString("utf8")));
|
||||
cache = {
|
||||
index,
|
||||
at: Date.now(),
|
||||
hash: crypto.createHash("sha256").update(body).digest("hex"),
|
||||
};
|
||||
lastError = null;
|
||||
const index = await fetchIndexAt(source.url);
|
||||
caches.set(key, { index, at: Date.now() });
|
||||
lastErrors.delete(key);
|
||||
return index;
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error.message : String(error);
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
lastErrors.set(key, message);
|
||||
// A stale index beats none when the registry is briefly unreachable.
|
||||
if (cache) {
|
||||
pluginLogger.warn(`Plugin registry check failed: ${lastError}`, {
|
||||
operation: "plugin_registry",
|
||||
});
|
||||
return cache.index;
|
||||
const stale = caches.get(key);
|
||||
if (stale) {
|
||||
pluginLogger.warn(
|
||||
`Plugin registry ${source.id} check failed: ${message}`,
|
||||
{ operation: "plugin_registry" },
|
||||
);
|
||||
return stale.index;
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
inflight = null;
|
||||
inflight.delete(key);
|
||||
}
|
||||
})();
|
||||
return inflight;
|
||||
inflight.set(key, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
export interface RegistryStats {
|
||||
plugins: Map<string, { downloads: number; activeInstalls: number | null }>;
|
||||
/** Downloads per plugin id. */
|
||||
plugins: Map<string, number>;
|
||||
}
|
||||
|
||||
/** stats.json sits next to index.json and is rebuilt daily by the registry. */
|
||||
export function getStatsUrl(): string | null {
|
||||
if (process.env.TERMIX_PLUGIN_STATS_URL) {
|
||||
export function getStatsUrl(
|
||||
source: RegistrySource = officialSource(),
|
||||
): string | null {
|
||||
if (source.tier === "custom") return null;
|
||||
if (source.tier === "official" && process.env.TERMIX_PLUGIN_STATS_URL) {
|
||||
return process.env.TERMIX_PLUGIN_STATS_URL;
|
||||
}
|
||||
const url = getRegistryUrl();
|
||||
const url = source.url;
|
||||
return /\/index\.json$/.test(url)
|
||||
? url.replace(/\/index\.json$/, "/stats.json")
|
||||
: null;
|
||||
@@ -306,27 +381,22 @@ function asCount(value: unknown): number | null {
|
||||
}
|
||||
|
||||
export function parseRegistryStats(raw: unknown): RegistryStats {
|
||||
const plugins = new Map<
|
||||
string,
|
||||
{ downloads: number; activeInstalls: number | null }
|
||||
>();
|
||||
const plugins = new Map<string, number>();
|
||||
const entries = (raw as { plugins?: unknown } | null)?.plugins;
|
||||
if (!entries || typeof entries !== "object" || Array.isArray(entries)) {
|
||||
return { plugins };
|
||||
}
|
||||
for (const [id, value] of Object.entries(entries)) {
|
||||
if (!ID_PATTERN.test(id) || !value || typeof value !== "object") continue;
|
||||
const entry = value as Record<string, unknown>;
|
||||
plugins.set(id, {
|
||||
downloads: asCount(entry.downloads) ?? 0,
|
||||
activeInstalls: asCount(entry.activeInstalls),
|
||||
});
|
||||
plugins.set(id, asCount((value as Record<string, unknown>).downloads) ?? 0);
|
||||
}
|
||||
return { plugins };
|
||||
}
|
||||
|
||||
export async function fetchRegistryStats(): Promise<RegistryStats | null> {
|
||||
const url = getStatsUrl();
|
||||
export async function fetchRegistryStats(
|
||||
source: RegistrySource = officialSource(),
|
||||
): Promise<RegistryStats | null> {
|
||||
const url = getStatsUrl(source);
|
||||
if (!url) return null;
|
||||
const response = await fetchFollowing(url);
|
||||
const body = await readLimited(response, MAX_INDEX_BYTES);
|
||||
@@ -335,9 +405,9 @@ export async function fetchRegistryStats(): Promise<RegistryStats | null> {
|
||||
|
||||
/** Test seam. */
|
||||
export function resetRegistryCache(): void {
|
||||
cache = null;
|
||||
inflight = null;
|
||||
lastError = null;
|
||||
caches.clear();
|
||||
inflight.clear();
|
||||
lastErrors.clear();
|
||||
}
|
||||
|
||||
/** Every release a channel may install, newest first. */
|
||||
@@ -414,6 +484,7 @@ export function assertArtifactMatches(
|
||||
export async function downloadRelease(
|
||||
pluginId: string,
|
||||
release: RegistryVersion,
|
||||
keys: readonly TrustedPluginKey[] = TRUSTED_PLUGIN_KEYS,
|
||||
): Promise<{ file: string; signatureFile: string }> {
|
||||
if (!isApiCompatible(release.api)) {
|
||||
throw new Error(
|
||||
@@ -433,6 +504,7 @@ export async function downloadRelease(
|
||||
buffer,
|
||||
release.sha256,
|
||||
release.signature,
|
||||
keys,
|
||||
);
|
||||
if (verified.ok === false) {
|
||||
throw new Error(`${pluginId} ${release.version}: ${verified.reason}`);
|
||||
|
||||
@@ -28,6 +28,49 @@ export const TRUSTED_PLUGIN_KEYS: readonly TrustedPluginKey[] = [
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Signs community plugins after a person reviewed them. Its own tier on
|
||||
* purpose: it can never replace a bundled plugin or count as official.
|
||||
*/
|
||||
export const COMMUNITY_PLUGIN_KEYS: readonly TrustedPluginKey[] = [
|
||||
{
|
||||
id: "be5b24ea07b36624",
|
||||
publicKey: "LYdHHGJeinCZ7UA8R/o5i1P5W3+dzktTDn5bxp4nPpI=",
|
||||
addedIn: "26.10.1",
|
||||
},
|
||||
];
|
||||
|
||||
export type KeyTier = "official" | "community" | "custom";
|
||||
|
||||
/** Keys of registries an admin added, pinned when they were added. */
|
||||
let customKeys: readonly TrustedPluginKey[] = [];
|
||||
|
||||
export function setCustomPluginKeys(keys: readonly TrustedPluginKey[]): void {
|
||||
customKeys = keys.filter((key) => toPublicKey(key.publicKey) !== null);
|
||||
}
|
||||
|
||||
export function getCustomPluginKeys(): readonly TrustedPluginKey[] {
|
||||
return customKeys;
|
||||
}
|
||||
|
||||
/** First 16 hex chars of the sha256 of a raw base64 public key. */
|
||||
export function pluginKeyId(publicKey: string): string {
|
||||
return crypto
|
||||
.createHash("sha256")
|
||||
.update(Buffer.from(publicKey, "base64"))
|
||||
.digest("hex")
|
||||
.slice(0, 16);
|
||||
}
|
||||
|
||||
/** Whether a string is a raw base64 Ed25519 public key. */
|
||||
export function isPluginPublicKey(value: unknown): value is string {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
/^[A-Za-z0-9+/]{43}=$/.test(value) &&
|
||||
toPublicKey(value) !== null
|
||||
);
|
||||
}
|
||||
|
||||
export type ArtifactVerification =
|
||||
{ ok: true; keyId: string } | { ok: false; reason: string };
|
||||
|
||||
@@ -86,6 +129,31 @@ export function verifyPluginArtifact(
|
||||
return { ok: false, reason: "signature does not match a trusted key" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks a file against every key this build trusts, official first, and
|
||||
* says which tier signed it.
|
||||
*/
|
||||
export function verifyAnyTrusted(
|
||||
buffer: Buffer,
|
||||
sha256: string,
|
||||
signature: string,
|
||||
): { ok: true; keyId: string; tier: KeyTier } | { ok: false; reason: string } {
|
||||
const tiers: Array<[KeyTier, readonly TrustedPluginKey[]]> = [
|
||||
["official", TRUSTED_PLUGIN_KEYS],
|
||||
["community", COMMUNITY_PLUGIN_KEYS],
|
||||
["custom", customKeys],
|
||||
];
|
||||
let reason = "signature does not match a trusted key";
|
||||
for (const [tier, keys] of tiers) {
|
||||
if (keys.length === 0) continue;
|
||||
const result = verifyPluginArtifact(buffer, sha256, signature, keys);
|
||||
if (result.ok === true) return { ok: true, keyId: result.keyId, tier };
|
||||
reason = result.reason;
|
||||
if (reason !== "signature does not match a trusted key") break;
|
||||
}
|
||||
return { ok: false, reason };
|
||||
}
|
||||
|
||||
/** TERMIX_REQUIRE_SIGNED_PLUGINS=true blocks unsigned user plugins. */
|
||||
export function requireSignedPlugins(): boolean {
|
||||
return process.env.TERMIX_REQUIRE_SIGNED_PLUGINS === "true";
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
unloadPlugin,
|
||||
} from "../../plugins/index.js";
|
||||
import { getPluginsDir } from "../../plugins/paths.js";
|
||||
import { setSyncedRegistryKeys } from "../../plugins/registries.js";
|
||||
import type { LoadedPlugin } from "../../plugins/loader.js";
|
||||
import {
|
||||
createCurrentPluginPermissionGrantRepository,
|
||||
@@ -283,10 +284,12 @@ async function mirrorOne(link: SyncLink, plugin: RemotePlugin): Promise<void> {
|
||||
}
|
||||
|
||||
export async function mirrorPlugins(link: SyncLink): Promise<void> {
|
||||
const { plugins } = await remoteJson<{ plugins: RemotePlugin[] }>(
|
||||
link,
|
||||
"/sync/v2/plugins",
|
||||
);
|
||||
const { plugins, registryKeys } = await remoteJson<{
|
||||
plugins: RemotePlugin[];
|
||||
registryKeys?: unknown[];
|
||||
}>(link, "/sync/v2/plugins");
|
||||
// Before any download: a plugin from a custom registry needs its key.
|
||||
await setSyncedRegistryKeys(Array.isArray(registryKeys) ? registryKeys : []);
|
||||
lastRemote = plugins;
|
||||
const remoteIds = new Set(plugins.map((plugin) => plugin.id));
|
||||
|
||||
|
||||
@@ -472,7 +472,7 @@ async function listPluginsForDesktop() {
|
||||
* /sync/v2/plugins:
|
||||
* get:
|
||||
* summary: The features this server runs, for a linked desktop to mirror
|
||||
* description: Every installed plugin with its version, where it came from, whether it is on, how a desktop should treat it and which sync entities it owns.
|
||||
* description: Every installed plugin with its version, where it came from, whether it is on, how a desktop should treat it and which sync entities it owns. registryKeys lists the signing keys of the custom registries an admin added, so plugins from them load on the desktop.
|
||||
* tags:
|
||||
* - Sync
|
||||
* responses:
|
||||
@@ -509,7 +509,9 @@ router.get(
|
||||
),
|
||||
});
|
||||
}
|
||||
res.json({ plugins });
|
||||
const { customRegistryKeys } =
|
||||
await import("../../plugins/registries.js");
|
||||
res.json({ plugins, registryKeys: await customRegistryKeys() });
|
||||
} catch (error) {
|
||||
syncLogger.error("Failed to list plugins for sync", error, {
|
||||
operation: "sync_plugins",
|
||||
|
||||
@@ -92,6 +92,13 @@ Host alias-only
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("accepts tabs and = between keywords and values", () => {
|
||||
const config = "Host\tweb\n\tHostName=10.0.0.5\n\tUser = deploy\n";
|
||||
expect(parseSSHConfig(config)).toEqual([
|
||||
{ name: "web", hostname: "10.0.0.5", user: "deploy" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("defaults port to undefined when not specified", () => {
|
||||
const config = `
|
||||
Host server
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { PluginManageError } from "../../../plugins/manage.js";
|
||||
import { RegistryError } from "../../../plugins/registries.js";
|
||||
import express from "express";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import type { Server } from "node:http";
|
||||
@@ -194,6 +195,19 @@ vi.mock("../../../utils/audit-logger.js", () => ({
|
||||
getRequestMeta: () => ({ ipAddress: "127.0.0.1", userAgent: "test" }),
|
||||
}));
|
||||
|
||||
const registriesMock = vi.hoisted(() => ({
|
||||
readRegistriesConfig: vi.fn(),
|
||||
previewRegistry: vi.fn(),
|
||||
addRegistry: vi.fn(),
|
||||
setRegistryEnabled: vi.fn(),
|
||||
removeRegistry: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../../plugins/registries.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../../../plugins/registries.js")>()),
|
||||
...registriesMock,
|
||||
}));
|
||||
|
||||
const onboardingMock = vi.hoisted(() => ({
|
||||
describeOnboardingPlugins: vi.fn(),
|
||||
applyOnboardingChoices: vi.fn(),
|
||||
@@ -443,6 +457,28 @@ describe("plugins route", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("marks server-only plugins on a linked desktop", async () => {
|
||||
state.plugins.set(
|
||||
"sample-plugin",
|
||||
makePlugin({ manifestJson: JSON.stringify({ desktop: "server" }) }),
|
||||
);
|
||||
|
||||
const read = async () =>
|
||||
(
|
||||
(await (await fetch(`${baseUrl}/plugins`)).json()) as Array<{
|
||||
runsOnServer: boolean;
|
||||
}>
|
||||
)[0].runsOnServer;
|
||||
|
||||
expect(await read()).toBe(false);
|
||||
manageMock.isLinkedDesktop.mockResolvedValue(true);
|
||||
try {
|
||||
expect(await read()).toBe(true);
|
||||
} finally {
|
||||
manageMock.isLinkedDesktop.mockResolvedValue(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("grants a declared capability", async () => {
|
||||
state.plugins.set("sample-plugin", makePlugin());
|
||||
|
||||
@@ -577,6 +613,7 @@ describe("plugins route", () => {
|
||||
version: "1.0.0",
|
||||
enabled: true,
|
||||
state: "enabled",
|
||||
runsOnServer: false,
|
||||
contributes: null,
|
||||
dependencies: {},
|
||||
optionalDependencies: {},
|
||||
@@ -744,6 +781,190 @@ describe("plugins route", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugin registries", () => {
|
||||
const call = (
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
user = "admin-1",
|
||||
) =>
|
||||
fetch(`${baseUrl}${path}`, {
|
||||
method,
|
||||
headers: { "content-type": "application/json", "x-test-user-id": user },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
for (const fn of Object.values(registriesMock)) fn.mockReset();
|
||||
manageMock.isLinkedDesktop.mockReset();
|
||||
manageMock.isLinkedDesktop.mockResolvedValue(false);
|
||||
auditMock.logAudit.mockClear();
|
||||
});
|
||||
|
||||
it("is admin only", async () => {
|
||||
for (const [method, path, body] of [
|
||||
["GET", "/plugins/registries", undefined],
|
||||
[
|
||||
"POST",
|
||||
"/plugins/registries/preview",
|
||||
{ url: "https://x.com/i.json" },
|
||||
],
|
||||
["POST", "/plugins/registries", { url: "https://x.com/i.json" }],
|
||||
["PATCH", "/plugins/registries/community", { enabled: true }],
|
||||
["DELETE", "/plugins/registries/acme", undefined],
|
||||
] as const) {
|
||||
const res = await call(method, path, body, "user-2");
|
||||
expect(res.status).toBe(403);
|
||||
}
|
||||
expect(registriesMock.setRegistryEnabled).not.toHaveBeenCalled();
|
||||
expect(registriesMock.removeRegistry).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lists the registries without the full public keys", async () => {
|
||||
registriesMock.readRegistriesConfig.mockResolvedValue({
|
||||
community: { enabled: true },
|
||||
custom: [
|
||||
{
|
||||
id: "acme",
|
||||
name: "Acme",
|
||||
url: "https://acme.example/index.json",
|
||||
publicKey: "secret-looking",
|
||||
keyId: "abcd",
|
||||
enabled: true,
|
||||
addedAt: "2026-10-11T00:00:00Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
const res = await call("GET", "/plugins/registries");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.community.enabled).toBe(true);
|
||||
expect(body.custom[0]).toEqual({
|
||||
id: "acme",
|
||||
name: "Acme",
|
||||
url: "https://acme.example/index.json",
|
||||
keyId: "abcd",
|
||||
enabled: true,
|
||||
addedAt: "2026-10-11T00:00:00Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("previews and adds a registry and audits the add", async () => {
|
||||
registriesMock.previewRegistry.mockResolvedValue({
|
||||
id: "acme",
|
||||
name: "Acme",
|
||||
url: "https://acme.example/index.json",
|
||||
keyId: "abcd",
|
||||
plugins: 2,
|
||||
});
|
||||
const preview = await call("POST", "/plugins/registries/preview", {
|
||||
url: "https://acme.example/index.json",
|
||||
});
|
||||
expect(preview.status).toBe(200);
|
||||
expect((await preview.json()).keyId).toBe("abcd");
|
||||
|
||||
registriesMock.addRegistry.mockResolvedValue({
|
||||
id: "acme",
|
||||
name: "Acme",
|
||||
url: "https://acme.example/index.json",
|
||||
publicKey: "k",
|
||||
keyId: "abcd",
|
||||
enabled: true,
|
||||
addedAt: "2026-10-11T00:00:00Z",
|
||||
});
|
||||
const added = await call("POST", "/plugins/registries", {
|
||||
url: "https://acme.example/index.json",
|
||||
keyId: "abcd",
|
||||
});
|
||||
expect(added.status).toBe(200);
|
||||
expect(registriesMock.addRegistry).toHaveBeenCalledWith(
|
||||
"https://acme.example/index.json",
|
||||
"abcd",
|
||||
);
|
||||
expect(auditMock.logAudit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "add_plugin_registry",
|
||||
resourceType: "plugin_registry",
|
||||
resourceId: "acme",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes registry errors through with their code", async () => {
|
||||
registriesMock.addRegistry.mockRejectedValue(
|
||||
new RegistryError("changed", 409, "KEY_MISMATCH", { keyId: "new" }),
|
||||
);
|
||||
const res = await call("POST", "/plugins/registries", {
|
||||
url: "https://acme.example/index.json",
|
||||
keyId: "old",
|
||||
});
|
||||
expect(res.status).toBe(409);
|
||||
expect(await res.json()).toEqual({
|
||||
error: "changed",
|
||||
code: "KEY_MISMATCH",
|
||||
keyId: "new",
|
||||
});
|
||||
});
|
||||
|
||||
it("turns a registry on and off and checks the body", async () => {
|
||||
expect(
|
||||
(await call("PATCH", "/plugins/registries/community", { enabled: "x" }))
|
||||
.status,
|
||||
).toBe(400);
|
||||
const res = await call("PATCH", "/plugins/registries/community", {
|
||||
enabled: true,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(registriesMock.setRegistryEnabled).toHaveBeenCalledWith(
|
||||
"community",
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses to remove a registry still in use", async () => {
|
||||
registriesMock.removeRegistry.mockRejectedValue(
|
||||
new RegistryError("in use", 409, "REGISTRY_IN_USE", {
|
||||
plugins: ["tool"],
|
||||
}),
|
||||
);
|
||||
const res = await call("DELETE", "/plugins/registries/acme");
|
||||
expect(res.status).toBe(409);
|
||||
expect((await res.json()).plugins).toEqual(["tool"]);
|
||||
});
|
||||
|
||||
it("leaves registries to the server on a linked desktop", async () => {
|
||||
manageMock.isLinkedDesktop.mockResolvedValue(true);
|
||||
const res = await call("PATCH", "/plugins/registries/community", {
|
||||
enabled: true,
|
||||
});
|
||||
expect(res.status).toBe(409);
|
||||
expect(registriesMock.setRegistryEnabled).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("installs from the registry the caller names", async () => {
|
||||
manageMock.installPlugin.mockReset();
|
||||
manageMock.installPlugin.mockResolvedValue({
|
||||
id: "neat",
|
||||
version: "1.0.0",
|
||||
state: "active",
|
||||
});
|
||||
const res = await call("POST", "/plugins/neat/install", {
|
||||
registryId: "community",
|
||||
capabilities: [],
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(manageMock.installPlugin).toHaveBeenCalledWith(
|
||||
"neat",
|
||||
expect.objectContaining({ registryId: "community" }),
|
||||
);
|
||||
const bad = await call("POST", "/plugins/neat/install", {
|
||||
registryId: "../x",
|
||||
capabilities: [],
|
||||
});
|
||||
expect(bad.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("install, update, uninstall", () => {
|
||||
const post = (path: string, body: unknown = {}, user = "admin-1") =>
|
||||
fetch(`${baseUrl}${path}`, {
|
||||
|
||||
@@ -61,6 +61,7 @@ function setup() {
|
||||
getC2SRelayHeaders: headers,
|
||||
getWebSocketOptions: (_url: string, options: unknown) => options,
|
||||
attachC2SAuth: vi.fn(),
|
||||
keepC2SSocketAlive: vi.fn(),
|
||||
logToFile: vi.fn(),
|
||||
emitC2STunnelStatuses: vi.fn(),
|
||||
checkLocalPortAvailable: async () => ({ available: true }),
|
||||
@@ -177,3 +178,32 @@ describe("client tunnel runtime authentication", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("client tunnel socket keepalive", () => {
|
||||
it("pings an open socket until it closes", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const context = vm.createContext({
|
||||
setInterval,
|
||||
clearInterval,
|
||||
WebSocket: { OPEN: 1 },
|
||||
});
|
||||
vm.runInContext(
|
||||
section("function keepC2SSocketAlive(", "// C2S (client-to-server)"),
|
||||
context,
|
||||
);
|
||||
const ws = Object.assign(new EventEmitter(), {
|
||||
readyState: 1,
|
||||
ping: vi.fn(),
|
||||
});
|
||||
context.keepC2SSocketAlive(ws);
|
||||
vi.advanceTimersByTime(50_000);
|
||||
expect(ws.ping).toHaveBeenCalledTimes(2);
|
||||
ws.emit("close");
|
||||
vi.advanceTimersByTime(50_000);
|
||||
expect(ws.ping).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
stats: null as null | { plugins: Map<string, number> },
|
||||
fail: false,
|
||||
sources: [{ id: "official", tier: "official" }] as Array<{
|
||||
id: string;
|
||||
tier: string;
|
||||
}>,
|
||||
statsBySource: {} as Record<string, { plugins: Map<string, number> } | null>,
|
||||
upserted: [] as Array<{ registryId: string; entries: unknown[] }>,
|
||||
rows: [] as Array<{ pluginId: string; count: number }>,
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/registry-index.js", () => ({
|
||||
OFFICIAL_REGISTRY_ID: "official",
|
||||
officialSource: () => ({ id: "official", tier: "official" }),
|
||||
fetchRegistryStats: async (source: { id: string }) => {
|
||||
if (state.fail) throw new Error("offline");
|
||||
return state.statsBySource[source.id] ?? state.stats;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/registries.js", () => ({
|
||||
listSources: async () => state.sources,
|
||||
}));
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginInstallCountRepository: () => ({
|
||||
upsertMany: async (registryId: string, entries: unknown[]) => {
|
||||
state.upserted.push({ registryId, entries });
|
||||
return entries.length;
|
||||
},
|
||||
listByRegistry: async () => state.rows,
|
||||
}),
|
||||
}));
|
||||
|
||||
const {
|
||||
readDownloadCounts,
|
||||
refreshDownloadCounts,
|
||||
resetDownloadCountRefresh,
|
||||
syncDownloadCounts,
|
||||
} = await import("../../plugins/download-counts.js");
|
||||
|
||||
beforeEach(() => {
|
||||
state.stats = null;
|
||||
state.fail = false;
|
||||
state.sources = [{ id: "official", tier: "official" }];
|
||||
state.statsBySource = {};
|
||||
state.upserted = [];
|
||||
state.rows = [];
|
||||
resetDownloadCountRefresh();
|
||||
});
|
||||
|
||||
describe("refreshDownloadCounts", () => {
|
||||
const docker = () => ({ plugins: new Map([["docker", 5]]) });
|
||||
|
||||
it("syncs on the first look and not again while fresh", async () => {
|
||||
state.stats = docker();
|
||||
await refreshDownloadCounts();
|
||||
await refreshDownloadCounts();
|
||||
expect(state.upserted).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not sync again right away even when forced", async () => {
|
||||
state.stats = docker();
|
||||
await refreshDownloadCounts();
|
||||
await refreshDownloadCounts(true);
|
||||
expect(state.upserted).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("syncs again when forced after a minute", async () => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
try {
|
||||
state.stats = docker();
|
||||
await refreshDownloadCounts();
|
||||
vi.setSystemTime(Date.now() + 61_000);
|
||||
await refreshDownloadCounts();
|
||||
expect(state.upserted).toHaveLength(1);
|
||||
await refreshDownloadCounts(true);
|
||||
expect(state.upserted).toHaveLength(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("syncDownloadCounts", () => {
|
||||
it("writes one row per plugin", async () => {
|
||||
state.stats = {
|
||||
plugins: new Map([
|
||||
["docker", 50],
|
||||
["tunnels", 8],
|
||||
]),
|
||||
};
|
||||
expect(await syncDownloadCounts()).toBe(2);
|
||||
expect(state.upserted).toEqual([
|
||||
{
|
||||
registryId: "official",
|
||||
entries: [
|
||||
{ pluginId: "docker", count: 50 },
|
||||
{ pluginId: "tunnels", count: 8 },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("stores counts per registry and skips custom ones", async () => {
|
||||
state.sources = [
|
||||
{ id: "official", tier: "official" },
|
||||
{ id: "community", tier: "community" },
|
||||
{ id: "mine", tier: "custom" },
|
||||
];
|
||||
state.statsBySource = {
|
||||
official: { plugins: new Map([["docker", 5]]) },
|
||||
community: { plugins: new Map([["neat", 3]]) },
|
||||
mine: null,
|
||||
};
|
||||
expect(await syncDownloadCounts()).toBe(2);
|
||||
expect(state.upserted.map((entry) => entry.registryId)).toEqual([
|
||||
"official",
|
||||
"community",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the old numbers when the registry is unreachable", async () => {
|
||||
state.fail = true;
|
||||
expect(await syncDownloadCounts()).toBe(0);
|
||||
expect(state.upserted).toEqual([]);
|
||||
});
|
||||
|
||||
it("does nothing for a registry with no stats file", async () => {
|
||||
expect(await syncDownloadCounts()).toBe(0);
|
||||
expect(state.upserted).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readDownloadCounts", () => {
|
||||
it("maps the stored rows by plugin id", async () => {
|
||||
state.rows = [{ pluginId: "docker", count: 20 }];
|
||||
const counts = await readDownloadCounts();
|
||||
expect(counts.get("docker")).toBe(20);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
isBareSpecifier,
|
||||
isUserPluginFile,
|
||||
} from "../../plugins/host-modules.js";
|
||||
|
||||
describe("isBareSpecifier", () => {
|
||||
it("accepts package names", () => {
|
||||
expect(isBareSpecifier("drizzle-orm")).toBe(true);
|
||||
expect(isBareSpecifier("drizzle-orm/sqlite-core")).toBe(true);
|
||||
expect(isBareSpecifier("@termix-ssh/plugin-sdk/backend")).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses paths, urls and builtins", () => {
|
||||
expect(isBareSpecifier("./local.js")).toBe(false);
|
||||
expect(isBareSpecifier("../up.js")).toBe(false);
|
||||
expect(isBareSpecifier("/abs/file.js")).toBe(false);
|
||||
expect(isBareSpecifier("file:///x.js")).toBe(false);
|
||||
expect(isBareSpecifier("node:fs")).toBe(false);
|
||||
expect(isBareSpecifier("C:\\x\\y.js")).toBe(false);
|
||||
expect(isBareSpecifier("#internal")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isUserPluginFile", () => {
|
||||
const root = path.resolve(os.tmpdir(), "termix-data", "plugins");
|
||||
|
||||
it("matches files under the plugins dir, query included", () => {
|
||||
const file = path.join(root, ".unpacked", "demo", "dist", "backend.js");
|
||||
expect(isUserPluginFile(`${pathToFileURL(file).href}?v=1`, root)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores everything else", () => {
|
||||
const outside = path.resolve(os.tmpdir(), "termix-data", "other.js");
|
||||
expect(isUserPluginFile(pathToFileURL(outside).href, root)).toBe(false);
|
||||
expect(isUserPluginFile(pathToFileURL(root).href, root)).toBe(false);
|
||||
expect(isUserPluginFile(undefined, root)).toBe(false);
|
||||
expect(isUserPluginFile("data:text/javascript,1", root)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("installHostModuleFallback", () => {
|
||||
let dataDir: string | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
if (dataDir) fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
dataDir = null;
|
||||
});
|
||||
|
||||
// A real Node process: vitest resolves imports on its own and would hide
|
||||
// what plain Node does.
|
||||
function importFromPlugin(withFallback: boolean) {
|
||||
dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-host-modules-"));
|
||||
const dir = path.join(dataDir, "plugins", ".unpacked", "demo");
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const entry = path.join(dir, "backend.mjs");
|
||||
fs.writeFileSync(
|
||||
entry,
|
||||
'import { sql } from "drizzle-orm";\nconsole.log(typeof sql);\n',
|
||||
);
|
||||
const hook = pathToFileURL(
|
||||
path.resolve(__dirname, "../../plugins/host-modules.ts"),
|
||||
).href;
|
||||
const script = [
|
||||
`const { installHostModuleFallback } = await import(${JSON.stringify(hook)});`,
|
||||
withFallback ? "installHostModuleFallback();" : "",
|
||||
`await import(${JSON.stringify(`${pathToFileURL(entry).href}?v=1`)});`,
|
||||
].join("\n");
|
||||
return spawnSync(
|
||||
process.execPath,
|
||||
["--import", "tsx", "--input-type=module", "-e", script],
|
||||
{
|
||||
cwd: path.resolve(__dirname, "../../../.."),
|
||||
env: { ...process.env, DATA_DIR: dataDir },
|
||||
encoding: "utf8",
|
||||
timeout: 30_000,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
it("lets a plugin outside the app import core's packages", () => {
|
||||
const result = importFromPlugin(true);
|
||||
expect(result.stderr).toBe("");
|
||||
expect(result.stdout.trim()).toBe("function");
|
||||
});
|
||||
|
||||
it("fails the same import without it", () => {
|
||||
const result = importFromPlugin(false);
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.stderr).toContain("Cannot find package 'drizzle-orm'");
|
||||
});
|
||||
});
|
||||
@@ -1,103 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
stats: null as null | {
|
||||
plugins: Map<string, { downloads: number; activeInstalls: number | null }>;
|
||||
},
|
||||
fail: false,
|
||||
upserted: [] as Array<{ registryId: string; entries: unknown[] }>,
|
||||
rows: [] as Array<{ pluginId: string; count: number; source: string }>,
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/registry-index.js", () => ({
|
||||
OFFICIAL_REGISTRY_ID: "official",
|
||||
fetchRegistryStats: async () => {
|
||||
if (state.fail) throw new Error("offline");
|
||||
return state.stats;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginInstallCountRepository: () => ({
|
||||
upsertMany: async (registryId: string, entries: unknown[]) => {
|
||||
state.upserted.push({ registryId, entries });
|
||||
return entries.length;
|
||||
},
|
||||
listByRegistry: async () => state.rows,
|
||||
}),
|
||||
}));
|
||||
|
||||
const {
|
||||
DOWNLOADS_SOURCE,
|
||||
TELEMETRY_SOURCE,
|
||||
preferredCount,
|
||||
readInstallCounts,
|
||||
syncInstallCounts,
|
||||
} = await import("../../plugins/install-counts.js");
|
||||
|
||||
beforeEach(() => {
|
||||
state.stats = null;
|
||||
state.fail = false;
|
||||
state.upserted = [];
|
||||
state.rows = [];
|
||||
});
|
||||
|
||||
describe("preferredCount", () => {
|
||||
it("prefers active installs and falls back to downloads", () => {
|
||||
expect(preferredCount({ downloads: 90, activeInstalls: 12 })).toEqual({
|
||||
count: 12,
|
||||
source: TELEMETRY_SOURCE,
|
||||
});
|
||||
expect(preferredCount({ downloads: 90, activeInstalls: null })).toEqual({
|
||||
count: 90,
|
||||
source: DOWNLOADS_SOURCE,
|
||||
});
|
||||
expect(preferredCount({ downloads: 90, activeInstalls: 0 })).toEqual({
|
||||
count: 90,
|
||||
source: DOWNLOADS_SOURCE,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("syncInstallCounts", () => {
|
||||
it("writes one row per plugin with its preferred source", async () => {
|
||||
state.stats = {
|
||||
plugins: new Map([
|
||||
["docker", { downloads: 50, activeInstalls: 20 }],
|
||||
["tunnels", { downloads: 8, activeInstalls: null }],
|
||||
]),
|
||||
};
|
||||
expect(await syncInstallCounts()).toBe(2);
|
||||
expect(state.upserted).toEqual([
|
||||
{
|
||||
registryId: "official",
|
||||
entries: [
|
||||
{ pluginId: "docker", count: 20, source: TELEMETRY_SOURCE },
|
||||
{ pluginId: "tunnels", count: 8, source: DOWNLOADS_SOURCE },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the old numbers when the registry is unreachable", async () => {
|
||||
state.fail = true;
|
||||
expect(await syncInstallCounts()).toBe(0);
|
||||
expect(state.upserted).toEqual([]);
|
||||
});
|
||||
|
||||
it("does nothing for a registry with no stats file", async () => {
|
||||
expect(await syncInstallCounts()).toBe(0);
|
||||
expect(state.upserted).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readInstallCounts", () => {
|
||||
it("maps the stored rows by plugin id", async () => {
|
||||
state.rows = [{ pluginId: "docker", count: 20, source: TELEMETRY_SOURCE }];
|
||||
const counts = await readInstallCounts();
|
||||
expect(counts.get("docker")).toEqual({
|
||||
count: 20,
|
||||
source: TELEMETRY_SOURCE,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -33,6 +34,8 @@ const db = vi.hoisted(() => ({
|
||||
|
||||
const registry = vi.hoisted(() => ({
|
||||
index: { registry: "official", name: "", plugins: [] as unknown[] },
|
||||
/** Indexes of the other registries, by id. Missing means unreachable. */
|
||||
others: {} as Record<string, Record<string, unknown>>,
|
||||
download: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -97,7 +100,14 @@ vi.mock("../../plugins/registry-index.js", async (importOriginal) => {
|
||||
await importOriginal<typeof import("../../plugins/registry-index.js")>();
|
||||
return {
|
||||
...actual,
|
||||
fetchRegistryIndex: async () => actual.parseRegistryIndex(registry.index),
|
||||
fetchRegistryIndex: async (source?: { id: string }) => {
|
||||
if (!source || source.id === "official") {
|
||||
return actual.parseRegistryIndex(registry.index);
|
||||
}
|
||||
const other = registry.others[source.id];
|
||||
if (!other) throw new Error(`${source.id} is offline`);
|
||||
return actual.parseRegistryIndex(other);
|
||||
},
|
||||
downloadRelease: registry.download,
|
||||
};
|
||||
});
|
||||
@@ -216,6 +226,7 @@ beforeEach(() => {
|
||||
db.settings.clear();
|
||||
db.grants.length = 0;
|
||||
registry.index.plugins = [];
|
||||
registry.others = {};
|
||||
registry.download.mockReset();
|
||||
});
|
||||
|
||||
@@ -856,3 +867,220 @@ describe("release notes", () => {
|
||||
expect(manage.readReleaseNotes(undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("more registries", () => {
|
||||
const customKey = (() => {
|
||||
const { publicKey } = crypto.generateKeyPairSync("ed25519");
|
||||
return publicKey
|
||||
.export({ format: "der", type: "spki" })
|
||||
.subarray(12)
|
||||
.toString("base64");
|
||||
})();
|
||||
|
||||
function turnOnCommunity(): void {
|
||||
db.settings.set(
|
||||
"plugin_registries",
|
||||
JSON.stringify({ community: { enabled: true }, custom: [] }),
|
||||
);
|
||||
}
|
||||
|
||||
it("lists community plugins only when it is on, never official ids", async () => {
|
||||
registry.index.plugins = [registryEntry("docker", [{ version: "1.0.0" }])];
|
||||
registry.others.community = {
|
||||
registry: "community",
|
||||
name: "Community",
|
||||
plugins: [
|
||||
registryEntry("docker", [{ version: "9.0.0" }]),
|
||||
registryEntry("neat", [{ version: "1.0.0" }]),
|
||||
],
|
||||
};
|
||||
await boot();
|
||||
|
||||
let listing = await manage.listRegistry();
|
||||
expect(listing.plugins.map((p) => p.id)).toEqual(["docker"]);
|
||||
expect(listing.registries.map((r) => r.id)).toEqual(["official"]);
|
||||
|
||||
turnOnCommunity();
|
||||
listing = await manage.listRegistry();
|
||||
expect(
|
||||
listing.plugins.map((p) => [p.id, p.registryId, p.tier, p.latestVersion]),
|
||||
).toEqual([
|
||||
["docker", "official", "official", "1.0.0"],
|
||||
["neat", "community", "community", "1.0.0"],
|
||||
]);
|
||||
expect(listing.registries.find((r) => r.id === "community")).toMatchObject({
|
||||
tier: "community",
|
||||
plugins: 1,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps listing official plugins when another registry is down", async () => {
|
||||
registry.index.plugins = [registryEntry("docker", [{ version: "1.0.0" }])];
|
||||
turnOnCommunity();
|
||||
await boot();
|
||||
const listing = await manage.listRegistry();
|
||||
expect(listing.plugins.map((p) => p.id)).toEqual(["docker"]);
|
||||
expect(listing.registries.find((r) => r.id === "community")?.error).toMatch(
|
||||
/offline/,
|
||||
);
|
||||
});
|
||||
|
||||
it("installs from the community registry with its keys and records it", async () => {
|
||||
registry.others.community = {
|
||||
registry: "community",
|
||||
plugins: [registryEntry("neat", [{ version: "1.0.0" }])],
|
||||
};
|
||||
turnOnCommunity();
|
||||
await boot();
|
||||
registry.download.mockImplementation(() => stageArtifact("neat"));
|
||||
|
||||
const result = await manage.installPlugin("neat", {
|
||||
userId: "admin",
|
||||
registryId: "community",
|
||||
capabilities: DEFAULT_CAPS,
|
||||
});
|
||||
expect(result.state).toBe("active");
|
||||
const keys = registry.download.mock.calls[0][2] as Array<{ id: string }>;
|
||||
expect(keys.map((key) => key.id)).toEqual(["be5b24ea07b36624"]);
|
||||
expect(db.rows.get("neat")).toMatchObject({
|
||||
registryId: "community",
|
||||
tier: "community",
|
||||
});
|
||||
});
|
||||
|
||||
it("updates a plugin from the registry it came from", async () => {
|
||||
registry.others.community = {
|
||||
registry: "community",
|
||||
plugins: [
|
||||
registryEntry("neat", [{ version: "1.1.0" }, { version: "1.0.0" }]),
|
||||
],
|
||||
};
|
||||
turnOnCommunity();
|
||||
await boot();
|
||||
registry.download.mockImplementation(() =>
|
||||
stageArtifact("neat", { version: "1.0.0" }),
|
||||
);
|
||||
await manage.installPlugin("neat", {
|
||||
userId: "admin",
|
||||
registryId: "community",
|
||||
version: "1.0.0",
|
||||
capabilities: DEFAULT_CAPS,
|
||||
});
|
||||
await manage.setPluginOptions("neat", { pinned: false });
|
||||
|
||||
registry.download.mockReset();
|
||||
registry.download.mockImplementation(() =>
|
||||
stageArtifact("neat", { version: "1.1.0" }),
|
||||
);
|
||||
const result = await manage.updatePlugin("neat", { userId: "admin" });
|
||||
expect(result.version).toBe("1.1.0");
|
||||
const [, release, keys] = registry.download.mock.calls[0];
|
||||
expect(release.url).toContain("neat-1.1.0");
|
||||
expect(keys.map((key: { id: string }) => key.id)).toEqual([
|
||||
"be5b24ea07b36624",
|
||||
]);
|
||||
expect(db.rows.get("neat")).toMatchObject({ registryId: "community" });
|
||||
});
|
||||
|
||||
it("stops updating a community plugin once the official registry lists its id", async () => {
|
||||
registry.others.community = {
|
||||
registry: "community",
|
||||
plugins: [
|
||||
registryEntry("neat", [{ version: "1.1.0" }, { version: "1.0.0" }]),
|
||||
],
|
||||
};
|
||||
turnOnCommunity();
|
||||
await boot();
|
||||
registry.download.mockImplementation(() => stageArtifact("neat"));
|
||||
await manage.installPlugin("neat", {
|
||||
userId: "admin",
|
||||
registryId: "community",
|
||||
version: "1.0.0",
|
||||
capabilities: DEFAULT_CAPS,
|
||||
});
|
||||
registry.index.plugins = [registryEntry("neat", [{ version: "5.0.0" }])];
|
||||
await expect(
|
||||
manage.updatePlugin("neat", { userId: "admin", version: "1.1.0" }),
|
||||
).rejects.toMatchObject({ code: "RESERVED_ID" });
|
||||
});
|
||||
|
||||
it("refuses an official id from another registry", async () => {
|
||||
registry.index.plugins = [registryEntry("docker", [{ version: "1.0.0" }])];
|
||||
registry.others.community = {
|
||||
registry: "community",
|
||||
plugins: [registryEntry("docker", [{ version: "9.0.0" }])],
|
||||
};
|
||||
turnOnCommunity();
|
||||
await boot();
|
||||
await expect(
|
||||
manage.installPlugin("docker", {
|
||||
userId: "admin",
|
||||
registryId: "community",
|
||||
capabilities: DEFAULT_CAPS,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "RESERVED_ID" });
|
||||
expect(registry.download).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a registry that is off", async () => {
|
||||
registry.others.community = {
|
||||
registry: "community",
|
||||
plugins: [registryEntry("neat", [{ version: "1.0.0" }])],
|
||||
};
|
||||
await boot();
|
||||
await expect(
|
||||
manage.installPlugin("neat", {
|
||||
userId: "admin",
|
||||
registryId: "community",
|
||||
capabilities: DEFAULT_CAPS,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "REGISTRY_OFF" });
|
||||
});
|
||||
|
||||
it("stops trusting a custom registry whose index names a new key", async () => {
|
||||
const other = crypto
|
||||
.generateKeyPairSync("ed25519")
|
||||
.publicKey.export({ format: "der", type: "spki" })
|
||||
.subarray(12)
|
||||
.toString("base64");
|
||||
db.settings.set(
|
||||
"plugin_registries",
|
||||
JSON.stringify({
|
||||
community: { enabled: false },
|
||||
custom: [
|
||||
{
|
||||
id: "mine",
|
||||
name: "Mine",
|
||||
url: "https://example.com/index.json",
|
||||
publicKey: customKey,
|
||||
enabled: true,
|
||||
addedAt: "2026-10-11T00:00:00Z",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
registry.others.mine = {
|
||||
registry: "mine",
|
||||
publicKey: customKey,
|
||||
plugins: [registryEntry("tool", [{ version: "1.0.0" }])],
|
||||
};
|
||||
await boot();
|
||||
let listing = await manage.listRegistry();
|
||||
expect(listing.plugins.find((p) => p.id === "tool")?.tier).toBe("custom");
|
||||
|
||||
registry.others.mine.publicKey = other;
|
||||
listing = await manage.listRegistry();
|
||||
expect(listing.plugins.find((p) => p.id === "tool")).toBeUndefined();
|
||||
expect(listing.registries.find((r) => r.id === "mine")?.error).toMatch(
|
||||
/different signing key/,
|
||||
);
|
||||
await expect(
|
||||
manage.installPlugin("tool", {
|
||||
userId: "admin",
|
||||
registryId: "mine",
|
||||
capabilities: DEFAULT_CAPS,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "KEY_CHANGED" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,259 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import crypto from "node:crypto";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
settings: new Map<string, string>(),
|
||||
plugins: [] as Array<{ id: string; registryId: string | null }>,
|
||||
indexes: new Map<string, unknown>(),
|
||||
}));
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentSettingsRepository: () => ({
|
||||
get: async (key: string) => state.settings.get(key) ?? null,
|
||||
set: async (key: string, value: string) =>
|
||||
void state.settings.set(key, value),
|
||||
}),
|
||||
createCurrentPluginRepository: () => ({
|
||||
listAll: async () => state.plugins,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/registry-index.js", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("../../plugins/registry-index.js")>();
|
||||
return {
|
||||
...actual,
|
||||
fetchIndexAt: async (url: string) => {
|
||||
const raw = state.indexes.get(url);
|
||||
if (!raw) throw new Error("answered 404");
|
||||
return actual.parseRegistryIndex(raw);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const registries = await import("../../plugins/registries.js");
|
||||
const { getCustomPluginKeys, pluginKeyId } =
|
||||
await import("../../plugins/trust.js");
|
||||
|
||||
function newKey(): string {
|
||||
return crypto
|
||||
.generateKeyPairSync("ed25519")
|
||||
.publicKey.export({ format: "der", type: "spki" })
|
||||
.subarray(12)
|
||||
.toString("base64");
|
||||
}
|
||||
|
||||
const URL_A = "https://example.com/a/index.json";
|
||||
|
||||
function serve(url: string, index: Record<string, unknown>): void {
|
||||
state.indexes.set(url, { plugins: [], ...index });
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
state.settings.clear();
|
||||
state.plugins = [];
|
||||
state.indexes.clear();
|
||||
await registries.loadCustomRegistryKeys();
|
||||
});
|
||||
|
||||
describe("registry sources", () => {
|
||||
it("is official only by default", async () => {
|
||||
const sources = await registries.listSources();
|
||||
expect(sources.map((source) => source.id)).toEqual(["official"]);
|
||||
});
|
||||
|
||||
it("adds community when it is turned on", async () => {
|
||||
await registries.setRegistryEnabled("community", true);
|
||||
const sources = await registries.listSources();
|
||||
expect(sources.map((source) => [source.id, source.tier])).toEqual([
|
||||
["official", "official"],
|
||||
["community", "community"],
|
||||
]);
|
||||
await registries.setRegistryEnabled("community", false);
|
||||
expect((await registries.listSources()).map((s) => s.id)).toEqual([
|
||||
"official",
|
||||
]);
|
||||
});
|
||||
|
||||
it("never turns the official registry off", async () => {
|
||||
await expect(
|
||||
registries.setRegistryEnabled("official", false),
|
||||
).rejects.toMatchObject({ code: "OFFICIAL" });
|
||||
});
|
||||
|
||||
it("reads a broken stored value as official only", () => {
|
||||
expect(registries.parseRegistriesConfig("{nope")).toEqual({
|
||||
community: { enabled: false },
|
||||
custom: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("adding a custom registry", () => {
|
||||
it("previews the name, plugin count and key, then pins that key", async () => {
|
||||
const key = newKey();
|
||||
serve(URL_A, {
|
||||
registry: "acme",
|
||||
name: "Acme Plugins",
|
||||
publicKey: key,
|
||||
plugins: [
|
||||
{
|
||||
id: "tool",
|
||||
versions: [
|
||||
{
|
||||
version: "1.0.0",
|
||||
api: "1",
|
||||
url: "https://example.com/tool.tmxplug",
|
||||
sha256: "a".repeat(64),
|
||||
signature: "sig",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
const preview = await registries.previewRegistry(URL_A);
|
||||
expect(preview).toEqual({
|
||||
id: "acme",
|
||||
name: "Acme Plugins",
|
||||
url: URL_A,
|
||||
keyId: pluginKeyId(key),
|
||||
plugins: 1,
|
||||
});
|
||||
|
||||
const added = await registries.addRegistry(URL_A, preview.keyId);
|
||||
expect(added).toMatchObject({ id: "acme", enabled: true, publicKey: key });
|
||||
expect(getCustomPluginKeys().map((k) => k.publicKey)).toEqual([key]);
|
||||
const sources = await registries.listSources();
|
||||
expect(sources.at(-1)).toMatchObject({
|
||||
id: "acme",
|
||||
tier: "custom",
|
||||
url: URL_A,
|
||||
});
|
||||
expect(sources.at(-1)?.keys.map((k) => k.publicKey)).toEqual([key]);
|
||||
});
|
||||
|
||||
it("refuses when the key changed after the preview", async () => {
|
||||
serve(URL_A, { registry: "acme", publicKey: newKey() });
|
||||
const preview = await registries.previewRegistry(URL_A);
|
||||
serve(URL_A, { registry: "acme", publicKey: newKey() });
|
||||
await expect(
|
||||
registries.addRegistry(URL_A, preview.keyId),
|
||||
).rejects.toMatchObject({ code: "KEY_MISMATCH" });
|
||||
expect((await registries.readRegistriesConfig()).custom).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses an index without a key", async () => {
|
||||
serve(URL_A, { registry: "acme" });
|
||||
await expect(registries.previewRegistry(URL_A)).rejects.toMatchObject({
|
||||
code: "NO_PUBLIC_KEY",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses plain http and junk", async () => {
|
||||
await expect(
|
||||
registries.previewRegistry("http://example.com/index.json"),
|
||||
).rejects.toMatchObject({ code: "INVALID_URL" });
|
||||
await expect(registries.previewRegistry("nope")).rejects.toMatchObject({
|
||||
code: "INVALID_URL",
|
||||
});
|
||||
await expect(registries.previewRegistry(42)).rejects.toMatchObject({
|
||||
code: "INVALID_URL",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses an index it cannot read", async () => {
|
||||
await expect(registries.previewRegistry(URL_A)).rejects.toMatchObject({
|
||||
code: "REGISTRY_UNREACHABLE",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses the same URL twice and the built in ones", async () => {
|
||||
const key = newKey();
|
||||
serve(URL_A, { registry: "acme", publicKey: key });
|
||||
await registries.addRegistry(URL_A, pluginKeyId(key));
|
||||
await expect(registries.previewRegistry(URL_A)).rejects.toMatchObject({
|
||||
code: "ALREADY_ADDED",
|
||||
});
|
||||
await expect(
|
||||
registries.previewRegistry(
|
||||
"https://raw.githubusercontent.com/Termix-SSH/Termix-Registry/main/community/index.json",
|
||||
),
|
||||
).rejects.toMatchObject({ code: "ALREADY_ADDED" });
|
||||
});
|
||||
|
||||
it("never takes a reserved or used id", async () => {
|
||||
const urlB = "https://example.com/b/index.json";
|
||||
const urlC = "https://example.com/c/index.json";
|
||||
const keyA = newKey();
|
||||
const keyB = newKey();
|
||||
const keyC = newKey();
|
||||
serve(URL_A, { registry: "official", publicKey: keyA });
|
||||
serve(urlB, { registry: "community", publicKey: keyB });
|
||||
serve(urlC, { registry: "Official", publicKey: keyC });
|
||||
expect((await registries.addRegistry(URL_A, pluginKeyId(keyA))).id).toBe(
|
||||
"official-2",
|
||||
);
|
||||
expect((await registries.addRegistry(urlB, pluginKeyId(keyB))).id).toBe(
|
||||
"community-2",
|
||||
);
|
||||
expect((await registries.addRegistry(urlC, pluginKeyId(keyC))).id).toBe(
|
||||
"official-3",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("turning off and removing a custom registry", () => {
|
||||
async function addAcme(): Promise<string> {
|
||||
const key = newKey();
|
||||
serve(URL_A, { registry: "acme", publicKey: key });
|
||||
await registries.addRegistry(URL_A, pluginKeyId(key));
|
||||
return key;
|
||||
}
|
||||
|
||||
it("keeps trusting the key of a registry that is off", async () => {
|
||||
const key = await addAcme();
|
||||
await registries.setRegistryEnabled("acme", false);
|
||||
expect((await registries.listSources()).map((s) => s.id)).toEqual([
|
||||
"official",
|
||||
]);
|
||||
expect(await registries.findSource("acme")).toBeNull();
|
||||
expect(getCustomPluginKeys().map((k) => k.publicKey)).toEqual([key]);
|
||||
});
|
||||
|
||||
it("refuses to remove a registry with installed plugins", async () => {
|
||||
await addAcme();
|
||||
state.plugins = [{ id: "tool", registryId: "acme" }];
|
||||
await expect(registries.removeRegistry("acme")).rejects.toMatchObject({
|
||||
code: "REGISTRY_IN_USE",
|
||||
details: { plugins: ["tool"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("removes it and stops trusting its key", async () => {
|
||||
await addAcme();
|
||||
await registries.removeRegistry("acme");
|
||||
expect((await registries.readRegistriesConfig()).custom).toEqual([]);
|
||||
expect(getCustomPluginKeys()).toEqual([]);
|
||||
});
|
||||
|
||||
it("only turns built in registries off", async () => {
|
||||
await expect(registries.removeRegistry("community")).rejects.toMatchObject({
|
||||
code: "BUILT_IN",
|
||||
});
|
||||
await expect(registries.removeRegistry("nope")).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("keys from a linked server", () => {
|
||||
it("trusts them next to the local ones and survives a reload", async () => {
|
||||
const key = newKey();
|
||||
await registries.setSyncedRegistryKeys([key, "junk", 42]);
|
||||
expect(getCustomPluginKeys().map((k) => k.publicKey)).toEqual([key]);
|
||||
await registries.loadCustomRegistryKeys();
|
||||
expect(getCustomPluginKeys().map((k) => k.publicKey)).toEqual([key]);
|
||||
await registries.setSyncedRegistryKeys([]);
|
||||
expect(getCustomPluginKeys()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -17,14 +17,27 @@ vi.mock("../../plugins/trust.js", async (importOriginal) => {
|
||||
await importOriginal<typeof import("../../plugins/trust.js")>();
|
||||
return {
|
||||
...actual,
|
||||
verifyPluginArtifact: (buffer: Buffer, sha256: string, signature: string) =>
|
||||
actual.verifyPluginArtifact(buffer, sha256, signature, [
|
||||
testKey.value as never,
|
||||
]),
|
||||
// The built in official keys are swapped for the test key. Keys a
|
||||
// caller passes for another registry are used as they are.
|
||||
verifyPluginArtifact: (
|
||||
buffer: Buffer,
|
||||
sha256: string,
|
||||
signature: string,
|
||||
keys?: readonly unknown[],
|
||||
) =>
|
||||
actual.verifyPluginArtifact(
|
||||
buffer,
|
||||
sha256,
|
||||
signature,
|
||||
keys && keys !== actual.TRUSTED_PLUGIN_KEYS
|
||||
? (keys as never)
|
||||
: [testKey.value as never],
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
communitySource,
|
||||
downloadRelease,
|
||||
fetchRegistryIndex,
|
||||
fetchRegistryStats,
|
||||
@@ -35,6 +48,7 @@ import {
|
||||
parseRegistryIndex,
|
||||
parseRegistryStats,
|
||||
resetRegistryCache,
|
||||
type RegistrySource,
|
||||
type RegistryVersion,
|
||||
} from "../../plugins/registry-index.js";
|
||||
|
||||
@@ -91,6 +105,25 @@ afterEach(() => {
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("parseRegistryIndex publicKey", () => {
|
||||
const key = publicKey
|
||||
.export({ format: "der", type: "spki" })
|
||||
.subarray(12)
|
||||
.toString("base64");
|
||||
|
||||
it("keeps a valid key and drops anything else", () => {
|
||||
expect(parseRegistryIndex({ publicKey: key, plugins: [] }).publicKey).toBe(
|
||||
key,
|
||||
);
|
||||
expect(
|
||||
parseRegistryIndex({ publicKey: "not a key", plugins: [] }).publicKey,
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
parseRegistryIndex({ publicKey: 42, plugins: [] }).publicKey,
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseRegistryIndex", () => {
|
||||
it("keeps valid entries and sorts versions newest first", () => {
|
||||
const index = parseRegistryIndex({
|
||||
@@ -289,7 +322,29 @@ describe("fetchRegistryIndex", () => {
|
||||
fetchMock.mockResolvedValueOnce(response(body));
|
||||
const first = await fetchRegistryIndex();
|
||||
fetchMock.mockResolvedValueOnce(response("down", 503));
|
||||
expect(await fetchRegistryIndex({ force: true })).toBe(first);
|
||||
expect(await fetchRegistryIndex(undefined, { force: true })).toBe(first);
|
||||
});
|
||||
|
||||
it("caches each registry on its own", async () => {
|
||||
const other: RegistrySource = {
|
||||
id: "mine",
|
||||
name: "Mine",
|
||||
url: "https://example.com/mine/index.json",
|
||||
tier: "custom",
|
||||
keys: [],
|
||||
};
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(response(body))
|
||||
.mockResolvedValueOnce(
|
||||
response(JSON.stringify({ registry: "mine", plugins: [] })),
|
||||
);
|
||||
const official = await fetchRegistryIndex();
|
||||
const mine = await fetchRegistryIndex(other);
|
||||
expect(official.plugins).toHaveLength(1);
|
||||
expect(mine.plugins).toHaveLength(0);
|
||||
expect(fetchMock.mock.calls[1][0]).toBe(other.url);
|
||||
expect(await fetchRegistryIndex(other)).toBe(mine);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("throws when there is nothing cached", async () => {
|
||||
@@ -384,6 +439,38 @@ describe("downloadRelease", () => {
|
||||
).rejects.toThrow(/sha256/);
|
||||
});
|
||||
|
||||
it("checks against the keys of the registry it came from", async () => {
|
||||
const signed = sign(artifact);
|
||||
const own = crypto.generateKeyPairSync("ed25519");
|
||||
const ownKey = {
|
||||
id: "own",
|
||||
publicKey: own.publicKey
|
||||
.export({ format: "der", type: "spki" })
|
||||
.subarray(12)
|
||||
.toString("base64"),
|
||||
addedIn: "admin",
|
||||
};
|
||||
fetchMock.mockResolvedValueOnce(response(artifact));
|
||||
await expect(
|
||||
downloadRelease("docker", version({ ...signed, size: artifact.length }), [
|
||||
ownKey,
|
||||
]),
|
||||
).rejects.toThrow(/trusted key/);
|
||||
|
||||
const digest = crypto.createHash("sha256").update(artifact).digest();
|
||||
fetchMock.mockResolvedValueOnce(response(artifact));
|
||||
const result = await downloadRelease(
|
||||
"docker",
|
||||
version({
|
||||
sha256: digest.toString("hex"),
|
||||
signature: crypto.sign(null, digest, own.privateKey).toString("base64"),
|
||||
size: artifact.length,
|
||||
}),
|
||||
[ownKey],
|
||||
);
|
||||
expect(fs.readFileSync(result.file)).toEqual(artifact);
|
||||
});
|
||||
|
||||
it("refuses a release built for another plugin API", async () => {
|
||||
await expect(
|
||||
downloadRelease("docker", version({ api: "2" })),
|
||||
@@ -407,19 +494,34 @@ describe("registry stats", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("has a community stats file and none for custom registries", () => {
|
||||
expect(getStatsUrl(communitySource())).toBe(
|
||||
"https://raw.githubusercontent.com/Termix-SSH/Termix-Registry/main/community/stats.json",
|
||||
);
|
||||
expect(
|
||||
getStatsUrl({
|
||||
id: "mine",
|
||||
name: "Mine",
|
||||
url: "https://example.com/index.json",
|
||||
tier: "custom",
|
||||
keys: [],
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps valid counts and drops the rest", () => {
|
||||
const stats = parseRegistryStats({
|
||||
plugins: {
|
||||
docker: { downloads: 120, activeInstalls: 40 },
|
||||
docker: { downloads: 120 },
|
||||
tunnels: { downloads: 7 },
|
||||
"../bad": { downloads: 1 },
|
||||
broken: { downloads: -3, activeInstalls: "many" },
|
||||
broken: { downloads: -3 },
|
||||
},
|
||||
});
|
||||
expect(Object.fromEntries(stats.plugins)).toEqual({
|
||||
docker: { downloads: 120, activeInstalls: 40 },
|
||||
tunnels: { downloads: 7, activeInstalls: null },
|
||||
broken: { downloads: 0, activeInstalls: null },
|
||||
docker: 120,
|
||||
tunnels: 7,
|
||||
broken: 0,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -428,9 +530,6 @@ describe("registry stats", () => {
|
||||
response(JSON.stringify({ plugins: { docker: { downloads: 3 } } })),
|
||||
);
|
||||
const stats = await fetchRegistryStats();
|
||||
expect(stats?.plugins.get("docker")).toEqual({
|
||||
downloads: 3,
|
||||
activeInstalls: null,
|
||||
});
|
||||
expect(stats?.plugins.get("docker")).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,8 @@ import { PluginLoader } from "../../plugins/loader.js";
|
||||
const testKey = vi.hoisted(() => ({
|
||||
privateKey: null as crypto.KeyObject | null,
|
||||
key: { id: "test", publicKey: "", addedIn: "2.9.0" },
|
||||
communityPrivateKey: null as crypto.KeyObject | null,
|
||||
community: { id: "community", publicKey: "", addedIn: "26.10.1" },
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/trust.js", async (importActual) => {
|
||||
@@ -23,6 +25,17 @@ vi.mock("../../plugins/trust.js", async (importActual) => {
|
||||
...actual,
|
||||
verifyPluginArtifact: (buffer: Buffer, sha256: string, signature: string) =>
|
||||
actual.verifyPluginArtifact(buffer, sha256, signature, [testKey.key]),
|
||||
verifyAnyTrusted: (buffer: Buffer, sha256: string, signature: string) => {
|
||||
const official = actual.verifyPluginArtifact(buffer, sha256, signature, [
|
||||
testKey.key,
|
||||
]);
|
||||
if (official.ok === true) return { ...official, tier: "official" };
|
||||
const community = actual.verifyPluginArtifact(buffer, sha256, signature, [
|
||||
testKey.community,
|
||||
]);
|
||||
if (community.ok === true) return { ...community, tier: "community" };
|
||||
return community;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -42,6 +55,12 @@ vi.mock("../../database/repositories/factory.js", () => ({
|
||||
.export({ format: "der", type: "spki" })
|
||||
.subarray(12)
|
||||
.toString("base64");
|
||||
const community = crypto.generateKeyPairSync("ed25519");
|
||||
testKey.communityPrivateKey = community.privateKey;
|
||||
testKey.community.publicKey = community.publicKey
|
||||
.export({ format: "der", type: "spki" })
|
||||
.subarray(12)
|
||||
.toString("base64");
|
||||
}
|
||||
|
||||
const cleanups: Array<() => void> = [];
|
||||
@@ -68,7 +87,7 @@ function setup() {
|
||||
async function writeArtifact(
|
||||
dir: string,
|
||||
id: string,
|
||||
sig: "valid" | "wrong-key" | "none",
|
||||
sig: "valid" | "community" | "wrong-key" | "none",
|
||||
version = "1.0.0",
|
||||
): Promise<string> {
|
||||
const build = tempRoot("termix-build-");
|
||||
@@ -82,7 +101,9 @@ async function writeArtifact(
|
||||
const signer =
|
||||
sig === "valid"
|
||||
? testKey.privateKey
|
||||
: crypto.generateKeyPairSync("ed25519").privateKey;
|
||||
: sig === "community"
|
||||
? testKey.communityPrivateKey
|
||||
: crypto.generateKeyPairSync("ed25519").privateKey;
|
||||
const digest = crypto
|
||||
.createHash("sha256")
|
||||
.update(fs.readFileSync(file))
|
||||
@@ -171,6 +192,27 @@ describe("updating a bundled plugin", () => {
|
||||
expect(plugin?.source).toBe("bundled");
|
||||
expect(plugin?.artifact).toBe(file);
|
||||
expect(plugin?.signedBy).toBe("test");
|
||||
expect(plugin?.signedTier).toBe("official");
|
||||
});
|
||||
|
||||
it("keeps the bundled copy for a community signed newer archive", async () => {
|
||||
const { userDir } = setup();
|
||||
await writeArtifact(userDir, "bundled-one", "community", "2.0.0");
|
||||
const loader = new PluginLoader();
|
||||
await loader.loadAll();
|
||||
expect(loader.get("bundled-one")?.manifest.version).toBe("1.0.0");
|
||||
});
|
||||
|
||||
it("loads a community signed plugin and records its tier", async () => {
|
||||
const { userDir } = setup();
|
||||
process.env.TERMIX_REQUIRE_SIGNED_PLUGINS = "true";
|
||||
await writeArtifact(userDir, "community-one", "community");
|
||||
const loader = new PluginLoader();
|
||||
await loader.loadAll();
|
||||
const plugin = loader.get("community-one");
|
||||
expect(plugin?.source).toBe("user");
|
||||
expect(plugin?.signedBy).toBe("community");
|
||||
expect(plugin?.signedTier).toBe("community");
|
||||
});
|
||||
|
||||
it("keeps the bundled copy for an unsigned newer archive", async () => {
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import crypto from "node:crypto";
|
||||
import {
|
||||
COMMUNITY_PLUGIN_KEYS,
|
||||
TRUSTED_PLUGIN_KEYS,
|
||||
isPluginPublicKey,
|
||||
pluginKeyId,
|
||||
requireSignedPlugins,
|
||||
setCustomPluginKeys,
|
||||
verifyAnyTrusted,
|
||||
verifyPluginArtifact,
|
||||
type TrustedPluginKey,
|
||||
} from "../../plugins/trust.js";
|
||||
@@ -97,6 +103,61 @@ describe("verifyPluginArtifact", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("key tiers", () => {
|
||||
const artifact = Buffer.from("a plugin archive");
|
||||
|
||||
it("derives key ids the same way the built in keys were made", () => {
|
||||
for (const key of [...TRUSTED_PLUGIN_KEYS, ...COMMUNITY_PLUGIN_KEYS]) {
|
||||
expect(pluginKeyId(key.publicKey)).toBe(key.id);
|
||||
expect(isPluginPublicKey(key.publicKey)).toBe(true);
|
||||
}
|
||||
expect(isPluginPublicKey("bm9wZQ==")).toBe(false);
|
||||
expect(isPluginPublicKey(42)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the community key apart from the official ones", () => {
|
||||
const official = new Set(TRUSTED_PLUGIN_KEYS.map((key) => key.id));
|
||||
for (const key of COMMUNITY_PLUGIN_KEYS) {
|
||||
expect(official.has(key.id)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("verifies a custom key only while it is set, as the custom tier", () => {
|
||||
const { key, privateKey } = keyPair();
|
||||
const { sha256, signature } = signed(artifact, privateKey);
|
||||
expect(verifyAnyTrusted(artifact, sha256, signature).ok).toBe(false);
|
||||
|
||||
setCustomPluginKeys([key]);
|
||||
expect(verifyAnyTrusted(artifact, sha256, signature)).toEqual({
|
||||
ok: true,
|
||||
keyId: "test",
|
||||
tier: "custom",
|
||||
});
|
||||
|
||||
setCustomPluginKeys([]);
|
||||
expect(verifyAnyTrusted(artifact, sha256, signature).ok).toBe(false);
|
||||
});
|
||||
|
||||
it("drops a custom key that is not a valid public key", () => {
|
||||
setCustomPluginKeys([{ id: "bad", publicKey: "bm9wZQ==", addedIn: "x" }]);
|
||||
const { privateKey } = keyPair();
|
||||
const { sha256, signature } = signed(artifact, privateKey);
|
||||
expect(verifyAnyTrusted(artifact, sha256, signature).ok).toBe(false);
|
||||
setCustomPluginKeys([]);
|
||||
});
|
||||
|
||||
it("stops at a sha256 mismatch", () => {
|
||||
const { key, privateKey } = keyPair();
|
||||
setCustomPluginKeys([key]);
|
||||
const { signature } = signed(artifact, privateKey);
|
||||
expect(verifyAnyTrusted(artifact, "0".repeat(64), signature)).toEqual({
|
||||
ok: false,
|
||||
reason: "sha256 does not match the file",
|
||||
});
|
||||
setCustomPluginKeys([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("requireSignedPlugins", () => {
|
||||
it("is on only for the exact value true", () => {
|
||||
const before = process.env.TERMIX_REQUIRE_SIGNED_PLUGINS;
|
||||
|
||||
@@ -10,6 +10,7 @@ const state = vi.hoisted(() => ({
|
||||
install: vi.fn(),
|
||||
fetch: vi.fn(),
|
||||
loadBundled: vi.fn(),
|
||||
syncedKeys: vi.fn(async (_keys: unknown[]) => {}),
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
@@ -29,6 +30,9 @@ vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
}),
|
||||
createCurrentPluginPermissionGrantRepository: () => ({}),
|
||||
}));
|
||||
vi.mock("../../../plugins/registries.js", () => ({
|
||||
setSyncedRegistryKeys: state.syncedKeys,
|
||||
}));
|
||||
vi.mock("../../../sync/client/http.js", () => ({
|
||||
remoteFetch: state.fetch,
|
||||
remoteJson: vi.fn(),
|
||||
@@ -100,6 +104,19 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("mirrorPlugins", () => {
|
||||
it("trusts the server's custom registry keys before downloading", async () => {
|
||||
vi.mocked(remoteJson).mockResolvedValue({
|
||||
plugins: [],
|
||||
registryKeys: ["a-key"],
|
||||
});
|
||||
await mirrorPlugins({} as never);
|
||||
expect(state.syncedKeys).toHaveBeenCalledWith(["a-key"]);
|
||||
|
||||
vi.mocked(remoteJson).mockResolvedValue({ plugins: [] });
|
||||
await mirrorPlugins({} as never);
|
||||
expect(state.syncedKeys).toHaveBeenLastCalledWith([]);
|
||||
});
|
||||
|
||||
it("keeps the installed copy when the download fails", async () => {
|
||||
serverHas("1.1.0");
|
||||
state.fetch.mockResolvedValue({ ok: false, status: 502 });
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
export const CORE_ONBOARDING_STEPS = {
|
||||
plugins: 1,
|
||||
"desktop-sync": 1,
|
||||
"desktop-updates": 1,
|
||||
preset: 1,
|
||||
appearance: 1,
|
||||
security: 1,
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
type EditingWidth,
|
||||
} from "@/components/surface/surface-scope";
|
||||
import { OnboardingHost } from "@/onboarding/OnboardingHost";
|
||||
import { DesktopUpdateToasts } from "@/shell/DesktopUpdateToasts";
|
||||
import { SplitView, type SplitViewActions } from "@/shell/split/SplitView";
|
||||
import { SplitDropOverlay } from "@/shell/split/SplitDropOverlay";
|
||||
import {
|
||||
@@ -3184,6 +3185,7 @@ export function AppShell({
|
||||
</Suspense>
|
||||
)}
|
||||
<OnboardingHost />
|
||||
{isElectron() && <DesktopUpdateToasts />}
|
||||
</ServerStatusProvider>
|
||||
);
|
||||
}
|
||||
@@ -221,6 +221,8 @@ export interface PluginSummary {
|
||||
enabled: boolean;
|
||||
/** enabled | disabled | blocked | failed, or the loader's live state. */
|
||||
state: string;
|
||||
/** A linked desktop leaves this plugin to its server. */
|
||||
runsOnServer?: boolean;
|
||||
contributes: PluginContributions | null;
|
||||
/** Lucide icon name from the manifest. */
|
||||
icon?: string;
|
||||
@@ -289,8 +291,14 @@ export interface RegistryPluginVersion {
|
||||
size: number;
|
||||
}
|
||||
|
||||
export type RegistryTier = "official" | "community" | "custom";
|
||||
|
||||
export interface RegistryPluginEntry {
|
||||
id: string;
|
||||
/** Which registry this entry comes from. */
|
||||
registryId?: string;
|
||||
registryName?: string;
|
||||
tier?: RegistryTier;
|
||||
name: string;
|
||||
description: string;
|
||||
author: string;
|
||||
@@ -313,9 +321,18 @@ export interface RegistryPluginEntry {
|
||||
pinnedVersion: string | null;
|
||||
autoUpdate: boolean;
|
||||
bundled: boolean;
|
||||
/** Active installs when known, release downloads otherwise. */
|
||||
installCount: number | null;
|
||||
installCountSource: string | null;
|
||||
/** How often the release files were downloaded, from the registry. */
|
||||
downloads: number | null;
|
||||
}
|
||||
|
||||
export interface RegistryListingSource {
|
||||
id: string;
|
||||
name: string;
|
||||
tier: RegistryTier;
|
||||
url: string;
|
||||
lastCheckedAt: string | null;
|
||||
error: string | null;
|
||||
plugins: number;
|
||||
}
|
||||
|
||||
export interface RegistryListing {
|
||||
@@ -325,6 +342,7 @@ export interface RegistryListing {
|
||||
lastCheckedAt: string | null;
|
||||
error: string | null;
|
||||
};
|
||||
registries?: RegistryListingSource[];
|
||||
managedByServer: boolean;
|
||||
plugins: RegistryPluginEntry[];
|
||||
}
|
||||
@@ -373,7 +391,7 @@ export async function getPluginRegistry(
|
||||
/** `capabilities` is exactly what the consent prompt showed. */
|
||||
export async function installPlugin(
|
||||
pluginId: string,
|
||||
options: { version?: string; capabilities: string[] },
|
||||
options: { version?: string; capabilities: string[]; registryId?: string },
|
||||
): Promise<{ id: string; version: string; state: string }> {
|
||||
try {
|
||||
const response = await rbacApi.post(
|
||||
@@ -405,6 +423,61 @@ export async function updatePlugin(
|
||||
}
|
||||
}
|
||||
|
||||
export interface CustomRegistryInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
keyId: string;
|
||||
enabled: boolean;
|
||||
addedAt: string;
|
||||
}
|
||||
|
||||
export interface RegistriesState {
|
||||
managedByServer: boolean;
|
||||
official: { id: string; url: string };
|
||||
community: { id: string; url: string; enabled: boolean };
|
||||
custom: CustomRegistryInfo[];
|
||||
}
|
||||
|
||||
export interface RegistryPreview {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
keyId: string;
|
||||
plugins: number;
|
||||
}
|
||||
|
||||
export async function getRegistries(): Promise<RegistriesState> {
|
||||
const response = await rbacApi.get("/plugins/registries");
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function previewRegistry(url: string): Promise<RegistryPreview> {
|
||||
const response = await rbacApi.post("/plugins/registries/preview", { url });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function addRegistry(
|
||||
url: string,
|
||||
keyId: string,
|
||||
): Promise<CustomRegistryInfo> {
|
||||
const response = await rbacApi.post("/plugins/registries", { url, keyId });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function setRegistryEnabled(
|
||||
id: string,
|
||||
enabled: boolean,
|
||||
): Promise<void> {
|
||||
await rbacApi.patch(`/plugins/registries/${encodeURIComponent(id)}`, {
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeRegistry(id: string): Promise<void> {
|
||||
await rbacApi.delete(`/plugins/registries/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
export async function updateAllPlugins(): Promise<UpdateAllResult> {
|
||||
try {
|
||||
const response = await rbacApi.post("/plugins/update-all");
|
||||
|
||||
+4
-39
@@ -59,6 +59,7 @@ import {
|
||||
normalizeLanguageCode,
|
||||
rememberLoginLanguage,
|
||||
} from "@/i18n/i18n";
|
||||
import { LANGUAGES } from "@/i18n/languages";
|
||||
import {
|
||||
removeSilentSigninFromSearch,
|
||||
shouldTriggerSilentSignin,
|
||||
@@ -66,44 +67,6 @@ import {
|
||||
import { Select2 } from "@/components/select2";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const LANGUAGES = [
|
||||
{ code: "en", label: "English" },
|
||||
{ code: "af", label: "Afrikaans" },
|
||||
{ code: "ar", label: "العربية" },
|
||||
{ code: "bn", label: "বাংলা" },
|
||||
{ code: "bg", label: "Български" },
|
||||
{ code: "ca", label: "Català" },
|
||||
{ code: "zh-CN", label: "中文 (简体)" },
|
||||
{ code: "zh-TW", label: "中文 (繁體)" },
|
||||
{ code: "cs", label: "Čeština" },
|
||||
{ code: "da", label: "Dansk" },
|
||||
{ code: "nl", label: "Nederlands" },
|
||||
{ code: "fi", label: "Suomi" },
|
||||
{ code: "fr", label: "Français" },
|
||||
{ code: "de", label: "Deutsch" },
|
||||
{ code: "el", label: "Ελληνικά" },
|
||||
{ code: "he", label: "עברית" },
|
||||
{ code: "hi", label: "हिन्दी" },
|
||||
{ code: "hu", label: "Magyar" },
|
||||
{ code: "id", label: "Indonesia" },
|
||||
{ code: "it", label: "Italiano" },
|
||||
{ code: "ja", label: "日本語" },
|
||||
{ code: "ko", label: "한국어" },
|
||||
{ code: "no", label: "Norsk" },
|
||||
{ code: "pl", label: "Polski" },
|
||||
{ code: "pt-PT", label: "Português (PT)" },
|
||||
{ code: "pt-BR", label: "Português (BR)" },
|
||||
{ code: "ro", label: "Română" },
|
||||
{ code: "ru", label: "Русский" },
|
||||
{ code: "sr", label: "Српски" },
|
||||
{ code: "es-ES", label: "Español" },
|
||||
{ code: "sv-SE", label: "Svenska" },
|
||||
{ code: "th", label: "ไทย" },
|
||||
{ code: "tr", label: "Türkçe" },
|
||||
{ code: "uk", label: "Українська" },
|
||||
{ code: "vi", label: "Tiếng Việt" },
|
||||
];
|
||||
|
||||
function LanguageRow({
|
||||
label,
|
||||
value,
|
||||
@@ -1138,7 +1101,9 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
port: embeddedServerFailure.port,
|
||||
})
|
||||
: t("messages.embeddedServerPortInUseUnknownPort")
|
||||
: t("messages.embeddedServerCrashed");
|
||||
: embeddedServerFailure.reason === "old-database"
|
||||
? t("messages.embeddedServerOldDatabase")
|
||||
: t("messages.embeddedServerCrashed");
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-background p-6">
|
||||
|
||||
+19
-41
@@ -3,46 +3,14 @@ import { initReactI18next } from "react-i18next";
|
||||
import LanguageDetector from "i18next-browser-languagedetector";
|
||||
|
||||
import enTranslation from "../locales/en.json";
|
||||
import { TRANSLATED_LANGUAGES } from "./languages";
|
||||
|
||||
type LocaleModule = { default: ResourceKey };
|
||||
|
||||
/** i18n code -> translated file name, shared by core and plugin locales. */
|
||||
export const LOCALE_FILES = {
|
||||
af: "af_ZA",
|
||||
ar: "ar_SA",
|
||||
bn: "bn_BD",
|
||||
bg: "bg_BG",
|
||||
ca: "ca_ES",
|
||||
cs: "cs_CZ",
|
||||
da: "da_DK",
|
||||
de: "de_DE",
|
||||
el: "el_GR",
|
||||
"es-ES": "es_ES",
|
||||
fi: "fi_FI",
|
||||
fr: "fr_FR",
|
||||
he: "he_IL",
|
||||
hi: "hi_IN",
|
||||
hu: "hu_HU",
|
||||
id: "id_ID",
|
||||
it: "it_IT",
|
||||
ja: "ja_JP",
|
||||
ko: "ko_KR",
|
||||
nl: "nl_NL",
|
||||
no: "no_NO",
|
||||
pl: "pl_PL",
|
||||
"pt-PT": "pt_PT",
|
||||
"pt-BR": "pt_BR",
|
||||
ro: "ro_RO",
|
||||
ru: "ru_RU",
|
||||
sr: "sr_SP",
|
||||
"sv-SE": "sv_SE",
|
||||
th: "th_TH",
|
||||
tr: "tr_TR",
|
||||
uk: "uk_UA",
|
||||
vi: "vi_VN",
|
||||
"zh-CN": "zh_CN",
|
||||
"zh-TW": "zh_TW",
|
||||
} as const satisfies Record<string, string>;
|
||||
export const LOCALE_FILES: Record<string, string> = Object.fromEntries(
|
||||
TRANSLATED_LANGUAGES.map(({ code, file }) => [code, file]),
|
||||
);
|
||||
|
||||
const translatedFiles = import.meta.glob<LocaleModule>(
|
||||
"../locales/translated/*.json",
|
||||
@@ -94,8 +62,20 @@ export function normalizeLanguageCode(language?: string | null): string {
|
||||
);
|
||||
if (exactMatch) return exactMatch;
|
||||
|
||||
const baseLanguage = normalized.split("-")[0];
|
||||
return supportedLngs.includes(baseLanguage) ? baseLanguage : "en";
|
||||
const baseLanguage = normalized.split("-")[0].toLowerCase();
|
||||
if (supportedLngs.includes(baseLanguage)) return baseLanguage;
|
||||
|
||||
// Regional variants we don't ship (es-MX, pt, sv) use the closest one we do.
|
||||
if (baseLanguage === "zh") {
|
||||
return /-(tw|hk|mo|hant)\b/i.test(normalized) ? "zh-TW" : "zh-CN";
|
||||
}
|
||||
if (baseLanguage === "nb" || baseLanguage === "nn") return "no";
|
||||
if (baseLanguage === "pt") return "pt-BR";
|
||||
return (
|
||||
supportedLngs.find((supported) =>
|
||||
supported.toLowerCase().startsWith(`${baseLanguage}-`),
|
||||
) ?? "en"
|
||||
);
|
||||
}
|
||||
|
||||
const localeBackend: BackendModule = {
|
||||
@@ -106,9 +86,7 @@ const localeBackend: BackendModule = {
|
||||
|
||||
if (namespace !== CORE_NAMESPACE) {
|
||||
const file =
|
||||
normalizedLanguage === "en"
|
||||
? "en"
|
||||
: (LOCALE_FILES as Record<string, string>)[normalizedLanguage];
|
||||
normalizedLanguage === "en" ? "en" : LOCALE_FILES[normalizedLanguage];
|
||||
if (!pluginLocaleResolver || !file) {
|
||||
callback(null, {});
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import languages from "../locales/languages.json";
|
||||
|
||||
/** One translated language. The translator in Termix-Registry keeps a copy of this list. */
|
||||
export interface Language {
|
||||
code: string;
|
||||
file: string;
|
||||
name: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const TRANSLATED_LANGUAGES: readonly Language[] = languages;
|
||||
|
||||
/** Every language the app can show, English first, for language pickers. */
|
||||
export const LANGUAGES: readonly { code: string; label: string }[] = [
|
||||
{ code: "en", label: "English" },
|
||||
...TRANSLATED_LANGUAGES.map(({ code, label }) => ({ code, label })),
|
||||
];
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
|
||||
export type DesktopUpdateMode = "automatic" | "prompt" | "never";
|
||||
|
||||
export type DesktopInstallKind = "auto" | "manual" | "store" | "disabled";
|
||||
|
||||
export type DesktopUpdateStatus =
|
||||
| "idle"
|
||||
| "checking"
|
||||
| "available"
|
||||
| "downloading"
|
||||
| "downloaded"
|
||||
| "up-to-date"
|
||||
| "error";
|
||||
|
||||
export interface DesktopUpdateState {
|
||||
kind: DesktopInstallKind;
|
||||
mode: DesktopUpdateMode;
|
||||
currentVersion: string;
|
||||
status: DesktopUpdateStatus;
|
||||
version: string | null;
|
||||
releaseUrl: string | null;
|
||||
progress: number | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface DesktopUpdatesBridge {
|
||||
getState: () => Promise<DesktopUpdateState | null>;
|
||||
check: () => Promise<DesktopUpdateState | null>;
|
||||
download: () => Promise<DesktopUpdateState | null>;
|
||||
install: () => Promise<boolean>;
|
||||
setMode: (mode: DesktopUpdateMode) => Promise<DesktopUpdateState | null>;
|
||||
onState: (callback: (state: DesktopUpdateState) => void) => () => void;
|
||||
}
|
||||
|
||||
type UpdatesWindow = Window &
|
||||
typeof globalThis & {
|
||||
electronAPI?: { updates?: DesktopUpdatesBridge };
|
||||
};
|
||||
|
||||
/** The main process updater, or null in the browser and on older builds. */
|
||||
export function desktopUpdates(): DesktopUpdatesBridge | null {
|
||||
if (!isElectron()) return null;
|
||||
return (window as UpdatesWindow).electronAPI?.updates ?? null;
|
||||
}
|
||||
|
||||
/** Whether this install has an updater the user can configure. */
|
||||
export function canConfigureUpdates(
|
||||
state: DesktopUpdateState | null,
|
||||
): state is DesktopUpdateState {
|
||||
return !!state && state.kind !== "store" && state.kind !== "disabled";
|
||||
}
|
||||
|
||||
/** Which toast, if any, this state calls for. */
|
||||
export function updateToastFor(
|
||||
state: DesktopUpdateState | null,
|
||||
): "prompt" | "manual" | "ready" | null {
|
||||
if (!state || !state.version || state.mode === "never") return null;
|
||||
if (state.status === "downloaded") return "ready";
|
||||
if (state.status !== "available") return null;
|
||||
if (state.kind === "manual") return "manual";
|
||||
if (state.kind === "auto" && state.mode === "prompt") return "prompt";
|
||||
return null;
|
||||
}
|
||||
|
||||
export function useDesktopUpdate(): DesktopUpdateState | null {
|
||||
const [state, setState] = useState<DesktopUpdateState | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const bridge = desktopUpdates();
|
||||
if (!bridge) return;
|
||||
let active = true;
|
||||
bridge
|
||||
.getState()
|
||||
.then((next) => active && next && setState(next))
|
||||
.catch(() => {});
|
||||
const off = bridge.onState((next) => active && setState(next));
|
||||
return () => {
|
||||
active = false;
|
||||
off();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return state;
|
||||
}
|
||||
@@ -38,5 +38,7 @@
|
||||
"pluginCatalog": "plugins",
|
||||
"cli": "cli",
|
||||
"develop": "develop",
|
||||
"communityRegistry": "develop/community-registry"
|
||||
"communityRegistry": "develop/community-registry",
|
||||
"pluginRegistries": "guide/plugins#registries",
|
||||
"hostRegistry": "develop/registries"
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { isElectron } from "@/lib/electron";
|
||||
|
||||
export type EmbeddedServerFailureReason = "port-in-use" | "crashed";
|
||||
export type EmbeddedServerFailureReason =
|
||||
"port-in-use" | "old-database" | "crashed";
|
||||
|
||||
export interface EmbeddedServerFailure {
|
||||
reason: EmbeddedServerFailureReason;
|
||||
@@ -41,9 +42,13 @@ export async function getEmbeddedServerFailure(): Promise<EmbeddedServerFailure
|
||||
// An unrecognized reason from a newer/older main process still means the
|
||||
// backend is down, which is the part the caller acts on -- so it is
|
||||
// reported as the generic crash rather than dropped.
|
||||
return failure.reason === "port-in-use"
|
||||
? { reason: "port-in-use", port: failure.port ?? null }
|
||||
: { reason: "crashed", port: null };
|
||||
if (failure.reason === "port-in-use") {
|
||||
return { reason: "port-in-use", port: failure.port ?? null };
|
||||
}
|
||||
if (failure.reason === "old-database") {
|
||||
return { reason: "old-database", port: null };
|
||||
}
|
||||
return { reason: "crashed", port: null };
|
||||
} catch {
|
||||
// No status channel, or the main process is tearing down. Neither is
|
||||
// evidence that the backend failed, so keep waiting.
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# Translations
|
||||
|
||||
Only `en.json` needs to be updated. Every other language in `translated/` is filled in automatically for any string that doesn't have a translation yet.
|
||||
Only `en.json` needs to be updated. Every other language in `translated/` is translated automatically on each release (and by the Translate workflow) with the Termix LLM server, using the translator in [Termix-Registry](https://github.com/Termix-SSH/Termix-Registry/tree/main/translate).
|
||||
|
||||
If an automatic translation is wrong, fix it in that language's file. Strings that already have a translation are never overwritten.
|
||||
New strings and strings whose English changed are translated. `translations-lock.json` tracks which English text each translation was made from, so don't edit it by hand.
|
||||
|
||||
If an automatic translation is wrong, fix it in that language's file. A fixed string is kept until its English changes.
|
||||
|
||||
To add a language, add it to `languages.json` here and in `translate/languages.json` in Termix-Registry.
|
||||
+120
-28
@@ -233,6 +233,37 @@
|
||||
"desktopLinked": "Linked to {{server}}",
|
||||
"pluginsDocsLink": "Read about each one"
|
||||
},
|
||||
"desktopUpdates": {
|
||||
"title": "Desktop app",
|
||||
"mode": "Updates",
|
||||
"modeHint": "How this app gets new versions.",
|
||||
"manualModeHint": "This copy can not update itself, so Termix only tells you when a new version is out.",
|
||||
"modeAutomatic": "Automatic",
|
||||
"modePrompt": "Ask first",
|
||||
"modeNever": "Never",
|
||||
"automaticBody": "Download new versions in the background and install them when you restart.",
|
||||
"promptBody": "Tell me when a new version is out and let me choose when to download it.",
|
||||
"neverBody": "Do not check for updates. You can still check by hand in Settings.",
|
||||
"onboardingTitle": "App updates",
|
||||
"onboardingBody": "The desktop app can now update itself. Pick how you want new versions to arrive. You can change this later in Settings, Updates.",
|
||||
"onboardingManual": "This copy of the app was installed in a way that can not update itself, so Termix will tell you when a new version is out with a link to download it.",
|
||||
"status": "Version {{version}}",
|
||||
"statusIdle": "Not checked yet",
|
||||
"statusChecking": "Checking for updates",
|
||||
"statusAvailable": "Version {{version}} is available",
|
||||
"statusDownloading": "Downloading {{version}} ({{progress}}%)",
|
||||
"statusReady": "Version {{version}} is ready. Restart to install it.",
|
||||
"statusUpToDate": "You have the newest version",
|
||||
"statusError": "Could not check for updates. Try again later.",
|
||||
"checkNow": "Check now",
|
||||
"download": "Download",
|
||||
"restart": "Restart now",
|
||||
"later": "Later",
|
||||
"available": "Termix {{version}} is available",
|
||||
"manualHint": "Download it from the release page and install it over this one.",
|
||||
"ready": "Termix {{version}} is ready to install",
|
||||
"readyHint": "It installs the next time Termix restarts."
|
||||
},
|
||||
"nav": {
|
||||
"home": "Home",
|
||||
"terminal": "Terminal",
|
||||
@@ -280,7 +311,8 @@
|
||||
},
|
||||
"searchEverything": "Search hosts, tabs and actions",
|
||||
"search": "Search",
|
||||
"hiddenCount": "{{count}} hidden",
|
||||
"hiddenCount_one": "{{count}} hidden",
|
||||
"hiddenCount_other": "{{count}} hidden",
|
||||
"showInRail": "Show in sidebar",
|
||||
"moveUp": "Move up",
|
||||
"moveDown": "Move down",
|
||||
@@ -344,7 +376,8 @@
|
||||
"unsavedChangesDescription": "Your host changes have not been saved. Discard them?",
|
||||
"keepEditing": "Keep editing",
|
||||
"discardChanges": "Discard changes",
|
||||
"movedToFolder": "Moved {{count}} host(s) to \"{{folder}}\"",
|
||||
"movedToFolder_one": "Moved {{count}} host to \"{{folder}}\"",
|
||||
"movedToFolder_other": "Moved {{count}} hosts to \"{{folder}}\"",
|
||||
"editHostTooltip": "Edit host",
|
||||
"behavior": "Behavior",
|
||||
"scrollbackBuffer": "Scrollback Buffer",
|
||||
@@ -545,7 +578,8 @@
|
||||
"collapseSubHosts": "Collapse sub-hosts",
|
||||
"expandSubHosts": "Expand sub-hosts",
|
||||
"cannotNestUnderDescendant": "Can't nest a host under its own sub-host",
|
||||
"movedToParent": "Moved {{count}} host(s) under \"{{parent}}\"",
|
||||
"movedToParent_one": "Moved {{count}} host under \"{{parent}}\"",
|
||||
"movedToParent_other": "Moved {{count}} hosts under \"{{parent}}\"",
|
||||
"newFolder": "New folder",
|
||||
"createFolderTitle": "Create folder",
|
||||
"editFolderTitle": "Edit folder",
|
||||
@@ -573,7 +607,8 @@
|
||||
"folderSaved": "Folder saved",
|
||||
"failedToSaveFolder": "Failed to save folder",
|
||||
"folderDeleted": "Deleted folder \"{{name}}\"",
|
||||
"deleteFolderConfirm": "Delete \"{{name}}\" and its {{count}} host(s)? This cannot be undone.",
|
||||
"deleteFolderConfirm_one": "Delete \"{{name}}\" and its {{count}} host? This cannot be undone.",
|
||||
"deleteFolderConfirm_other": "Delete \"{{name}}\" and its {{count}} hosts? This cannot be undone.",
|
||||
"failedToMoveHosts": "Failed to move hosts",
|
||||
"failedToReorder": "Failed to reorder",
|
||||
"expandAll": "Expand all folders",
|
||||
@@ -641,7 +676,8 @@
|
||||
"copyLink": "Copy Link",
|
||||
"deleteCredentialConfirm": "Delete credential \"{{name}}\"?",
|
||||
"deletedCredential": "Deleted {{name}}",
|
||||
"deleteHostsConfirm": "Delete {{count}} host{{plural}}? This cannot be undone.",
|
||||
"deleteHostsConfirm_one": "Delete {{count}} host? This cannot be undone.",
|
||||
"deleteHostsConfirm_other": "Delete {{count}} hosts? This cannot be undone.",
|
||||
"movedToRoot": "Moved to root",
|
||||
"addTagsPlaceholder": "Add tags...",
|
||||
"authDetails": "Authentication Details",
|
||||
@@ -700,7 +736,8 @@
|
||||
"groupFeatureFlags": "Plugin settings",
|
||||
"groupAdvanced": "Advanced config",
|
||||
"preview": "Preview",
|
||||
"moreHosts": "... {{count}} more hosts",
|
||||
"moreHosts_one": "... {{count}} more host",
|
||||
"moreHosts_other": "... {{count}} more hosts",
|
||||
"summary": "{{selected}} of {{total}} hosts",
|
||||
"credentialsIncluded": "credentials included",
|
||||
"credentialsExcluded": "credentials excluded",
|
||||
@@ -713,16 +750,20 @@
|
||||
"sampleDownloaded": "Sample file downloaded",
|
||||
"failedToDeleteCredential2": "Failed to delete credential",
|
||||
"noFolderOption": "(No folder)",
|
||||
"nSelected": "{{count}} selected",
|
||||
"nSelected_one": "{{count}} selected",
|
||||
"nSelected_other": "{{count}} selected",
|
||||
"featuresMenu": "Plugins",
|
||||
"moveMenu": "Move",
|
||||
"connectSelected": "Connect",
|
||||
"cancelSelection": "Cancel",
|
||||
"deletedCount": "Deleted {{count}} hosts",
|
||||
"failedToDeleteCount": "Failed to delete {{count}} hosts",
|
||||
"deletedCount_one": "Deleted {{count}} host",
|
||||
"deletedCount_other": "Deleted {{count}} hosts",
|
||||
"failedToDeleteCount_one": "Failed to delete {{count}} host",
|
||||
"failedToDeleteCount_other": "Failed to delete {{count}} hosts",
|
||||
"duplicatedHost": "Duplicated \"{{name}}\"",
|
||||
"failedToDuplicateHost": "Failed to duplicate host",
|
||||
"updatedCount": "Updated {{count}} hosts",
|
||||
"updatedCount_one": "Updated {{count}} host",
|
||||
"updatedCount_other": "Updated {{count}} hosts",
|
||||
"friendlyNameLabel": "Friendly Name",
|
||||
"descriptionLabel": "Description",
|
||||
"loadingHosts": "Loading hosts...",
|
||||
@@ -762,7 +803,8 @@
|
||||
"shareHostTitle": "Share: {{name}}",
|
||||
"shareFolder": "Share Folder",
|
||||
"shareFolderTitle": "Share folder: {{name}}",
|
||||
"folderSharedSuccessfully": "Shared {{count}} host(s) in folder",
|
||||
"folderSharedSuccessfully_one": "Shared {{count}} host in folder",
|
||||
"folderSharedSuccessfully_other": "Shared {{count}} hosts in folder",
|
||||
"failedToShareFolder": "Failed to share folder",
|
||||
"sharing": {
|
||||
"loadError": "Failed to load sharing data. Please try again.",
|
||||
@@ -921,7 +963,8 @@
|
||||
"unsplit": "Unsplit",
|
||||
"layouts": "Layouts",
|
||||
"applyLayout": "Change layout",
|
||||
"maxPanes": "A split can hold up to {{count}} panes",
|
||||
"maxPanes_one": "A split can hold up to {{count}} pane",
|
||||
"maxPanes_other": "A split can hold up to {{count}} panes",
|
||||
"dropSplit": "Drop to split",
|
||||
"dropHere": "Drop to show here",
|
||||
"paneActions": "Pane actions",
|
||||
@@ -1084,6 +1127,7 @@
|
||||
"databaseConnectionFailed": "Failed to connect to the database server",
|
||||
"embeddedServerPortInUse": "Port {{port}} is already in use by another program. This is usually a Termix Docker container or a leftover Termix process from a previous session. Close it, then restart Termix.",
|
||||
"embeddedServerPortInUseUnknownPort": "One of the ports Termix needs is already in use by another program. Close whatever is using it, then restart Termix.",
|
||||
"embeddedServerOldDatabase": "Your local data is from Termix 2.8 or older and has to pass through 2.9 first. Install Termix 2.9.1, open it once so it can move your data, then update to this version again.",
|
||||
"embeddedServerCrashed": "The local server stopped unexpectedly. Restart Termix, and check the desktop log if this keeps happening.",
|
||||
"resetCodeSent": "Reset code sent to Docker logs",
|
||||
"codeVerified": "Code verified successfully",
|
||||
@@ -1237,7 +1281,8 @@
|
||||
"statusCheck": "Status Check",
|
||||
"sec": "sec",
|
||||
"logLevel": "Log Level",
|
||||
"usersCount": "{{count}} users",
|
||||
"usersCount_one": "{{count}} user",
|
||||
"usersCount_other": "{{count}} users",
|
||||
"searchUsers": "Search users...",
|
||||
"noUsers": "No users",
|
||||
"noUsersMatch": "No users match your search",
|
||||
@@ -1247,7 +1292,8 @@
|
||||
"roleName": "Name",
|
||||
"roleDisplayName": "Display Name",
|
||||
"roleDescription": "Description",
|
||||
"rolesCount": "{{count}} roles",
|
||||
"rolesCount_one": "{{count}} role",
|
||||
"rolesCount_other": "{{count}} roles",
|
||||
"createRole": "Create",
|
||||
"creating": "Creating...",
|
||||
"exportDatabase": "Export Database",
|
||||
@@ -1261,7 +1307,8 @@
|
||||
"changeFile": "Change",
|
||||
"import": "Import",
|
||||
"importing": "Importing...",
|
||||
"apiKeysCount": "{{count}} keys",
|
||||
"apiKeysCount_one": "{{count}} key",
|
||||
"apiKeysCount_other": "{{count}} keys",
|
||||
"apiKeysDocsLink": "View docs",
|
||||
"apiKeysCliHint": "Use an API key with the Termix CLI to manage servers from your terminal.",
|
||||
"apiKeysCliLink": "View CLI docs",
|
||||
@@ -1283,7 +1330,8 @@
|
||||
"systemBadge": "SYS",
|
||||
"customBadge": "CUSTOM",
|
||||
"youBadge": "YOU",
|
||||
"sessionsActive": "{{count}} active",
|
||||
"sessionsActive_one": "{{count}} active",
|
||||
"sessionsActive_other": "{{count}} active",
|
||||
"sessionActive": "Active: {{time}}",
|
||||
"sessionExpires": "Exp: {{time}}",
|
||||
"revokeAll": "All",
|
||||
@@ -1392,14 +1440,16 @@
|
||||
"exportUserDataDesc": "Download this user's hosts, credentials and file manager data as JSON. Secrets are decrypted.",
|
||||
"exportUserDataSuccess": "User data exported",
|
||||
"exportUserDataFailed": "Failed to export user data",
|
||||
"hostsCount": "{{count}} hosts",
|
||||
"hostsCount_one": "{{count}} host",
|
||||
"hostsCount_other": "{{count}} hosts",
|
||||
"addHostForUser": "Add Host",
|
||||
"noHostsForUser": "This user has no hosts",
|
||||
"connectToHost": "Connect",
|
||||
"deleteHostConfirm": "Delete host \"{{name}}\" belonging to {{username}}?",
|
||||
"hostDeletedSuccess": "Host deleted",
|
||||
"hostDeleteFailed": "Failed to delete host",
|
||||
"credentialsCount": "{{count}} credentials",
|
||||
"credentialsCount_one": "{{count}} credential",
|
||||
"credentialsCount_other": "{{count}} credentials",
|
||||
"addCredentialForUser": "Add Credential",
|
||||
"noCredentialsForUser": "This user has no credentials",
|
||||
"deleteCredentialConfirm": "Delete credential \"{{name}}\" belonging to {{username}}?",
|
||||
@@ -1428,7 +1478,8 @@
|
||||
"importError": "Database import failed",
|
||||
"sectionHostDefaults": "Host Defaults",
|
||||
"rolePermissions": {
|
||||
"count": "{{count}} permissions",
|
||||
"count_one": "{{count}} permission",
|
||||
"count_other": "{{count}} permissions",
|
||||
"editAction": "Edit permissions",
|
||||
"loadError": "Failed to load the permissions catalog",
|
||||
"saved": "Role permissions saved",
|
||||
@@ -1647,7 +1698,8 @@
|
||||
"apiKeyCreatedWarning": "Key created - copy it now, it won't be shown again.",
|
||||
"copyApiKey": "Copy API key",
|
||||
"copiedToClipboard": "Copied to clipboard",
|
||||
"apiKeyCount": "{{count}} keys",
|
||||
"apiKeyCount_one": "{{count}} key",
|
||||
"apiKeyCount_other": "{{count}} keys",
|
||||
"newKey": "New Key",
|
||||
"noApiKeys": "No API keys yet.",
|
||||
"apiKeyActive": "Active",
|
||||
@@ -1848,19 +1900,23 @@
|
||||
"tabs": {
|
||||
"installed": "Installed",
|
||||
"browse": "Browse",
|
||||
"updates": "Updates"
|
||||
"updates": "Updates",
|
||||
"registries": "Registries"
|
||||
},
|
||||
"status": {
|
||||
"summary": "{{installed}} installed, {{running}} running",
|
||||
"running": "Running",
|
||||
"disabled": "Disabled",
|
||||
"failed": "Stopped unexpectedly",
|
||||
"blocked": "Waiting on another plugin"
|
||||
"blocked": "Waiting on another plugin",
|
||||
"server": "Runs on your server"
|
||||
},
|
||||
"groups": {
|
||||
"running": "Running",
|
||||
"notRunning": "Not running"
|
||||
"notRunning": "Not running",
|
||||
"onServer": "On your server"
|
||||
},
|
||||
"runsOnServerHint": "This plugin only runs on your server, so it stays off in the desktop app. It still works there.",
|
||||
"sort": {
|
||||
"popular": "Most popular",
|
||||
"name": "Name",
|
||||
@@ -1878,7 +1934,7 @@
|
||||
"version": "Version",
|
||||
"status": "Status",
|
||||
"category": "Category",
|
||||
"installs": "Installs"
|
||||
"downloads": "Downloads"
|
||||
},
|
||||
"risk": {
|
||||
"critical": "Critical",
|
||||
@@ -2030,8 +2086,47 @@
|
||||
"noneInstalled": "No plugins installed yet. Browse to add some.",
|
||||
"nothingHere": "Nothing here.",
|
||||
"nothingHereHint": "Try another category or search.",
|
||||
"communitySoon": "Community plugins are coming in a later update. Plugin authors can already submit theirs to the community registry.",
|
||||
"showInstalled": "Show installed",
|
||||
"allInstalled": "Everything here is already installed.",
|
||||
"allInstalledHint": "Turn on Show installed to see them.",
|
||||
"communitySubmit": "How to submit",
|
||||
"registries": {
|
||||
"intro": "Registries are lists of plugins you can install. The official one is always on. You can turn on the community registry or add your own.",
|
||||
"hostYourOwn": "Host your own registry",
|
||||
"add": "Add registry",
|
||||
"addTitle": "Add a registry",
|
||||
"addHint": "Paste the link to the registry's index.json. Termix reads it and shows the key it signs plugins with. Only add registries from people you trust.",
|
||||
"urlLabel": "Index URL",
|
||||
"check": "Check",
|
||||
"addConfirm": "Trust and add",
|
||||
"added": "Added {{name}}",
|
||||
"customWarning": "Plugins from this registry are not reviewed by the Termix team. Only the owner of the registry decides what is in it. Every download must be signed by the key above.",
|
||||
"keyFingerprint": "Key {{keyId}}",
|
||||
"pluginCount_one": "{{count}} plugin",
|
||||
"pluginCount_other": "{{count}} plugins",
|
||||
"officialName": "Official",
|
||||
"communityName": "Community",
|
||||
"alwaysOn": "Always on",
|
||||
"communityHint": "Plugins made by other people. Each version is checked by a person and by automated checks before it is listed.",
|
||||
"communityConfirmTitle": "Turn on community plugins?",
|
||||
"communityConfirmBody": "Community plugins are made by other people, not the Termix team. Every version is checked by a person and by automated checks before it is listed, but mistakes can still get through. Only install plugins you trust and check what each one asks for.",
|
||||
"communityConfirm": "Turn on",
|
||||
"communityBadge": "Community",
|
||||
"communityBadgeHint": "From the community registry. Made by other people and reviewed before it was listed.",
|
||||
"customBadge": "Custom",
|
||||
"customBadgeHint": "From {{name}}, a registry an admin added. Not reviewed by the Termix team.",
|
||||
"remove": "Remove",
|
||||
"removeTitle": "Remove {{name}}?",
|
||||
"removeBody": "Its plugins will no longer show in Browse. You can add it again later.",
|
||||
"removed": "Removed {{name}}",
|
||||
"inUse": "Uninstall the plugins from {{name}} first: {{list}}",
|
||||
"changeFailed": "Could not change the registry: {{error}}",
|
||||
"loadFailed": "Could not load registries: {{error}}",
|
||||
"unreachable": "Could not read it: {{error}}",
|
||||
"filter": "Registry",
|
||||
"allRegistries": "All registries",
|
||||
"browseHint": "Turn on community plugins or add your own registry in the Registries tab."
|
||||
},
|
||||
"upToDate": "Everything is up to date.",
|
||||
"lastChecked": "Last checked {{time}}",
|
||||
"updatesReady_one": "{{count}} update ready.",
|
||||
@@ -2054,11 +2149,8 @@
|
||||
"signedOnly": "TERMIX_REQUIRE_SIGNED_PLUGINS is set, so installing from a file stays blocked."
|
||||
},
|
||||
"count": {
|
||||
"active_one": "{{formatted}} active install",
|
||||
"active_other": "{{formatted}} active installs",
|
||||
"downloads_one": "{{formatted}} download",
|
||||
"downloads_other": "{{formatted}} downloads",
|
||||
"activeHint": "Instances that reported this plugin turned on in the last week. Instances with usage statistics turned off are not counted.",
|
||||
"downloadsHint": "How many times a release was downloaded. This is not the number of installs."
|
||||
},
|
||||
"unverified": "Unverified",
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
[
|
||||
{ "code": "af", "file": "af_ZA", "name": "Afrikaans", "label": "Afrikaans" },
|
||||
{ "code": "ar", "file": "ar_SA", "name": "Arabic", "label": "العربية" },
|
||||
{ "code": "bn", "file": "bn_BD", "name": "Bengali", "label": "বাংলা" },
|
||||
{ "code": "bg", "file": "bg_BG", "name": "Bulgarian", "label": "Български" },
|
||||
{ "code": "ca", "file": "ca_ES", "name": "Catalan", "label": "Català" },
|
||||
{
|
||||
"code": "zh-CN",
|
||||
"file": "zh_CN",
|
||||
"name": "Simplified Chinese",
|
||||
"label": "中文 (简体)"
|
||||
},
|
||||
{
|
||||
"code": "zh-TW",
|
||||
"file": "zh_TW",
|
||||
"name": "Traditional Chinese (Taiwan)",
|
||||
"label": "中文 (繁體)"
|
||||
},
|
||||
{ "code": "hr", "file": "hr_HR", "name": "Croatian", "label": "Hrvatski" },
|
||||
{ "code": "cs", "file": "cs_CZ", "name": "Czech", "label": "Čeština" },
|
||||
{ "code": "da", "file": "da_DK", "name": "Danish", "label": "Dansk" },
|
||||
{ "code": "nl", "file": "nl_NL", "name": "Dutch", "label": "Nederlands" },
|
||||
{ "code": "et", "file": "et_EE", "name": "Estonian", "label": "Eesti" },
|
||||
{ "code": "fi", "file": "fi_FI", "name": "Finnish", "label": "Suomi" },
|
||||
{ "code": "fr", "file": "fr_FR", "name": "French", "label": "Français" },
|
||||
{ "code": "de", "file": "de_DE", "name": "German", "label": "Deutsch" },
|
||||
{ "code": "el", "file": "el_GR", "name": "Greek", "label": "Ελληνικά" },
|
||||
{ "code": "he", "file": "he_IL", "name": "Hebrew", "label": "עברית" },
|
||||
{ "code": "hi", "file": "hi_IN", "name": "Hindi", "label": "हिन्दी" },
|
||||
{ "code": "hu", "file": "hu_HU", "name": "Hungarian", "label": "Magyar" },
|
||||
{
|
||||
"code": "id",
|
||||
"file": "id_ID",
|
||||
"name": "Indonesian",
|
||||
"label": "Indonesia"
|
||||
},
|
||||
{ "code": "it", "file": "it_IT", "name": "Italian", "label": "Italiano" },
|
||||
{ "code": "ja", "file": "ja_JP", "name": "Japanese", "label": "日本語" },
|
||||
{ "code": "ko", "file": "ko_KR", "name": "Korean", "label": "한국어" },
|
||||
{ "code": "lv", "file": "lv_LV", "name": "Latvian", "label": "Latviešu" },
|
||||
{
|
||||
"code": "lt",
|
||||
"file": "lt_LT",
|
||||
"name": "Lithuanian",
|
||||
"label": "Lietuvių"
|
||||
},
|
||||
{ "code": "ms", "file": "ms_MY", "name": "Malay", "label": "Bahasa Melayu" },
|
||||
{
|
||||
"code": "no",
|
||||
"file": "no_NO",
|
||||
"name": "Norwegian Bokmål",
|
||||
"label": "Norsk"
|
||||
},
|
||||
{ "code": "pl", "file": "pl_PL", "name": "Polish", "label": "Polski" },
|
||||
{
|
||||
"code": "pt-PT",
|
||||
"file": "pt_PT",
|
||||
"name": "European Portuguese",
|
||||
"label": "Português (PT)"
|
||||
},
|
||||
{
|
||||
"code": "pt-BR",
|
||||
"file": "pt_BR",
|
||||
"name": "Brazilian Portuguese",
|
||||
"label": "Português (BR)"
|
||||
},
|
||||
{ "code": "ro", "file": "ro_RO", "name": "Romanian", "label": "Română" },
|
||||
{ "code": "ru", "file": "ru_RU", "name": "Russian", "label": "Русский" },
|
||||
{
|
||||
"code": "sr",
|
||||
"file": "sr_SP",
|
||||
"name": "Serbian (Cyrillic script)",
|
||||
"label": "Српски"
|
||||
},
|
||||
{ "code": "sk", "file": "sk_SK", "name": "Slovak", "label": "Slovenčina" },
|
||||
{
|
||||
"code": "sl",
|
||||
"file": "sl_SI",
|
||||
"name": "Slovenian",
|
||||
"label": "Slovenščina"
|
||||
},
|
||||
{
|
||||
"code": "es-ES",
|
||||
"file": "es_ES",
|
||||
"name": "Spanish (Spain)",
|
||||
"label": "Español"
|
||||
},
|
||||
{ "code": "sv-SE", "file": "sv_SE", "name": "Swedish", "label": "Svenska" },
|
||||
{ "code": "th", "file": "th_TH", "name": "Thai", "label": "ไทย" },
|
||||
{ "code": "tr", "file": "tr_TR", "name": "Turkish", "label": "Türkçe" },
|
||||
{
|
||||
"code": "uk",
|
||||
"file": "uk_UA",
|
||||
"name": "Ukrainian",
|
||||
"label": "Українська"
|
||||
},
|
||||
{
|
||||
"code": "vi",
|
||||
"file": "vi_VN",
|
||||
"name": "Vietnamese",
|
||||
"label": "Tiếng Việt"
|
||||
}
|
||||
]
|
||||
+1593
-1197
File diff suppressed because it is too large.
Load diff
+1722
-1102
File diff suppressed because it is too large.
Load diff
+1672
-1276
File diff suppressed because it is too large.
Load diff
+1761
-1365
File diff suppressed because it is too large.
Load diff
+1626
-1174
File diff suppressed because it is too large.
Load diff
+1687
-1179
File diff suppressed because it is too large.
Load diff
+1455
-1059
File diff suppressed because it is too large.
Load diff
+1406
-1010
File diff suppressed because it is too large.
Load diff
+1801
-1405
File diff suppressed because it is too large.
Load diff
+1403
-951
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+1631
-1235
File diff suppressed because it is too large.
Load diff
+1372
-920
File diff suppressed because it is too large.
Load diff
+1871
-1419
File diff suppressed because it is too large.
Load diff
+1595
-1199
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+1701
-1305
File diff suppressed because it is too large.
Load diff
+1496
-1156
File diff suppressed because it is too large.
Load diff
+1435
-983
File diff suppressed because it is too large.
Load diff
+1481
-1141
File diff suppressed because it is too large.
Load diff
+1456
-1116
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+1624
-1228
File diff suppressed because it is too large.
Load diff
+1480
-1084
File diff suppressed because it is too large.
Load diff
+1482
-974
File diff suppressed because it is too large.
Load diff
+1624
-1172
File diff suppressed because it is too large.
Load diff
+1518
-1066
File diff suppressed because it is too large.
Load diff
+1866
-1414
File diff suppressed because it is too large.
Load diff
+1615
-1107
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+1667
-1215
File diff suppressed because it is too large.
Load diff
+1467
-1071
File diff suppressed because it is too large.
Load diff
+1525
-1185
File diff suppressed because it is too large.
Load diff
+1487
-1091
File diff suppressed because it is too large.
Load diff
+1667
-1159
File diff suppressed because it is too large.
Load diff
+1384
-1044
File diff suppressed because it is too large.
Load diff
+1312
-972
File diff suppressed because it is too large.
Load diff
+1474
-1134
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1,10 +1,19 @@
|
||||
import { LayoutTemplate, Monitor, Palette, Puzzle, Shield } from "lucide-react";
|
||||
import {
|
||||
LayoutTemplate,
|
||||
Monitor,
|
||||
MonitorDown,
|
||||
Palette,
|
||||
Puzzle,
|
||||
Shield,
|
||||
} from "lucide-react";
|
||||
import { CORE_ONBOARDING_STEPS } from "@/types/onboarding";
|
||||
import { PluginsStep } from "./steps/PluginsStep";
|
||||
import { PresetStep } from "./steps/PresetStep";
|
||||
import { AppearanceStep } from "./steps/AppearanceStep";
|
||||
import { SecurityStep } from "./steps/SecurityStep";
|
||||
import { DesktopSyncStep } from "./steps/DesktopSyncStep";
|
||||
import { DesktopUpdatesStep } from "./steps/DesktopUpdatesStep";
|
||||
import { desktopUpdates } from "@/lib/desktop-updates";
|
||||
import type { OnboardingStepDef } from "./onboarding-registry";
|
||||
|
||||
/**
|
||||
@@ -38,6 +47,17 @@ export const CORE_STEPS: OnboardingStepDef[] = [
|
||||
position: 20,
|
||||
isRelevant: (ctx) => ctx.isDesktop,
|
||||
},
|
||||
{
|
||||
id: "desktop-updates",
|
||||
version: CORE_ONBOARDING_STEPS["desktop-updates"],
|
||||
titleKey: "desktopUpdates.onboardingTitle",
|
||||
icon: MonitorDown,
|
||||
Component: DesktopUpdatesStep,
|
||||
audience: "all",
|
||||
position: 25,
|
||||
// App Store builds have no updater.
|
||||
isRelevant: (ctx) => ctx.isDesktop && desktopUpdates() !== null,
|
||||
},
|
||||
{
|
||||
id: "preset",
|
||||
version: CORE_ONBOARDING_STEPS.preset,
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BellRing, Download, PauseCircle } from "lucide-react";
|
||||
import {
|
||||
desktopUpdates,
|
||||
useDesktopUpdate,
|
||||
type DesktopUpdateMode,
|
||||
} from "@/lib/desktop-updates";
|
||||
|
||||
/**
|
||||
* Desktop only: how the app updates itself. Can be changed later in
|
||||
* Settings, Updates.
|
||||
*/
|
||||
export function DesktopUpdatesStep() {
|
||||
const { t } = useTranslation();
|
||||
const state = useDesktopUpdate();
|
||||
const [picked, setPicked] = useState<DesktopUpdateMode | null>(null);
|
||||
const mode = picked ?? state?.mode ?? "prompt";
|
||||
|
||||
const choose = (next: DesktopUpdateMode) => {
|
||||
setPicked(next);
|
||||
void desktopUpdates()
|
||||
?.setMode(next)
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
const options = [
|
||||
{
|
||||
id: "automatic" as const,
|
||||
icon: Download,
|
||||
title: t("desktopUpdates.modeAutomatic"),
|
||||
body: t("desktopUpdates.automaticBody"),
|
||||
},
|
||||
{
|
||||
id: "prompt" as const,
|
||||
icon: BellRing,
|
||||
title: t("desktopUpdates.modePrompt"),
|
||||
body: t("desktopUpdates.promptBody"),
|
||||
},
|
||||
{
|
||||
id: "never" as const,
|
||||
icon: PauseCircle,
|
||||
title: t("desktopUpdates.modeNever"),
|
||||
body: t("desktopUpdates.neverBody"),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{state?.kind === "manual"
|
||||
? t("desktopUpdates.onboardingManual")
|
||||
: t("desktopUpdates.onboardingBody")}
|
||||
</p>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{options.map(({ id, icon: Icon, title, body }) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => choose(id)}
|
||||
className={`flex items-start gap-2.5 border p-2.5 text-left transition-colors ${
|
||||
mode === id
|
||||
? "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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Lightbulb,
|
||||
Pin,
|
||||
RotateCcw,
|
||||
Server,
|
||||
Settings,
|
||||
Trash2,
|
||||
TriangleAlert,
|
||||
@@ -30,8 +31,9 @@ import { getPluginData, type PluginDataSummary } from "@/api/plugins-api";
|
||||
import {
|
||||
BetaBadge,
|
||||
CapabilityRow,
|
||||
InstallCountFact,
|
||||
DownloadCount,
|
||||
PluginIconBox,
|
||||
SourceBadge,
|
||||
UnverifiedBadge,
|
||||
} from "./plugin-bits";
|
||||
import {
|
||||
@@ -107,6 +109,10 @@ export function PluginDetail({
|
||||
</span>
|
||||
<Facts className="text-[11px] text-muted-foreground/70">
|
||||
{plugin.unverified && <UnverifiedBadge />}
|
||||
<SourceBadge
|
||||
source={plugin.source}
|
||||
registryName={plugin.registryName}
|
||||
/>
|
||||
{plugin.isBeta && <BetaBadge />}
|
||||
{plugin.author && <span>{plugin.author}</span>}
|
||||
{plugin.version ? (
|
||||
@@ -122,10 +128,7 @@ export function PluginDetail({
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
<InstallCountFact
|
||||
count={plugin.installCount}
|
||||
source={plugin.installCountSource}
|
||||
/>
|
||||
<DownloadCount count={plugin.downloads} />
|
||||
</Facts>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col gap-1.5">
|
||||
@@ -225,6 +228,15 @@ export function PluginDetail({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{plugin.status === "server" && (
|
||||
<div className="flex items-start gap-2 border border-border bg-muted/30 px-3 py-2.5">
|
||||
<Server className="mt-px size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="text-[11px] leading-snug text-muted-foreground">
|
||||
{t("plugins.manager.runsOnServerHint")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-2 lg:grid-cols-3">
|
||||
<div className="flex flex-col gap-2 lg:col-span-2">
|
||||
<PluginVideo videoId={plugin.videoId} name={plugin.name} />
|
||||
|
||||
+121
-31
@@ -6,6 +6,7 @@ import {
|
||||
Columns3,
|
||||
Download,
|
||||
Globe,
|
||||
Library,
|
||||
Puzzle,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
@@ -40,10 +41,12 @@ import { InlineView, SurfaceScope } from "@/components/surface/surface-scope";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
BetaBadge,
|
||||
InstallCountFact,
|
||||
DownloadCount,
|
||||
PluginIconBox,
|
||||
SourceBadge,
|
||||
UnverifiedBadge,
|
||||
} from "./plugin-bits";
|
||||
import { RegistriesSection } from "./RegistriesSection";
|
||||
import { PluginConsentPrompt } from "./PluginConsentPrompt";
|
||||
import { PluginDetail } from "./PluginDetail";
|
||||
import {
|
||||
@@ -60,7 +63,7 @@ import { usePluginsManager, type PluginsManager } from "./use-plugins-manager";
|
||||
import { docsUrl } from "@/lib/docs";
|
||||
import { DocsLink } from "@/components/docs-link";
|
||||
|
||||
type Section = "installed" | "browse" | "updates";
|
||||
type Section = "installed" | "browse" | "updates" | "registries";
|
||||
|
||||
const GRID_SIZES = ["auto", "2", "3", "4", "5", "6"] as const;
|
||||
type GridSize = (typeof GRID_SIZES)[number];
|
||||
@@ -146,6 +149,11 @@ function PluginsTabBody() {
|
||||
icon: <Download className="size-3" />,
|
||||
count: updates.filter((p) => !p.pinnedVersion).length || undefined,
|
||||
},
|
||||
{
|
||||
id: "registries",
|
||||
label: t("plugins.manager.tabs.registries"),
|
||||
icon: <Library className="size-3" />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -217,12 +225,14 @@ function PluginsTabBody() {
|
||||
layout={layout}
|
||||
layoutControls={layoutControls}
|
||||
/>
|
||||
) : (
|
||||
) : section === "updates" ? (
|
||||
<UpdatesSection
|
||||
plugins={updates}
|
||||
manager={manager}
|
||||
onOpen={setDetailId}
|
||||
/>
|
||||
) : (
|
||||
<RegistriesSection manager={manager} />
|
||||
)}
|
||||
</div>
|
||||
</PanelShell>
|
||||
@@ -442,6 +452,7 @@ function NameCell({ plugin, muted }: { plugin: PluginEntry; muted?: boolean }) {
|
||||
{plugin.name}
|
||||
</span>
|
||||
{plugin.unverified && <UnverifiedBadge />}
|
||||
<SourceBadge source={plugin.source} registryName={plugin.registryName} />
|
||||
{plugin.isBeta && <BetaBadge />}
|
||||
</div>
|
||||
);
|
||||
@@ -483,7 +494,10 @@ function InstalledSection({
|
||||
sort,
|
||||
);
|
||||
const running = rows.filter((p) => p.status === "running");
|
||||
const stopped = rows.filter((p) => p.status !== "running");
|
||||
const onServer = rows.filter((p) => p.status === "server");
|
||||
const stopped = rows.filter(
|
||||
(p) => p.status !== "running" && p.status !== "server",
|
||||
);
|
||||
const pending = plugins.filter((p) => p.updateAvailable && !p.pinnedVersion);
|
||||
|
||||
const listColumns: DataColumn<PluginEntry>[] = [
|
||||
@@ -587,6 +601,14 @@ function InstalledSection({
|
||||
{collection(running)}
|
||||
</Group>
|
||||
)}
|
||||
{onServer.length > 0 && (
|
||||
<Group
|
||||
title={t("plugins.manager.groups.onServer")}
|
||||
count={onServer.length}
|
||||
>
|
||||
{collection(onServer)}
|
||||
</Group>
|
||||
)}
|
||||
{stopped.length > 0 && (
|
||||
<Group
|
||||
title={t("plugins.manager.groups.notRunning")}
|
||||
@@ -693,6 +715,10 @@ function InstalledCard({
|
||||
facts={
|
||||
<>
|
||||
{plugin.unverified && <UnverifiedBadge />}
|
||||
<SourceBadge
|
||||
source={plugin.source}
|
||||
registryName={plugin.registryName}
|
||||
/>
|
||||
{plugin.isBeta && <BetaBadge />}
|
||||
{plugin.author && <span>{plugin.author}</span>}
|
||||
<span>v{plugin.version}</span>
|
||||
@@ -717,7 +743,9 @@ function InstalledCard({
|
||||
<div className="flex items-center gap-2 border-t border-border px-3 py-2">
|
||||
<span className="size-1.5 shrink-0 bg-muted-foreground/40" />
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{t("plugins.manager.status.disabled")}
|
||||
{plugin.status === "server"
|
||||
? t("plugins.manager.status.server")
|
||||
: t("plugins.manager.status.disabled")}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
@@ -806,11 +834,13 @@ function StatusCell({ plugin }: { plugin: PluginEntry }) {
|
||||
? t("plugins.manager.status.blocked")
|
||||
: failed
|
||||
? t("plugins.manager.status.failed")
|
||||
: !running
|
||||
? t("plugins.manager.status.disabled")
|
||||
: plugin.pinnedVersion
|
||||
? t("plugins.manager.pinnedTo", { version: plugin.pinnedVersion })
|
||||
: t("plugins.manager.status.running");
|
||||
: plugin.status === "server"
|
||||
? t("plugins.manager.status.server")
|
||||
: !running
|
||||
? t("plugins.manager.status.disabled")
|
||||
: plugin.pinnedVersion
|
||||
? t("plugins.manager.pinnedTo", { version: plugin.pinnedVersion })
|
||||
: t("plugins.manager.status.running");
|
||||
return (
|
||||
<span
|
||||
className="flex min-w-0 items-center gap-1.5 text-muted-foreground"
|
||||
@@ -920,18 +950,34 @@ function BrowseSection({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [category, setCategory] = useState<string | null>(null);
|
||||
const [registry, setRegistry] = useState<string | null>(null);
|
||||
const [sort, setSort] = useState<SortKey>("popular");
|
||||
const [installedChoice, setInstalledChoice] = useStoredChoice(
|
||||
"pluginsTab.browseInstalled",
|
||||
["hide", "show"],
|
||||
"hide",
|
||||
);
|
||||
const showInstalled = installedChoice === "show";
|
||||
const categories = useMemo(() => categoriesOf(plugins), [plugins]);
|
||||
const registries = manager.registries;
|
||||
const registryFilter = registries.some((entry) => entry.id === registry)
|
||||
? registry
|
||||
: null;
|
||||
|
||||
const rows = sortPlugins(
|
||||
plugins.filter(
|
||||
(p) =>
|
||||
(showInstalled || !p.installed) &&
|
||||
matchesQuery(p, query) &&
|
||||
(category === null || p.category === category),
|
||||
(category === null || p.category === category) &&
|
||||
(registryFilter === null || p.registryId === registryFilter),
|
||||
),
|
||||
sort,
|
||||
);
|
||||
|
||||
const allInstalledHidden =
|
||||
!showInstalled && plugins.length > 0 && plugins.every((p) => p.installed);
|
||||
|
||||
const listColumns: DataColumn<PluginEntry>[] = [
|
||||
{
|
||||
key: "name",
|
||||
@@ -956,16 +1002,13 @@ function BrowseSection({
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "installs",
|
||||
header: t("plugins.manager.table.installs"),
|
||||
key: "downloads",
|
||||
header: t("plugins.manager.table.downloads"),
|
||||
width: "110px",
|
||||
hideBelow: "lg",
|
||||
cell: (p) => (
|
||||
<span className="text-muted-foreground">
|
||||
<InstallCountFact
|
||||
count={p.installCount}
|
||||
source={p.installCountSource}
|
||||
/>
|
||||
<DownloadCount count={p.downloads} />
|
||||
</span>
|
||||
),
|
||||
},
|
||||
@@ -996,6 +1039,44 @@ function BrowseSection({
|
||||
placeholder={t("plugins.manager.searchPlugins")}
|
||||
>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<label className="flex items-center gap-1.5">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("plugins.manager.showInstalled")}
|
||||
</span>
|
||||
<FakeSwitch
|
||||
checked={showInstalled}
|
||||
onChange={(v) => setInstalledChoice(v ? "show" : "hide")}
|
||||
/>
|
||||
</label>
|
||||
{registries.length > 1 && (
|
||||
<Select
|
||||
value={registryFilter ?? "__all"}
|
||||
onValueChange={(v) => setRegistry(v === "__all" ? null : v)}
|
||||
>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="w-auto gap-1.5 text-xs"
|
||||
aria-label={t("plugins.manager.registries.filter")}
|
||||
>
|
||||
<Library className="size-3.5 text-muted-foreground" />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all" className="text-xs">
|
||||
{t("plugins.manager.registries.allRegistries")}
|
||||
</SelectItem>
|
||||
{registries.map((entry) => (
|
||||
<SelectItem
|
||||
key={entry.id}
|
||||
value={entry.id}
|
||||
className="text-xs"
|
||||
>
|
||||
{entry.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
<SortSelect value={sort} onChange={setSort} />
|
||||
{layoutControls}
|
||||
</div>
|
||||
@@ -1022,15 +1103,23 @@ function BrowseSection({
|
||||
)}
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Puzzle}
|
||||
title={t("plugins.manager.nothingHere")}
|
||||
hint={
|
||||
plugins.length === 0
|
||||
? undefined
|
||||
: t("plugins.manager.nothingHereHint")
|
||||
}
|
||||
/>
|
||||
allInstalledHidden ? (
|
||||
<EmptyState
|
||||
icon={Puzzle}
|
||||
title={t("plugins.manager.allInstalled")}
|
||||
hint={t("plugins.manager.allInstalledHint")}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={Puzzle}
|
||||
title={t("plugins.manager.nothingHere")}
|
||||
hint={
|
||||
plugins.length === 0
|
||||
? undefined
|
||||
: t("plugins.manager.nothingHereHint")
|
||||
}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<PluginCollection
|
||||
plugins={rows}
|
||||
@@ -1048,7 +1137,7 @@ function BrowseSection({
|
||||
)}
|
||||
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{t("plugins.manager.communitySoon")}{" "}
|
||||
{t("plugins.manager.registries.browseHint")}{" "}
|
||||
<DocsLink core="communityRegistry" className="text-[11px]">
|
||||
{t("plugins.manager.communitySubmit")}
|
||||
</DocsLink>
|
||||
@@ -1076,13 +1165,14 @@ function BrowseCard({
|
||||
onOpen={onOpen}
|
||||
facts={
|
||||
<>
|
||||
<SourceBadge
|
||||
source={plugin.source}
|
||||
registryName={plugin.registryName}
|
||||
/>
|
||||
{plugin.author && <span>{plugin.author}</span>}
|
||||
{plugin.latestVersion && <span>v{plugin.latestVersion}</span>}
|
||||
{plugin.category && <span>{plugin.category}</span>}
|
||||
<InstallCountFact
|
||||
count={plugin.installCount}
|
||||
source={plugin.installCountSource}
|
||||
/>
|
||||
<DownloadCount count={plugin.downloads} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { Library, Trash2 } from "lucide-react";
|
||||
import { Input } from "@/components/input";
|
||||
import { FakeSwitch } from "@/components/section-card";
|
||||
import { PANEL } from "@/components/panel-layout";
|
||||
import {
|
||||
AddButton,
|
||||
FormFooter,
|
||||
ListBadge,
|
||||
ListRow,
|
||||
ListRowAction,
|
||||
} from "@/components/list-kit";
|
||||
import { InlineView, useConfirm } from "@/components/surface/surface-scope";
|
||||
import { DocsLink } from "@/components/docs-link";
|
||||
import {
|
||||
addRegistry,
|
||||
getRegistries,
|
||||
previewRegistry,
|
||||
removeRegistry,
|
||||
setRegistryEnabled,
|
||||
type RegistriesState,
|
||||
type RegistryListingSource,
|
||||
type RegistryPreview,
|
||||
} from "@/api/plugins-api";
|
||||
import type { PluginsManager } from "./use-plugins-manager";
|
||||
|
||||
function errorOf(error: unknown): {
|
||||
message: string;
|
||||
code?: string;
|
||||
data: Record<string, unknown>;
|
||||
} {
|
||||
const data = (error as { response?: { data?: Record<string, unknown> } })
|
||||
?.response?.data;
|
||||
return {
|
||||
message:
|
||||
typeof data?.error === "string"
|
||||
? data.error
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: String(error),
|
||||
code: typeof data?.code === "string" ? data.code : undefined,
|
||||
data: data ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
export function RegistriesSection({ manager }: { manager: PluginsManager }) {
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
const [state, setState] = useState<RegistriesState | null>(null);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setState(await getRegistries());
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
t("plugins.manager.registries.loadFailed", {
|
||||
error: errorOf(error).message,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const statusOf = (id: string): RegistryListingSource | undefined =>
|
||||
manager.registries.find((entry) => entry.id === id);
|
||||
|
||||
const toggle = async (id: string, enabled: boolean) => {
|
||||
if (enabled && id === "community") {
|
||||
const ok = await confirm({
|
||||
title: t("plugins.manager.registries.communityConfirmTitle"),
|
||||
description: t("plugins.manager.registries.communityConfirmBody"),
|
||||
confirmLabel: t("plugins.manager.registries.communityConfirm"),
|
||||
destructive: false,
|
||||
});
|
||||
if (!ok) return;
|
||||
}
|
||||
setBusy(id);
|
||||
try {
|
||||
await setRegistryEnabled(id, enabled);
|
||||
await load();
|
||||
await manager.refresh();
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
t("plugins.manager.registries.changeFailed", {
|
||||
error: errorOf(error).message,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (id: string, name: string) => {
|
||||
const ok = await confirm({
|
||||
title: t("plugins.manager.registries.removeTitle", { name }),
|
||||
description: t("plugins.manager.registries.removeBody"),
|
||||
confirmLabel: t("plugins.manager.registries.remove"),
|
||||
});
|
||||
if (!ok) return;
|
||||
setBusy(id);
|
||||
try {
|
||||
await removeRegistry(id);
|
||||
toast.success(t("plugins.manager.registries.removed", { name }));
|
||||
await load();
|
||||
await manager.refresh();
|
||||
} catch (error) {
|
||||
const info = errorOf(error);
|
||||
toast.error(
|
||||
info.code === "REGISTRY_IN_USE"
|
||||
? t("plugins.manager.registries.inUse", {
|
||||
name,
|
||||
list: ((info.data.plugins as string[]) ?? []).join(", "),
|
||||
})
|
||||
: t("plugins.manager.registries.changeFailed", {
|
||||
error: info.message,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const readOnly = manager.managedByServer || state?.managedByServer;
|
||||
|
||||
const meta = (id: string, url: string, extra?: string) => {
|
||||
const status = statusOf(id);
|
||||
const parts = [url];
|
||||
if (status && !status.error) {
|
||||
parts.push(
|
||||
t("plugins.manager.registries.pluginCount", { count: status.plugins }),
|
||||
);
|
||||
}
|
||||
if (extra) parts.push(extra);
|
||||
return parts.join(" | ");
|
||||
};
|
||||
|
||||
const errorLine = (id: string) => {
|
||||
const error = statusOf(id)?.error;
|
||||
return error ? (
|
||||
<span className="text-[11px] leading-snug text-destructive">
|
||||
{t("plugins.manager.registries.unreachable", { error })}
|
||||
</span>
|
||||
) : null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col ${PANEL.gap} ${PANEL.body}`}>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="min-w-0 flex-1 text-[11px] leading-snug text-muted-foreground">
|
||||
{t("plugins.manager.registries.intro")}{" "}
|
||||
<DocsLink core="hostRegistry" className="text-[11px]">
|
||||
{t("plugins.manager.registries.hostYourOwn")}
|
||||
</DocsLink>
|
||||
</p>
|
||||
{!readOnly && (
|
||||
<AddButton
|
||||
label={t("plugins.manager.registries.add")}
|
||||
onClick={() => setAdding(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{state && (
|
||||
<div className="flex flex-col border-t border-border/40">
|
||||
<ListRow
|
||||
stripe={0}
|
||||
icon={<Library />}
|
||||
title={t("plugins.manager.registries.officialName")}
|
||||
badges={
|
||||
<ListBadge tone="brand">
|
||||
{t("plugins.manager.registries.alwaysOn")}
|
||||
</ListBadge>
|
||||
}
|
||||
meta={meta("official", state.official.url)}
|
||||
>
|
||||
{errorLine("official")}
|
||||
</ListRow>
|
||||
|
||||
<ListRow
|
||||
stripe={1}
|
||||
icon={<Library />}
|
||||
tone={state.community.enabled ? "brand" : "muted"}
|
||||
dimmed={!state.community.enabled}
|
||||
title={t("plugins.manager.registries.communityName")}
|
||||
meta={meta("community", state.community.url)}
|
||||
trailing={
|
||||
<FakeSwitch
|
||||
checked={state.community.enabled}
|
||||
disabled={readOnly || busy === "community"}
|
||||
onChange={(next) => void toggle("community", next)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span className="text-[11px] leading-snug text-muted-foreground">
|
||||
{t("plugins.manager.registries.communityHint")}{" "}
|
||||
<DocsLink core="communityRegistry" className="text-[11px]">
|
||||
{t("plugins.manager.communitySubmit")}
|
||||
</DocsLink>
|
||||
</span>
|
||||
{state.community.enabled && errorLine("community")}
|
||||
</ListRow>
|
||||
|
||||
{state.custom.map((entry, index) => (
|
||||
<ListRow
|
||||
key={entry.id}
|
||||
stripe={index}
|
||||
icon={<Library />}
|
||||
tone={entry.enabled ? "warning" : "muted"}
|
||||
dimmed={!entry.enabled}
|
||||
title={entry.name}
|
||||
badges={
|
||||
<ListBadge tone="warning">
|
||||
{t("plugins.manager.registries.customBadge")}
|
||||
</ListBadge>
|
||||
}
|
||||
meta={meta(
|
||||
entry.id,
|
||||
entry.url,
|
||||
t("plugins.manager.registries.keyFingerprint", {
|
||||
keyId: entry.keyId,
|
||||
}),
|
||||
)}
|
||||
trailing={
|
||||
<FakeSwitch
|
||||
checked={entry.enabled}
|
||||
disabled={readOnly || busy === entry.id}
|
||||
onChange={(next) => void toggle(entry.id, next)}
|
||||
/>
|
||||
}
|
||||
actions={
|
||||
readOnly ? undefined : (
|
||||
<ListRowAction
|
||||
label={t("plugins.manager.registries.remove")}
|
||||
tone="destructive"
|
||||
disabled={busy === entry.id}
|
||||
onClick={() => void remove(entry.id, entry.name)}
|
||||
>
|
||||
<Trash2 />
|
||||
</ListRowAction>
|
||||
)
|
||||
}
|
||||
>
|
||||
{entry.enabled && errorLine(entry.id)}
|
||||
</ListRow>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AddRegistryView
|
||||
open={adding}
|
||||
onOpenChange={setAdding}
|
||||
onAdded={async () => {
|
||||
setAdding(false);
|
||||
await load();
|
||||
await manager.refresh();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddRegistryView({
|
||||
open,
|
||||
onOpenChange,
|
||||
onAdded,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onAdded: () => Promise<void>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [url, setUrl] = useState("");
|
||||
const [preview, setPreview] = useState<RegistryPreview | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [working, setWorking] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setUrl("");
|
||||
setPreview(null);
|
||||
setError(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const check = async () => {
|
||||
setWorking(true);
|
||||
setError(null);
|
||||
setPreview(null);
|
||||
try {
|
||||
setPreview(await previewRegistry(url.trim()));
|
||||
} catch (failure) {
|
||||
setError(errorOf(failure).message);
|
||||
} finally {
|
||||
setWorking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const add = async () => {
|
||||
if (!preview) return;
|
||||
setWorking(true);
|
||||
setError(null);
|
||||
try {
|
||||
const added = await addRegistry(preview.url, preview.keyId);
|
||||
toast.success(
|
||||
t("plugins.manager.registries.added", { name: added.name }),
|
||||
);
|
||||
await onAdded();
|
||||
} catch (failure) {
|
||||
const info = errorOf(failure);
|
||||
if (info.code === "KEY_MISMATCH") setPreview(null);
|
||||
setError(info.message);
|
||||
} finally {
|
||||
setWorking(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<InlineView
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t("plugins.manager.registries.addTitle")}
|
||||
icon={<Library className="size-4" />}
|
||||
footer={
|
||||
<FormFooter
|
||||
onCancel={() => onOpenChange(false)}
|
||||
onSave={() => void (preview ? add() : check())}
|
||||
saveLabel={
|
||||
preview
|
||||
? t("plugins.manager.registries.addConfirm")
|
||||
: t("plugins.manager.registries.check")
|
||||
}
|
||||
saving={working}
|
||||
disabled={!url.trim()}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
<p className="text-[11px] leading-snug text-muted-foreground">
|
||||
{t("plugins.manager.registries.addHint")}
|
||||
</p>
|
||||
<Input
|
||||
value={url}
|
||||
onChange={(e) => {
|
||||
setUrl(e.target.value);
|
||||
setPreview(null);
|
||||
setError(null);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && url.trim() && !working) {
|
||||
e.preventDefault();
|
||||
void (preview ? add() : check());
|
||||
}
|
||||
}}
|
||||
placeholder="https://example.com/termix/index.json"
|
||||
className="h-8 text-xs"
|
||||
aria-label={t("plugins.manager.registries.urlLabel")}
|
||||
autoFocus
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-[11px] leading-snug text-destructive">{error}</p>
|
||||
)}
|
||||
{preview && (
|
||||
<div className="flex flex-col gap-1.5 border border-warning/40 bg-warning/5 p-3">
|
||||
<span className="text-sm font-semibold">{preview.name}</span>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{t("plugins.manager.registries.pluginCount", {
|
||||
count: preview.plugins,
|
||||
})}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{t("plugins.manager.registries.keyFingerprint", {
|
||||
keyId: preview.keyId,
|
||||
})}
|
||||
</span>
|
||||
<p className="text-[11px] leading-snug text-muted-foreground">
|
||||
{t("plugins.manager.registries.customWarning")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</InlineView>
|
||||
);
|
||||
}
|
||||
@@ -88,6 +88,32 @@ export function UnverifiedBadge() {
|
||||
);
|
||||
}
|
||||
|
||||
/** Marks a plugin from the community registry or a custom one. */
|
||||
export function SourceBadge({
|
||||
source,
|
||||
registryName,
|
||||
}: {
|
||||
source: string;
|
||||
registryName?: string | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (source !== "community" && source !== "custom") return null;
|
||||
const label =
|
||||
source === "community"
|
||||
? t("plugins.manager.registries.communityBadge")
|
||||
: (registryName ?? t("plugins.manager.registries.customBadge"));
|
||||
return (
|
||||
<span
|
||||
className="max-w-32 shrink-0 truncate border border-border bg-muted/40 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-muted-foreground"
|
||||
title={t(`plugins.manager.registries.${source}BadgeHint`, {
|
||||
name: label,
|
||||
})}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function BetaBadge() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
@@ -100,31 +126,16 @@ export function BetaBadge() {
|
||||
);
|
||||
}
|
||||
|
||||
/** Active installs when the registry has them, release downloads otherwise. */
|
||||
export function InstallCountFact({
|
||||
count,
|
||||
source,
|
||||
}: {
|
||||
count: number | null;
|
||||
source: string | null;
|
||||
}) {
|
||||
/** How often the plugin's release files were downloaded. */
|
||||
export function DownloadCount({ count }: { count: number | null }) {
|
||||
const { t } = useTranslation();
|
||||
if (count === null || count <= 0) return null;
|
||||
const active = source === "aggregate-telemetry";
|
||||
return (
|
||||
<span
|
||||
title={
|
||||
active
|
||||
? t("plugins.manager.count.activeHint")
|
||||
: t("plugins.manager.count.downloadsHint")
|
||||
}
|
||||
>
|
||||
{t(
|
||||
active
|
||||
? "plugins.manager.count.active"
|
||||
: "plugins.manager.count.downloads",
|
||||
{ count, formatted: formatCount(count) },
|
||||
)}
|
||||
<span title={t("plugins.manager.count.downloadsHint")}>
|
||||
{t("plugins.manager.count.downloads", {
|
||||
count,
|
||||
formatted: formatCount(count),
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -24,7 +24,8 @@ import {
|
||||
normalizeGitHubRepo,
|
||||
} from "@/lib/issue-url";
|
||||
|
||||
export type PluginStatus = "running" | "disabled" | "failed" | "blocked";
|
||||
export type PluginStatus =
|
||||
"running" | "disabled" | "failed" | "blocked" | "server";
|
||||
|
||||
/** One row of the Plugins tab: what is installed merged with the registry. */
|
||||
export interface PluginEntry {
|
||||
@@ -61,14 +62,17 @@ export interface PluginEntry {
|
||||
inRegistry: boolean;
|
||||
contributes: Record<string, unknown> | null;
|
||||
publishedAt: string | null;
|
||||
installCount: number | null;
|
||||
installCountSource: string | null;
|
||||
downloads: number | null;
|
||||
source: PluginSource;
|
||||
/** Installed from a file or folder with no registry signature behind it. */
|
||||
unverified: boolean;
|
||||
/** The registry it is listed in or was installed from. */
|
||||
registryId: string | null;
|
||||
registryName: string | null;
|
||||
}
|
||||
|
||||
export type PluginSource = "bundled" | "official" | "unverified";
|
||||
export type PluginSource =
|
||||
"bundled" | "official" | "community" | "custom" | "unverified";
|
||||
|
||||
export function isPrerelease(version: string | null | undefined): boolean {
|
||||
return Boolean(
|
||||
@@ -96,10 +100,14 @@ export function pluginSource(summary: PluginSummary): PluginSource {
|
||||
if (summary.source === "bundled" || summary.tier === "bundled") {
|
||||
return "bundled";
|
||||
}
|
||||
return summary.signedBy ? "official" : "unverified";
|
||||
if (!summary.signedBy) return "unverified";
|
||||
return summary.tier === "community" || summary.tier === "custom"
|
||||
? summary.tier
|
||||
: "official";
|
||||
}
|
||||
|
||||
export function pluginStatus(summary: PluginSummary): PluginStatus {
|
||||
if (summary.runsOnServer) return "server";
|
||||
if (summary.state === "failed") return "failed";
|
||||
if (summary.state === "blocked") return "blocked";
|
||||
if (!summary.enabled) return "disabled";
|
||||
@@ -136,7 +144,10 @@ export function mergePlugins(
|
||||
latestBeta: entry?.latestBeta ?? null,
|
||||
channel: summary.channel ?? entry?.channel ?? "stable",
|
||||
isBeta: isPrerelease(summary.version),
|
||||
updateAvailable: entry?.updateAvailable ?? false,
|
||||
// The server keeps its own copy up to date.
|
||||
updateAvailable: summary.runsOnServer
|
||||
? false
|
||||
: (entry?.updateAvailable ?? false),
|
||||
addedCapabilities: entry?.addedCapabilities ?? [],
|
||||
versions: entry?.versions ?? [],
|
||||
capabilities: summary.capabilities ?? [],
|
||||
@@ -149,10 +160,11 @@ export function mergePlugins(
|
||||
inRegistry: Boolean(entry),
|
||||
contributes: (summary.contributes as Record<string, unknown>) ?? null,
|
||||
publishedAt: latestPublished(entry?.versions ?? []),
|
||||
installCount: entry?.installCount ?? null,
|
||||
installCountSource: entry?.installCountSource ?? null,
|
||||
downloads: entry?.downloads ?? null,
|
||||
source: pluginSource(summary),
|
||||
unverified: pluginSource(summary) === "unverified",
|
||||
registryId: entry?.registryId ?? summary.registryId ?? null,
|
||||
registryName: entry?.registryName ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -191,10 +203,11 @@ export function mergePlugins(
|
||||
inRegistry: true,
|
||||
contributes: null,
|
||||
publishedAt: latestPublished(entry.versions),
|
||||
installCount: entry.installCount ?? null,
|
||||
installCountSource: entry.installCountSource ?? null,
|
||||
source: "official",
|
||||
downloads: entry.downloads ?? null,
|
||||
source: entry.tier ?? "official",
|
||||
unverified: false,
|
||||
registryId: entry.registryId ?? "official",
|
||||
registryName: entry.registryName ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -230,10 +243,11 @@ export function uploadEntry(preview: PluginUploadPreview): PluginEntry {
|
||||
inRegistry: false,
|
||||
contributes: null,
|
||||
publishedAt: null,
|
||||
installCount: null,
|
||||
installCountSource: null,
|
||||
downloads: null,
|
||||
source: "unverified",
|
||||
unverified: true,
|
||||
registryId: null,
|
||||
registryName: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -252,7 +266,7 @@ export function sortPlugins(rows: PluginEntry[], key: SortKey): PluginEntry[] {
|
||||
if (key === "popular") {
|
||||
sorted.sort(
|
||||
(a, b) =>
|
||||
(b.installCount ?? -1) - (a.installCount ?? -1) ||
|
||||
(b.downloads ?? -1) - (a.downloads ?? -1) ||
|
||||
a.name.localeCompare(b.name),
|
||||
);
|
||||
} else if (key === "updated") {
|
||||
|
||||
@@ -183,6 +183,7 @@ export function usePluginsManager() {
|
||||
const result = await installPlugin(plugin.id, {
|
||||
version: version === plugin.latestVersion ? undefined : version,
|
||||
capabilities,
|
||||
registryId: plugin.registryId ?? undefined,
|
||||
});
|
||||
if (result.state === "failed" || result.state === "blocked") {
|
||||
toast.error(
|
||||
@@ -585,6 +586,8 @@ export function usePluginsManager() {
|
||||
registryError,
|
||||
managedByServer: registry?.managedByServer ?? false,
|
||||
lastCheckedAt: registry?.registry.lastCheckedAt ?? null,
|
||||
registries: registry?.registries ?? [],
|
||||
reload: () => load(),
|
||||
busy,
|
||||
consent,
|
||||
setConsent,
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { ExternalLink, MonitorDown } from "lucide-react";
|
||||
import { Button } from "@/components/button";
|
||||
import { SectionCard, SettingRow } from "@/components/section-card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/select";
|
||||
import {
|
||||
canConfigureUpdates,
|
||||
desktopUpdates,
|
||||
useDesktopUpdate,
|
||||
type DesktopUpdateMode,
|
||||
type DesktopUpdateState,
|
||||
} from "@/lib/desktop-updates";
|
||||
|
||||
const MODES: DesktopUpdateMode[] = ["automatic", "prompt", "never"];
|
||||
|
||||
function StatusText({ state }: { state: DesktopUpdateState }) {
|
||||
const { t } = useTranslation();
|
||||
const version = state.version;
|
||||
switch (state.status) {
|
||||
case "checking":
|
||||
return <>{t("desktopUpdates.statusChecking")}</>;
|
||||
case "available":
|
||||
return <>{t("desktopUpdates.statusAvailable", { version })}</>;
|
||||
case "downloading":
|
||||
return (
|
||||
<>
|
||||
{t("desktopUpdates.statusDownloading", {
|
||||
version,
|
||||
progress: state.progress ?? 0,
|
||||
})}
|
||||
</>
|
||||
);
|
||||
case "downloaded":
|
||||
return <>{t("desktopUpdates.statusReady", { version })}</>;
|
||||
case "up-to-date":
|
||||
return <>{t("desktopUpdates.statusUpToDate")}</>;
|
||||
case "error":
|
||||
return <>{t("desktopUpdates.statusError")}</>;
|
||||
default:
|
||||
return <>{t("desktopUpdates.statusIdle")}</>;
|
||||
}
|
||||
}
|
||||
|
||||
/** Desktop only: how this install updates itself. */
|
||||
export function DesktopUpdatesCard() {
|
||||
const { t } = useTranslation();
|
||||
const state = useDesktopUpdate();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
if (!canConfigureUpdates(state)) return null;
|
||||
const bridge = desktopUpdates();
|
||||
|
||||
const run = async (action: () => Promise<unknown> | undefined) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await action();
|
||||
} catch {
|
||||
toast.error(t("settings.updates.saveFailed"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const modeLabel = (mode: DesktopUpdateMode) =>
|
||||
mode === "automatic"
|
||||
? t("desktopUpdates.modeAutomatic")
|
||||
: mode === "prompt"
|
||||
? t("desktopUpdates.modePrompt")
|
||||
: t("desktopUpdates.modeNever");
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
title={t("desktopUpdates.title")}
|
||||
icon={<MonitorDown className="size-3.5" />}
|
||||
rowId="updates-desktop"
|
||||
>
|
||||
<SettingRow
|
||||
label={t("desktopUpdates.mode")}
|
||||
description={
|
||||
state.kind === "manual"
|
||||
? t("desktopUpdates.manualModeHint")
|
||||
: t("desktopUpdates.modeHint")
|
||||
}
|
||||
>
|
||||
<Select
|
||||
disabled={busy}
|
||||
value={state.mode}
|
||||
onValueChange={(next) =>
|
||||
void run(() => bridge?.setMode(next as DesktopUpdateMode))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-40 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MODES.map((mode) => (
|
||||
<SelectItem key={mode} value={mode}>
|
||||
{modeLabel(mode)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("desktopUpdates.status", { version: state.currentVersion })}
|
||||
description={<StatusText state={state} />}
|
||||
>
|
||||
{state.status === "downloaded" ? (
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => void bridge?.install()}
|
||||
>
|
||||
{t("desktopUpdates.restart")}
|
||||
</Button>
|
||||
) : state.status === "available" && state.kind === "auto" ? (
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8"
|
||||
disabled={busy}
|
||||
onClick={() => void run(() => bridge?.download())}
|
||||
>
|
||||
{t("desktopUpdates.download")}
|
||||
</Button>
|
||||
) : state.status === "available" && state.releaseUrl ? (
|
||||
<Button size="sm" variant="outline" className="h-8 gap-1.5" asChild>
|
||||
<a
|
||||
href={state.releaseUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t("desktopUpdates.download")}
|
||||
<ExternalLink className="size-3" />
|
||||
</a>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8"
|
||||
disabled={
|
||||
busy ||
|
||||
state.status === "checking" ||
|
||||
state.status === "downloading"
|
||||
}
|
||||
onClick={() => void run(() => bridge?.check())}
|
||||
>
|
||||
{t("desktopUpdates.checkNow")}
|
||||
</Button>
|
||||
)}
|
||||
</SettingRow>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -387,7 +387,7 @@ export function SettingsTab({
|
||||
)}
|
||||
</SettingsEmbedContext.Provider>
|
||||
{page.id === "keybindings" && <KeybindingsSettings />}
|
||||
{page.id === "updates" && isAdmin && <UpdatesSettings />}
|
||||
{page.id === "updates" && <UpdatesSettings isAdmin={isAdmin} />}
|
||||
{pluginFeature && (
|
||||
<>
|
||||
{pluginFeature.user && (
|
||||
|
||||
@@ -22,6 +22,7 @@ import { isElectron } from "@/lib/electron";
|
||||
import { isPrerelease } from "@/plugins/plugin-model";
|
||||
import { OPEN_PLUGINS_EVENT } from "@/plugins/open-plugins";
|
||||
import { docsUrl } from "@/lib/docs";
|
||||
import { DesktopUpdatesCard } from "./DesktopUpdatesCard";
|
||||
|
||||
const DOCKER_BETA_IMAGE = "ghcr.io/termix-ssh/termix:beta";
|
||||
|
||||
@@ -61,7 +62,7 @@ function ReleaseRow({
|
||||
);
|
||||
}
|
||||
|
||||
export function UpdatesSettings() {
|
||||
function InstanceUpdates() {
|
||||
const { t } = useTranslation();
|
||||
const [channel, setChannel] = useState<UpdateChannelState | null>(null);
|
||||
const [releases, setReleases] = useState<{
|
||||
@@ -317,3 +318,12 @@ export function UpdatesSettings() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function UpdatesSettings({ isAdmin }: { isAdmin: boolean }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<DesktopUpdatesCard />
|
||||
{isAdmin && <InstanceUpdates />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
UI_FONTS,
|
||||
} from "@/lib/theme";
|
||||
import { changeAppLanguage, normalizeLanguageCode } from "@/i18n/i18n";
|
||||
import { LANGUAGES } from "@/i18n/languages";
|
||||
import { getUserPreferences, saveUserPreferences } from "@/api/open-tabs-api";
|
||||
import type { FontSizeId, ThemeId, UiFontId } from "@/types/ui-types";
|
||||
|
||||
@@ -48,44 +49,6 @@ const THEME_PALETTES: Record<
|
||||
gruvbox: { bg: "#282828", panel: "#3c3836", text: "#ebdbb2" },
|
||||
};
|
||||
|
||||
export const LANGUAGES = [
|
||||
{ code: "en", label: "English" },
|
||||
{ code: "af", label: "Afrikaans" },
|
||||
{ code: "ar", label: "العربية" },
|
||||
{ code: "bn", label: "বাংলা" },
|
||||
{ code: "bg", label: "Български" },
|
||||
{ code: "ca", label: "Català" },
|
||||
{ code: "zh-CN", label: "中文 (简体)" },
|
||||
{ code: "zh-TW", label: "中文 (繁體)" },
|
||||
{ code: "cs", label: "Čeština" },
|
||||
{ code: "da", label: "Dansk" },
|
||||
{ code: "nl", label: "Nederlands" },
|
||||
{ code: "fi", label: "Suomi" },
|
||||
{ code: "fr", label: "Français" },
|
||||
{ code: "de", label: "Deutsch" },
|
||||
{ code: "el", label: "Ελληνικά" },
|
||||
{ code: "he", label: "עברית" },
|
||||
{ code: "hi", label: "हिन्दी" },
|
||||
{ code: "hu", label: "Magyar" },
|
||||
{ code: "id", label: "Indonesia" },
|
||||
{ code: "it", label: "Italiano" },
|
||||
{ code: "ja", label: "日本語" },
|
||||
{ code: "ko", label: "한국어" },
|
||||
{ code: "no", label: "Norsk" },
|
||||
{ code: "pl", label: "Polski" },
|
||||
{ code: "pt-PT", label: "Português (PT)" },
|
||||
{ code: "pt-BR", label: "Português (BR)" },
|
||||
{ code: "ro", label: "Română" },
|
||||
{ code: "ru", label: "Русский" },
|
||||
{ code: "sr", label: "Српски" },
|
||||
{ code: "es-ES", label: "Español" },
|
||||
{ code: "sv-SE", label: "Svenska" },
|
||||
{ code: "th", label: "ไทย" },
|
||||
{ code: "tr", label: "Türkçe" },
|
||||
{ code: "uk", label: "Українська" },
|
||||
{ code: "vi", label: "Tiếng Việt" },
|
||||
];
|
||||
|
||||
export const THEME_LABEL_KEYS: Record<ThemeId, string> = {
|
||||
system: "newUi.sidebar.userProfile.themeSystem",
|
||||
light: "newUi.sidebar.userProfile.themeLight",
|
||||
|
||||
@@ -38,6 +38,8 @@ export interface SettingsPage {
|
||||
icon: LucideIcon;
|
||||
/** Only for admins. */
|
||||
admin?: boolean;
|
||||
/** Also shown to non-admins in the desktop app. */
|
||||
desktopToo?: boolean;
|
||||
/** Left out of the desktop app. */
|
||||
browserOnly?: boolean;
|
||||
/** The profile section this page shows. */
|
||||
@@ -135,6 +137,7 @@ export const CORE_SETTINGS_PAGES: SettingsPage[] = [
|
||||
labelKey: "settings.updates.title",
|
||||
icon: FlaskConical,
|
||||
admin: true,
|
||||
desktopToo: true,
|
||||
}),
|
||||
core({
|
||||
id: "admin-users",
|
||||
@@ -225,7 +228,9 @@ export function visibleSettingsPages(
|
||||
{ isAdmin, isElectron }: { isAdmin: boolean; isElectron: boolean },
|
||||
): SettingsPage[] {
|
||||
return pages.filter(
|
||||
(page) => (!page.admin || isAdmin) && (!page.browserOnly || !isElectron),
|
||||
(page) =>
|
||||
(!page.admin || isAdmin || (page.desktopToo && isElectron)) &&
|
||||
(!page.browserOnly || !isElectron),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ export const SETTINGS_SEARCH_INDEX: Record<string, string[]> = {
|
||||
"admin.apiKeyUser",
|
||||
"admin.apiKeysCliHint",
|
||||
"admin.apiKeysCliLink",
|
||||
"admin.apiKeysCount",
|
||||
"admin.apiKeysCount_other",
|
||||
"admin.apiKeysDocsLink",
|
||||
"admin.copiedToClipboard",
|
||||
"admin.createKey",
|
||||
@@ -162,7 +162,7 @@ export const SETTINGS_SEARCH_INDEX: Record<string, string[]> = {
|
||||
"admin.roleDescription",
|
||||
"admin.roleDisplayName",
|
||||
"admin.roleName",
|
||||
"admin.rolePermissions.count",
|
||||
"admin.rolePermissions.count_other",
|
||||
"admin.rolePermissions.editAction",
|
||||
"admin.rolePermissions.loadError",
|
||||
"admin.rolePermissions.pluginDisabled",
|
||||
@@ -170,7 +170,7 @@ export const SETTINGS_SEARCH_INDEX: Record<string, string[]> = {
|
||||
"admin.rolePermissions.saveError",
|
||||
"admin.rolePermissions.saved",
|
||||
"admin.rolePermissions.saving",
|
||||
"admin.rolesCount",
|
||||
"admin.rolesCount_other",
|
||||
"admin.sectionRoles",
|
||||
"admin.systemBadge",
|
||||
"common.cancel",
|
||||
@@ -187,7 +187,7 @@ export const SETTINGS_SEARCH_INDEX: Record<string, string[]> = {
|
||||
"admin.sectionSessions",
|
||||
"admin.sessionActive",
|
||||
"admin.sessionExpires",
|
||||
"admin.sessionsActive",
|
||||
"admin.sessionsActive_other",
|
||||
"admin.youBadge",
|
||||
],
|
||||
"admin-ssl": [
|
||||
@@ -282,7 +282,7 @@ export const SETTINGS_SEARCH_INDEX: Record<string, string[]> = {
|
||||
"admin.userAdminAccess",
|
||||
"admin.userAdminAccessDesc",
|
||||
"admin.userRoles",
|
||||
"admin.usersCount",
|
||||
"admin.usersCount_other",
|
||||
"common.cancel",
|
||||
"common.loading",
|
||||
"common.next",
|
||||
@@ -294,7 +294,7 @@ export const SETTINGS_SEARCH_INDEX: Record<string, string[]> = {
|
||||
"admin.revokeKey",
|
||||
"admin.revokeKeyConfirm",
|
||||
"newUi.sidebar.userProfile.apiKeyActive",
|
||||
"newUi.sidebar.userProfile.apiKeyCount",
|
||||
"newUi.sidebar.userProfile.apiKeyCount_other",
|
||||
"newUi.sidebar.userProfile.apiKeyCreateFailed",
|
||||
"newUi.sidebar.userProfile.apiKeyCreated",
|
||||
"newUi.sidebar.userProfile.apiKeyCreatedWarning",
|
||||
@@ -434,6 +434,24 @@ export const SETTINGS_SEARCH_INDEX: Record<string, string[]> = {
|
||||
"newUi.sidebar.userProfile.updatePassword",
|
||||
],
|
||||
"updates": [
|
||||
"desktopUpdates.checkNow",
|
||||
"desktopUpdates.download",
|
||||
"desktopUpdates.manualModeHint",
|
||||
"desktopUpdates.mode",
|
||||
"desktopUpdates.modeAutomatic",
|
||||
"desktopUpdates.modeHint",
|
||||
"desktopUpdates.modeNever",
|
||||
"desktopUpdates.modePrompt",
|
||||
"desktopUpdates.restart",
|
||||
"desktopUpdates.status",
|
||||
"desktopUpdates.statusAvailable",
|
||||
"desktopUpdates.statusChecking",
|
||||
"desktopUpdates.statusDownloading",
|
||||
"desktopUpdates.statusError",
|
||||
"desktopUpdates.statusIdle",
|
||||
"desktopUpdates.statusReady",
|
||||
"desktopUpdates.statusUpToDate",
|
||||
"desktopUpdates.title",
|
||||
"settings.updates.allPluginsBeta",
|
||||
"settings.updates.allPluginsStable",
|
||||
"settings.updates.backToStable",
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
desktopUpdates,
|
||||
updateToastFor,
|
||||
useDesktopUpdate,
|
||||
} from "@/lib/desktop-updates";
|
||||
|
||||
const TOAST_ID = "desktop-update";
|
||||
const LATER_KEY = "termix-desktop-update-later";
|
||||
|
||||
function readLater(): string | null {
|
||||
try {
|
||||
return localStorage.getItem(LATER_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveLater(value: string) {
|
||||
try {
|
||||
localStorage.setItem(LATER_KEY, value);
|
||||
} catch {
|
||||
// Only means the toast comes back next launch.
|
||||
}
|
||||
}
|
||||
|
||||
/** Desktop only: tells the user about a new version and installs it. */
|
||||
export function DesktopUpdateToasts() {
|
||||
const { t } = useTranslation();
|
||||
const state = useDesktopUpdate();
|
||||
const shown = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const kind = updateToastFor(state);
|
||||
if (!state || !kind) return;
|
||||
const key = `${kind}:${state.version}`;
|
||||
if (shown.current === key || readLater() === key) return;
|
||||
shown.current = key;
|
||||
|
||||
const version = state.version;
|
||||
const later = {
|
||||
label: t("desktopUpdates.later"),
|
||||
onClick: () => saveLater(key),
|
||||
};
|
||||
|
||||
if (kind === "ready") {
|
||||
toast(t("desktopUpdates.ready", { version }), {
|
||||
id: TOAST_ID,
|
||||
duration: Infinity,
|
||||
description: t("desktopUpdates.readyHint"),
|
||||
action: {
|
||||
label: t("desktopUpdates.restart"),
|
||||
onClick: () => void desktopUpdates()?.install(),
|
||||
},
|
||||
cancel: later,
|
||||
});
|
||||
} else if (kind === "prompt") {
|
||||
toast(t("desktopUpdates.available", { version }), {
|
||||
id: TOAST_ID,
|
||||
duration: Infinity,
|
||||
action: {
|
||||
label: t("desktopUpdates.download"),
|
||||
onClick: () => void desktopUpdates()?.download(),
|
||||
},
|
||||
cancel: later,
|
||||
});
|
||||
} else {
|
||||
const url = state.releaseUrl;
|
||||
toast(t("desktopUpdates.available", { version }), {
|
||||
id: TOAST_ID,
|
||||
duration: Infinity,
|
||||
description: t("desktopUpdates.manualHint"),
|
||||
action: {
|
||||
label: t("desktopUpdates.download"),
|
||||
onClick: () => {
|
||||
if (url) window.open(url, "_blank", "noopener,noreferrer");
|
||||
},
|
||||
},
|
||||
cancel: later,
|
||||
});
|
||||
}
|
||||
}, [state, t]);
|
||||
|
||||
return null;
|
||||
}
|
||||
Loaded 100 of 174 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user