@termix-ssh/plugin-sdk 1.0.21 → 1.0.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/PLUGIN-GUIDE.md +62 -3
  3. package/cli/commands/build.mjs +55 -36
  4. package/cli/commands/validate.mjs +4 -0
  5. package/dist/backend.d.ts +32 -7
  6. package/dist/frontend.d.ts +7 -0
  7. package/dist/host/{PluginsTab-HQGSDSHS.js → PluginsTab-CM2B33BD.js} +22 -14
  8. package/dist/host/chunk-7J4QNGFM.js +345 -0
  9. package/dist/host/{chunk-A5CULDWV.js → chunk-HQ6NNFSX.js} +109 -1574
  10. package/dist/host/chunk-I5F2PSMR.js +1526 -0
  11. package/dist/host/{chunk-FGX27MAA.js → chunk-JRV2HIDK.js} +1 -1
  12. package/dist/host/{chunk-JFQGI4OD.js → chunk-P26QKKU5.js} +38 -9
  13. package/dist/host/{chunk-WWFRBU2E.js → chunk-ROB2SH5Q.js} +76 -49
  14. package/dist/host/chunk-TK3Y437H.js +53 -0
  15. package/dist/host/{chunk-GTHINW3L.js → chunk-VKNGNEF3.js} +34650 -34968
  16. package/dist/host/core-classes.json +1 -1
  17. package/dist/host/frame-host.css +2 -0
  18. package/dist/host/frame-host.js +242 -0
  19. package/dist/host/{linked-server-UDSFY3N7.js → linked-server-J5G457OA.js} +1 -1
  20. package/dist/host/{rbac-api-AAAPMF52.js → rbac-api-PY22W5UY.js} +5 -1
  21. package/dist/host/testing-host.js +9 -6
  22. package/dist/host/types/src/types/host-sidebar-preferences.d.ts +2 -0
  23. package/dist/host/types/src/types/index.d.ts +8 -0
  24. package/dist/host/types/src/ui/api/plugins-api.d.ts +2 -1
  25. package/dist/host/types/src/ui/api/rbac-api.d.ts +19 -1
  26. package/dist/host/types/src/ui/components/data-view.d.ts +1 -1
  27. package/dist/host/types/src/ui/main-axios.d.ts +1 -1
  28. package/dist/host/types/src/ui/plugin-host/loader.d.ts +6 -0
  29. package/dist/host/types/src/ui/plugin-host/sdk-ui.d.ts +1 -1
  30. package/dist/host/types/src/ui/plugins/use-plugins-manager.d.ts +1 -1
  31. package/dist/host/types/src/ui/shell/tab-registry.d.ts +2 -0
  32. package/dist/host/ui.js +356 -369
  33. package/dist/host/{use-permissions-VAND5GEG.js → use-permissions-EWRI3WLT.js} +1 -1
  34. package/dist/host-commands.d.ts +7 -0
  35. package/dist/host-commands.js +35 -5
  36. package/dist/manifest.d.ts +28 -1
  37. package/dist/manifest.js +108 -1
  38. package/package.json +5 -1
  39. package/schema/manifest.schema.json +42 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.22
4
+
5
+ Plugin API 1.13.0.
6
+
7
+ ### Added
8
+
9
+ - `contributes.mobile` in the manifest says how a plugin shows in the Termix mobile app: `webview`, `native` or `none`, with an optional mobile bundle, screen sizes, views and a note
10
+ - `termix-plugin build` bundles `src/mobile` into the `contributes.mobile.entry` file, and `validate` checks that it exists
11
+ - `@termix-ssh/plugin-sdk/frame-host` runs one plugin view outside the shell, with core's bridge, registries and CSS, so the mobile app can show `webview` plugins
12
+ - SSH transport providers get the host's owner, sync id, connection origin and local only flag in `claims` and `open`, and core's SSH relay takes a `syncId` instead of a host id
13
+ - `ctx.sync.host.entities.idsOf` maps sync ids to the local ids of the user's own rows of one type
14
+ - Rail items take `useStatus` for a small ok, warning or error dot on the icon
15
+ - `shell.editHost(hostId)` opens the editor of a saved host
16
+ - A sync entity can be read only with `load`, `write` and `erase`, so a plugin can sync rows a user does not own, like a shared library, down to linked desktops
17
+ - `getFolderAuthOverride` and `setFolderAuthOverride` in `@termix-ssh/plugin-sdk/ui` read and set a user's own login for the shared hosts in a folder
18
+
19
+ ### Fixed
20
+
21
+ - `@termix-ssh/plugin-sdk/ui` is built in production mode, so outside Termix it no longer sends requests to the Vite dev server path
22
+ - `execCommand` buffers output and decodes it once as strict UTF-8 with a GBK fallback, exposed as `decodeCommandOutput`, so messages from a Windows host on a legacy codepage are readable instead of � characters
23
+
3
24
  ## 1.0.21
