Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion package/capos/capos-webdesktop/htdocs/assets/app.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

20 changes: 19 additions & 1 deletion package/capos/capos-webdesktop/src/api.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -103,9 +103,19 @@ void handleSystemInfo() {
}

void handleSnapFind(const std::map<std::string, std::string>& query) {
const auto name = query.find("name");
if (name != query.end() && !trim(name->second).empty()) {
const auto snapName = trim(name->second);
if (!validSnapName(snapName)) {
sendJson(400, jsonError("invalid snap name", "INVALID_SNAP_NAME"));
return;
}
sendSnapdResponse(snapdRequest("GET", "/v2/find?name=" + percentEncode(snapName)));
return;
}
const auto it = query.find("q");
if (it == query.end() || trim(it->second).empty()) {
sendJson(400, jsonError("q is required", "INVALID_REQUEST"));
sendJson(400, jsonError("q or name is required", "INVALID_REQUEST"));
return;
}
sendSnapdResponse(snapdRequest("GET", "/v2/find?q=" + percentEncode(trim(it->second))));
Expand Down Expand Up @@ -156,6 +166,14 @@ void handleSnapAction(const Session& session, const std::string& snapName, const
const auto form = parseKv(readRequestBody());
if (const auto channel = form.find("channel"); channel != form.end() && !channel->second.empty())
payload += ",\"channel\":\"" + jsonEscape(channel->second) + "\"";
if (const auto confinement = form.find("confinement"); confinement != form.end() && !confinement->second.empty()) {
if (confinement->second == "classic") payload += ",\"classic\":true";
else if (confinement->second == "devmode") payload += ",\"devmode\":true";
else if (confinement->second != "strict") {
sendJson(400, jsonError("invalid snap confinement", "INVALID_CONFINEMENT"));
return;
}
}
}
payload += '}';
sendSnapdResponse(snapdRequest("POST", "/v2/snaps/" + percentEncode(snapName), payload));
Expand Down
10 changes: 8 additions & 2 deletions website/snap/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
Search, Server, Settings, ShieldCheck, Sparkles, Star, Store, UploadCloud, Users, X
} from 'lucide-react';
import { abortPackageUpload, createUpstream, createVersion, finalizePackage, getAdminState, getAppDetails, getCatalog, getStorefront, requestPackageUpload, saveUpstreams, searchApps, uploadPackagePart } from './api';
import { snapInstallCommand } from './install';
import type { AdminState, StoreApp, StorefrontData, Upstream } from './types';
import { useWebDesktopBridge } from './webdesktop';

