mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45: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
@@ -488,16 +488,6 @@ export function createPluginContext(
|
||||
const dispose = syncRegistry.registerEntity(pluginId, entity);
|
||||
handle.bag.add(dispose, `sync entity ${entity.type}`);
|
||||
},
|
||||
recordTombstone: async (userId, entityType, syncId) => {
|
||||
if (!syncId) return;
|
||||
const { createCurrentSyncTombstoneRepository } =
|
||||
await import("../database/repositories/factory.js");
|
||||
await createCurrentSyncTombstoneRepository().record(
|
||||
userId,
|
||||
entityType,
|
||||
syncId,
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
kv: {
|
||||
|
||||
@@ -478,6 +478,50 @@ export async function deactivatePlugin(pluginId: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Switches a plugin on or off and records it, the same as the admin route. */
|
||||
export async function setPluginEnabled(
|
||||
pluginId: string,
|
||||
enabled: boolean,
|
||||
): Promise<void> {
|
||||
const { createCurrentPluginRepository } =
|
||||
await import("../database/repositories/factory.js");
|
||||
await createCurrentPluginRepository().update(pluginId, {
|
||||
state: enabled ? "enabled" : "disabled",
|
||||
lastError: null,
|
||||
});
|
||||
if (enabled) await activatePlugin(pluginId);
|
||||
else await deactivatePlugin(pluginId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a user plugin from a .tmxplug in the plugins directory and gives it
|
||||
* a row. It starts disabled, like any plugin that arrives on disk. A copy
|
||||
* already loaded under the same id is stopped and replaced.
|
||||
*/
|
||||
export async function installPluginArtifact(
|
||||
file: string,
|
||||
): Promise<LoadedPlugin> {
|
||||
const { loader: pluginLoader } = getPluginRuntime();
|
||||
const bundledIds = new Set(
|
||||
pluginLoader
|
||||
.list()
|
||||
.filter((plugin) => plugin.source === "bundled")
|
||||
.map((plugin) => plugin.id),
|
||||
);
|
||||
const plugin = await pluginLoader.loadArtifact(file, bundledIds);
|
||||
await seedPlugins([plugin]);
|
||||
await syncCapabilityGrants([plugin]);
|
||||
return plugin;
|
||||
}
|
||||
|
||||
/** Stops a plugin and forgets it, before a new version is installed. */
|
||||
export async function unloadPlugin(pluginId: string): Promise<void> {
|
||||
const { loader: pluginLoader } = getPluginRuntime();
|
||||
if (!pluginLoader.get(pluginId)) return;
|
||||
await deactivatePlugin(pluginId);
|
||||
pluginLoader.forget(pluginId);
|
||||
}
|
||||
|
||||
export async function shutdownPlugins(): Promise<void> {
|
||||
if (!loader) return;
|
||||
for (const plugin of loader.list()) unregisterPluginHttp(plugin.id);
|
||||
|
||||
@@ -91,6 +91,15 @@ export class PluginLoader {
|
||||
return this.plugins.get(pluginId);
|
||||
}
|
||||
|
||||
/** Drops a stopped plugin from the list, so a new copy can load in its place. */
|
||||
forget(pluginId: string): void {
|
||||
const plugin = this.plugins.get(pluginId);
|
||||
if (plugin?.state === "active" || plugin?.state === "activating") {
|
||||
throw new Error(`Plugin ${pluginId} is still running`);
|
||||
}
|
||||
this.plugins.delete(pluginId);
|
||||
}
|
||||
|
||||
/** Reads and validates one plugin directory. Starts nothing. */
|
||||
async load(dir: string, source: PluginSource): Promise<LoadedPlugin> {
|
||||
const manifestPath = getPluginManifestPath(dir);
|
||||
|
||||
@@ -1,20 +1,55 @@
|
||||
/**
|
||||
* Which entities remote sync carries.
|
||||
* Which entities sync between a linked desktop and its server.
|
||||
*
|
||||
* Core registers its ten at boot and a plugin registers its own through
|
||||
* ctx.sync, so the list is built rather than hardcoded in four places. The
|
||||
* wire names are unchanged: existing tombstones and rows on the other side of
|
||||
* a pair match on those strings, so renaming one would orphan data.
|
||||
* Core registers its own at boot and a plugin registers its own through
|
||||
* ctx.sync, so the list is built rather than hardcoded. The wire names are
|
||||
* part of the protocol: records on both sides match on them, so renaming one
|
||||
* orphans data.
|
||||
*
|
||||
* Order is dependency order, not declaration order. A row referencing another
|
||||
* entity is resolved by that entity's syncId, so the target has to exist on
|
||||
* the far side first. That is why every registration carries an explicit
|
||||
* order rather than relying on registration sequence.
|
||||
* entity is resolved by that entity's syncId, so the target has to be written
|
||||
* first. That is why every registration carries an explicit order.
|
||||
*/
|
||||
|
||||
import type { SyncEntityRegistration } from "@termix/plugin-sdk/backend";
|
||||
import type {
|
||||
SyncEntityRegistration,
|
||||
SyncRow,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
|
||||
export interface RegisteredSyncEntity extends SyncEntityRegistration {
|
||||
/** Core-only extras a plugin registration cannot set. */
|
||||
export interface CoreSyncEntityOptions {
|
||||
/** Server-to-desktop only. A desktop never pushes it. */
|
||||
readOnly?: boolean;
|
||||
/**
|
||||
* Builds the user's rows instead of reading `table`, for data that is not
|
||||
* a table row the user owns (hosts shared with them, plugin settings).
|
||||
* Rows come back already on the wire: plaintext, with a syncId.
|
||||
*/
|
||||
load?: (userId: string) => Promise<SyncRow[]>;
|
||||
/** Writes an inbound row, for an entity with `load`. */
|
||||
write?: (userId: string, wire: SyncRow) => Promise<void>;
|
||||
/** Deletes a row by syncId, for an entity with `load`. */
|
||||
erase?: (userId: string, syncId: string) => Promise<void>;
|
||||
/**
|
||||
* Whether `load` would have returned this syncId if it existed. A row
|
||||
* `load` cannot see right now (its plugin is off) is not a delete.
|
||||
*/
|
||||
covers?: (syncId: string) => boolean;
|
||||
/** Keys kept off the content hash and out of writes. */
|
||||
hashIgnore?: readonly string[];
|
||||
/**
|
||||
* Widens reference lookups past rows the user owns, e.g. a credential
|
||||
* shared with them is a valid target for their host's credentialId.
|
||||
*/
|
||||
canReference?: (
|
||||
userId: string,
|
||||
ownerId: string,
|
||||
id: number,
|
||||
) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface RegisteredSyncEntity
|
||||
extends SyncEntityRegistration, CoreSyncEntityOptions {
|
||||
/** Which plugin registered it, or "core". */
|
||||
owner: string;
|
||||
order: number;
|
||||
@@ -24,6 +59,7 @@ export interface RegisteredSyncEntity extends SyncEntityRegistration {
|
||||
}
|
||||
|
||||
const entities = new Map<string, RegisteredSyncEntity>();
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
/** Core's own entities register under this owner. */
|
||||
export const CORE_OWNER = "core";
|
||||
@@ -31,6 +67,7 @@ export const CORE_OWNER = "core";
|
||||
export function registerEntity(
|
||||
owner: string,
|
||||
entity: SyncEntityRegistration,
|
||||
core: CoreSyncEntityOptions = {},
|
||||
): () => void {
|
||||
if (!entity.type) {
|
||||
throw new Error("A sync entity needs a type");
|
||||
@@ -43,25 +80,35 @@ export function registerEntity(
|
||||
);
|
||||
}
|
||||
|
||||
entities.set(entity.type, {
|
||||
const registered: RegisteredSyncEntity = {
|
||||
...entity,
|
||||
...(owner === CORE_OWNER ? core : {}),
|
||||
owner,
|
||||
order: entity.order ?? 100,
|
||||
userColumn: entity.userColumn ?? "userId",
|
||||
readOnlyFields: entity.readOnlyFields ?? [],
|
||||
encryptedFields: entity.encryptedFields ?? [],
|
||||
});
|
||||
};
|
||||
entities.set(entity.type, registered);
|
||||
notify();
|
||||
|
||||
return () => {
|
||||
const current = entities.get(entity.type);
|
||||
// Identity-checked: a plugin that restarted must not revoke the
|
||||
// registration its own restart installed.
|
||||
if (current && current.owner === owner) entities.delete(entity.type);
|
||||
if (entities.get(entity.type) === registered) {
|
||||
entities.delete(entity.type);
|
||||
notify();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function getEntity(type: string): RegisteredSyncEntity | undefined {
|
||||
return entities.get(type);
|
||||
const direct = entities.get(type);
|
||||
if (direct) return direct;
|
||||
for (const entity of entities.values()) {
|
||||
if (entity.answersTo?.includes(type)) return entity;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function hasEntity(type: string): boolean {
|
||||
@@ -76,14 +123,34 @@ export function listEntities(): RegisteredSyncEntity[] {
|
||||
});
|
||||
}
|
||||
|
||||
/** The ordered wire names, which is what the Electron client syncs by. */
|
||||
export function listEntityTypes(): string[] {
|
||||
return listEntities().map((entity) => entity.type);
|
||||
}
|
||||
|
||||
export function unregisterByOwner(owner: string): void {
|
||||
let changed = false;
|
||||
for (const [type, entity] of [...entities]) {
|
||||
if (entity.owner === owner) entities.delete(type);
|
||||
if (entity.owner === owner) {
|
||||
entities.delete(type);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) notify();
|
||||
}
|
||||
|
||||
/** Called whenever the set of entities changes (a plugin came or went). */
|
||||
export function onEntitiesChanged(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
function notify(): void {
|
||||
for (const listener of listeners) {
|
||||
try {
|
||||
listener();
|
||||
} catch {
|
||||
// A listener's failure is its own problem.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user