feat: rebuild desktop sync as linked server accounts, fix packaged plugin bundles and UI scaling

This commit is contained in:
LukeGus committed 2026-09-26 00:41:25 -05:00
1 parent 7c02daccf4
commit 6cd9a2846d
242 files changed
+39127 -8558

No files matched your search

-10
View File
@@ -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: {
+44
View File
@@ -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);
+9
View File
@@ -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);
+84 -17
View File
@@ -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.
}
}
}