4
25
 
5
26
  Plugin API 1.12.0.
package/PLUGIN-GUIDE.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Termix plugin guide
2
2
 
3
- SDK 1.0.21 | Plugin API 1.12.0
3
+ SDK 1.0.22 | Plugin API 1.13.0
4
4
 
5
5
  This guide is for anyone, person or AI coding tool, building a Termix plugin. It ships inside
6
6
  `@termix-ssh/plugin-sdk`, so the copy in your `node_modules` always matches the SDK you have installed.
@@ -175,7 +175,49 @@ Unknown fields are refused at every level, so a typo fails validation. The schem
175
175
 
176
176
  `contributes` holds `tabs`, `panels`, `dashboardCards`, `actions`, `actionSlots`, `permissions`,
177
177
  `settings`, `protocols`, `auth`, `syncEntities`, `keybindingActions`, `http`, `guest`, `guestViews`,
178
- `uiPresets`, `uses`, `layouts` and `themes`. Frontend `register*` calls need their ids declared here first.
178
+ `uiPresets`, `uses`, `layouts`, `themes` and `mobile`. Frontend `register*` calls need their ids declared here first.
179
+
180
+ ### Mobile app
181
+
182
+ `contributes.mobile` says how the plugin shows in the Termix mobile app. Leave it out and the app treats the
183
+ plugin as having nothing to show (official plugins are mapped by the app itself).
184
+
185
+ ```json
186
+ "mobile": { "mode": "webview", "views": ["hello"], "sizes": ["phone", "tablet"] }
187
+ ```
188
+
189
+ | Field | Notes |
190
+ | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
191
+ | `mode` | `webview` (the app shows your UI from the server), `native` (the app has its own screens) or `none`. |
192
+ | `entry` | `webview` only. A bundle made for the app, like `dist/mobile.js`, next to `frontend.js`. Unset: your normal frontend runs in a phone sized frame. |
193
+ | `sizes` | `webview` only. `phone`, `tablet` or both (the default). |
194
+ | `views` | `webview` only. Ids from your `tabs`, `panels` or `dashboardCards` the app offers. Default: every tab. |
195
+ | `feature` | `native` only. The app feature that stands in for the plugin. Default: the plugin id. |
196
+ | `note` | A locale key or plain text the app shows next to the plugin. |
197
+
198
+ `build` makes `entry` from `src/mobile/index.tsx`, and it shares `frontend.css`.
199
+
200
+ How a `webview` plugin runs in the app:
201
+
202
+ - Only your plugin loads, with the same app object, SDK hooks and `/ui` kit as the web app. Each view opens
203
+ on its own screen: a tab with `host-context-menu` in `openFrom` opens from a host, the rest from the app's
204
+ plugin list.
205
+ - Every request and socket goes through the app, which adds the login, proxy headers and client
206
+ certificate. The app only lets through your own `/plugin-api`, `/plugin-ws` and `/plugin-assets`, your
207
+ settings and a short list of core reads (hosts, folders, permissions, current user, version, health,
208
+ activity). Other core routes answer 403, so call your own backend instead.
209
+ - Images load straight from the server, without proxy headers or a client certificate.
210
+ - Nothing in `localStorage` survives closing the view. Keep preferences in settings.
211
+ - Other plugins' views, extensions and components are not there, and neither is the shell (`useShell()`
212
+ is null). `/ui` names newer than the app's copy of the SDK make the view say it needs a newer app.
213
+ - The app checks your `engine.api` against the plugin API of its own copy of the SDK. When the app's API
214
+ is too old for the range, it asks the user to update the app instead of opening the view. When the range
215
+ is for an older API major, it asks to update the plugin. Ask only for the API you use.
216
+ - Turning a plugin off on the server hides it in the app the next time the app checks: when it comes back
217
+ to the front, when the list is pulled down, or when any request answers 503 with your `pluginId`.
218
+
219
+ Termix 26.10.5 and older refuse any manifest with `mobile`, so a plugin that declares it needs
220
+ `"engine": { "termix": ">=26.10.6", "api": "^1.13" }`.
179
221
 
