mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
v26.10.3 (#1692)
* feat: shell controller, TabStage, layout and theme plugins with repair view * feat: move the classic layout into its own bundled plugin * feat: move color themes into a bundled themes plugin * fix: plugin tests and type checks work with a linked sdk * fix: home tab, layout switch and missing layout fallbacks for looks * chore: add classic layout and themes to the readme * chore: drop example plugin links from the plugin guide * feat: plugin filters by type, paged browse, hide plugins needing newer termix * feat: move desktop, status, import, audit and branding features into plugins * feat: host lock api so a plugin can load hosts from a config file (#1581) * chore: add hosts config to the plugin table * feat: login policy hook for plugins and optional https redirect (#1584) * chore: add login security to the plugin table * feat: api key socket tickets so a master can open remote sessions (#1654) * feat: sync moves to a plugin, desktop gets multiple accounts (#1580, #1654) * chore: add sync to the plugin table * feat: move the sync screens and wizard out of core into the plugin (#1580) * fix: add a neutral linked desktop hook for plugins (#1580) * fix: show the linked account's name and admin flag in the app (#1580) * feat: ssh transport providers, core ssh relay and plugin folders api * feat: support FIDO2 security keys through ssh agent * feat: add companion panels beside host tabs * feat: mTLS client certificates for desktop server links * feat: native web view for plugins in the desktop app * fix: detect the desktop link window by Electron's default user agent Fixes #1675 * fix: changelog for desktop link fix * fix: keep text primary keys in the plugin data export * fix: ship sharp binaries for Windows arm64 builds * feat: client cert on the sync link types (sdk 1.0.18) * chore: update translations * feat: themed title bar for the desktop app (#1690) * chore: changelog for the themed title bar * fix: draw the title bar in the sidebar color * feat: per host layout for the terminal panel bar * chore: sdk 1.0.19 for the panel bar layout * chore: bundle all 43 plugins for 26.10.3 * chore: update translations --------- Co-authored-by: SirHephaistos <60616553+SirHephaistos@users.noreply.github.com> Co-authored-by: Giovanny Aranda <garanda21@gmail.com>
This commit is contained in:
577 files changed
+76479
-101831
No files matched your search
@@ -0,0 +1,94 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { Select2 } from "@/components/select2";
|
||||
import {
|
||||
chooseDefaultLook,
|
||||
chooseLayout,
|
||||
useLookState,
|
||||
} from "@/look/look-store";
|
||||
import { layoutLabel, useLayouts } from "@/look/layout-registry";
|
||||
import { themeLabel, useThemes } from "@/look/theme-registry";
|
||||
|
||||
const selectClass =
|
||||
"w-full px-2.5 py-1.5 text-xs bg-background border border-border text-foreground outline-none focus:ring-1 focus:ring-ring appearance-none pr-7";
|
||||
|
||||
/**
|
||||
* The layout picker, plus the default layout and theme for everyone when an
|
||||
* admin is looking. Lists what the layout and theme registries hold, so
|
||||
* plugin layouts and themes show up as soon as they are installed.
|
||||
*/
|
||||
export function LookSettings({ isAdmin }: { isAdmin: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const look = useLookState();
|
||||
const layouts = useLayouts();
|
||||
const themes = useThemes();
|
||||
const response = look.response;
|
||||
|
||||
const failed = () => toast.error(t("looks.saveFailed"));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Select2
|
||||
value={response?.layoutId ?? ""}
|
||||
disabled={!response}
|
||||
onChange={(e) =>
|
||||
void chooseLayout(e.target.value || null).catch(failed)
|
||||
}
|
||||
className={selectClass}
|
||||
>
|
||||
<option value="">{t("looks.followDefault")}</option>
|
||||
{layouts.map((layout) => (
|
||||
<option key={layout.id} value={layout.id}>
|
||||
{layoutLabel(layout, t)}
|
||||
</option>
|
||||
))}
|
||||
</Select2>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{t("looks.layoutHint")}
|
||||
</p>
|
||||
|
||||
{isAdmin && response && (
|
||||
<div className="flex flex-col gap-1.5 border border-border p-2">
|
||||
<span className="text-[11px] font-medium text-foreground">
|
||||
{t("looks.defaultsLabel")}
|
||||
</span>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{t("looks.defaultsHint")}
|
||||
</p>
|
||||
<Select2
|
||||
value={response.defaults.layout ?? ""}
|
||||
onChange={(e) =>
|
||||
void chooseDefaultLook({ layout: e.target.value || null })
|
||||
.then(() => toast.success(t("looks.defaultSaved")))
|
||||
.catch(failed)
|
||||
}
|
||||
className={selectClass}
|
||||
>
|
||||
<option value="">{t("looks.followDefault")}</option>
|
||||
{layouts.map((layout) => (
|
||||
<option key={layout.id} value={layout.id}>
|
||||
{layoutLabel(layout, t)}
|
||||
</option>
|
||||
))}
|
||||
</Select2>
|
||||
<Select2
|
||||
value={response.defaults.theme ?? ""}
|
||||
onChange={(e) =>
|
||||
void chooseDefaultLook({ theme: e.target.value || null })
|
||||
.then(() => toast.success(t("looks.defaultSaved")))
|
||||
.catch(failed)
|
||||
}
|
||||
className={selectClass}
|
||||
>
|
||||
<option value="">{t("looks.followDefault")}</option>
|
||||
{themes.map((theme) => (
|
||||
<option key={theme.id} value={theme.id}>
|
||||
{themeLabel(theme, t)}
|
||||
</option>
|
||||
))}
|
||||
</Select2>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Check,
|
||||
Copy,
|
||||
Download,
|
||||
LayoutTemplate,
|
||||
Palette,
|
||||
History,
|
||||
Power,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
TriangleAlert,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/button";
|
||||
import { ListRow } from "@/components/list-kit";
|
||||
import { InlineView, SurfaceScope } from "@/components/surface/surface-scope";
|
||||
import { useTheme } from "@/components/theme-provider";
|
||||
import { usePermissions } from "@/hooks/use-permissions";
|
||||
import { PluginConsentPrompt } from "@/plugins/PluginConsentPrompt";
|
||||
import { usePluginsManager } from "@/plugins/use-plugins-manager";
|
||||
import { PluginIconBox } from "@/plugins/plugin-bits";
|
||||
import { getPluginRecord } from "@/plugin-host/plugin-store";
|
||||
import { restoreBundledPlugin } from "@/api/plugins-api";
|
||||
import { ShellConfirmCapture } from "@/shell/controller/ShellControllerProvider";
|
||||
import {
|
||||
chooseDefaultLook,
|
||||
chooseLayout,
|
||||
chooseThemeForAccount,
|
||||
} from "./look-store";
|
||||
import { layoutLabel, useLayouts } from "./layout-registry";
|
||||
import { themeLabel, useThemes } from "./theme-registry";
|
||||
import { useChooseTheme } from "./use-look-theme";
|
||||
import type { LayoutStatus } from "./layout-status";
|
||||
|
||||
type RepairStatus = Extract<LayoutStatus, { kind: "repair" }>;
|
||||
|
||||
const LOOK_CAPABILITIES = ["ui:layout", "ui:theme"];
|
||||
|
||||
/**
|
||||
* What an admin can do about a broken layout: update or disable its plugin,
|
||||
* and install another layout or theme. Uses the Plugins tab's own flows.
|
||||
*/
|
||||
function AdminTools({
|
||||
status,
|
||||
installOpen,
|
||||
setInstallOpen,
|
||||
onRetry,
|
||||
}: {
|
||||
status: RepairStatus;
|
||||
installOpen: boolean;
|
||||
setInstallOpen: (open: boolean) => void;
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const manager = usePluginsManager();
|
||||
const entry = status.pluginId
|
||||
? manager.plugins.find((plugin) => plugin.id === status.pluginId)
|
||||
: undefined;
|
||||
const installable = manager.plugins.filter(
|
||||
(plugin) =>
|
||||
!plugin.installed &&
|
||||
plugin.versions[0]?.capabilities?.some((capability) =>
|
||||
LOOK_CAPABILITIES.includes(capability),
|
||||
),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{entry?.installed && entry.updateAvailable && entry.latestVersion && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => void manager.requestUpdate(entry)}
|
||||
disabled={manager.busy.has(entry.id)}
|
||||
>
|
||||
<Download />
|
||||
{t("looks.repair.update", {
|
||||
name: entry.name,
|
||||
version: entry.latestVersion,
|
||||
})}
|
||||
</Button>
|
||||
)}
|
||||
{entry?.installed && entry.enabled && !entry.managedByServer && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => void manager.toggle(entry)}
|
||||
disabled={manager.busy.has(entry.id)}
|
||||
>
|
||||
<Power />
|
||||
{t("looks.repair.disable", { name: entry.name })}
|
||||
</Button>
|
||||
)}
|
||||
{entry?.installed && entry.bundled && !entry.managedByServer && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
void restoreBundledPlugin(entry.id)
|
||||
.then(onRetry)
|
||||
.catch(() => toast.error(t("looks.repair.restoreFailed")));
|
||||
}}
|
||||
disabled={manager.busy.has(entry.id)}
|
||||
>
|
||||
<History />
|
||||
{t("looks.repair.restore", { name: entry.name })}
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" variant="outline" onClick={() => setInstallOpen(true)}>
|
||||
<Download />
|
||||
{t("looks.repair.install")}
|
||||
</Button>
|
||||
|
||||
<InlineView
|
||||
open={installOpen}
|
||||
onOpenChange={setInstallOpen}
|
||||
title={t("looks.repair.installTitle")}
|
||||
icon={<LayoutTemplate className="size-3.5" />}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
{manager.loading && (
|
||||
<p className="p-3 text-xs text-muted-foreground">
|
||||
{t("common.loading")}
|
||||
</p>
|
||||
)}
|
||||
{!manager.loading && installable.length === 0 && (
|
||||
<p className="p-3 text-xs text-muted-foreground">
|
||||
{manager.registryError
|
||||
? t("looks.repair.registryUnreachable")
|
||||
: t("looks.repair.nothingToInstall")}
|
||||
</p>
|
||||
)}
|
||||
{installable.map((plugin, index) => (
|
||||
<ListRow
|
||||
key={`${plugin.registryId ?? ""}:${plugin.id}`}
|
||||
stripe={index}
|
||||
icon={<PluginIconBox name={plugin.icon} size="sm" />}
|
||||
title={plugin.name}
|
||||
meta={plugin.description}
|
||||
trailing={
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
disabled={manager.busy.has(plugin.id)}
|
||||
onClick={() => manager.requestInstall(plugin)}
|
||||
>
|
||||
{t("looks.repair.installButton")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</InlineView>
|
||||
|
||||
<PluginConsentPrompt
|
||||
request={manager.consent}
|
||||
onCancel={() => manager.setConsent(null)}
|
||||
onConfirm={() => void manager.confirmConsent()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shown instead of a layout that cannot be drawn. Small on purpose: it has
|
||||
* to work when everything else is broken, and gets people back to a working
|
||||
* look in one click.
|
||||
*/
|
||||
export function RepairView({
|
||||
status,
|
||||
onRetry,
|
||||
}: {
|
||||
status: RepairStatus;
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { isAdmin } = usePermissions();
|
||||
const { theme } = useTheme();
|
||||
const chooseTheme = useChooseTheme();
|
||||
const layouts = useLayouts();
|
||||
const themes = useThemes();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [installOpen, setInstallOpen] = useState(false);
|
||||
|
||||
const record = status.pluginId ? getPluginRecord(status.pluginId) : null;
|
||||
const layoutName =
|
||||
layouts.find((layout) => layout.id === status.layoutId) ?? null;
|
||||
const name = layoutName
|
||||
? layoutLabel(layoutName, t)
|
||||
: status.layoutId || t("looks.repair.noLayout");
|
||||
const details = [
|
||||
`${t("looks.repair.layout")}: ${name} (${status.layoutId})`,
|
||||
status.pluginId
|
||||
? `${t("looks.repair.plugin")}: ${record?.summary.name ?? status.pluginId} ${status.version ?? ""}`.trim()
|
||||
: null,
|
||||
`${t("looks.repair.reason")}: ${t(`looks.repair.reasons.${status.reason}`)}`,
|
||||
status.error ? `${t("looks.repair.error")}: ${status.error}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
const copy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(details);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
toast.error(t("looks.repair.copyFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
const run = (action: () => Promise<void>) => () =>
|
||||
void action().catch(() => toast.error(t("looks.repair.saveFailed")));
|
||||
|
||||
const resetOwn = run(async () => {
|
||||
try {
|
||||
localStorage.removeItem("vite-ui-theme");
|
||||
} catch {
|
||||
// Private mode.
|
||||
}
|
||||
await chooseThemeForAccount(null);
|
||||
await chooseLayout(null);
|
||||
});
|
||||
|
||||
const resetForEveryone = run(() =>
|
||||
chooseDefaultLook({ layout: null, theme: null }),
|
||||
);
|
||||
|
||||
const safe = status.reason === "safe-url" || status.reason === "safe-env";
|
||||
|
||||
return (
|
||||
<SurfaceScope kind="tab" className="h-dvh w-screen bg-background">
|
||||
<ShellConfirmCapture />
|
||||
<div className="flex min-h-0 flex-1 flex-col items-center overflow-y-auto px-4 py-10">
|
||||
<div className="flex w-full max-w-xl flex-col gap-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center border border-border bg-muted">
|
||||
<TriangleAlert className="size-4 text-warning" />
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<h1 className="text-sm font-semibold text-foreground">
|
||||
{safe ? t("looks.repair.safeTitle") : t("looks.repair.title")}
|
||||
</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(`looks.repair.reasons.${status.reason}`)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col border border-border">
|
||||
<div className="flex items-center justify-between gap-2 border-b border-border bg-muted/30 px-3 py-2">
|
||||
<span className="truncate text-xs text-foreground">
|
||||
{name}
|
||||
{status.pluginId && (
|
||||
<span className="text-muted-foreground">
|
||||
{" | "}
|
||||
{record?.summary.name ?? status.pluginId}
|
||||
{status.version ? ` ${status.version}` : ""}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<Button size="xs" variant="ghost" onClick={() => void copy()}>
|
||||
{copied ? <Check /> : <Copy />}
|
||||
{copied ? t("looks.repair.copied") : t("looks.repair.copy")}
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="max-h-40 overflow-auto whitespace-pre-wrap break-words px-3 py-2 font-mono text-[11px] text-muted-foreground">
|
||||
{status.error ?? t("looks.repair.noError")}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{!safe && (
|
||||
<Button size="sm" onClick={onRetry}>
|
||||
<RefreshCw />
|
||||
{t("looks.repair.retry")}
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" variant="outline" onClick={resetOwn}>
|
||||
<RotateCcw />
|
||||
{t("looks.repair.reset")}
|
||||
</Button>
|
||||
{isAdmin && (
|
||||
<Button size="sm" variant="outline" onClick={resetForEveryone}>
|
||||
<RotateCcw />
|
||||
{t("looks.repair.resetEveryone")}
|
||||
</Button>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<AdminTools
|
||||
status={status}
|
||||
installOpen={installOpen}
|
||||
setInstallOpen={setInstallOpen}
|
||||
onRetry={onRetry}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isAdmin && (
|
||||
<p className="border border-border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
|
||||
{t("looks.repair.askAdmin")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<section className="flex flex-col gap-1.5">
|
||||
<h2 className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
<LayoutTemplate className="size-3" />
|
||||
{t("looks.repair.switchLayout")}
|
||||
</h2>
|
||||
<div className="flex flex-col border border-border">
|
||||
{layouts.map((layout, index) => (
|
||||
<ListRow
|
||||
key={layout.id}
|
||||
stripe={index}
|
||||
title={layoutLabel(layout, t)}
|
||||
meta={layout.id}
|
||||
selected={layout.id === status.layoutId}
|
||||
onClick={run(() => chooseLayout(layout.id))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-1.5">
|
||||
<h2 className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
<Palette className="size-3" />
|
||||
{t("looks.repair.switchTheme")}
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{themes.map((entry) => (
|
||||
<button
|
||||
key={entry.id}
|
||||
type="button"
|
||||
title={themeLabel(entry, t)}
|
||||
aria-pressed={theme === entry.id}
|
||||
onClick={() => chooseTheme(entry.id)}
|
||||
className={`flex items-center gap-1.5 border px-2 py-1 text-[11px] ${theme === entry.id ? "border-accent-brand text-foreground" : "border-border text-muted-foreground hover:border-foreground/40"}`}
|
||||
>
|
||||
<span
|
||||
className="size-3 border border-border/60"
|
||||
style={{ background: entry.swatch.background }}
|
||||
/>
|
||||
{themeLabel(entry, t)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</SurfaceScope>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useSyncExternalStore, type ComponentType } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { createRegistry } from "@/lib/registry";
|
||||
|
||||
/** A layout someone can choose, known from a manifest before its code loads. */
|
||||
export interface LayoutDescriptor {
|
||||
id: string;
|
||||
/** i18n key in the plugin namespace, or plain text. */
|
||||
label: string;
|
||||
description?: string;
|
||||
defaultTheme?: string;
|
||||
/** Set for a plugin layout. */
|
||||
pluginId?: string;
|
||||
}
|
||||
|
||||
/** A layout whose code is running, from app.registerLayout. */
|
||||
export interface RegisteredLayout {
|
||||
id: string;
|
||||
pluginId?: string;
|
||||
component: ComponentType;
|
||||
/** Shown on phones and narrow windows. Never in the desktop app. */
|
||||
mobileComponent?: ComponentType;
|
||||
defaultTheme?: string;
|
||||
}
|
||||
|
||||
let descriptorSnapshot: LayoutDescriptor[] = [];
|
||||
const descriptorListeners = new Set<() => void>();
|
||||
|
||||
const registered = createRegistry<RegisteredLayout>();
|
||||
|
||||
export const registerLayout = registered.register;
|
||||
export const getRegisteredLayout = registered.get;
|
||||
export const useRegisteredLayouts = registered.useList;
|
||||
export const resetRegisteredLayouts = registered.reset;
|
||||
|
||||
/** Every layout that can be chosen, from the installed plugins. */
|
||||
export function listLayouts(): LayoutDescriptor[] {
|
||||
return descriptorSnapshot;
|
||||
}
|
||||
|
||||
export function getLayoutDescriptor(id: string): LayoutDescriptor | undefined {
|
||||
return descriptorSnapshot.find((layout) => layout.id === id);
|
||||
}
|
||||
|
||||
/** Replaces the plugin layouts at once, from the plugin list. */
|
||||
export function setPluginLayouts(layouts: LayoutDescriptor[]): void {
|
||||
const key = (list: LayoutDescriptor[]) =>
|
||||
JSON.stringify(
|
||||
list.map((l) => [l.id, l.pluginId, l.label, l.defaultTheme]),
|
||||
);
|
||||
if (key(layouts) === key(descriptorSnapshot)) return;
|
||||
descriptorSnapshot = layouts;
|
||||
for (const listener of descriptorListeners) listener();
|
||||
}
|
||||
|
||||
function subscribeDescriptors(listener: () => void): () => void {
|
||||
descriptorListeners.add(listener);
|
||||
return () => {
|
||||
descriptorListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export function useLayouts(): LayoutDescriptor[] {
|
||||
return useSyncExternalStore(subscribeDescriptors, listLayouts, listLayouts);
|
||||
}
|
||||
|
||||
export function layoutLabel(layout: LayoutDescriptor, t: TFunction): string {
|
||||
if (!layout.pluginId) return layout.label;
|
||||
return t(`${layout.pluginId}:${layout.label}`, {
|
||||
defaultValue: layout.label,
|
||||
});
|
||||
}
|
||||
|
||||
/** Test seam. */
|
||||
export function resetPluginLayouts(): void {
|
||||
setPluginLayouts([]);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { UnavailableLook } from "@/api/look-api";
|
||||
import type { PluginRecord } from "@/plugin-host/plugin-store";
|
||||
import type { RegisteredLayout } from "./layout-registry";
|
||||
|
||||
/** Why the repair view shows instead of the chosen layout. */
|
||||
export type RepairReason =
|
||||
/** ?safe-layout=1 in the address. */
|
||||
| "safe-url"
|
||||
/** TERMIX_SAFE_LAYOUT on the server. */
|
||||
| "safe-env"
|
||||
/** No installed plugin provides the layout. */
|
||||
| "missing"
|
||||
/** Its plugin is switched off. */
|
||||
| "disabled"
|
||||
/** Its plugin waits on a missing dependency or was refused. */
|
||||
| "blocked"
|
||||
/** Its plugin does not fit this Termix or plugin API. */
|
||||
| "incompatible"
|
||||
/** Its code threw while starting. */
|
||||
| "failed"
|
||||
/** It crashed while drawing, twice. */
|
||||
| "crashed";
|
||||
|
||||
export type LayoutStatus =
|
||||
| { kind: "ok"; layout: RegisteredLayout }
|
||||
| { kind: "loading" }
|
||||
| {
|
||||
kind: "repair";
|
||||
reason: RepairReason;
|
||||
layoutId: string;
|
||||
pluginId?: string;
|
||||
version?: string | null;
|
||||
error?: string | null;
|
||||
};
|
||||
|
||||
/** A crash this many times in a row hands over to the repair view. */
|
||||
export const MAX_LAYOUT_CRASHES = 2;
|
||||
|
||||
export interface LayoutStatusInput {
|
||||
layoutId: string;
|
||||
safeFromUrl: boolean;
|
||||
safeFromEnv: boolean;
|
||||
/** The look and the plugin list have both been read. */
|
||||
ready: boolean;
|
||||
registered: RegisteredLayout | undefined;
|
||||
/** The installed plugin whose manifest declares the layout, if any. */
|
||||
owner: PluginRecord | undefined;
|
||||
/** A plugin the server skipped that declares the layout, if any. */
|
||||
unavailable: UnavailableLook | undefined;
|
||||
crashes: number;
|
||||
lastCrash: string | null;
|
||||
}
|
||||
|
||||
/** Whether the chosen layout can be drawn, and why not when it cannot. */
|
||||
export function layoutStatus(input: LayoutStatusInput): LayoutStatus {
|
||||
const repair = (
|
||||
reason: RepairReason,
|
||||
extra: { error?: string | null } = {},
|
||||
): LayoutStatus => ({
|
||||
kind: "repair",
|
||||
reason,
|
||||
layoutId: input.layoutId,
|
||||
pluginId: input.owner?.summary.id ?? input.unavailable?.id,
|
||||
version: input.owner?.summary.version ?? input.unavailable?.version ?? null,
|
||||
error: extra.error ?? null,
|
||||
});
|
||||
|
||||
if (input.safeFromUrl) return repair("safe-url");
|
||||
if (input.safeFromEnv) return repair("safe-env");
|
||||
if (input.crashes >= MAX_LAYOUT_CRASHES) {
|
||||
return repair("crashed", { error: input.lastCrash });
|
||||
}
|
||||
if (input.registered) return { kind: "ok", layout: input.registered };
|
||||
if (!input.ready) return { kind: "loading" };
|
||||
|
||||
if (input.unavailable && !input.owner) {
|
||||
return repair("incompatible", { error: input.unavailable.error });
|
||||
}
|
||||
const owner = input.owner;
|
||||
if (!owner) return repair("missing");
|
||||
const summary = owner.summary;
|
||||
if (!summary.enabled) return repair("disabled");
|
||||
if (owner.frontend === "blocked" || summary.state === "blocked") {
|
||||
return repair("blocked", { error: owner.error ?? summary.lastError });
|
||||
}
|
||||
if (owner.frontend === "failed" || summary.state === "failed") {
|
||||
return repair("failed", { error: owner.error ?? summary.lastError });
|
||||
}
|
||||
if (owner.frontend === "loading") return { kind: "loading" };
|
||||
// Just chosen: the runtime is about to load its code.
|
||||
if (
|
||||
summary.frontend &&
|
||||
(owner.frontend === "inactive" || owner.frontend === "none")
|
||||
) {
|
||||
return { kind: "loading" };
|
||||
}
|
||||
// Running, but it never registered the layout it declares.
|
||||
return repair("missing");
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import {
|
||||
getLook,
|
||||
saveDefaultLook,
|
||||
saveUserLook,
|
||||
type LookResponse,
|
||||
} from "@/api/look-api";
|
||||
import { resolveLayoutId, safeLayoutInUrl } from "./resolve-look";
|
||||
|
||||
const LAST_LAYOUT_KEY = "termix-layout";
|
||||
|
||||
interface LookState {
|
||||
/** The server answered, or failed to; the look is decided either way. */
|
||||
loaded: boolean;
|
||||
response: LookResponse | null;
|
||||
}
|
||||
|
||||
let state: LookState = { loaded: false, response: null };
|
||||
let request: Promise<LookResponse | null> | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function setState(next: LookState) {
|
||||
state = next;
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
function readLastLayout(): string | null {
|
||||
try {
|
||||
return localStorage.getItem(LAST_LAYOUT_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetches the look once per session. Never rejects: no answer means defaults. */
|
||||
export function loadLook(): Promise<LookResponse | null> {
|
||||
request ??= getLook()
|
||||
.then((response) => {
|
||||
setState({ loaded: true, response });
|
||||
return response;
|
||||
})
|
||||
.catch(() => {
|
||||
setState({ loaded: true, response: null });
|
||||
return null;
|
||||
});
|
||||
return request;
|
||||
}
|
||||
|
||||
/** Fetches again, e.g. after an admin changed the defaults. */
|
||||
export function reloadLook(): Promise<LookResponse | null> {
|
||||
request = null;
|
||||
return loadLook();
|
||||
}
|
||||
|
||||
/** On logout: the next user has their own look. */
|
||||
export function resetLook(): void {
|
||||
request = null;
|
||||
setState({ loaded: false, response: null });
|
||||
}
|
||||
|
||||
export function getLookState(): LookState {
|
||||
return state;
|
||||
}
|
||||
|
||||
export function subscribeLook(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export function useLookState(): LookState {
|
||||
return useSyncExternalStore(subscribeLook, getLookState, getLookState);
|
||||
}
|
||||
|
||||
/** The layout to show now. Remembered so a failed fetch next time still knows. */
|
||||
export function chosenLayoutId(): string {
|
||||
const id = resolveLayoutId(state.response, readLastLayout());
|
||||
if (state.response) {
|
||||
try {
|
||||
localStorage.setItem(LAST_LAYOUT_KEY, id);
|
||||
} catch {
|
||||
// Private mode; the server is asked every time anyway.
|
||||
}
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
/** The URL or the server asks for the repair view. */
|
||||
export function safeLayoutRequested(): boolean {
|
||||
return (
|
||||
(typeof window !== "undefined" &&
|
||||
safeLayoutInUrl(window.location.search)) ||
|
||||
state.response?.safeLayout === true
|
||||
);
|
||||
}
|
||||
|
||||
function patchResponse(patch: Partial<LookResponse>) {
|
||||
if (!state.response) return;
|
||||
setState({ ...state, response: { ...state.response, ...patch } });
|
||||
}
|
||||
|
||||
/** Tells the plugin runtime to load the chosen layout's code. */
|
||||
function announceLayoutChange() {
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new Event("termix:plugins-changed"));
|
||||
}
|
||||
}
|
||||
|
||||
/** The user's own layout; null goes back to the default. */
|
||||
export async function chooseLayout(layoutId: string | null): Promise<void> {
|
||||
await saveUserLook({ layoutId });
|
||||
patchResponse({ layoutId });
|
||||
announceLayoutChange();
|
||||
}
|
||||
|
||||
/** The user's own theme; null goes back to the default. */
|
||||
export async function chooseThemeForAccount(
|
||||
themeId: string | null,
|
||||
): Promise<void> {
|
||||
await saveUserLook({ themeId });
|
||||
patchResponse({ themeId });
|
||||
}
|
||||
|
||||
/** Admin only: what everyone sees until they choose their own. */
|
||||
export async function chooseDefaultLook(defaults: {
|
||||
layout?: string | null;
|
||||
theme?: string | null;
|
||||
}): Promise<void> {
|
||||
const saved = await saveDefaultLook(defaults);
|
||||
patchResponse({ defaults: saved });
|
||||
announceLayoutChange();
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { LookResponse } from "@/api/look-api";
|
||||
|
||||
/** The theme a fresh install starts in when nothing else says otherwise. */
|
||||
export const FALLBACK_THEME = "dark";
|
||||
|
||||
/**
|
||||
* The layout to show: the user's choice, else the admin default, else the
|
||||
* default look. Empty when none of them names one, which the repair view
|
||||
* answers. Without an answer from the server the last layout this browser
|
||||
* showed stands in.
|
||||
*/
|
||||
export function resolveLayoutId(
|
||||
look: LookResponse | null,
|
||||
lastShown: string | null = null,
|
||||
): string {
|
||||
if (!look) return lastShown ?? "";
|
||||
return look.layoutId ?? look.defaults.layout ?? look.defaultLook.layout ?? "";
|
||||
}
|
||||
|
||||
/**
|
||||
* The theme to show: the user's choice, else the one this browser kept
|
||||
* (today's per-device setting, so existing users see no change), else the
|
||||
* admin default, else the layout's own, else the default look's.
|
||||
*/
|
||||
export function resolveThemeId(
|
||||
look: LookResponse | null,
|
||||
localTheme: string | null,
|
||||
layoutDefaultTheme?: string,
|
||||
): string {
|
||||
return (
|
||||
look?.themeId ??
|
||||
localTheme ??
|
||||
look?.defaults.theme ??
|
||||
layoutDefaultTheme ??
|
||||
look?.defaultLook.theme ??
|
||||
FALLBACK_THEME
|
||||
);
|
||||
}
|
||||
|
||||
/** ?safe-layout=1 in the address bar opens the repair view. */
|
||||
export function safeLayoutInUrl(search: string): boolean {
|
||||
const value = new URLSearchParams(search).get("safe-layout");
|
||||
return value === "1" || value === "true";
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { arePluginThemesKnown, type ThemeDef } from "./theme-registry";
|
||||
|
||||
const LINK_ID = "termix-theme-css";
|
||||
/** The last plugin theme's stylesheet, so a reload shows it before login too. */
|
||||
const CACHE_KEY = "termix-theme-css";
|
||||
|
||||
function readCache(): { id: string; href: string } | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(CACHE_KEY);
|
||||
const parsed = raw ? (JSON.parse(raw) as unknown) : null;
|
||||
if (
|
||||
parsed &&
|
||||
typeof (parsed as { id?: unknown }).id === "string" &&
|
||||
typeof (parsed as { href?: unknown }).href === "string"
|
||||
) {
|
||||
return parsed as { id: string; href: string };
|
||||
}
|
||||
} catch {
|
||||
// Unreadable or private mode.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function writeCache(value: { id: string; href: string } | null) {
|
||||
try {
|
||||
if (value) localStorage.setItem(CACHE_KEY, JSON.stringify(value));
|
||||
else localStorage.removeItem(CACHE_KEY);
|
||||
} catch {
|
||||
// Private mode; the theme still applies once plugins load.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the one plugin theme stylesheet the shown theme needs, and only that
|
||||
* one. Built-in themes need none. A theme whose plugin has not loaded yet
|
||||
* keeps the stylesheet it had last time.
|
||||
*/
|
||||
export function applyThemeCss(id: string, theme: ThemeDef | undefined): void {
|
||||
if (typeof document === "undefined") return;
|
||||
const cached = readCache();
|
||||
let href: string | null = null;
|
||||
if (theme?.cssHref) {
|
||||
href = theme.cssHref;
|
||||
writeCache({ id, href });
|
||||
} else if (cached?.id === id && !arePluginThemesKnown()) {
|
||||
// Its plugin has not said yet whether it still provides this theme.
|
||||
href = cached.href;
|
||||
} else if (cached && arePluginThemesKnown()) {
|
||||
writeCache(null);
|
||||
}
|
||||
|
||||
let link = document.getElementById(LINK_ID) as HTMLLinkElement | null;
|
||||
if (!href) {
|
||||
link?.remove();
|
||||
return;
|
||||
}
|
||||
if (!link) {
|
||||
link = document.createElement("link");
|
||||
link.id = LINK_ID;
|
||||
link.rel = "stylesheet";
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
const absolute = new URL(href, window.location.href).href;
|
||||
if (link.href !== absolute) link.href = absolute;
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
|
||||
/** Follows the OS between the light and dark theme. Not a real theme. */
|
||||
export const SYSTEM_THEME = "system";
|
||||
|
||||
export interface ThemeSwatch {
|
||||
background: string;
|
||||
panel: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface ThemeDef {
|
||||
/** Also the class put on <html> while the theme is active. */
|
||||
id: string;
|
||||
/** i18n key: core's for built-ins, the plugin namespace's for plugins. */
|
||||
label: string;
|
||||
mode: "light" | "dark";
|
||||
swatch: ThemeSwatch;
|
||||
/** Set for a plugin theme. */
|
||||
pluginId?: string;
|
||||
/** Where a plugin theme's CSS is served. Built-ins live in index.css. */
|
||||
cssHref?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The two themes core keeps as a fallback, with their tokens in index.css.
|
||||
* The rest come from theme plugins. A plugin theme with the same id takes
|
||||
* the built-in's place while it is installed.
|
||||
*/
|
||||
export const BUILTIN_THEMES: readonly ThemeDef[] = [
|
||||
{
|
||||
id: "light",
|
||||
label: "newUi.sidebar.userProfile.themeLight",
|
||||
mode: "light",
|
||||
swatch: { background: "#ffffff", panel: "#eef0f3", text: "#1f2937" },
|
||||
},
|
||||
{
|
||||
id: "dark",
|
||||
label: "newUi.sidebar.userProfile.themeDark",
|
||||
mode: "dark",
|
||||
swatch: { background: "#1a1c22", panel: "#25272e", text: "#e5e7eb" },
|
||||
},
|
||||
];
|
||||
|
||||
const builtins = new Map(BUILTIN_THEMES.map((theme) => [theme.id, theme]));
|
||||
let pluginThemes = new Map<string, ThemeDef>();
|
||||
/** False until the plugin list has been read once this session. */
|
||||
let pluginThemesKnown = false;
|
||||
const listeners = new Set<() => void>();
|
||||
let snapshot: ThemeDef[] | null = null;
|
||||
|
||||
function emit() {
|
||||
snapshot = null;
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
/** Built-ins in their order, a plugin theme in a built-in's place, then the rest as declared. */
|
||||
export function listThemes(): ThemeDef[] {
|
||||
if (!snapshot) {
|
||||
const replaced = BUILTIN_THEMES.map(
|
||||
(theme) => pluginThemes.get(theme.id) ?? theme,
|
||||
);
|
||||
const extra = [...pluginThemes.values()].filter(
|
||||
(theme) => !builtins.has(theme.id),
|
||||
);
|
||||
snapshot = [...replaced, ...extra];
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export function getTheme(id: string): ThemeDef | undefined {
|
||||
return pluginThemes.get(id) ?? builtins.get(id);
|
||||
}
|
||||
|
||||
/** Replaces every plugin theme at once, from the plugin list. */
|
||||
export function setPluginThemes(themes: ThemeDef[]): void {
|
||||
const first = !pluginThemesKnown;
|
||||
pluginThemesKnown = true;
|
||||
const next = new Map(themes.map((theme) => [theme.id, theme]));
|
||||
const same =
|
||||
next.size === pluginThemes.size &&
|
||||
[...next].every(([id, theme]) => {
|
||||
const current = pluginThemes.get(id);
|
||||
return (
|
||||
current?.cssHref === theme.cssHref &&
|
||||
current?.pluginId === theme.pluginId &&
|
||||
current?.label === theme.label &&
|
||||
current?.mode === theme.mode
|
||||
);
|
||||
});
|
||||
if (same && !first) return;
|
||||
pluginThemes = next;
|
||||
emit();
|
||||
}
|
||||
|
||||
export function subscribeThemes(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export function useThemes(): ThemeDef[] {
|
||||
return useSyncExternalStore(subscribeThemes, listThemes, listThemes);
|
||||
}
|
||||
|
||||
/** The light or dark mode a theme id stands for, following the OS for system. */
|
||||
export function themeMode(id: string): "light" | "dark" {
|
||||
if (id === SYSTEM_THEME) {
|
||||
return typeof window !== "undefined" &&
|
||||
window.matchMedia?.("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
}
|
||||
const theme = getTheme(id);
|
||||
if (theme) return theme.mode;
|
||||
// A theme that went away follows the OS, once the plugins have said so.
|
||||
return pluginThemesKnown ? themeMode(SYSTEM_THEME) : "dark";
|
||||
}
|
||||
|
||||
/** The theme to show for a saved id: itself, or System when it is gone. */
|
||||
export function effectiveThemeId(id: string): string {
|
||||
if (id === SYSTEM_THEME || getTheme(id) || !pluginThemesKnown) return id;
|
||||
return SYSTEM_THEME;
|
||||
}
|
||||
|
||||
export function themeLabel(theme: ThemeDef, t: TFunction): string {
|
||||
if (!theme.pluginId) return t(theme.label);
|
||||
return t(`${theme.pluginId}:${theme.label}`, { defaultValue: theme.label });
|
||||
}
|
||||
|
||||
export function arePluginThemesKnown(): boolean {
|
||||
return pluginThemesKnown;
|
||||
}
|
||||
|
||||
/** On logout, and a test seam. */
|
||||
export function resetPluginThemes(): void {
|
||||
pluginThemes = new Map();
|
||||
pluginThemesKnown = false;
|
||||
emit();
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useCallback, useLayoutEffect } from "react";
|
||||
import { useTheme } from "@/components/theme-provider";
|
||||
import { getUserPreferences, saveUserPreferences } from "@/api/open-tabs-api";
|
||||
import { chooseThemeForAccount, useLookState } from "./look-store";
|
||||
import { getLayoutDescriptor, getRegisteredLayout } from "./layout-registry";
|
||||
import { resolveThemeId } from "./resolve-look";
|
||||
|
||||
const LOCAL_THEME_KEY = "vite-ui-theme";
|
||||
|
||||
function readLocalTheme(): string | null {
|
||||
try {
|
||||
return localStorage.getItem(LOCAL_THEME_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the theme the look settles on once it is known. A theme someone
|
||||
* picked themselves is kept in this browser too; a default is only shown,
|
||||
* so a later default change still reaches them.
|
||||
*/
|
||||
export function useLookTheme(layoutId: string): void {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const look = useLookState();
|
||||
const layoutDefault =
|
||||
getRegisteredLayout(layoutId)?.defaultTheme ??
|
||||
getLayoutDescriptor(layoutId)?.defaultTheme;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!look.loaded) return;
|
||||
const next = resolveThemeId(look.response, readLocalTheme(), layoutDefault);
|
||||
if (next === theme) return;
|
||||
setTheme(next, !!look.response?.themeId);
|
||||
// Only when the look itself changes, not on every theme switch.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [look, layoutDefault]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks a theme for this user: shown now and kept in this browser. Unless
|
||||
* they keep settings on this device only, it is saved to their account too,
|
||||
* which is what other devices follow.
|
||||
*/
|
||||
export function useChooseTheme(): (themeId: string) => void {
|
||||
const { setTheme } = useTheme();
|
||||
return useCallback(
|
||||
(themeId: string) => {
|
||||
setTheme(themeId);
|
||||
void syncThemeChoice(themeId);
|
||||
},
|
||||
[setTheme],
|
||||
);
|
||||
}
|
||||
|
||||
async function syncThemeChoice(themeId: string): Promise<void> {
|
||||
const prefs = await getUserPreferences().catch(() => null);
|
||||
// A device-only profile must not be overridden by an account choice.
|
||||
const local = prefs?.storageMode === "local";
|
||||
await chooseThemeForAccount(local ? null : themeId).catch(() => {});
|
||||
// Older clients still read the profile field.
|
||||
if (prefs?.storageMode === "cloud") {
|
||||
await saveUserPreferences({ theme: themeId }).catch(() => {});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user