diff --git a/src/apps/officer-web/App.tsx b/src/apps/officer-web/App.tsx index 99789e3c..b2bbca2c 100644 --- a/src/apps/officer-web/App.tsx +++ b/src/apps/officer-web/App.tsx @@ -1,4 +1,4 @@ -import { BrowserRouter, Routes, Route, Navigate } from 'react-router'; +import { BrowserRouter, Routes, Route, Navigate, useLocation } from 'react-router'; import * as Authentication from './Screens/Authentication'; import * as Dashboard from './Screens/Dashboard'; import { useAuth } from 'hooks/useAuth'; @@ -10,6 +10,22 @@ import { useInitialData } from '@/state/useInitialData'; // that does not exist, so it is always []. Different thing entirely; see plugins/offscale/PLUGIN.md. import { plugins as installedPlugins } from './Plugins.gen'; +/** + * `/plugins` → `/marketplace`, KEEPING the query string. + * + * `` drops it, and that was not theoretical: every row in the plugin list + * linked to `/plugins?selected=`, so clicking one redirected to a bare `/marketplace` and the detail + * pane sat on "Select a plugin." The screen looked broken while both halves were working — the link set a + * param the redirect then threw away. + * + * Rows now point at `/marketplace` directly, so this only serves old links and bookmarks. It preserves + * the search anyway, because that is what made the redirect wrong in the first place. + */ +const PluginsRedirect = () => { + const { search } = useLocation(); + return ; +}; + export function App() { const { isLoading, isAuthenticated } = useAuth(); const { plugins, isLoading: isServerSettingsLoading } = useServerSettings(); @@ -73,7 +89,7 @@ export function App() { {/* The screen was `/plugins` until marketplace.officer.dev existed. Redirected rather than dropped: the word appears in the dock, in docs, and in whatever anyone bookmarked, and a 404 on a route that worked yesterday reads as the feature having been removed. */} - } /> + } /> {/* Installed plugins. Core routes above stay hand-written; everything below is generated from what is installed, because a bundler cannot follow a runtime import specifier. The wildcard hands the whole subtree to the plugin's own router, which react-router nests natively. */} diff --git a/src/servers/plugins/mount.ts b/src/servers/plugins/mount.ts index 1fd0d775..1ffff8aa 100644 --- a/src/servers/plugins/mount.ts +++ b/src/servers/plugins/mount.ts @@ -6,6 +6,7 @@ import { discoverPlugins } from './discover'; import { mountPrefix, type DiscoveredPlugin } from './manifest'; import { generatePluginsModule, rebuildFrontend } from './generate'; import { iconUrl } from '../app-store/assets'; +import { publishAssets } from '../app-store/assets'; // Turning what is on disk plus what is in the database into a mounted application. // @@ -146,6 +147,26 @@ export async function refreshPluginMounts(options: { skipFrontend?: boolean } = const mounted = await mountablePlugins(snapshot); rebuildHonoApp(mounted); + // Republish every enabled plugin's `assets/` on the way past. + // + // Publishing used to happen ONLY at install, and `offscale` proved why that is not enough: it was + // installed before it shipped an icon, so `public/plugins/offscale/` never existed and the tile drew a + // 404 forever. Nothing short of uninstalling and reinstalling would have fixed it, and "reinstall the + // plugin to see its new icon" is the same bad answer as "restart the server to see it". + // + // A copy of files that already exist in the plugin's own source, so re-running it is free and cannot + // lose anything — the same property that makes `unpublishAssets` the one thing uninstall may delete. + // Failures are logged, never thrown: an icon is not worth refusing to mount a plugin over. + await Promise.all( + enabled.map(async ({ plugin }) => { + try { + await publishAssets(plugin.appName, plugin.dir); + } catch (err) { + console.error(`[plugins] ${plugin.appName}: assets not published —`, err instanceof Error ? err.message : err); + } + }), + ); + // The frontend half. `Plugins.gen.tsx` is what the bundler reads, so it is written first and the SPA is // rebuilt from it — ~1s for a 25MB bundle, which is why an install can do this rather than ask for a // restart. Skipped at boot, where the build already happened and repeating it would delay `serve()`. diff --git a/src/workspaces/officerdev/src/apps/Plugins/PluginsList.tsx b/src/workspaces/officerdev/src/apps/Plugins/PluginsList.tsx index 42c85bcf..f21b9f90 100644 --- a/src/workspaces/officerdev/src/apps/Plugins/PluginsList.tsx +++ b/src/workspaces/officerdev/src/apps/Plugins/PluginsList.tsx @@ -34,7 +34,7 @@ export const PluginsList = () => { {plugins.map((plugin) => ( { {available.map((plugin) => (