Expand Down Expand Up @@ -337,8 +338,12 @@ function Storefront() {
};
const actionFor = (app: StoreApp, detail = false): AppAction => {
if (!webdesktop.connected) {
if (detail && app.source === 'upstream' && !app.confinementByArchitecture) {
return { kind: 'install', disabled: true, title: 'Loading architecture-specific install metadata' };
}
const installCommand = snapInstallCommand(app);
return detail
? { kind: 'install', label: copiedInstall === app.name ? 'Copied' : 'Install', title: `Copy: sudo snap install ${app.name}` }
? { kind: 'install', label: copiedInstall === app.name ? 'Copied' : 'Install', title: `Copy: ${installCommand}` }
: { kind: 'get' };
}
if (webdesktop.installed.has(app.name)) return { kind: 'open' };
Expand All @@ -361,8 +366,9 @@ function Storefront() {
openApp(app);
return;
}
if (app.source === 'upstream' && !app.confinementByArchitecture) return;
try {
if (!await copyText(`sudo snap install ${app.name}`)) return;
if (!await copyText(snapInstallCommand(app))) return;
Comment thread
fwerkor marked this conversation as resolved.
setCopiedInstall(app.name);
window.setTimeout(() => setCopiedInstall(current => current === app.name ? null : current), 1600);
} catch {
Expand Down
46 changes: 46 additions & 0 deletions website/snap/src/install.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type { StoreApp } from './types';

export function installConfinement(value?: string) {
const confinement = value?.trim().toLowerCase();
return confinement === 'classic' || confinement === 'devmode' || confinement === 'strict'
? confinement
: undefined;
}

function installFlag(confinement?: string) {
const mode = installConfinement(confinement);
return mode === 'classic' ? '--classic' : mode === 'devmode' ? '--devmode' : '';
}

function commandFor(name: string, confinement?: string) {
const flag = installFlag(confinement);
return `sudo snap install ${name}${flag ? ` ${flag}` : ''}`;
}

const unamePatterns: Record<string, string> = {
amd64: 'x86_64',
arm64: 'aarch64|arm64',
armhf: 'armv7l|armv7*',
i386: 'i386|i486|i586|i686',
powerpc: 'ppc|powerpc',
ppc64el: 'ppc64le',
riscv64: 'riscv64',
s390x: 's390x',
};

export function snapInstallCommand(app: Pick<StoreApp, 'name' | 'confinement' | 'confinementByArchitecture'>) {
const modes = Object.entries(app.confinementByArchitecture || {})
.map(([architecture, value]) => [architecture, installConfinement(value)] as const)
.filter((entry): entry is readonly [string, 'strict' | 'classic' | 'devmode'] => Boolean(entry[1]));
if (!modes.length) return commandFor(app.name, app.confinement);

const uniqueModes = new Set(modes.map(([, mode]) => mode));
if (uniqueModes.size === 1) return commandFor(app.name, modes[0][1]);

const cases = modes.flatMap(([architecture, mode]) => {
const pattern = unamePatterns[architecture];
return pattern ? [`${pattern}) ${commandFor(app.name, mode)} ;;`] : [];
});
if (!cases.length) return commandFor(app.name, app.confinement);
return `case "$(uname -m)" in ${cases.join(' ')} *) echo "Unsupported architecture: $(uname -m)" >&2; exit 1 ;; esac`;
}
1 change: 1 addition & 0 deletions website/snap/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export interface StoreApp {
links?: { label: string; url: string }[];
publisherUsername?: string;
confinement?: 'strict' | 'classic' | 'devmode' | string;
confinementByArchitecture?: Record<string, 'strict' | 'classic' | 'devmode' | string>;
releasedAt?: string;
updated?: string;
}
Expand Down
19 changes: 13 additions & 6 deletions website/snap/worker/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ function publicCacheKey(request: Request, env: Env) {
const key = new URL(source.origin + source.pathname);
if (source.pathname === '/api/storefront' || source.pathname === '/api/catalog') {
key.searchParams.set('version', safeVersion(source.searchParams.get('version'), env));
if (source.pathname === '/api/catalog') key.searchParams.set('schema', 'v3');
if (source.pathname === '/api/catalog') key.searchParams.set('schema', 'v4');
} else if (source.pathname === '/api/search') {
key.searchParams.set('version', safeVersion(source.searchParams.get('version'), env));
key.searchParams.set('q', (source.searchParams.get('q') || '').trim().toLowerCase());
Expand All @@ -75,7 +75,7 @@ function publicCacheResponse(response: Response, status: 'HIT' | 'MISS', browser
}

async function edgeCached(request: Request, env: Env, ctx: ExecutionContext, edgeTtl: number, browserTtl: number, loader: () => Promise<Response>) {
const cache = await caches.open('capos-snap-public-v2');
const cache = await caches.open('capos-snap-public-v4');
const key = publicCacheKey(request, env);
const cached = await cache.match(key);
if (cached) return publicCacheResponse(cached, 'HIT', browserTtl);
Expand Down Expand Up @@ -181,6 +181,7 @@ function canonicalApp(result: Record<string, any>, includeRich = false) {
featured: categories.some((c: any) => c.featured),
version: revision.version || '—',
channel: revision.channel || 'stable',
confinement: revision.confinement || undefined,
architectures: ['amd64', 'arm64'],
webdesktop: 'unknown',
updated: 'Upstream',
Expand All @@ -206,7 +207,7 @@ function canonicalApp(result: Record<string, any>, includeRich = false) {
};
}

const CANONICAL_LIST_FIELDS = 'title,summary,publisher,version,media,categories,channel,revision';
const CANONICAL_LIST_FIELDS = 'title,summary,publisher,version,media,categories,channel,revision,confinement';
Comment thread
fwerkor marked this conversation as resolved.

async function canonicalFind(base: string, query: URLSearchParams, cacheTtl = 120) {
const params = new URLSearchParams(query); params.set('fields',CANONICAL_LIST_FIELDS);
Expand All @@ -223,6 +224,9 @@ async function canonicalInfo(base: string, name: string, cacheTtl = 3600) {
const payload = await response.json<Record<string,any>>();
const channelMap = Array.isArray(payload['channel-map']) ? payload['channel-map'] : [];
const preferred = channelMap.find((entry:any) => entry.channel?.architecture === 'amd64' && entry.channel?.risk === 'stable') || channelMap[0] || {};
const confinementByArchitecture = Object.fromEntries(channelMap
.filter((entry:any) => entry.channel?.name === 'stable' && ['strict','classic','devmode'].includes(entry.confinement))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Match Canonical's full stable channel name

Canonical's v2 info response identifies stable channels with full names such as latest/stable (and risk: "stable"), rather than name: "stable", so this filter normally produces an empty confinement map. Because {} passes the UI's metadata-loaded check, copied commands then fall back to the amd64 app.confinement and can still use the wrong flag on another architecture. Fresh evidence against the resolved thread is that the new test fixture shortens these names to stable, masking the production response shape; select the default track's stable entry and reject an empty map instead.

Useful? React with 👍 / 👎.

.map((entry:any) => [entry.channel.architecture, entry.confinement]));
const app = canonicalApp({
'snap-id': payload['snap-id'],
name: payload.name || name,
Expand All @@ -234,8 +238,11 @@ async function canonicalInfo(base: string, name: string, cacheTtl = 3600) {
releasedAt: preferred.channel?.['released-at'] || preferred['created-at'],
},
}, true);
app.architectures = [...new Set(channelMap.map((entry:any) => entry.channel?.architecture).filter(Boolean))] as string[];
return app;
return {
...app,
architectures: [...new Set(channelMap.map((entry:any) => entry.channel?.architecture).filter(Boolean))] as string[],
confinementByArchitecture,
};
}

const CANONICAL_CATALOG_CATEGORIES = [
Expand All @@ -244,7 +251,7 @@ const CANONICAL_CATALOG_CATEGORIES = [
'news-and-weather', 'personalisation', 'photo-and-video', 'productivity', 'science',
'security', 'server-and-cloud', 'social', 'utilities'
];
const CATALOG_SNAPSHOT_KEY = '_cache/canonical-catalog-v3.json';
const CATALOG_SNAPSHOT_KEY = '_cache/canonical-catalog-v4.json';
Comment thread
fwerkor marked this conversation as resolved.
const CATALOG_SNAPSHOT_TTL = 6 * 3600;
let catalogRefreshPromise: Promise<void> | null = null;

Expand Down
63 changes: 57 additions & 6 deletions website/snap/worker/store-proxy.test.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import assert from 'node:assert/strict';
import worker from './index.ts';
import { snapInstallCommand } from '../src/install.ts';

const upstreamRows = [{
id: 1,
Expand Down Expand Up @@ -45,10 +46,19 @@ const ctx = { waitUntil(promise) { pending.push(Promise.resolve(promise)); } };
const realFetch = globalThis.fetch;
const realCaches = globalThis.caches;
const upstreamRequests = [];
const cacheNames = [];
const cacheLookups = [];

globalThis.caches = {
async open() {
return { match: async () => undefined, put: async () => undefined };
async open(name) {
cacheNames.push(name);
return {
match: async key => {
cacheLookups.push(key instanceof Request ? key.url : String(key));
return undefined;
},
put: async () => undefined,
};
},
};

Expand Down Expand Up @@ -109,10 +119,35 @@ globalThis.fetch = async (input, init) => {
},
'store-url': 'https://snapcraft.io/nextcloud',
},
'channel-map': [{
channel: { architecture: 'amd64', name: 'stable', risk: 'stable', 'released-at': '2026-08-14T08:20:00+00:00' },
confinement: 'strict',
version: '31.0.8',
'channel-map': [
{
channel: { architecture: 'amd64', name: 'stable', risk: 'stable', 'released-at': '2026-08-14T08:20:00+00:00' },
confinement: 'strict',
version: '31.0.8',
},
{
channel: { architecture: 'arm64', name: 'stable', risk: 'stable', 'released-at': '2026-08-14T08:20:00+00:00' },
confinement: 'classic',
version: '31.0.8',
},
],
}), { headers: { 'content-type': 'application/json' } });
}

if (url.pathname === '/v2/snaps/find') {
const fields = url.searchParams.get('fields') || '';
assert.match(fields, /confinement/);
return new Response(JSON.stringify({
results: [{
name: 'code',
'snap-id': 'code-id',
snap: {
title: 'Code',
summary: 'Code editing. Redefined.',
publisher: { 'display-name': 'Microsoft', username: 'vscode', validation: 'verified' },
categories: [{ name: 'development' }],
},
revision: { channel: 'stable', confinement: 'classic', revision: 258, version: '110a328e' },
}],
}), { headers: { 'content-type': 'application/json' } });
}
Expand Down Expand Up @@ -192,11 +227,27 @@ try {
assert.equal(detail.links.find(link => link.label === 'Video')?.url, 'https://notyoutube.com/watch?v=lookalike');
assert.equal(detail.license, 'AGPL-3.0+');
assert.equal(detail.confinement, 'strict');
assert.deepEqual(detail.confinementByArchitecture, { amd64: 'strict', arm64: 'classic' });
const installCommand = snapInstallCommand(detail);
assert.match(installCommand, /x86_64\) sudo snap install nextcloud ;;/);
assert.match(installCommand, /aarch64\|arm64\) sudo snap install nextcloud --classic ;;/);
assert.doesNotMatch(installCommand, /^sudo snap install nextcloud/);
assert.equal(detail.releasedAt, '2026-08-14T08:20:00+00:00');
assert.equal(detail.links.find(link => link.label === 'Report a bug')?.url, 'https://github.com/nextcloud-snap/nextcloud-snap/issues');
assert.equal(detail.links.find(link => link.label === 'Contact')?.url, 'mailto:support@nextcloud.com');
assert.equal(detail.storeUrl, 'https://snapcraft.io/nextcloud');

const searchResponse = await worker.fetch(new Request('https://snap.capos.top/api/search?version=rolling&q=code'), env, ctx);
assert.equal(searchResponse.status, 200);
const search = await searchResponse.json();
assert.equal(search.apps[0].name, 'code');
assert.equal(search.apps[0].confinement, 'classic');

const catalogResponse = await worker.fetch(new Request('https://snap.capos.top/api/catalog?version=rolling'), env, ctx);
assert.equal(catalogResponse.status, 200);
assert.ok(cacheNames.every(name => name === 'capos-snap-public-v4'));
assert.ok(cacheLookups.some(value => new URL(value).pathname === '/api/catalog' && new URL(value).searchParams.get('schema') === 'v4'));

localRows = [{
id: 'local-special-name',
name: 'local+tool.test',
Expand Down
Loading