mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: rebuild desktop sync as linked server accounts, fix packaged plugin bundles and UI scaling
This commit is contained in:
1 parent
7c02daccf4
commit
6cd9a2846d
242 files changed
+39127
-8558
No files matched your search
@@ -132,64 +132,104 @@ export interface PluginDatabase {
|
||||
export type SyncRow = Record<string, unknown>;
|
||||
|
||||
export interface SyncEntityReference {
|
||||
/** Stored column holding a local numeric id, e.g. "credentialId". */
|
||||
/**
|
||||
* Where a local numeric id lives. A plain column ("credentialId"), or a
|
||||
* path into a JSON column: "jumpHosts[].hostId" walks every item of the
|
||||
* jumpHosts array, "layout.panes[].hostId" walks nested objects. A JSON
|
||||
* column may be stored as a string or as parsed JSON.
|
||||
*/
|
||||
field: string;
|
||||
/** Wire field holding the portable id, e.g. "credentialSyncId". */
|
||||
syncField: string;
|
||||
/**
|
||||
* Wire field holding the portable id, e.g. "credentialSyncId". Only for a
|
||||
* plain column; a JSON path is rewritten in place.
|
||||
*/
|
||||
syncField?: string;
|
||||
/** The entity the id points at. */
|
||||
entityType: string;
|
||||
/** How a JSON path stores the local id. Defaults to "number". */
|
||||
idType?: "number" | "string";
|
||||
}
|
||||
|
||||
export interface SyncEntityPermissions {
|
||||
/** Checked before a pushed row that does not exist yet is written. */
|
||||
create?: string;
|
||||
/** Checked before a pushed change to an existing row is written. */
|
||||
update?: string;
|
||||
/** Checked before a pushed delete runs. */
|
||||
delete?: string;
|
||||
}
|
||||
|
||||
export interface SyncWriteEvent {
|
||||
/** The local id of the row that was written. */
|
||||
id: number | null;
|
||||
userId: string;
|
||||
/** The row as it arrived, before references were resolved. */
|
||||
wire: SyncRow;
|
||||
created: boolean;
|
||||
}
|
||||
|
||||
export interface SyncEntityRegistration {
|
||||
/** Stable wire name. Never change it: tombstones and remote rows match on it. */
|
||||
/** Stable wire name. Never change it: records on both sides match on it. */
|
||||
type: string;
|
||||
/** The table, as returned by ctx.db.define. */
|
||||
table: unknown;
|
||||
/** Column holding the owning user id. Defaults to "userId". */
|
||||
userColumn?: string;
|
||||
/** Fields DataCrypto translates between plaintext wire and encrypted row. */
|
||||
/** Fields stored encrypted with the owner's data key. Plaintext on the wire. */
|
||||
encryptedFields?: readonly string[];
|
||||
/** Local-id to sync-id translations applied on the way out and back. */
|
||||
references?: readonly SyncEntityReference[];
|
||||
/** Lower sorts first. Reference targets must sort before their referrers. */
|
||||
order?: number;
|
||||
/** Fields that must never be overwritten by an inbound payload. */
|
||||
/**
|
||||
* Fields that stay on this side: never sent, never overwritten by an
|
||||
* inbound row, and ignored when deciding whether a row changed.
|
||||
*/
|
||||
readOnlyFields?: readonly string[];
|
||||
/** One row per user rather than many, keyed on the owner. */
|
||||
singleton?: boolean;
|
||||
/**
|
||||
* Names core uses when its own rows point at this entity without knowing
|
||||
* which plugin provides it, e.g. "commandSnippet" for a host quick action.
|
||||
*/
|
||||
answersTo?: readonly string[];
|
||||
/**
|
||||
* Rows this returns false for are left out of sync in both directions, for
|
||||
* state that belongs to one install (a per-device "last session").
|
||||
*/
|
||||
shouldSync?: (row: SyncRow) => boolean;
|
||||
/** RBAC permissions a pushed change needs on the server. */
|
||||
permissions?: SyncEntityPermissions;
|
||||
/**
|
||||
* Escape hatch for a row whose references are not plain columns, such as ids
|
||||
* embedded in a JSON blob. Runs instead of `references`, not alongside it.
|
||||
* Runs after `references` on the way out, for anything they cannot
|
||||
* express. Gets the row with references already translated.
|
||||
*/
|
||||
serialize?: (
|
||||
row: SyncRow,
|
||||
resolveSyncId: (entityType: string, id: number) => Promise<string | null>,
|
||||
) => Promise<SyncRow>;
|
||||
/** Runs after `references` on the way in. */
|
||||
deserialize?: (
|
||||
row: SyncRow,
|
||||
resolveId: (entityType: string, syncId: string) => Promise<number | null>,
|
||||
) => Promise<SyncRow>;
|
||||
/** Runs after a synced row was written on this side. */
|
||||
afterWrite?: (event: SyncWriteEvent) => Promise<void>;
|
||||
/**
|
||||
* Replaces the plain delete of a synced row, for rows with cleanup of their
|
||||
* own. Gets the stored row.
|
||||
*/
|
||||
remove?: (row: SyncRow, userId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
/** Adds an entity to remote sync between a desktop backend and a server. */
|
||||
/**
|
||||
* Adds an entity to sync between a linked desktop and its server.
|
||||
*
|
||||
* Deletes need nothing extra: core notices a registered row is gone and
|
||||
* sends the delete itself.
|
||||
*/
|
||||
export interface PluginSync {
|
||||
registerEntity: (entity: SyncEntityRegistration) => void;
|
||||
/**
|
||||
* Records that a row was deleted, so a pull on the other side of remote
|
||||
* sync removes it too. Call it with the row's syncId right after deleting
|
||||
* it, for a registered entity whose rows can be deleted (a no-op silently
|
||||
* loses deletes across devices otherwise). A falsy syncId is ignored.
|
||||
*/
|
||||
recordTombstone: (
|
||||
userId: string,
|
||||
entityType: string,
|
||||
syncId: string | null | undefined,
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface PluginRegistry {
|
||||
|
||||
@@ -337,8 +337,27 @@ export interface PluginContributions {
|
||||
"simple" | "balanced" | "advanced",
|
||||
Record<string, unknown>
|
||||
>;
|
||||
/**
|
||||
* Wire names of the sync entities this plugin registers with
|
||||
* ctx.sync.registerEntity, so a server can name them while the plugin is
|
||||
* off and a desktop can list them in its sync settings.
|
||||
*/
|
||||
syncEntities?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* How a desktop linked to a server treats this plugin.
|
||||
* - "mirror": follows the server, installed and switched on or off with it.
|
||||
* - "local": each desktop decides for itself (a serial port is local).
|
||||
* - "server": only makes sense on a server, never runs on a linked desktop.
|
||||
*/
|
||||
export type PluginDesktopMode = "mirror" | "local" | "server";
|
||||
export const PLUGIN_DESKTOP_MODES: readonly PluginDesktopMode[] = [
|
||||
"mirror",
|
||||
"local",
|
||||
"server",
|
||||
];
|
||||
|
||||
export interface PluginHttpContribution {
|
||||
/**
|
||||
* Old URLs something outside Termix still calls (a webhook a third party
|
||||
@@ -405,6 +424,8 @@ export interface PluginManifest {
|
||||
* dependencies".
|
||||
*/
|
||||
nativeDependencies?: string[];
|
||||
/** How a linked desktop treats this plugin. Defaults to "mirror". */
|
||||
desktop?: PluginDesktopMode;
|
||||
}
|
||||
|
||||
export const DEFAULT_BACKEND_ENTRY = "dist/backend.js";
|
||||
@@ -439,6 +460,7 @@ const ALLOWED_TOP_LEVEL = new Set([
|
||||
"locales",
|
||||
"platforms",
|
||||
"nativeDependencies",
|
||||
"desktop",
|
||||
]);
|
||||
|
||||
const ALLOWED_CONTRIBUTES = new Set([
|
||||
@@ -455,6 +477,7 @@ const ALLOWED_CONTRIBUTES = new Set([
|
||||
"http",
|
||||
"uiPresets",
|
||||
"auth",
|
||||
"syncEntities",
|
||||
]);
|
||||
|
||||
const ALLOWED_SETTINGS_FIELD = [
|
||||
@@ -587,6 +610,14 @@ export function validateManifest(manifest: unknown): string[] {
|
||||
validateCapabilities(m.capabilities, errors);
|
||||
validatePlatforms(m.platforms, errors);
|
||||
validateNativeDependencies(m.nativeDependencies, errors);
|
||||
if (
|
||||
m.desktop !== undefined &&
|
||||
!PLUGIN_DESKTOP_MODES.includes(m.desktop as PluginDesktopMode)
|
||||
) {
|
||||
errors.push(
|
||||
`Field "desktop" must be one of: ${PLUGIN_DESKTOP_MODES.join(", ")}`,
|
||||
);
|
||||
}
|
||||
validateDependencyMap(m.dependencies, "dependencies", errors);
|
||||
validateDependencyMap(m.optionalDependencies, "optionalDependencies", errors);
|
||||
validateProvides(m.provides, errors);
|
||||
@@ -761,6 +792,23 @@ function validateProvides(provides: unknown, errors: string[]): void {
|
||||
|
||||
const PROVIDER_NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
|
||||
|
||||
function validateSyncEntities(value: unknown, errors: string[]) {
|
||||
const where = "contributes.syncEntities";
|
||||
if (!Array.isArray(value) || value.length === 0) {
|
||||
errors.push(`${where} must be a non-empty array`);
|
||||
return;
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
for (const name of value) {
|
||||
if (typeof name !== "string" || !/^[a-zA-Z][a-zA-Z0-9]*$/.test(name)) {
|
||||
errors.push(`${where} entries must be letters and digits`);
|
||||
continue;
|
||||
}
|
||||
if (seen.has(name)) errors.push(`${where} duplicates "${name}"`);
|
||||
seen.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
function validateNameList(value: unknown, where: string, errors: string[]) {
|
||||
if (!Array.isArray(value) || value.length === 0) {
|
||||
errors.push(`${where} must be a non-empty array`);
|
||||
@@ -888,6 +936,9 @@ function validateContributes(
|
||||
errors.push('Field "contributes.guestViews" needs "contributes.guest"');
|
||||
}
|
||||
}
|
||||
if (contributes.syncEntities !== undefined) {
|
||||
validateSyncEntities(contributes.syncEntities, errors);
|
||||
}
|
||||
validatePermissions(contributes.permissions, pluginId, errors);
|
||||
validateActions(contributes.actions, errors);
|
||||
validateActionSlots(contributes.actionSlots, errors);
|
||||
|
||||
@@ -260,8 +260,6 @@ export interface FakePluginContext {
|
||||
tables: PluginTableDefinition[];
|
||||
/** Sync entities registered through ctx.sync.registerEntity, in order. */
|
||||
syncEntities: SyncEntityRegistration[];
|
||||
/** Tombstones recorded through ctx.sync.recordTombstone, in order. */
|
||||
tombstones: Array<{ userId: string; entityType: string; syncId: string }>;
|
||||
/**
|
||||
* WebSocket routes registered through ctx.ws, in order, with the handler
|
||||
* and options, so a test can hand a route a fake socket.
|
||||
@@ -412,11 +410,6 @@ export function createFakeContext(
|
||||
const kv = new Map<string, unknown>();
|
||||
const tables: PluginTableDefinition[] = [];
|
||||
const syncEntities: SyncEntityRegistration[] = [];
|
||||
const tombstones: Array<{
|
||||
userId: string;
|
||||
entityType: string;
|
||||
syncId: string;
|
||||
}> = [];
|
||||
const wsRoutes: FakeWsRoute[] = [];
|
||||
const httpRouters: Array<PluginRouterOptions | undefined> = [];
|
||||
const listeners = new Map<string, Set<(payload: unknown) => void>>();
|
||||
@@ -626,10 +619,6 @@ export function createFakeContext(
|
||||
registerEntity: (entity) => {
|
||||
syncEntities.push(entity);
|
||||
},
|
||||
recordTombstone: async (userId, entityType, syncId) => {
|
||||
if (!syncId) return;
|
||||
tombstones.push({ userId, entityType, syncId });
|
||||
},
|
||||
},
|
||||
|
||||
registry: {
|
||||
@@ -1241,7 +1230,6 @@ export function createFakeContext(
|
||||
kv,
|
||||
tables,
|
||||
syncEntities,
|
||||
tombstones,
|
||||
wsRoutes,
|
||||
httpRouters,
|
||||
settings,
|
||||
|
||||
Reference in new issue
Block a user