* 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:
authored and GitHub committed 2026-10-12 11:35:48 -05:00
1 parent 6873bc92bb
commit 62ac931468
174 files changed
+94334 -39995

No files matched your search

+103
View File
@@ -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();
}
}