mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 15:15:24 +00:00
* feat: let the login page send the token back to the mobile app * feat: add host-scripts entry to the plugin sdk * feat: let plugins say how they show in the mobile app * feat: frame host for running one plugin view outside the shell * fix: build the SDK host modules in production mode * chore: note the SDK host build fix in the changelog * feat: list plugin engine ranges for the mobile app * feat: host color and icon columns that sync * chore: update translations and plugin pins * fix: test * fix: show source address when rejecting proxy auth * fix: deleting your own account removes all of its data * fix(deps): update body-parser to 2.4.0 (#1736) * fix: body-parser 보안 패치 반영 * chore: 자동 검사를 막던 기존 JSON 서식 정리 --------- Co-authored-by: renovys <180682734+renovys@users.noreply.github.com> * fix(sdk): decode command output as GBK when it is not valid UTF-8 (#1735) * fix: honor shared credentials on shared hosts (#1730) * fix: honor shared credentials on shared hosts and in personal SSH auth * fix: snapshot shared SSH auth from a credential shared with the host owner * fix: size DataView list columns by the table's own width (#1713) * fix: size DataView list columns by the table's own width The list grid was built from every column, so columns hidden below md/lg left empty tracks and the visible cells landed under the wrong headers. Each width now gets a template of only its visible columns (CSS variables + container queries on the table, so a narrow panel counts, not the window), the header uses the same gap as the rows, and rows use a minimum height so wrapped cells no longer overlap. * test: wait for the title bar offset effect before asserting The bar shows once getTitleBarVisible resolves, but --termix-titlebar-height is set in a later passive effect, so reading it right after findByText raced (3-5 of 40 runs under load, on main too). * chore: rerun CI against dev-26.10.6 * fix: unlock passphrase keys once per connect burst (#1737) * feat: give ssh transports the host origin and sync id (#1717) * fix: wake other clients after a sync push (Plugin-Sync#1) * feat: look up local row ids by sync id for the sync plugin (Plugin-Sync#4) * feat: rail items can show a status dot (Plugin-Sync#5) * fix: add the desktop shared copy banner text (#1709) * feat: sidebar preference for alternating rows (#1712) * fix: striped rows text * feat: shell can open a saved host's editor for plugins (Plugin-SSH-Terminal#36) * fix: show the logo in the macos menu bar (#1728) * fix: number shortcuts skip the home tab (#1724) * fix: show plugins that could not be downloaded as failed (#1714) * fix: pass the host id as text when opening its editor * fix: retry a plugin bundle that was not ready yet (#1708) * fix: let commonjs plugins fall back to core packages on the desktop * fix: let non docker installs switch nginx to https (#1711) * feat: remove a folder share from its hosts too (#1719) * chore: changelog for merged pull requests * fix: accept the release .sig when installing a plugin from a file * feat: folder logins for shared hosts, read only plugin sync, full desktop builds * fix: test * fix: stop dev runner restart loops and narrow plugin watchers * chore: update translations and plugin pins * chore: lint, format, and bump version to 26.10.6 --------- Co-authored-by: renovys <renovy@gmail.com> Co-authored-by: renovys <180682734+renovys@users.noreply.github.com> Co-authored-by: Sebastian Cao <cycsmail@gmail.com> Co-authored-by: Jonathan Rehuel Lewerissa <jonathan.rehuel16@gmail.com> Co-authored-by: Rhiz3K <33246262+Rhiz3K@users.noreply.github.com>
208 lines
6.0 KiB
TypeScript
208 lines
6.0 KiB
TypeScript
/**
|
|
* Which entities sync between a linked desktop and its server.
|
|
*
|
|
* 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 be written
|
|
* first. That is why every registration carries an explicit order.
|
|
*/
|
|
|
|
import type {
|
|
SyncEntityRegistration,
|
|
SyncRow,
|
|
} from "@termix-ssh/plugin-sdk/backend";
|
|
|
|
/** Extras core sets for its own entities. A plugin may set the first four. */
|
|
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;
|
|
userColumn: string;
|
|
readOnlyFields: readonly string[];
|
|
encryptedFields: readonly string[];
|
|
}
|
|
|
|
/** What a plugin registration may carry. Anything else is dropped. */
|
|
const PLUGIN_KEYS = [
|
|
"type",
|
|
"table",
|
|
"userColumn",
|
|
"encryptedFields",
|
|
"references",
|
|
"order",
|
|
"readOnlyFields",
|
|
"singleton",
|
|
"answersTo",
|
|
"shouldSync",
|
|
"permissions",
|
|
"serialize",
|
|
"deserialize",
|
|
"afterWrite",
|
|
"remove",
|
|
"readOnly",
|
|
"load",
|
|
"write",
|
|
"erase",
|
|
] as const;
|
|
|
|
function pluginEntity(entity: SyncEntityRegistration): SyncEntityRegistration {
|
|
const picked: Record<string, unknown> = {};
|
|
for (const key of PLUGIN_KEYS) {
|
|
if (entity[key] !== undefined) picked[key] = entity[key];
|
|
}
|
|
const out = picked as unknown as SyncEntityRegistration;
|
|
if (out.load || out.write || out.erase) {
|
|
if (!out.load || !out.write || !out.erase) {
|
|
throw new Error(
|
|
`Sync entity "${entity.type}" needs load, write and erase together`,
|
|
);
|
|
}
|
|
// A plugin only ever syncs its own rows both ways; rows it builds for
|
|
// a user come down as read only copies.
|
|
if (out.readOnly !== true) {
|
|
throw new Error(
|
|
`Sync entity "${entity.type}" with load must be readOnly`,
|
|
);
|
|
}
|
|
} else if (!out.table) {
|
|
throw new Error(`Sync entity "${entity.type}" needs a table or load`);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
const entities = new Map<string, RegisteredSyncEntity>();
|
|
const listeners = new Set<() => void>();
|
|
|
|
/** Core's own entities register under this owner. */
|
|
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");
|
|
}
|
|
|
|
const existing = entities.get(entity.type);
|
|
if (existing && existing.owner !== owner) {
|
|
throw new Error(
|
|
`Sync entity "${entity.type}" is already registered by "${existing.owner}"`,
|
|
);
|
|
}
|
|
|
|
const registered: RegisteredSyncEntity = {
|
|
...(owner === CORE_OWNER ? entity : pluginEntity(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 () => {
|
|
// Identity-checked: a plugin that restarted must not revoke the
|
|
// registration its own restart installed.
|
|
if (entities.get(entity.type) === registered) {
|
|
entities.delete(entity.type);
|
|
notify();
|
|
}
|
|
};
|
|
}
|
|
|
|
export function getEntity(type: string): RegisteredSyncEntity | undefined {
|
|
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 {
|
|
return entities.has(type);
|
|
}
|
|
|
|
/** Every entity, in dependency order. */
|
|
export function listEntities(): RegisteredSyncEntity[] {
|
|
return [...entities.values()].sort((a, b) => {
|
|
if (a.order !== b.order) return a.order - b.order;
|
|
return a.type.localeCompare(b.type);
|
|
});
|
|
}
|
|
|
|
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);
|
|
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.
|
|
}
|
|
}
|
|
}
|
|
|
|
export function resetSyncRegistry(): void {
|
|
entities.clear();
|
|
}
|