caldav leaves the platform
calendar and contacts are a plugin now — plugins/caldav, its own repository like every other one. a fresh machine runs five processes instead of seven and gets a calendar by installing one plugin, which is the whole point: the platform stops carrying a python calendar server for people who may not want one. what left: the radicale sidecar, four routers, the panel apps, the dav_app_passwords table, the two screens, the two dock tiles, the permission registry entry, the settings section, and radicale from every package list. three platform things had to change first, each committed on its own:6b5c87daa plugin can be more than one screen;e0df28ffa first-party plugin can claim a reserved root path. this commit adds the third, `personal` on a plugin permission — a member granted READ must still mint and revoke their own app password, because that password is how their own phone reaches their own calendar. those rows are theirs and nobody else's. two fixes the work surfaced rather than caused. the dock pinned mountPrefix, so a multi-screen plugin pinned a tile pointing at /caldav — a route no plugin serves. and the OPTIONS carve-out was a literal list of four DAV paths; it asks the claim registry now, so it follows whoever owns the route. /calendar stays in DEFAULT_DOCK_PATHS. it is installed by default and a fresh dock that silently dropped it would read as a downgrade. docs:check earned its keep twice here: it caught spotlight still offering /settings/integrations/dav-app-passwords after the section was deleted, and the permission count going 22 -> 21. neither is greppable and both were already wrong when it ran. verified against alpha, which is still on the pre-extraction code: /.well-known/ caldav 301s, /dav/ answers 401 with the same WWW-Authenticate realm byte for byte, /api/caldav/passwords 401s, and OPTIONS reaches the DAV router rather than being answered as a CORS preflight. installed here end to end — schema applied, sidecar started, two tiles with their own artwork behind one permission. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
@@ -94,7 +94,11 @@ function findWeb(
|
||||
};
|
||||
}
|
||||
return {
|
||||
web: { screens: [{ segment: null, label: manifest?.label ?? appName, icon: manifest?.icon, panels, layout }] },
|
||||
web: {
|
||||
screens: [
|
||||
{ segment: null, label: manifest?.label ?? appName, icon: manifest?.icon, art: null, panels, layout },
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -131,7 +135,15 @@ function findWeb(
|
||||
problems.push(`screen "${screen.segment}" is declared but missing ${missing.join(' and ')}`);
|
||||
continue;
|
||||
}
|
||||
screens.push({ segment: screen.segment, label: screen.label, icon: screen.icon ?? manifest?.icon, panels, layout });
|
||||
screens.push({
|
||||
segment: screen.segment,
|
||||
label: screen.label,
|
||||
icon: screen.icon ?? manifest?.icon,
|
||||
// Its own artwork if it ships any — see DiscoveredScreen.art.
|
||||
art: fileOrNull(join(dir, 'assets', `${screen.segment}.png`)),
|
||||
panels,
|
||||
layout,
|
||||
});
|
||||
}
|
||||
|
||||
// A directory nobody declared is a screen with no tile and no label — it would render at a URL with
|
||||
|
||||
@@ -417,9 +417,15 @@ export async function installPlugin(
|
||||
|
||||
// The tile, for the person who just installed it. `plugin.web` because `pluginDockManifests` only
|
||||
// offers a tile for a plugin that has a frontend — a backend-only plugin has no screen to pin.
|
||||
//
|
||||
// One pin per SCREEN, and the route is the SCREEN's, not the mount prefix. Caldav pins /calendar and
|
||||
// /contacts; pinning /caldav would put a tile on the dock pointing at a route no plugin serves, since
|
||||
// a plugin declaring screens mounts at the segments it chose rather than at its name.
|
||||
if (plugin.web && forUserId !== undefined) {
|
||||
const route = mountPrefix(plugin);
|
||||
if (await addToDock(forUserId, route)) await step(steps, onStep, `dock: ${route} pinned`);
|
||||
for (const screen of plugin.web.screens) {
|
||||
const route = screen.segment ? `/${screen.segment}` : mountPrefix(plugin);
|
||||
if (await addToDock(forUserId, route)) await step(steps, onStep, `dock: ${route} pinned`);
|
||||
}
|
||||
}
|
||||
|
||||
// MOUNT BEFORE STARTING THE SIDECAR, and the order is not cosmetic.
|
||||
|
||||
@@ -46,6 +46,19 @@ export type PluginPermission = {
|
||||
* broken feature where a withheld permission should be.
|
||||
*/
|
||||
readOnlyWrites?: string[];
|
||||
/**
|
||||
* Sub-paths, relative to the plugin's mount prefix, that a READ grant may still mutate because they
|
||||
* hold only the CALLER'S OWN data.
|
||||
*
|
||||
* Caldav is the case that needed it: a member granted read on the calendar must still be able to mint
|
||||
* and revoke their own DAV app password, because that password is how their phone reaches their own
|
||||
* calendar. Every one of those routes is scoped to the caller's user id in the plugin's own queries —
|
||||
* `personal` is the platform agreeing that a write there is not a write to anything shared.
|
||||
*
|
||||
* Distinct from `readOnlyWrites`, which is for requests that LOOK like writes and are not. These are
|
||||
* real writes, to rows nobody else can see.
|
||||
*/
|
||||
personal?: string[];
|
||||
/**
|
||||
* Sub-paths, relative to the plugin's mount prefix, that ONLY the owner may reach — at any granted
|
||||
* level, by any method.
|
||||
@@ -530,6 +543,15 @@ export type DiscoveredScreen = {
|
||||
label: string;
|
||||
/** Lucide icon NAME for this screen's tile, falling back to the plugin's. */
|
||||
icon?: string;
|
||||
/**
|
||||
* `assets/<segment>.png` — this screen's own artwork, when it ships some.
|
||||
*
|
||||
* A single-screen plugin has one tile and `assets/icon.png` is it. Two screens sharing that one image
|
||||
* would be two identical tiles side by side, saying nothing about which is which — so a screen may
|
||||
* bring its own, found by name for the same reason the icon is: presence is the declaration, and
|
||||
* `publishAssets` already copies the whole assets directory.
|
||||
*/
|
||||
art: string | null;
|
||||
panels: string;
|
||||
layout: string;
|
||||
};
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
import { discoverPlugins } from './discover';
|
||||
import { mountPrefix, type DiscoveredPlugin } from './manifest';
|
||||
import { generatePluginsModule, rebuildFrontend } from './generate';
|
||||
import { iconUrl } from '../app-store/assets';
|
||||
import { iconUrl, screenArtUrl } from '../app-store/assets';
|
||||
import { publishAssets } from '../app-store/assets';
|
||||
import { importFresh } from './import-fresh';
|
||||
|
||||
@@ -139,6 +139,10 @@ function pluginPermissions(state: PluginState): Permission[] {
|
||||
api: index === 0 ? [prefix] : [],
|
||||
routes: index === 0 ? [prefix] : [],
|
||||
...(permission.readOnlyWrites ? { readOnlyWrites: permission.readOnlyWrites } : {}),
|
||||
// Rows only the caller can see, so a read grant may still write them — a member minting the app
|
||||
// password their own phone syncs with. Threaded like the two below, and meaningful for the same
|
||||
// reason: only the entry that claims the paths can match anything.
|
||||
...(permission.personal ? { personal: permission.personal } : {}),
|
||||
// Only meaningful on the entry that claims the paths — a later permission claims no `api`, so an
|
||||
// `ownerOnly` on it would match nothing. Threaded anyway rather than dropped: silently ignoring a
|
||||
// declaration is how a plugin author concludes the field does not work.
|
||||
@@ -309,13 +313,18 @@ export async function pluginDockManifests(): Promise<
|
||||
// A plugin with several screens is the one case where the shared artwork is WRONG: two
|
||||
// identical tiles side by side say nothing about which is which. So a screen naming its own
|
||||
// glyph overrides the image, and one that does not falls back to the plugin's.
|
||||
...(screen.icon && plugin.web!.screens.length > 1
|
||||
? { icon: screen.icon }
|
||||
: plugin.icon
|
||||
// Order: this screen's own artwork, then the plugin's, then a glyph. The middle one is skipped
|
||||
// for a multi-screen plugin that gave this screen no art of its own — two identical tiles say
|
||||
// nothing about which is which, and a distinct glyph beats a repeated picture.
|
||||
...(screen.art
|
||||
? { image: screenArtUrl(plugin.appName, screen.segment!) }
|
||||
: plugin.icon && plugin.web!.screens.length === 1
|
||||
? { image: iconUrl(plugin.appName) }
|
||||
: { icon: screen.icon ?? plugin.manifest.icon }),
|
||||
// Artwork defaults to `bare`, a glyph to `badge`; the manifest overrides either way.
|
||||
tile: plugin.manifest.tile ?? (plugin.icon && plugin.web!.screens.length === 1 ? 'bare' : 'badge'),
|
||||
tile:
|
||||
plugin.manifest.tile ??
|
||||
(screen.art || (plugin.icon && plugin.web!.screens.length === 1) ? 'bare' : 'badge'),
|
||||
color: plugin.manifest.color,
|
||||
rootRoute: route,
|
||||
routes: [route],
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { chmodSync, existsSync, mkdirSync, renameSync, rmSync, statSync } from 'node:fs';
|
||||
import { homedir, userInfo } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { PACKAGE_MANAGERS, type DiscoveredPlugin, type DpkgArchitecture, type OsDependency, type PackageManager } from './manifest';
|
||||
import {
|
||||
PACKAGE_MANAGERS,
|
||||
type DiscoveredPlugin,
|
||||
type DpkgArchitecture,
|
||||
type OsDependency,
|
||||
type PackageManager,
|
||||
} from './manifest';
|
||||
|
||||
// Host binaries a plugin needs, and getting them onto the machine.
|
||||
//
|
||||
@@ -375,9 +381,7 @@ export function reportDependencies(plugin: DiscoveredPlugin): DependencyReport {
|
||||
// Only for the ones actually being installed. A binary already present needed its architecture enabled
|
||||
// at some point in the past, and re-reporting that would tell the owner the machine is about to change
|
||||
// when it is not.
|
||||
const architectures = [
|
||||
...new Set(missing.map((d) => d.multiarch).filter((a): a is DpkgArchitecture => !!a)),
|
||||
];
|
||||
const architectures = [...new Set(missing.map((d) => d.multiarch).filter((a): a is DpkgArchitecture => !!a))];
|
||||
|
||||
return { manager, dependencies, missing, packagesToInstall, unresolvable, architectures, canInstall, blockedReason };
|
||||
}
|
||||
@@ -487,7 +491,6 @@ export async function installRelease(dep: DependencyStatus): Promise<{ ok: boole
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Enable a foreign architecture, so a package that only exists for it can be installed.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user