180
222
  Capabilities and permissions are different things. A capability (`hosts:read`, with a colon) is what your
181
223
  code may reach. A permission (`hello.use`, with a dot) is what a user may do.
@@ -304,7 +346,7 @@ router.post("/notes", async (req, res) => {
304
346
  - `ctx.rbac.require("manage")` on a single route, `await ctx.rbac.has("manage")` in code. A full id like
305
347
  `hosts.edit` checks a core permission.
306
348
  - Host access: `ctx.hosts.checkAccess(hostId, "connect" | "view" | "edit" | "manage")`.
307
- - Transports: with `ssh:transport`, `ctx.ssh.transports.provide({ claims(hostId), open({ id, ip, port }) })`
349
+ - Transports: with `ssh:transport`, `ctx.ssh.transports.provide({ claims(hostId, host), open(host) })`, where `host` has `id`, `ip`, `port` and on Termix 26.10.6+ also `userId`, `syncId`, `connectionOrigin` and `localOnly`
308
350
  returns a stream the SSH client uses instead of a direct, proxy or jump host connection. Core also serves
309
351
  `/plugin-ws/core/ssh-relay?hostId=` for callers with connect access, so one Termix can relay to another.
310
352
  - Managed hosts: with `hosts:lock`, `ctx.hosts.locks.provide({ isLocked, note, allowManualCreate })` makes
@@ -453,6 +495,23 @@ Other storage: `ctx.kv` for a few values, `ctx.settings` for settings, `ctx.secr
453
495
  `ctx.files.dataDir()` for files. To sync a table to a linked desktop, use `ctx.sync.registerEntity` and
454
496
  `contributes.syncEntities`. The entity name is part of the protocol and never changes.
455
497
 
498
+ A registered table syncs the rows each user owns, both ways. For data you keep for the whole server (a shared
499
+ library, rows other users share), register a read only entity with `table: null`, `readOnly: true` and `load`,
500
+ `write` and `erase` (Termix 26.10.6+). `load(userId)` returns the rows that user should get, each with a
501
+ `syncId`. The desktop keeps them with `write` and drops them with `erase`, and never pushes them back:
502
+
503
+ ```ts
504
+ ctx.sync.registerEntity({
505
+ type: "sharedIcons",
506
+ table: null,
507
+ readOnly: true,
508
+ order: 70,
509
+ load: (userId) => iconsVisibleTo(userId),
510
+ write: (userId, row) => storeCopy(userId, row),
511
+ erase: (userId, syncId) => dropCopy(userId, syncId),
512
+ });
513
+ ```
514
+
456
515
  ## Frontend
457
516
 
458
517
  ```tsx
@@ -25,6 +25,43 @@ const FRONTEND_ENTRIES = [
25
25
  "src/frontend/index.mjs",
26
26
  "src/frontend/index.js",
27
27
  ];
28
+ const MOBILE_ENTRIES = [
29
+ "src/mobile/index.tsx",
30
+ "src/mobile/index.ts",
31
+ "src/mobile/index.mjs",
32
+ "src/mobile/index.js",
33
+ ];
34
+
35
+ /** Builds a bundle that runs in a browser against core's shared modules. */
36
+ async function buildBrowserBundle(entry, outfile, outDir) {
37
+ await esbuild.build({
38
+ entryPoints: [entry],
39
+ outfile,
40
+ bundle: true,
41
+ format: "esm",
42
+ platform: "browser",
43
+ target: "es2022",
44
+ jsx: "automatic",
45
+ sourcemap: true,
46
+ logLevel: "warning",
47
+ external: FRONTEND_EXTERNALS,
48
+ // Vite replaces these in the dev server; a plugin bundle has to have
49
+ // them too, or code that reads them throws when the plugin loads.
50
+ define: {
51
+ "process.env.NODE_ENV": '"production"',
52
+ "import.meta.env.DEV": "false",
53
+ "import.meta.env.PROD": "true",
54
+ "import.meta.env.MODE": '"production"',
55
+ "import.meta.env.SSR": "false",
56
+ "import.meta.env":
57
+ '{"DEV":false,"PROD":true,"MODE":"production","SSR":false,"BASE_URL":"/"}',
58
+ },
59
+ plugins: [
60
+ externalRequireInterop(FRONTEND_EXTERNALS),
61
+ staticUrlImports({ outDir }),
62
+ ],
63
+ });
64
+ }
28
65
 
29
66
  /**
30
67
  * A bundled CommonJS package that calls require("react/jsx-runtime") (or any
@@ -192,15 +229,12 @@ export async function buildTailwind(cwd, outDir) {
192
229
  // build() adds to what a compiler already emitted, so each half gets its own.
193
230
  const compiler = () =>
194
231
  compile(input, { base: path.dirname(THEME_CSS), onDependency: () => {} });
195
- const scanner = new Scanner({
196
- sources: [
197
- {
198
- base: path.join(cwd, "src", "frontend"),
199
- pattern: "**/*",
200
- negated: false,
201
- },
202
- ],
203
- });
232
+ // The mobile bundle uses the same stylesheet.
233
+ const sources = ["frontend", "mobile"]
234
+ .map((dir) => path.join(cwd, "src", dir))
235
+ .filter((dir, index) => index === 0 || fs.existsSync(dir))
236
+ .map((base) => ({ base, pattern: "**/*", negated: false }));
237
+ const scanner = new Scanner({ sources });
204
238
  // Flattened and minified the way core's own CSS is, so it does not rely
