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) => (