clicking a plugin works again, and offscale gets its icon

Two regressions and one long-standing hole, all found by actually opening the page.

── Clicking a row did nothing. That one is mine ──

Rows link to `?selected=<app>`, and they still pointed at `/plugins`. My redirect
was `<Navigate to="/marketplace" replace />`, which DROPS the query string — so a
click went to `/plugins?selected=music`, redirected to a bare `/marketplace`, and
the detail pane sat on "Select a plugin." Both halves worked; the redirect threw
away what the link had just set.

Rows now point at `/marketplace` directly. The redirect also preserves `search`,
because old bookmarks carry the same param and losing it is exactly what made this
wrong the first time.

── Offscale's icon 404'd since it shipped one ──

Assets were published to `public/plugins/<app>/` at INSTALL and nowhere else.
Offscale was installed before it had an `assets/icon.png`, so the directory never
existed and the tile drew a 404 permanently — nothing short of uninstalling and
reinstalling would have fixed it. "Reinstall the plugin to see its new icon" is
the same bad answer as "restart the server to see it", which the route right next
to this already refuses to accept.

Publishing now happens on every mount. It is a copy of files that still exist in
the plugin's own source, so re-running it is free and cannot lose anything — the
property that already makes unpublish the one thing uninstall may delete. A
failure is logged, never thrown: an icon is not worth refusing to mount over.

Published offscale's by hand too, so the fix is visible without waiting for a
restart. Verified 200 through the running server for both plugins.

── The third report was not a bug ──

"No connection to the marketplace" is the catalogue working correctly: all three
plugins are already on disk here, and `router.ts` drops catalogue entries whose
plugin is installed, because offering to install something installed is worse than
showing nothing. With nothing left to offer, a working store and a broken one look
identical on that screen. Nothing in the logs, which is what a reachable store
looks like — the client only speaks when it cannot reach one.

tsgo clean, frontend builds, 873 pass / 7 fail unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 09:20:11 +00:00
co-authored by Claude Opus 5
parent bfb20b6916
commit 8ad5a5563e
3 changed files with 41 additions and 4 deletions
+18 -2
View File
@@ -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.
*
* `<Navigate to="/marketplace" />` drops it, and that was not theoretical: every row in the plugin list
* linked to `/plugins?selected=<app>`, 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 <Navigate to={{ pathname: '/marketplace', search }} replace />;
};
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. */}
<Route path="/plugins" element={<Navigate to="/marketplace" replace />} />
<Route path="/plugins" element={<PluginsRedirect />} />
{/* 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. */}
+21
View File
@@ -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()`.
@@ -34,7 +34,7 @@ export const PluginsList = () => {
{plugins.map((plugin) => (
<Link
key={plugin.appName}
to={`/plugins?selected=${encodeURIComponent(plugin.appName)}`}
to={`/marketplace?selected=${encodeURIComponent(plugin.appName)}`}
className={`flex items-center gap-3 px-3 py-2.5 border-b border-duck-dark/5 transition-colors ${
selected === plugin.appName ? 'bg-duck-teal/10' : 'hover:bg-duck-dark/5'
}`}
@@ -73,7 +73,7 @@ export const PluginsList = () => {
{available.map((plugin) => (
<Link
key={`available-${plugin.appName}`}
to={`/plugins?selected=${encodeURIComponent(plugin.appName)}`}
to={`/marketplace?selected=${encodeURIComponent(plugin.appName)}`}
className={`flex items-center gap-3 border-b border-duck-dark/5 px-3 py-2.5 transition-colors ${
selected === plugin.appName ? 'bg-duck-teal/10' : 'hover:bg-duck-dark/5'
}`}