205
239
  // on native CSS nesting.
206
240
  const emit = async (candidates, options) =>
@@ -297,33 +331,18 @@ export async function build({ cwd }) {
297
331
 
298
332
  const frontendEntry = resolveEntry(cwd, FRONTEND_ENTRIES);
299
333
  if (frontendEntry) {
300
- await esbuild.build({
301
- entryPoints: [frontendEntry],
302
- outfile: path.join(outDir, "frontend.js"),
303
- bundle: true,
304
- format: "esm",
305
- platform: "browser",
306
- target: "es2022",
307
- jsx: "automatic",
308
- sourcemap: true,
309
- logLevel: "warning",
310
- external: FRONTEND_EXTERNALS,
311
- // Vite replaces these in the dev server; a plugin bundle has to have
312
- // them too, or code that reads them throws when the plugin loads.
313
- define: {
314
- "process.env.NODE_ENV": '"production"',
315
- "import.meta.env.DEV": "false",
316
- "import.meta.env.PROD": "true",
317
- "import.meta.env.MODE": '"production"',
318
- "import.meta.env.SSR": "false",
319
- "import.meta.env":
320
- '{"DEV":false,"PROD":true,"MODE":"production","SSR":false,"BASE_URL":"/"}',
321
- },
322
- plugins: [
323
- externalRequireInterop(FRONTEND_EXTERNALS),
324
- staticUrlImports({ outDir }),
325
- ],
326
- });
334
+ await buildBrowserBundle(
335
+ frontendEntry,
336
+ path.join(outDir, "frontend.js"),
337
+ outDir,
338
+ );
339
+ }
340
+
341
+ // A bundle the mobile app loads in its WebView, next to the frontend one.
342
+ const mobileEntry = resolveEntry(cwd, MOBILE_ENTRIES);
343
+ const mobileOut = manifest.contributes?.mobile?.entry;
344
+ if (mobileEntry && typeof mobileOut === "string") {
345
+ await buildBrowserBundle(mobileEntry, path.join(cwd, mobileOut), outDir);
327
346
  }
328
347
 
329
348
  if (frontendEntry) await buildTailwind(cwd, outDir);
@@ -28,6 +28,10 @@ export async function validate({ cwd }) {
28
28
  // A theme only plugin has no frontend code.
29
29
  ...(isThemeOnly(raw) ? [] : [raw.frontend ?? "dist/frontend.js"]),
30
30
  raw.locales ?? "locales",
31
+ // Built from src/mobile by termix-plugin build.
32
+ ...(typeof raw.contributes?.mobile?.entry === "string"
33
+ ? [raw.contributes.mobile.entry]
34
+ : []),
31
35
  ];
32
36
  for (const rel of referenced) {
33
37
  if (!fs.existsSync(path.join(cwd, rel))) {
package/dist/backend.d.ts CHANGED
@@ -149,8 +149,24 @@ export interface SyncWriteEvent {
149
149
  export interface SyncEntityRegistration {
150
150
  /** Stable wire name. Never change it: records on both sides match on it. */
151
151
  type: string;
152
- /** The table, as returned by ctx.db.define. */
152
+ /** The table, as returned by ctx.db.define. Null for an entity with `load`. */
153
153
  table: unknown;
154
+ /**
155
+ * Server to desktop only: a desktop never pushes these rows. Required with
156
+ * `load`.
157
+ */
158
+ readOnly?: boolean;
159
+ /**
160
+ * Builds the rows the user gets instead of reading the rows they own in
161
+ * `table`, for data kept for the whole server (a shared library, rows other
162
+ * users share with them). Rows are on the wire: plaintext, each with a
163
+ * syncId. Needs `readOnly: true`, `write` and `erase`.
164
+ */
165
+ load?: (userId: string) => Promise<SyncRow[]>;
166
+ /** Keeps a copy of an inbound row on the desktop, for an entity with `load`. */
167
+ write?: (userId: string, wire: SyncRow) => Promise<void>;
168
+ /** Drops the desktop copy with this syncId, for an entity with `load`. */
169
+ erase?: (userId: string, syncId: string) => Promise<void>;
154
170
  /** Column holding the owning user id. Defaults to "userId". */
155
171
  userColumn?: string;
156
172
  /** Fields stored encrypted with the owner's data key. Plaintext on the wire. */
@@ -391,6 +407,8 @@ export interface PluginSyncHost {
391
407
  ensureCore: () => Promise<void>;
392
408
  onChanged: (listener: () => void) => () => void;
393
409
  onLocalWrite: (listener: () => void) => () => void;
410
+ /** Local ids of the user's own rows of a type, by sync id. Termix 26.10.6+. */
411
+ idsOf?: (type: string, userId: string, syncIds: string[]) => Promise<Record<string, number>>;
394
412
  };
395
413
  /** Rows and bookkeeping of one local user's replica. */
396
414
  replica: {
@@ -1503,13 +1521,20 @@ export interface PluginNetwork {
1503
1521
  * asked for every connection, so it must be quick. `open` returns a stream
1504
1522
  * the SSH client runs over, like a relayed socket.
1505
1523
  */
1524
+ /** The host a transport is asked about. Fields past port come from Termix 26.10.6. */
1525
+ export interface PluginSshTransportHost {
1526
+ id: number;
1527
+ ip: string;
1528
+ port: number;
1529
+ /** Owner of the host row. */
1530
+ userId?: string | null;
1531
+ syncId?: string | null;
1532
+ connectionOrigin?: "local" | "remote" | null;
1533
+ localOnly?: boolean;
1534
+ }
1506
1535
  export interface PluginSshTransportProvider {
1507
- claims: (hostId: number) => boolean;
1508
- open: (host: {
1509
- id: number;
1510
- ip: string;
1511
- port: number;
1512
- }) => Promise<import("node:stream").Duplex>;
1536
+ claims: (hostId: number, host?: PluginSshTransportHost) => boolean;
1537
+ open: (host: PluginSshTransportHost) => Promise<import("node:stream").Duplex>;
1513
1538
  }
1514
1539
  export interface PluginSshTransports {
1515
1540
  /** Needs ssh:transport. Removed on deactivate. */
@@ -185,6 +185,8 @@ export interface ShellApi {
185
185
  * user reviews and saves it. Absent in some shells.
186
186
  */
187
187
  openHostEditor?: (draft?: HostDraft) => void;
188
+ /** Opens the editor of a saved host. Termix 26.10.6+, absent in some shells. */
189
+ editHost?: (hostId: number) => void;
188
190
  /** Opens a rail view in the left sidebar. */
189
191
  openRailView: (id: string) => void;
190
192
  /** Closes a rail view wherever it is shown. */
@@ -412,6 +414,11 @@ export interface RailItemContribution {
412
414
  * Zero or nothing shows no badge.
413
415
  */
414
416
  useBadge?: () => number | null | undefined;
417
+ /**
418
+ * A hook for a small status dot on the icon: "ok" (green), "warning" or
419
+ * "error". Nothing shows no dot. Layouts from Termix 26.10.6 draw it.
420
+ */
421
+ useStatus?: () => "ok" | "warning" | "error" | null | undefined;
415
422
  }
416
423
  export interface PanelProps {
417
424
  /**
@@ -29,12 +29,19 @@ import {
29
29
  sortPlugins,
30
30
  uploadEntry,
31
31
  youtubeEmbedUrl
32
- } from "./chunk-WWFRBU2E.js";
32
+ } from "./chunk-ROB2SH5Q.js";
33
+ import {
34
+ Button,
35
+ FakeSwitch,
36
+ Input,
37
+ PluginIcon,
38
+ SectionCard,
39
+ hasSettingsPage
40
+ } from "./chunk-7J4QNGFM.js";
33
41
  import {
34
42
  ArrowDownWideNarrow,
35
43
  BookOpen,
36
44
  Bug,
37
- Button,
38
45
  Check,
39
46
  ChevronLeft,
40
47
  ChevronRight,
@@ -44,13 +51,11 @@ import {
44
51
  Download,
45
52
  ExternalLink,
46
53
  Facts,
47
- FakeSwitch,
48
54
  FlaskConical,
49
55
  GRID_SIZES,
50
56
  Globe,
51
57
  GroupHeading,
52
58
  InlineView,
53
- Input,
54
59
  Library,
55
60
  Lightbulb,
56
61
  ListFilter,
@@ -58,12 +63,10 @@ import {
58
63
  PanePrompt,
59
64
  PanelShell,
60
65
  Pin,
61
- PluginIcon,
62
66
  Puzzle,
63
67
  RefreshCw,
64
68
  RotateCcw,
65
69
  Search,
66
- SectionCard,
67
70
  Select,
68
71
  SelectContent,
69
72
  SelectItem,
@@ -88,7 +91,6 @@ import {
88
91
  getPluginRegistry,
89
92
  getPlugins,
90
93
  getRegistries,
91
- hasSettingsPage,
92
94
  installPlugin,
93
95
  installUploadedPlugin,
94
96
  openPluginSettings,
@@ -107,8 +109,8 @@ import {
107
109
  updatePlugin,
108
110
  uploadPlugin,
109
111
  useConfirm
110
- } from "./chunk-GTHINW3L.js";
111
- import "./chunk-JFQGI4OD.js";
112
+ } from "./chunk-VKNGNEF3.js";
113
+ import "./chunk-P26QKKU5.js";
112
114
  import {
113
115
  __toESM,
114
116
  define_import_meta_env_default,
@@ -1560,9 +1562,12 @@ function usePluginsManager() {
1560
1562
  [run, t, failToast]
1561
1563
  );
1562
1564
  const requestUpload = useCallback2(
1563
- async (file) => {
1565
+ async (file, signatureFile) => {
1564
1566
  try {
1565
- const preview = await uploadPlugin(file);
1567
+ const preview = await uploadPlugin(
1568
+ file,
1569
+ signatureFile ? await signatureFile.text() : void 0
1570
+ );
1566
1571
  setConsent({
1567
1572
  plugin: uploadEntry(preview),
1568
1573
  mode: "upload",
@@ -2576,12 +2581,15 @@ function UploadButton({ manager }) {
2576
2581
  {
2577
2582
  ref: input,
2578
2583
  type: "file",
2579
- accept: ".tmxplug",
2584
+ accept: ".tmxplug,.sig",
2585
+ multiple: true,
2580
2586
  className: "hidden",
2581
2587
  onChange: (e) => {
2582
- const file = e.target.files?.[0];
2588
+ const files = [...e.target.files ?? []];
2583
2589
  e.target.value = "";
2584
- if (file) void manager.requestUpload(file);
2590
+ const file = files.find((f) => f.name.endsWith(".tmxplug"));
2591
+ const signature = files.find((f) => f.name.endsWith(".sig"));
2592
+ if (file) void manager.requestUpload(file, signature);
2585
2593
  }
2586
2594
  }
2587
2595
  ),