From f3a733b4f2970b1c86dee4774cb44367ce999c68 Mon Sep 17 00:00:00 2001 From: Cao Yuhang Date: Thu, 20 Aug 2026 07:26:35 +0000 Subject: [PATCH 1/3] store: honor snap confinement on install --- .../capos-webdesktop/htdocs/assets/app.js | 5 ++-- package/capos/capos-webdesktop/src/api.cpp | 8 +++++++ website/snap/src/App.tsx | 8 ++++--- website/snap/src/install.ts | 14 +++++++++++ website/snap/src/webdesktop.ts | 6 ++--- website/snap/worker/index.ts | 5 ++-- website/snap/worker/store-proxy.test.mjs | 24 +++++++++++++++++++ 7 files changed, 60 insertions(+), 10 deletions(-) create mode 100644 website/snap/src/install.ts diff --git a/package/capos/capos-webdesktop/htdocs/assets/app.js b/package/capos/capos-webdesktop/htdocs/assets/app.js index a1160ec1eb..fb7a61189d 100644 --- a/package/capos/capos-webdesktop/htdocs/assets/app.js +++ b/package/capos/capos-webdesktop/htdocs/assets/app.js @@ -39,8 +39,9 @@ function storeFrame(){return state.selected==="@store"?$("desktopFrame"):null;} function postStore(message){const frame=storeFrame();if(frame?.contentWindow)frame.contentWindow.postMessage(message,STORE_ORIGIN);} function postStoreState(){postStore({type:"capos-webdesktop:state",version:1,installed:state.installed.map(s=>s.name),installing:Object.fromEntries(state.installing),canInstall:state.session?.is_sudo===true});} function validSnapName(name){return typeof name==="string"&&name.length<=64&&/^(?=.*[a-z])[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(name);} -async function installFromStore(name,channel){if(!state.session?.is_sudo)throw new Error("安装 Snap 需要 sudo 权限");if(state.installed.some(s=>s.name===name)){postStoreState();return}if(state.installing.has(name))return;state.installing.set(name,1);postStore({type:"capos-webdesktop:progress",version:1,name,progress:1});try{const body=new URLSearchParams({channel:channel||"stable"}).toString();await snapAction(name,"install",`安装 ${name}`,body,progress=>{state.installing.set(name,progress);postStore({type:"capos-webdesktop:progress",version:1,name,progress});});state.installing.delete(name);postStoreState();}catch(error){state.installing.delete(name);postStoreState();const message=error instanceof Error?error.message:String(error);postStore({type:"capos-webdesktop:error",version:1,name,message});toast(message,true);}} -function onStoreMessage(event){const frame=storeFrame();if(!frame||event.origin!==STORE_ORIGIN||event.source!==frame.contentWindow||!event.data||typeof event.data!=="object")return;const message=event.data;if(message.type==="capos-store:hello"){postStoreState();return}if(message.type==="capos-store:install"){if(!validSnapName(message.name)){postStore({type:"capos-webdesktop:error",version:1,message:"无效的 Snap 名称"});return}void installFromStore(message.name,typeof message.channel==="string"?message.channel:"stable");return}if(message.type==="capos-store:open"){if(!validSnapName(message.name)||!state.installed.some(s=>s.name===message.name)){postStore({type:"capos-webdesktop:error",version:1,name:message.name,message:"该 Snap 尚未安装"});return}void openSnap(message.name);}} +function installConfinement(value){return value==="classic"||value==="devmode"||value==="strict"?value:"";} +async function installFromStore(name,channel,confinement){if(!state.session?.is_sudo)throw new Error("安装 Snap 需要 sudo 权限");if(state.installed.some(s=>s.name===name)){postStoreState();return}if(state.installing.has(name))return;state.installing.set(name,1);postStore({type:"capos-webdesktop:progress",version:1,name,progress:1});try{const params=new URLSearchParams({channel:channel||"stable"});const mode=installConfinement(confinement);if(mode)params.set("confinement",mode);await snapAction(name,"install",`安装 ${name}`,params.toString(),progress=>{state.installing.set(name,progress);postStore({type:"capos-webdesktop:progress",version:1,name,progress});});state.installing.delete(name);postStoreState();}catch(error){state.installing.delete(name);postStoreState();const message=error instanceof Error?error.message:String(error);postStore({type:"capos-webdesktop:error",version:1,name,message});toast(message,true);}} +function onStoreMessage(event){const frame=storeFrame();if(!frame||event.origin!==STORE_ORIGIN||event.source!==frame.contentWindow||!event.data||typeof event.data!=="object")return;const message=event.data;if(message.type==="capos-store:hello"){postStoreState();return}if(message.type==="capos-store:install"){if(!validSnapName(message.name)){postStore({type:"capos-webdesktop:error",version:1,message:"无效的 Snap 名称"});return}void installFromStore(message.name,typeof message.channel==="string"?message.channel:"stable",typeof message.confinement==="string"?message.confinement:"");return}if(message.type==="capos-store:open"){if(!validSnapName(message.name)||!state.installed.some(s=>s.name===message.name)){postStore({type:"capos-webdesktop:error",version:1,name:message.name,message:"该 Snap 尚未安装"});return}void openSnap(message.name);}} async function onInstalledClick(e){const b=e.target.closest("button[data-act]");if(!b)return;const {act,name}=b.dataset;try{if(act==="open")return openSnap(name);if(act==="detect"){await endpointFor(name,true);renderInstalled();return}if(act==="start"||act==="stop")return serviceAction(name,act);if(act==="refresh")return snapAction(name,"refresh",`更新 ${name}`);if(act==="remove"){if(await ask("卸载 Snap",`确认卸载 ${name}?`))await snapAction(name,"remove",`卸载 ${name}`)}}catch(e){toast(e.message,true)}} diff --git a/package/capos/capos-webdesktop/src/api.cpp b/package/capos/capos-webdesktop/src/api.cpp index 0daa0a6404..5f5f7b0fd5 100644 --- a/package/capos/capos-webdesktop/src/api.cpp +++ b/package/capos/capos-webdesktop/src/api.cpp @@ -156,6 +156,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)); diff --git a/website/snap/src/App.tsx b/website/snap/src/App.tsx index b253e28744..18083bdda0 100644 --- a/website/snap/src/App.tsx +++ b/website/snap/src/App.tsx @@ -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'; @@ -292,8 +293,9 @@ function Storefront() { const closeDetail = () => { setSelected(null); setDetailLoadingName(null); }; const actionFor = (app: StoreApp, detail = false): AppAction => { if (!webdesktop.connected) { + 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' }; @@ -309,7 +311,7 @@ function Storefront() { const performAction = async (app: StoreApp, detail = false) => { if (webdesktop.connected) { if (webdesktop.installed.has(app.name)) webdesktop.open(app.name); - else if (webdesktop.canInstall && !Object.prototype.hasOwnProperty.call(webdesktop.installing, app.name)) webdesktop.install(app.name, app.channel || 'stable'); + else if (webdesktop.canInstall && !Object.prototype.hasOwnProperty.call(webdesktop.installing, app.name)) webdesktop.install(app.name, app.channel || 'stable', app.confinement); return; } if (!detail) { @@ -317,7 +319,7 @@ function Storefront() { return; } try { - if (!await copyText(`sudo snap install ${app.name}`)) return; + if (!await copyText(snapInstallCommand(app))) return; setCopiedInstall(app.name); window.setTimeout(() => setCopiedInstall(current => current === app.name ? null : current), 1600); } catch { diff --git a/website/snap/src/install.ts b/website/snap/src/install.ts new file mode 100644 index 0000000000..90c053dfdd --- /dev/null +++ b/website/snap/src/install.ts @@ -0,0 +1,14 @@ +import type { StoreApp } from './types'; + +export function installConfinement(value?: string) { + const confinement = value?.trim().toLowerCase(); + return confinement === 'classic' || confinement === 'devmode' || confinement === 'strict' + ? confinement + : undefined; +} + +export function snapInstallCommand(app: Pick) { + const confinement = installConfinement(app.confinement); + const flag = confinement === 'classic' ? '--classic' : confinement === 'devmode' ? '--devmode' : ''; + return `sudo snap install ${app.name}${flag ? ` ${flag}` : ''}`; +} diff --git a/website/snap/src/webdesktop.ts b/website/snap/src/webdesktop.ts index 63b930e730..1d861c1275 100644 --- a/website/snap/src/webdesktop.ts +++ b/website/snap/src/webdesktop.ts @@ -16,7 +16,7 @@ export interface WebDesktopBridge { installed: ReadonlySet; installing: Readonly>; error: string | null; - install: (name: string, channel?: string) => void; + install: (name: string, channel?: string, confinement?: string) => void; open: (name: string) => void; } @@ -87,9 +87,9 @@ export function useWebDesktopBridge(): WebDesktopBridge { }; }, []); - const install = useCallback((name: string, channel = 'stable') => { + const install = useCallback((name: string, channel = 'stable', confinement?: string) => { setError(null); - window.parent.postMessage({ type: 'capos-store:install', version: 1, name, channel }, '*'); + window.parent.postMessage({ type: 'capos-store:install', version: 1, name, channel, confinement }, '*'); }, []); const open = useCallback((name: string) => { diff --git a/website/snap/worker/index.ts b/website/snap/worker/index.ts index 6cafa06195..e869db164e 100644 --- a/website/snap/worker/index.ts +++ b/website/snap/worker/index.ts @@ -180,6 +180,7 @@ function canonicalApp(result: Record, 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', @@ -205,7 +206,7 @@ function canonicalApp(result: Record, 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'; async function canonicalFind(base: string, query: URLSearchParams, cacheTtl = 120) { const params = new URLSearchParams(query); params.set('fields',CANONICAL_LIST_FIELDS); @@ -243,7 +244,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'; const CATALOG_SNAPSHOT_TTL = 6 * 3600; let catalogRefreshPromise: Promise | null = null; diff --git a/website/snap/worker/store-proxy.test.mjs b/website/snap/worker/store-proxy.test.mjs index 4f68c176d9..2779003342 100644 --- a/website/snap/worker/store-proxy.test.mjs +++ b/website/snap/worker/store-proxy.test.mjs @@ -115,6 +115,24 @@ globalThis.fetch = async (input, init) => { }), { 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' } }); + } + if (url.pathname === '/v2/redirect-test') { return new Response(null, { status: 302, @@ -195,6 +213,12 @@ try { 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'); + assert.equal(new URL(upstreamRequests[0].url).origin, 'https://api.snapcraft.io'); assert.equal(upstreamRequests[0].headers.get('Snap-Device-Series'), '16'); console.log('snap store proxy tests: PASS'); From 11d006e56128b215a18c7e4643d63f4960f0a400 Mon Sep 17 00:00:00 2001 From: Cao Yuhang Date: Thu, 20 Aug 2026 07:43:51 +0000 Subject: [PATCH 2/3] store: resolve install mode on target --- .../capos-webdesktop/htdocs/assets/app.js | 5 +++-- package/capos/capos-webdesktop/src/api.cpp | 12 +++++++++++- website/snap/src/App.tsx | 2 +- website/snap/src/webdesktop.ts | 6 +++--- website/snap/worker/index.ts | 4 ++-- website/snap/worker/store-proxy.test.mjs | 18 ++++++++++++++++-- 6 files changed, 36 insertions(+), 11 deletions(-) diff --git a/package/capos/capos-webdesktop/htdocs/assets/app.js b/package/capos/capos-webdesktop/htdocs/assets/app.js index fb7a61189d..6793809877 100644 --- a/package/capos/capos-webdesktop/htdocs/assets/app.js +++ b/package/capos/capos-webdesktop/htdocs/assets/app.js @@ -40,8 +40,9 @@ function postStore(message){const frame=storeFrame();if(frame?.contentWindow)fra function postStoreState(){postStore({type:"capos-webdesktop:state",version:1,installed:state.installed.map(s=>s.name),installing:Object.fromEntries(state.installing),canInstall:state.session?.is_sudo===true});} function validSnapName(name){return typeof name==="string"&&name.length<=64&&/^(?=.*[a-z])[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(name);} function installConfinement(value){return value==="classic"||value==="devmode"||value==="strict"?value:"";} -async function installFromStore(name,channel,confinement){if(!state.session?.is_sudo)throw new Error("安装 Snap 需要 sudo 权限");if(state.installed.some(s=>s.name===name)){postStoreState();return}if(state.installing.has(name))return;state.installing.set(name,1);postStore({type:"capos-webdesktop:progress",version:1,name,progress:1});try{const params=new URLSearchParams({channel:channel||"stable"});const mode=installConfinement(confinement);if(mode)params.set("confinement",mode);await snapAction(name,"install",`安装 ${name}`,params.toString(),progress=>{state.installing.set(name,progress);postStore({type:"capos-webdesktop:progress",version:1,name,progress});});state.installing.delete(name);postStoreState();}catch(error){state.installing.delete(name);postStoreState();const message=error instanceof Error?error.message:String(error);postStore({type:"capos-webdesktop:error",version:1,name,message});toast(message,true);}} -function onStoreMessage(event){const frame=storeFrame();if(!frame||event.origin!==STORE_ORIGIN||event.source!==frame.contentWindow||!event.data||typeof event.data!=="object")return;const message=event.data;if(message.type==="capos-store:hello"){postStoreState();return}if(message.type==="capos-store:install"){if(!validSnapName(message.name)){postStore({type:"capos-webdesktop:error",version:1,message:"无效的 Snap 名称"});return}void installFromStore(message.name,typeof message.channel==="string"?message.channel:"stable",typeof message.confinement==="string"?message.confinement:"");return}if(message.type==="capos-store:open"){if(!validSnapName(message.name)||!state.installed.some(s=>s.name===message.name)){postStore({type:"capos-webdesktop:error",version:1,name:message.name,message:"该 Snap 尚未安装"});return}void openSnap(message.name);}} +async function storeConfinement(name,channel){const data=await request(`/snapd/find?name=${encodeURIComponent(name)}`);const snaps=Array.isArray(snapResult(data))?snapResult(data):[];const snap=snaps.find(item=>item?.name===name);if(!snap)throw new Error(`无法解析 ${name} 的安装元数据`);const requested=channel||"stable";const channels=snap.channels&&typeof snap.channels==="object"?Object.entries(snap.channels):[];const match=channels.find(([key,info])=>key===requested||key.endsWith(`/${requested}`)||info?.channel===requested);const mode=installConfinement(match?.[1]?.confinement||snap.confinement);if(!mode)throw new Error(`无法确定 ${name} 的 confinement`);return mode;} +async function installFromStore(name,channel){if(!state.session?.is_sudo)throw new Error("安装 Snap 需要 sudo 权限");if(state.installed.some(s=>s.name===name)){postStoreState();return}if(state.installing.has(name))return;state.installing.set(name,1);postStore({type:"capos-webdesktop:progress",version:1,name,progress:1});try{const params=new URLSearchParams({channel:channel||"stable"});const mode=await storeConfinement(name,channel);if(mode!=="strict")params.set("confinement",mode);await snapAction(name,"install",`安装 ${name}`,params.toString(),progress=>{state.installing.set(name,progress);postStore({type:"capos-webdesktop:progress",version:1,name,progress});});state.installing.delete(name);postStoreState();}catch(error){state.installing.delete(name);postStoreState();const message=error instanceof Error?error.message:String(error);postStore({type:"capos-webdesktop:error",version:1,name,message});toast(message,true);}} +function onStoreMessage(event){const frame=storeFrame();if(!frame||event.origin!==STORE_ORIGIN||event.source!==frame.contentWindow||!event.data||typeof event.data!=="object")return;const message=event.data;if(message.type==="capos-store:hello"){postStoreState();return}if(message.type==="capos-store:install"){if(!validSnapName(message.name)){postStore({type:"capos-webdesktop:error",version:1,message:"无效的 Snap 名称"});return}void installFromStore(message.name,typeof message.channel==="string"?message.channel:"stable");return}if(message.type==="capos-store:open"){if(!validSnapName(message.name)||!state.installed.some(s=>s.name===message.name)){postStore({type:"capos-webdesktop:error",version:1,name:message.name,message:"该 Snap 尚未安装"});return}void openSnap(message.name);}} async function onInstalledClick(e){const b=e.target.closest("button[data-act]");if(!b)return;const {act,name}=b.dataset;try{if(act==="open")return openSnap(name);if(act==="detect"){await endpointFor(name,true);renderInstalled();return}if(act==="start"||act==="stop")return serviceAction(name,act);if(act==="refresh")return snapAction(name,"refresh",`更新 ${name}`);if(act==="remove"){if(await ask("卸载 Snap",`确认卸载 ${name}?`))await snapAction(name,"remove",`卸载 ${name}`)}}catch(e){toast(e.message,true)}} diff --git a/package/capos/capos-webdesktop/src/api.cpp b/package/capos/capos-webdesktop/src/api.cpp index 5f5f7b0fd5..91f567837a 100644 --- a/package/capos/capos-webdesktop/src/api.cpp +++ b/package/capos/capos-webdesktop/src/api.cpp @@ -103,9 +103,19 @@ void handleSystemInfo() { } void handleSnapFind(const std::map& 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)))); diff --git a/website/snap/src/App.tsx b/website/snap/src/App.tsx index 18083bdda0..bfa6b567f4 100644 --- a/website/snap/src/App.tsx +++ b/website/snap/src/App.tsx @@ -311,7 +311,7 @@ function Storefront() { const performAction = async (app: StoreApp, detail = false) => { if (webdesktop.connected) { if (webdesktop.installed.has(app.name)) webdesktop.open(app.name); - else if (webdesktop.canInstall && !Object.prototype.hasOwnProperty.call(webdesktop.installing, app.name)) webdesktop.install(app.name, app.channel || 'stable', app.confinement); + else if (webdesktop.canInstall && !Object.prototype.hasOwnProperty.call(webdesktop.installing, app.name)) webdesktop.install(app.name, app.channel || 'stable'); return; } if (!detail) { diff --git a/website/snap/src/webdesktop.ts b/website/snap/src/webdesktop.ts index 1d861c1275..63b930e730 100644 --- a/website/snap/src/webdesktop.ts +++ b/website/snap/src/webdesktop.ts @@ -16,7 +16,7 @@ export interface WebDesktopBridge { installed: ReadonlySet; installing: Readonly>; error: string | null; - install: (name: string, channel?: string, confinement?: string) => void; + install: (name: string, channel?: string) => void; open: (name: string) => void; } @@ -87,9 +87,9 @@ export function useWebDesktopBridge(): WebDesktopBridge { }; }, []); - const install = useCallback((name: string, channel = 'stable', confinement?: string) => { + const install = useCallback((name: string, channel = 'stable') => { setError(null); - window.parent.postMessage({ type: 'capos-store:install', version: 1, name, channel, confinement }, '*'); + window.parent.postMessage({ type: 'capos-store:install', version: 1, name, channel }, '*'); }, []); const open = useCallback((name: string) => { diff --git a/website/snap/worker/index.ts b/website/snap/worker/index.ts index e869db164e..665aa69ba1 100644 --- a/website/snap/worker/index.ts +++ b/website/snap/worker/index.ts @@ -55,7 +55,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()); @@ -74,7 +74,7 @@ function publicCacheResponse(response: Response, status: 'HIT' | 'MISS', browser } async function edgeCached(request: Request, env: Env, ctx: ExecutionContext, edgeTtl: number, browserTtl: number, loader: () => Promise) { - const cache = await caches.open('capos-snap-public-v2'); + const cache = await caches.open('capos-snap-public-v3'); const key = publicCacheKey(request, env); const cached = await cache.match(key); if (cached) return publicCacheResponse(cached, 'HIT', browserTtl); diff --git a/website/snap/worker/store-proxy.test.mjs b/website/snap/worker/store-proxy.test.mjs index 2779003342..acd9896f3a 100644 --- a/website/snap/worker/store-proxy.test.mjs +++ b/website/snap/worker/store-proxy.test.mjs @@ -43,10 +43,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, + }; }, }; @@ -219,6 +228,11 @@ try { 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-v3')); + assert.ok(cacheLookups.some(value => new URL(value).pathname === '/api/catalog' && new URL(value).searchParams.get('schema') === 'v4')); + assert.equal(new URL(upstreamRequests[0].url).origin, 'https://api.snapcraft.io'); assert.equal(upstreamRequests[0].headers.get('Snap-Device-Series'), '16'); console.log('snap store proxy tests: PASS'); From 3775c1b7ae90c6a7a50422a4df096e090ef73f6a Mon Sep 17 00:00:00 2001 From: Cao Yuhang Date: Thu, 20 Aug 2026 07:57:12 +0000 Subject: [PATCH 3/3] store: make copied install commands architecture aware --- website/snap/src/App.tsx | 4 +++ website/snap/src/install.ts | 40 +++++++++++++++++++++--- website/snap/src/types.ts | 1 + website/snap/worker/index.ts | 12 +++++-- website/snap/worker/store-proxy.test.mjs | 25 +++++++++++---- 5 files changed, 69 insertions(+), 13 deletions(-) diff --git a/website/snap/src/App.tsx b/website/snap/src/App.tsx index f24bba014c..8d26f0a5d6 100644 --- a/website/snap/src/App.tsx +++ b/website/snap/src/App.tsx @@ -338,6 +338,9 @@ 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: ${installCommand}` } @@ -363,6 +366,7 @@ function Storefront() { openApp(app); return; } + if (app.source === 'upstream' && !app.confinementByArchitecture) return; try { if (!await copyText(snapInstallCommand(app))) return; setCopiedInstall(app.name); diff --git a/website/snap/src/install.ts b/website/snap/src/install.ts index 90c053dfdd..db26f4cfc7 100644 --- a/website/snap/src/install.ts +++ b/website/snap/src/install.ts @@ -7,8 +7,40 @@ export function installConfinement(value?: string) { : undefined; } -export function snapInstallCommand(app: Pick) { - const confinement = installConfinement(app.confinement); - const flag = confinement === 'classic' ? '--classic' : confinement === 'devmode' ? '--devmode' : ''; - return `sudo snap install ${app.name}${flag ? ` ${flag}` : ''}`; +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 = { + 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) { + 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`; } diff --git a/website/snap/src/types.ts b/website/snap/src/types.ts index 10e39719f6..1006cccfd5 100644 --- a/website/snap/src/types.ts +++ b/website/snap/src/types.ts @@ -30,6 +30,7 @@ export interface StoreApp { links?: { label: string; url: string }[]; publisherUsername?: string; confinement?: 'strict' | 'classic' | 'devmode' | string; + confinementByArchitecture?: Record; releasedAt?: string; updated?: string; } diff --git a/website/snap/worker/index.ts b/website/snap/worker/index.ts index 039e03c277..7a7ebf168b 100644 --- a/website/snap/worker/index.ts +++ b/website/snap/worker/index.ts @@ -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) { - const cache = await caches.open('capos-snap-public-v3'); + 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); @@ -224,6 +224,9 @@ async function canonicalInfo(base: string, name: string, cacheTtl = 3600) { const payload = await response.json>(); 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)) + .map((entry:any) => [entry.channel.architecture, entry.confinement])); const app = canonicalApp({ 'snap-id': payload['snap-id'], name: payload.name || name, @@ -235,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 = [ diff --git a/website/snap/worker/store-proxy.test.mjs b/website/snap/worker/store-proxy.test.mjs index 1749f5d5f9..62a5b00aa2 100644 --- a/website/snap/worker/store-proxy.test.mjs +++ b/website/snap/worker/store-proxy.test.mjs @@ -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, @@ -118,11 +119,18 @@ 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' } }); } @@ -219,6 +227,11 @@ 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'); @@ -232,7 +245,7 @@ try { 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-v3')); + 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 = [{