mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-06 12:33:16 -05:00
feat(ui): install an add-on from a file
Add an Install from file button to the Add-ons page. The file goes to the owner, then the install dialog opens with the host access step, the file sha256 and the notice that nothing signs a local file. An upgrade does not turn the add-on on, an older version needs a tick, and the same version is refused. The link that local-ai addons install prints opens the dialog for the staged file. The list, the details and the host access page show that the add-on came from an unsigned local file. Assisted-by: Claude:claude-sonnet-5-5 eslint playwright
This commit is contained in:
23 files changed
+892
-21
No files matched your search
@@ -0,0 +1,195 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { ADMIN, app, installedAddon, sessionFor, mockAddons } from './addons-fixtures.js'
|
||||
|
||||
// Install from a file: the button on the Add-ons page, the dialog with the
|
||||
// unsigned notice and the host access step, the upgrade and the downgrade
|
||||
// rules, and the link that `local-ai addons install` prints. The owner decides
|
||||
// every answer, so these specs drive the page against a stand-in for the file
|
||||
// routes and the host-grants routes and record what the page sent.
|
||||
|
||||
const shots = process.env.UI_SHOTS_DIR
|
||||
|
||||
async function shoot(page, name) {
|
||||
if (!shots) return
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' })
|
||||
await page.waitForTimeout(400)
|
||||
await page.screenshot({ path: `${shots}/${name}.png` })
|
||||
}
|
||||
|
||||
const ID = 'c'.repeat(32)
|
||||
const SHA = 'd'.repeat(64)
|
||||
|
||||
const op = (name, title, text, risk, over = {}) => ({ name, title, text, risk, status: 'not_allowed', ...over })
|
||||
|
||||
function fileView(over = {}) {
|
||||
return {
|
||||
fileId: ID, name: 'memory-0.2.0-linux-amd64.zip', size: 5364318, sha256: SHA, addonId: 'memory', title: 'Short memory',
|
||||
version: '0.2.0', platform: 'linux/amd64', api: { min: 2, max: 2 }, required: ['workspace.materialize'], optional: ['egress.check'],
|
||||
installed: false, installedVersion: '', relation: 'new', source: 'local-file', origin: 'local file, unsigned', ...over,
|
||||
}
|
||||
}
|
||||
|
||||
const PREVIEW = {
|
||||
addonId: 'memory', title: 'Short memory', digest: 'sha256:' + SHA, version: '0.2.0', installed: false, official: false, revision: 4, nonce: 'nonce-f',
|
||||
requested: { required: ['workspace.materialize'], optional: ['egress.check'] }, canInstall: true, blockedRequired: [], added: [], blanket: { applies: false },
|
||||
ops: [
|
||||
op('workspace.materialize', 'Copy files in', 'Copy files of a chat into the working area of the add-on.', 'changes data'),
|
||||
op('egress.check', 'Ask about the network', 'Ask the host whether a network connection is allowed.', 'network'),
|
||||
],
|
||||
}
|
||||
|
||||
async function open(page, { file = fileView(), installed = [], uploadStatus = 200, uploadBody } = {}) {
|
||||
await page.route('**/api/auth/status', route => route.fulfill({ json: sessionFor(ADMIN, { addons: true }) }))
|
||||
const state = await mockAddons(page, { apps: [], installed })
|
||||
const seen = { uploads: [], described: [], removed: [], preview: [], install: [], dispatch: state.log.dispatch }
|
||||
await page.route(/\/api\/addons\/files(\/[0-9a-f]+)?$/, route => {
|
||||
const req = route.request()
|
||||
const json = (payload, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(payload) })
|
||||
if (req.method() === 'POST') {
|
||||
seen.uploads.push({ contentType: req.headers()['content-type'], body: req.postDataBuffer()?.toString('latin1') || '' })
|
||||
return json(uploadBody || file, uploadStatus)
|
||||
}
|
||||
const id = req.url().split('/').pop()
|
||||
if (req.method() === 'DELETE') { seen.removed.push(id); return route.fulfill({ status: 204 }) }
|
||||
seen.described.push(id)
|
||||
return id === ID ? json(file) : json({ error: { type: 'not_found', message: 'No such file' } }, 404)
|
||||
})
|
||||
await page.route(/\/api\/addons\/host-grants(\/.*)?(\?.*)?$/, route => {
|
||||
const req = route.request()
|
||||
const path = new URL(req.url()).pathname.replace('/api/addons/host-grants', '') || '/'
|
||||
const body = req.postData() ? JSON.parse(req.postData()) : null
|
||||
const json = (payload, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(payload) })
|
||||
if (path === '/') return json({ revision: 4, locked: false, blanket: { mode: 'off', includeOptional: false, setBy: 'ui', revision: 0 }, floor: { source: 'file', addons: [] }, deny: { file: {}, ui: {} }, addons: [] })
|
||||
if (path === '/preview') { seen.preview.push(body); return json({ ...PREVIEW, installed: file.installed }) }
|
||||
if (path === '/install') {
|
||||
seen.install.push({ body, confirm: req.headers()['x-localai-confirm'] })
|
||||
if (!file.installed) {
|
||||
state.installed.push(installedAddon({ id: 'memory', title: 'Short memory', version: file.version, sourceId: 'local-file', verification: 'sha256', enabled: false, state: 'stopped', generation: 1 }))
|
||||
state.apps.push(app({ id: 'memory', name: 'memory', title: 'Short memory', version: file.version, generation: 1, enabled: false, state: 'stopped', mainPage: undefined, hasUI: false }))
|
||||
}
|
||||
return json({ payload: { OperationID: body.operationId, AddonID: body.addonId, Phase: 'completed', Generation: 1 } })
|
||||
}
|
||||
return json({ error: { type: 'addon_error', message: 'unknown' } }, 404)
|
||||
})
|
||||
return { state, seen }
|
||||
}
|
||||
|
||||
test.describe('Install from a file', () => {
|
||||
test('uploads the zip, shows the unsigned notice, the digest and the host operations, and sends the approval', async ({ page }) => {
|
||||
const { seen } = await open(page)
|
||||
await page.goto('/app/addons')
|
||||
await page.getByTestId('install-file-input').setInputFiles({ name: 'memory-0.2.0-linux-amd64.zip', mimeType: 'application/zip', buffer: Buffer.from('PK-fake-zip') })
|
||||
const dialog = page.getByTestId('install-dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
expect(seen.uploads).toHaveLength(1)
|
||||
expect(seen.uploads[0].contentType).toContain('multipart/form-data')
|
||||
expect(seen.uploads[0].body).toContain('name="file"; filename="memory-0.2.0-linux-amd64.zip"')
|
||||
await expect(dialog.getByTestId('install-unsigned')).toContainText('Local file, unsigned')
|
||||
await expect(dialog.getByTestId('install-unsigned')).toContainText('Nothing signs this file')
|
||||
const facts = dialog.getByTestId('install-file-facts')
|
||||
await expect(facts.getByTestId('install-file-sha')).toHaveText(SHA)
|
||||
await expect(facts).toContainText('memory 0.2.0')
|
||||
await expect(facts).toContainText('linux/amd64')
|
||||
// The same step as for a library entry; a file is never official.
|
||||
const step = dialog.getByTestId('install-host-access')
|
||||
await expect(step).toBeVisible()
|
||||
await expect(step.locator('[data-op="workspace.materialize"]').getByRole('switch')).toBeDisabled()
|
||||
await expect(step.getByTestId('hostaccess-not-official')).toHaveCount(0)
|
||||
expect(seen.preview).toEqual([{ sourceId: 'local-file', addonId: 'memory', version: ID }])
|
||||
await step.locator('[data-op="egress.check"]').getByRole('switch').click()
|
||||
await shoot(page, 'install-from-file-dialog')
|
||||
await dialog.getByTestId('install-confirm').click()
|
||||
await expect(dialog.getByTestId('flow-steps')).toBeVisible()
|
||||
await expect.poll(() => seen.install.length).toBe(1)
|
||||
const { body, confirm } = seen.install[0]
|
||||
expect(confirm).toBe('memory@sha256:' + SHA)
|
||||
expect(body).toMatchObject({ action: 'install', sourceId: 'local-file', addonId: 'memory', version: ID })
|
||||
expect(body.downgrade).toBeUndefined()
|
||||
expect(body.approve).toMatchObject({ digest: 'sha256:' + SHA, revision: 4, nonce: 'nonce-f' })
|
||||
expect(body.approve.ops.sort()).toEqual(['egress.check', 'workspace.materialize'])
|
||||
// A fresh install is turned on, like a library install.
|
||||
await expect.poll(() => seen.dispatch.some(d => d.action === 'enable')).toBe(true)
|
||||
})
|
||||
|
||||
test('an upgrade says what it replaces, installs through the upgrade action and does not turn the add-on on', async ({ page }) => {
|
||||
const { seen } = await open(page, { file: fileView({ installed: true, installedVersion: '0.1.0', relation: 'upgrade' }), installed: [installedAddon({ id: 'memory', title: 'Short memory', version: '0.1.0', sourceId: 'local-file', verification: 'sha256', enabled: false, state: 'stopped' })] })
|
||||
await page.goto('/app/addons')
|
||||
await page.getByTestId('install-file-input').setInputFiles({ name: 'm.zip', mimeType: 'application/zip', buffer: Buffer.from('PK') })
|
||||
const dialog = page.getByTestId('install-dialog')
|
||||
await expect(dialog.getByTestId('install-file-upgrade')).toContainText('replaces version 0.1.0 with version 0.2.0')
|
||||
await dialog.getByTestId('install-confirm').click()
|
||||
await expect.poll(() => seen.install.length).toBe(1)
|
||||
expect(seen.install[0].body.action).toBe('upgrade')
|
||||
expect(seen.install[0].body.downgrade).toBeUndefined()
|
||||
await expect(dialog).toContainText('now runs version 0.2.0')
|
||||
expect(seen.dispatch.filter(d => d.action === 'enable')).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('an older version needs a tick and sends the downgrade confirmation', async ({ page }) => {
|
||||
const { seen } = await open(page, { file: fileView({ version: '0.0.9', installed: true, installedVersion: '0.1.0', relation: 'downgrade' }), installed: [installedAddon({ id: 'memory', title: 'Short memory', version: '0.1.0', sourceId: 'local-file', verification: 'sha256', enabled: false, state: 'stopped' })] })
|
||||
await page.goto('/app/addons')
|
||||
await page.getByTestId('install-file-input').setInputFiles({ name: 'm.zip', mimeType: 'application/zip', buffer: Buffer.from('PK') })
|
||||
const dialog = page.getByTestId('install-dialog')
|
||||
await expect(dialog.getByTestId('install-file-downgrade')).toContainText('older than the installed version 0.1.0')
|
||||
await shoot(page, 'install-from-file-downgrade')
|
||||
await expect(dialog.getByTestId('install-confirm')).toBeDisabled()
|
||||
await dialog.getByTestId('install-file-downgrade-confirm').check()
|
||||
await expect(dialog.getByTestId('install-confirm')).toBeEnabled()
|
||||
await dialog.getByTestId('install-confirm').click()
|
||||
await expect.poll(() => seen.install.length).toBe(1)
|
||||
expect(seen.install[0].body.downgrade).toBe(true)
|
||||
expect(seen.install[0].body.action).toBe('upgrade')
|
||||
})
|
||||
|
||||
test('the same version cannot be installed again', async ({ page }) => {
|
||||
await open(page, { file: fileView({ installed: true, installedVersion: '0.2.0', relation: 'same' }), installed: [installedAddon({ id: 'memory', title: 'Short memory', version: '0.2.0', sourceId: 'local-file', verification: 'sha256' })] })
|
||||
await page.goto('/app/addons')
|
||||
await page.getByTestId('install-file-input').setInputFiles({ name: 'm.zip', mimeType: 'application/zip', buffer: Buffer.from('PK') })
|
||||
const dialog = page.getByTestId('install-dialog')
|
||||
await expect(dialog.getByTestId('install-file-same')).toContainText('installed already')
|
||||
await expect(dialog.getByTestId('install-confirm')).toBeDisabled()
|
||||
})
|
||||
|
||||
test('shows the reason the owner gives when it refuses the file', async ({ page }) => {
|
||||
await open(page, { uploadStatus: 400, uploadBody: { error: { type: 'wrong_platform', code: 400, message: 'This file is for linux/arm64; this host is linux/amd64' } } })
|
||||
await page.goto('/app/addons')
|
||||
await page.getByTestId('install-file-input').setInputFiles({ name: 'm.zip', mimeType: 'application/zip', buffer: Buffer.from('PK') })
|
||||
const err = page.getByTestId('install-file-error')
|
||||
await expect(err).toContainText('The file was not accepted.')
|
||||
await expect(err).toContainText('This file is for linux/arm64; this host is linux/amd64')
|
||||
await expect(page.getByTestId('install-dialog')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('the link of the command line opens the dialog for the staged file, and cancel removes the file', async ({ page }) => {
|
||||
const { seen } = await open(page)
|
||||
await page.goto(`/app/addons?file=${ID}`)
|
||||
const dialog = page.getByTestId('install-dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
expect(seen.described).toEqual([ID])
|
||||
expect(seen.uploads).toHaveLength(0)
|
||||
await dialog.getByRole('button', { name: 'Cancel' }).click()
|
||||
await expect(dialog).toHaveCount(0)
|
||||
await expect.poll(() => seen.removed).toEqual([ID])
|
||||
await expect(page).not.toHaveURL(/file=/)
|
||||
})
|
||||
|
||||
test('a link to a file that is gone says so', async ({ page }) => {
|
||||
await open(page)
|
||||
await page.goto(`/app/addons?file=${'e'.repeat(32)}`)
|
||||
await expect(page.getByTestId('install-file-error')).toContainText('The file is gone')
|
||||
})
|
||||
|
||||
test('the list and the host access page name an add-on from a file as unsigned', async ({ page }) => {
|
||||
await page.route('**/api/auth/status', route => route.fulfill({ json: sessionFor(ADMIN, { addons: true }) }))
|
||||
const installed = installedAddon({ id: 'memory', title: 'Short memory', version: '0.2.0', sourceId: 'local-file', verification: 'sha256' })
|
||||
await mockAddons(page, { apps: [{ id: 'memory', name: 'memory', title: 'Short memory', description: 'Notes.', version: '0.2.0', generation: 1, enabled: true, state: 'running', hasUI: false, tools: [], toolCount: 0, launchMode: 'always' }], installed: [installed] })
|
||||
await page.route(/\/api\/addons\/host-grants$/, route => route.fulfill({ json: {
|
||||
revision: 1, locked: false, blanket: { mode: 'off', includeOptional: false, setBy: 'ui', revision: 0 }, floor: { source: 'file', addons: [] }, deny: { file: {}, ui: {} },
|
||||
addons: [{ id: 'memory', title: 'Short memory', version: '0.2.0', state: 'running', sourceId: 'local-file', requested: { required: [], optional: [] }, ops: [], noLongerRequested: [], newOptional: [], identityMismatch: false, stopped: '' }],
|
||||
} }))
|
||||
await page.goto('/app/addons')
|
||||
await expect(page.locator('[data-source="local-file"]').first()).toContainText('Local file, unsigned')
|
||||
await page.goto('/app/addons?tab=access')
|
||||
await expect(page.getByTestId('host-local-file')).toContainText('Local file, unsigned')
|
||||
})
|
||||
})
|
||||
@@ -897,5 +897,38 @@
|
||||
"reviewNamed": "Den Host-Zugriff von {{name}} prüfen und freigeben",
|
||||
"title": "{{name}} {{version}} wartet auf deine Freigabe"
|
||||
}
|
||||
},
|
||||
"file": {
|
||||
"button": "Aus Datei installieren",
|
||||
"checking": "Die Datei wird geprüft …",
|
||||
"pick": "Eine ZIP-Datei eines Add-ons auswählen",
|
||||
"rejected": "Die Datei wurde nicht akzeptiert.",
|
||||
"gone": "Die Datei ist nicht mehr da. Eine Datei bleibt nach dem Hochladen eine Stunde lang erhalten. Lade sie erneut hoch.",
|
||||
"installTitle": "{{name}} aus einer Datei installieren?",
|
||||
"upgradeTitle": "{{name}} aus einer Datei aktualisieren?",
|
||||
"confirmBody": "Version {{version}} aus der Datei {{name}} wird installiert und dann eingeschaltet.",
|
||||
"confirmBodyUpgrade": "Version {{version}} aus der Datei {{name}} wird installiert. Das Add-on bleibt ein- oder ausgeschaltet, wie es jetzt ist.",
|
||||
"upgradeAction": "Auf {{version}} aktualisieren",
|
||||
"upgraded": "{{name}} läuft jetzt in Version {{version}}.",
|
||||
"unsignedTitle": "Lokale Datei, nicht signiert",
|
||||
"unsignedNotice": "Niemand signiert diese Datei, deshalb kann LocalAI nicht erkennen, wer sie erstellt hat. Installiere sie nur, wenn du ihrer Herkunft vertraust.",
|
||||
"unsignedShort": "Lokale Datei, nicht signiert",
|
||||
"badge": "Lokale Datei, nicht signiert",
|
||||
"sourceValue": "Lokale Datei, nicht signiert",
|
||||
"hostNotice": "Dieses Add-on stammt aus einer lokalen Datei, die niemand signiert. Gib ihm nur den Hostzugriff, den es nachweislich braucht.",
|
||||
"facts": {
|
||||
"file": "Datei",
|
||||
"sha256": "SHA-256 der Datei",
|
||||
"addon": "Add-on",
|
||||
"platform": "Plattform",
|
||||
"api": "Host-API"
|
||||
},
|
||||
"relation": {
|
||||
"upgrade": "Dies ersetzt Version {{installed}} durch Version {{version}}. Die Daten des Add-ons bleiben erhalten.",
|
||||
"same": "Version {{version}} ist bereits installiert. Wähle eine Datei mit einer anderen Version.",
|
||||
"downgradeTitle": "Ältere Version",
|
||||
"downgrade": "Dies installiert Version {{version}}, die älter ist als die installierte Version {{installed}}. Eine ältere Version kann Daten, die eine neuere geschrieben hat, eventuell nicht lesen.",
|
||||
"downgradeConfirm": "Ich möchte die ältere Version installieren"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -897,5 +897,38 @@
|
||||
"reviewNamed": "Review and approve the host access of {{name}}",
|
||||
"title": "{{name}} {{version}} waits for your approval"
|
||||
}
|
||||
},
|
||||
"file": {
|
||||
"button": "Install from file",
|
||||
"checking": "Checking the file…",
|
||||
"pick": "Choose an add-on zip file",
|
||||
"rejected": "The file was not accepted.",
|
||||
"gone": "The file is gone. A file waits for one hour after the upload. Upload it again.",
|
||||
"installTitle": "Install {{name}} from a file?",
|
||||
"upgradeTitle": "Update {{name}} from a file?",
|
||||
"confirmBody": "Version {{version}} from the file {{name}} is installed, then turned on.",
|
||||
"confirmBodyUpgrade": "Version {{version}} from the file {{name}} is installed. The add-on stays on or off as it is now.",
|
||||
"upgradeAction": "Update to {{version}}",
|
||||
"upgraded": "{{name}} now runs version {{version}}.",
|
||||
"unsignedTitle": "Local file, unsigned",
|
||||
"unsignedNotice": "Nothing signs this file, so LocalAI cannot tell who made it. Install it only if you trust where it came from.",
|
||||
"unsignedShort": "Local file, unsigned",
|
||||
"badge": "Local file, unsigned",
|
||||
"sourceValue": "Local file, unsigned",
|
||||
"hostNotice": "This add-on came from a local file that nothing signs. Give it only the host access you are sure it needs.",
|
||||
"facts": {
|
||||
"file": "File",
|
||||
"sha256": "File sha256",
|
||||
"addon": "Add-on",
|
||||
"platform": "Platform",
|
||||
"api": "Host API"
|
||||
},
|
||||
"relation": {
|
||||
"upgrade": "This replaces version {{installed}} with version {{version}}. The data of the add-on is kept.",
|
||||
"same": "Version {{version}} is installed already. Choose a file with another version.",
|
||||
"downgradeTitle": "Older version",
|
||||
"downgrade": "This installs version {{version}}, which is older than the installed version {{installed}}. An older version may not read data that a newer one wrote.",
|
||||
"downgradeConfirm": "I want to install the older version"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -897,5 +897,38 @@
|
||||
"reviewNamed": "Revisar y aprobar el acceso al host de {{name}}",
|
||||
"title": "{{name}} {{version}} espera tu aprobación"
|
||||
}
|
||||
},
|
||||
"file": {
|
||||
"button": "Instalar desde un archivo",
|
||||
"checking": "Comprobando el archivo…",
|
||||
"pick": "Elegir un archivo zip de un complemento",
|
||||
"rejected": "No se aceptó el archivo.",
|
||||
"gone": "El archivo ya no está. Un archivo se conserva una hora después de subirlo. Súbelo de nuevo.",
|
||||
"installTitle": "¿Instalar {{name}} desde un archivo?",
|
||||
"upgradeTitle": "¿Actualizar {{name}} desde un archivo?",
|
||||
"confirmBody": "La versión {{version}} del archivo {{name}} se instala y después se activa.",
|
||||
"confirmBodyUpgrade": "La versión {{version}} del archivo {{name}} se instala. El complemento sigue activado o desactivado como está ahora.",
|
||||
"upgradeAction": "Actualizar a {{version}}",
|
||||
"upgraded": "{{name}} ahora ejecuta la versión {{version}}.",
|
||||
"unsignedTitle": "Archivo local, sin firma",
|
||||
"unsignedNotice": "Nadie firma este archivo, así que LocalAI no puede saber quién lo creó. Instálalo solo si confías en su origen.",
|
||||
"unsignedShort": "Archivo local, sin firma",
|
||||
"badge": "Archivo local, sin firma",
|
||||
"sourceValue": "Archivo local, sin firma",
|
||||
"hostNotice": "Este complemento viene de un archivo local que nadie firma. Dale solo el acceso al host que estés seguro de que necesita.",
|
||||
"facts": {
|
||||
"file": "Archivo",
|
||||
"sha256": "SHA-256 del archivo",
|
||||
"addon": "Complemento",
|
||||
"platform": "Plataforma",
|
||||
"api": "API del host"
|
||||
},
|
||||
"relation": {
|
||||
"upgrade": "Esto reemplaza la versión {{installed}} por la versión {{version}}. Los datos del complemento se conservan.",
|
||||
"same": "La versión {{version}} ya está instalada. Elige un archivo con otra versión.",
|
||||
"downgradeTitle": "Versión anterior",
|
||||
"downgrade": "Esto instala la versión {{version}}, que es anterior a la versión instalada {{installed}}. Una versión anterior puede no leer los datos que escribió una más nueva.",
|
||||
"downgradeConfirm": "Quiero instalar la versión anterior"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -901,5 +901,38 @@
|
||||
"reviewNamed": "Examiner et approuver l'accès à l'hôte de {{name}}",
|
||||
"title": "{{name}} {{version}} attend votre approbation"
|
||||
}
|
||||
},
|
||||
"file": {
|
||||
"button": "Installer depuis un fichier",
|
||||
"checking": "Vérification du fichier…",
|
||||
"pick": "Choisir un fichier zip de module",
|
||||
"rejected": "Le fichier n’a pas été accepté.",
|
||||
"gone": "Le fichier n’est plus là. Un fichier est conservé une heure après l’envoi. Envoyez-le à nouveau.",
|
||||
"installTitle": "Installer {{name}} depuis un fichier ?",
|
||||
"upgradeTitle": "Mettre à jour {{name}} depuis un fichier ?",
|
||||
"confirmBody": "La version {{version}} du fichier {{name}} est installée, puis activée.",
|
||||
"confirmBodyUpgrade": "La version {{version}} du fichier {{name}} est installée. Le module reste activé ou désactivé comme il l’est maintenant.",
|
||||
"upgradeAction": "Passer à {{version}}",
|
||||
"upgraded": "{{name}} fonctionne maintenant en version {{version}}.",
|
||||
"unsignedTitle": "Fichier local, non signé",
|
||||
"unsignedNotice": "Personne ne signe ce fichier, donc LocalAI ne peut pas savoir qui l’a créé. Ne l’installez que si vous faites confiance à son origine.",
|
||||
"unsignedShort": "Fichier local, non signé",
|
||||
"badge": "Fichier local, non signé",
|
||||
"sourceValue": "Fichier local, non signé",
|
||||
"hostNotice": "Ce module vient d’un fichier local que personne ne signe. Ne lui donnez que l’accès à l’hôte dont vous êtes sûr qu’il a besoin.",
|
||||
"facts": {
|
||||
"file": "Fichier",
|
||||
"sha256": "SHA-256 du fichier",
|
||||
"addon": "Module",
|
||||
"platform": "Plateforme",
|
||||
"api": "API de l’hôte"
|
||||
},
|
||||
"relation": {
|
||||
"upgrade": "Cela remplace la version {{installed}} par la version {{version}}. Les données du module sont conservées.",
|
||||
"same": "La version {{version}} est déjà installée. Choisissez un fichier d’une autre version.",
|
||||
"downgradeTitle": "Version plus ancienne",
|
||||
"downgrade": "Cela installe la version {{version}}, plus ancienne que la version installée {{installed}}. Une version plus ancienne peut ne pas lire les données écrites par une version plus récente.",
|
||||
"downgradeConfirm": "Je veux installer la version plus ancienne"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -897,5 +897,38 @@
|
||||
"reviewNamed": "Tinjau dan setujui akses host {{name}}",
|
||||
"title": "{{name}} {{version}} menunggu persetujuan Anda"
|
||||
}
|
||||
},
|
||||
"file": {
|
||||
"button": "Pasang dari berkas",
|
||||
"checking": "Memeriksa berkas…",
|
||||
"pick": "Pilih berkas zip add-on",
|
||||
"rejected": "Berkas tidak diterima.",
|
||||
"gone": "Berkas sudah tidak ada. Berkas disimpan satu jam setelah diunggah. Unggah lagi.",
|
||||
"installTitle": "Pasang {{name}} dari berkas?",
|
||||
"upgradeTitle": "Perbarui {{name}} dari berkas?",
|
||||
"confirmBody": "Versi {{version}} dari berkas {{name}} dipasang, lalu dinyalakan.",
|
||||
"confirmBodyUpgrade": "Versi {{version}} dari berkas {{name}} dipasang. Add-on tetap menyala atau mati seperti sekarang.",
|
||||
"upgradeAction": "Perbarui ke {{version}}",
|
||||
"upgraded": "{{name}} sekarang berjalan di versi {{version}}.",
|
||||
"unsignedTitle": "Berkas lokal, tanpa tanda tangan",
|
||||
"unsignedNotice": "Tidak ada yang menandatangani berkas ini, jadi LocalAI tidak bisa tahu siapa yang membuatnya. Pasang hanya jika Anda memercayai asalnya.",
|
||||
"unsignedShort": "Berkas lokal, tanpa tanda tangan",
|
||||
"badge": "Berkas lokal, tanpa tanda tangan",
|
||||
"sourceValue": "Berkas lokal, tanpa tanda tangan",
|
||||
"hostNotice": "Add-on ini berasal dari berkas lokal yang tidak ditandatangani. Beri hanya akses host yang Anda yakini dibutuhkannya.",
|
||||
"facts": {
|
||||
"file": "Berkas",
|
||||
"sha256": "SHA-256 berkas",
|
||||
"addon": "Add-on",
|
||||
"platform": "Platform",
|
||||
"api": "API host"
|
||||
},
|
||||
"relation": {
|
||||
"upgrade": "Ini mengganti versi {{installed}} dengan versi {{version}}. Data add-on tetap disimpan.",
|
||||
"same": "Versi {{version}} sudah terpasang. Pilih berkas dengan versi lain.",
|
||||
"downgradeTitle": "Versi lebih lama",
|
||||
"downgrade": "Ini memasang versi {{version}}, yang lebih lama daripada versi terpasang {{installed}}. Versi yang lebih lama mungkin tidak bisa membaca data yang ditulis versi yang lebih baru.",
|
||||
"downgradeConfirm": "Saya ingin memasang versi yang lebih lama"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -897,5 +897,38 @@
|
||||
"reviewNamed": "Controlla e approva l'accesso all'host di {{name}}",
|
||||
"title": "{{name}} {{version}} aspetta la tua approvazione"
|
||||
}
|
||||
},
|
||||
"file": {
|
||||
"button": "Installa da file",
|
||||
"checking": "Controllo del file…",
|
||||
"pick": "Scegli un file zip di un componente",
|
||||
"rejected": "Il file non è stato accettato.",
|
||||
"gone": "Il file non c’è più. Un file resta per un’ora dopo il caricamento. Caricalo di nuovo.",
|
||||
"installTitle": "Installare {{name}} da un file?",
|
||||
"upgradeTitle": "Aggiornare {{name}} da un file?",
|
||||
"confirmBody": "La versione {{version}} del file {{name}} viene installata e poi attivata.",
|
||||
"confirmBodyUpgrade": "La versione {{version}} del file {{name}} viene installata. Il componente resta attivo o disattivo come è ora.",
|
||||
"upgradeAction": "Aggiorna a {{version}}",
|
||||
"upgraded": "{{name}} ora esegue la versione {{version}}.",
|
||||
"unsignedTitle": "File locale, non firmato",
|
||||
"unsignedNotice": "Nessuno firma questo file, quindi LocalAI non può sapere chi l’ha creato. Installalo solo se ti fidi della sua origine.",
|
||||
"unsignedShort": "File locale, non firmato",
|
||||
"badge": "File locale, non firmato",
|
||||
"sourceValue": "File locale, non firmato",
|
||||
"hostNotice": "Questo componente arriva da un file locale che nessuno firma. Dagli solo l’accesso all’host che sei sicuro gli serva.",
|
||||
"facts": {
|
||||
"file": "File",
|
||||
"sha256": "SHA-256 del file",
|
||||
"addon": "Componente",
|
||||
"platform": "Piattaforma",
|
||||
"api": "API dell’host"
|
||||
},
|
||||
"relation": {
|
||||
"upgrade": "Questo sostituisce la versione {{installed}} con la versione {{version}}. I dati del componente vengono conservati.",
|
||||
"same": "La versione {{version}} è già installata. Scegli un file con un’altra versione.",
|
||||
"downgradeTitle": "Versione precedente",
|
||||
"downgrade": "Questo installa la versione {{version}}, più vecchia della versione installata {{installed}}. Una versione più vecchia può non leggere i dati scritti da una più nuova.",
|
||||
"downgradeConfirm": "Voglio installare la versione precedente"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -897,5 +897,38 @@
|
||||
"reviewNamed": "{{name}}의 호스트 접근 검토 후 승인",
|
||||
"title": "{{name}} {{version}}이(가) 승인을 기다립니다"
|
||||
}
|
||||
},
|
||||
"file": {
|
||||
"button": "파일에서 설치",
|
||||
"checking": "파일을 확인하는 중…",
|
||||
"pick": "애드온 zip 파일 선택",
|
||||
"rejected": "파일이 받아들여지지 않았습니다.",
|
||||
"gone": "파일이 없습니다. 파일은 업로드 후 한 시간 동안 보관됩니다. 다시 업로드하세요.",
|
||||
"installTitle": "파일에서 {{name}}을(를) 설치할까요?",
|
||||
"upgradeTitle": "파일에서 {{name}}을(를) 업데이트할까요?",
|
||||
"confirmBody": "파일 {{name}}의 {{version}} 버전을 설치한 다음 켭니다.",
|
||||
"confirmBodyUpgrade": "파일 {{name}}의 {{version}} 버전을 설치합니다. 애드온은 지금처럼 켜짐 또는 꺼짐 상태를 유지합니다.",
|
||||
"upgradeAction": "{{version}}(으)로 업데이트",
|
||||
"upgraded": "{{name}}이(가) 이제 {{version}} 버전으로 실행됩니다.",
|
||||
"unsignedTitle": "로컬 파일, 서명 없음",
|
||||
"unsignedNotice": "이 파일에는 서명한 사람이 없어서 LocalAI는 누가 만들었는지 알 수 없습니다. 출처를 신뢰할 때만 설치하세요.",
|
||||
"unsignedShort": "로컬 파일, 서명 없음",
|
||||
"badge": "로컬 파일, 서명 없음",
|
||||
"sourceValue": "로컬 파일, 서명 없음",
|
||||
"hostNotice": "이 애드온은 아무도 서명하지 않은 로컬 파일에서 왔습니다. 꼭 필요하다고 확신하는 호스트 접근 권한만 주세요.",
|
||||
"facts": {
|
||||
"file": "파일",
|
||||
"sha256": "파일 SHA-256",
|
||||
"addon": "애드온",
|
||||
"platform": "플랫폼",
|
||||
"api": "호스트 API"
|
||||
},
|
||||
"relation": {
|
||||
"upgrade": "설치된 {{installed}} 버전을 {{version}} 버전으로 바꿉니다. 애드온의 데이터는 유지됩니다.",
|
||||
"same": "{{version}} 버전은 이미 설치되어 있습니다. 다른 버전의 파일을 선택하세요.",
|
||||
"downgradeTitle": "이전 버전",
|
||||
"downgrade": "설치된 {{installed}} 버전보다 오래된 {{version}} 버전을 설치합니다. 이전 버전은 새 버전이 쓴 데이터를 읽지 못할 수 있습니다.",
|
||||
"downgradeConfirm": "이전 버전을 설치하겠습니다"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -897,5 +897,38 @@
|
||||
"reviewNamed": "Revisar e aprovar o acesso ao host de {{name}}",
|
||||
"title": "{{name}} {{version}} aguarda sua aprovação"
|
||||
}
|
||||
},
|
||||
"file": {
|
||||
"button": "Instalar de um arquivo",
|
||||
"checking": "Verificando o arquivo…",
|
||||
"pick": "Escolher um arquivo zip de complemento",
|
||||
"rejected": "O arquivo não foi aceito.",
|
||||
"gone": "O arquivo não está mais lá. Um arquivo fica por uma hora depois do envio. Envie de novo.",
|
||||
"installTitle": "Instalar {{name}} de um arquivo?",
|
||||
"upgradeTitle": "Atualizar {{name}} de um arquivo?",
|
||||
"confirmBody": "A versão {{version}} do arquivo {{name}} é instalada e depois ligada.",
|
||||
"confirmBodyUpgrade": "A versão {{version}} do arquivo {{name}} é instalada. O complemento continua ligado ou desligado como está agora.",
|
||||
"upgradeAction": "Atualizar para {{version}}",
|
||||
"upgraded": "{{name}} agora roda na versão {{version}}.",
|
||||
"unsignedTitle": "Arquivo local, sem assinatura",
|
||||
"unsignedNotice": "Ninguém assina este arquivo, então o LocalAI não sabe quem o criou. Instale só se confiar na origem.",
|
||||
"unsignedShort": "Arquivo local, sem assinatura",
|
||||
"badge": "Arquivo local, sem assinatura",
|
||||
"sourceValue": "Arquivo local, sem assinatura",
|
||||
"hostNotice": "Este complemento veio de um arquivo local que ninguém assina. Dê a ele só o acesso ao host de que você tem certeza que ele precisa.",
|
||||
"facts": {
|
||||
"file": "Arquivo",
|
||||
"sha256": "SHA-256 do arquivo",
|
||||
"addon": "Complemento",
|
||||
"platform": "Plataforma",
|
||||
"api": "API do host"
|
||||
},
|
||||
"relation": {
|
||||
"upgrade": "Isto troca a versão {{installed}} pela versão {{version}}. Os dados do complemento são mantidos.",
|
||||
"same": "A versão {{version}} já está instalada. Escolha um arquivo com outra versão.",
|
||||
"downgradeTitle": "Versão mais antiga",
|
||||
"downgrade": "Isto instala a versão {{version}}, que é mais antiga que a versão instalada {{installed}}. Uma versão mais antiga pode não ler dados escritos por uma mais nova.",
|
||||
"downgradeConfirm": "Quero instalar a versão mais antiga"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -897,5 +897,38 @@
|
||||
"reviewNamed": "查看并批准 {{name}} 的主机访问",
|
||||
"title": "{{name}} {{version}} 等待你的批准"
|
||||
}
|
||||
},
|
||||
"file": {
|
||||
"button": "从文件安装",
|
||||
"checking": "正在检查文件…",
|
||||
"pick": "选择附加组件的 zip 文件",
|
||||
"rejected": "文件未被接受。",
|
||||
"gone": "文件已不存在。文件在上传后保留一小时。请重新上传。",
|
||||
"installTitle": "从文件安装 {{name}}?",
|
||||
"upgradeTitle": "从文件更新 {{name}}?",
|
||||
"confirmBody": "将安装文件 {{name}} 中的 {{version}} 版本,然后启用。",
|
||||
"confirmBodyUpgrade": "将安装文件 {{name}} 中的 {{version}} 版本。附加组件保持现在的开启或关闭状态。",
|
||||
"upgradeAction": "更新到 {{version}}",
|
||||
"upgraded": "{{name}} 现在运行 {{version}} 版本。",
|
||||
"unsignedTitle": "本地文件,未签名",
|
||||
"unsignedNotice": "没有人为此文件签名,因此 LocalAI 无法确定它由谁制作。只有在你信任其来源时才安装。",
|
||||
"unsignedShort": "本地文件,未签名",
|
||||
"badge": "本地文件,未签名",
|
||||
"sourceValue": "本地文件,未签名",
|
||||
"hostNotice": "此附加组件来自没有人签名的本地文件。只授予你确信它需要的主机访问权限。",
|
||||
"facts": {
|
||||
"file": "文件",
|
||||
"sha256": "文件 SHA-256",
|
||||
"addon": "附加组件",
|
||||
"platform": "平台",
|
||||
"api": "主机 API"
|
||||
},
|
||||
"relation": {
|
||||
"upgrade": "这会把 {{installed}} 版本替换为 {{version}} 版本。附加组件的数据会保留。",
|
||||
"same": "{{version}} 版本已安装。请选择另一个版本的文件。",
|
||||
"downgradeTitle": "较旧的版本",
|
||||
"downgrade": "这会安装 {{version}} 版本,它比已安装的 {{installed}} 版本更旧。较旧的版本可能无法读取较新版本写入的数据。",
|
||||
"downgradeConfirm": "我要安装较旧的版本"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import Button from '../ui/Button'
|
||||
import { apiUrl } from '../../utils/basePath'
|
||||
import { AppIcon, ApprovalChip, BuiltinIcon, StateChip, UpdateChip, VersionText } from './AddonBits'
|
||||
import AddonMenu from './AddonMenu'
|
||||
import { isLocalFile } from './fileInstall'
|
||||
import { addedByAddon, stateChip } from './addonState'
|
||||
|
||||
// One installed add-on as a row: identity tile, name and state, one line of
|
||||
@@ -49,7 +50,7 @@ export default function AddonRow({ tile, pinned, selected = false, attention = '
|
||||
)}
|
||||
{!app && <span className="ad-meta__item">{t('meta.builtinLong')}</span>}
|
||||
{source && source.sourceId && (
|
||||
<span className="ad-meta__item"><Icon name="shield" aria-hidden="true" /> {t(signed ? 'meta.signed' : 'meta.checked', { library: source.sourceId })}</span>
|
||||
<span className="ad-meta__item" data-source={source.sourceId}><Icon name="shield" aria-hidden="true" /> {isLocalFile(source.sourceId) ? t('file.badge') : t(signed ? 'meta.signed' : 'meta.checked', { library: source.sourceId })}</span>
|
||||
)}
|
||||
{app && <span className="ad-meta__item"><VersionText version={app.version} /></span>}
|
||||
</span>
|
||||
|
||||
@@ -10,6 +10,7 @@ import ApproveUpgradeDialog, { WaitingBanner } from './ApproveUpgradeDialog'
|
||||
import BlanketPanel from './BlanketPanel'
|
||||
import DenySection from './DenySection'
|
||||
import { opNote } from './HostAccessList'
|
||||
import { isLocalFile } from './fileInstall'
|
||||
import { stateChip } from './addonState'
|
||||
import { describeError } from './errors'
|
||||
import { blanketHolders, editedOps, groupOps, opEditable, pageOps, pendingApprovals } from './hostGrantsState'
|
||||
@@ -98,6 +99,7 @@ export default function HostAccessView({ grants, api = hostGrantsApi }) {
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
{isLocalFile(addon.sourceId) && <Callout tone="warn" testId="host-local-file" className="ad-callout" title={t('file.unsignedTitle')}>{t('file.hostNotice')}</Callout>}
|
||||
{stoppedText && <Callout tone="warn" testId="host-stopped" className="ad-callout">{stoppedText}</Callout>}
|
||||
{addon.identityMismatch && <Callout tone="warn" testId="host-mismatch" className="ad-callout" actions={!locked && <Button size="sm" disabled={grants.busy} onClick={() => revokeAll(addon)}>{t('hostAccess.removeStale')}</Button>}>{t('hostAccess.identityMismatch')}</Callout>}
|
||||
{addon.newOptional.length > 0 && <Callout tone="info" testId="host-new-optional" className="ad-callout">{t('hostAccess.newOptional', { ops: addon.newOptional.join(', ') })}</Callout>}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Icon from '../Icon'
|
||||
@@ -13,6 +13,7 @@ import Callout from '../ui/Callout'
|
||||
import HostAccessList from './HostAccessList'
|
||||
import { useHostPreview } from './useHostPreview'
|
||||
import { approvalBody, hostErrorKey, installBlock, needsStep } from './hostGrantsState'
|
||||
import { fileAction, fileGate } from './fileInstall'
|
||||
|
||||
// The built-in chat tools that the entry replaces and that an installed add-on
|
||||
// already replaces: only one of them can serve the tool, so the dialog says so
|
||||
@@ -42,6 +43,10 @@ export default function InstallDialog({ entry, flow, actions, onClose, installed
|
||||
// really stages.
|
||||
const host = useHostPreview(entry, { enabled: Boolean(entry) && step === 'ask' })
|
||||
const approval = useRef(null)
|
||||
// An install from a file: a package nothing signs. The person sees the file's
|
||||
// own facts and, for an older version, ticks a box first.
|
||||
const local = entry ? entry.local : undefined
|
||||
const [downgrade, setDowngrade] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!entry) return undefined
|
||||
@@ -74,13 +79,20 @@ export default function InstallDialog({ entry, flow, actions, onClose, installed
|
||||
if (!entry) return null
|
||||
const showHost = host.status === 'ready' && needsStep(host.preview)
|
||||
const block = showHost ? installBlock(host.preview) : null
|
||||
const gate = local ? fileGate(local, { downgradeTicked: downgrade }) : { allowed: true }
|
||||
const start = () => {
|
||||
if (local) {
|
||||
// A file installs only with the approval of this dialog, with or without
|
||||
// host operations: the owner refuses a file install that has none.
|
||||
approval.current = approvalBody(host.preview, host.ticked, { action: fileAction(local), sourceId: entry.sourceId, addonId: entry.id, version: entry.version, downgrade: local.relation === 'downgrade' && downgrade })
|
||||
return actions.installAndTurnOn(entry, { approval: approval.current, turnOn: !local.installed })
|
||||
}
|
||||
approval.current = showHost
|
||||
? approvalBody(host.preview, host.ticked, { action: 'install', sourceId: entry.sourceId, addonId: entry.id, version: entry.version })
|
||||
: null
|
||||
return actions.installAndTurnOn(entry, approval.current ? { approval: approval.current } : {})
|
||||
}
|
||||
const retry = () => actions.installAndTurnOn(entry, { skipInstall: flow.failedStep === 'turn-on', approval: approval.current })
|
||||
const retry = () => actions.installAndTurnOn(entry, { skipInstall: flow.failedStep === 'turn-on', approval: approval.current, turnOn: !(local && local.installed) })
|
||||
const stepState = (id) => {
|
||||
if (step === 'ask') return 'pending'
|
||||
if (step === 'done') return 'done'
|
||||
@@ -92,13 +104,35 @@ export default function InstallDialog({ entry, flow, actions, onClose, installed
|
||||
<div className="confirm-dialog-backdrop" onClick={running ? undefined : onClose}>
|
||||
<div ref={ref} className="confirm-dialog ad-dialog" role="alertdialog" aria-modal="true" aria-labelledby="install-dialog-title" data-testid="install-dialog" data-running={running ? 'true' : 'false'} data-step={step} onClick={(e) => e.stopPropagation()}>
|
||||
<div className="confirm-dialog-header">
|
||||
<span id="install-dialog-title" className="confirm-dialog-title">{t('flow.confirmTitle', { name: entry.title })}</span>
|
||||
<span id="install-dialog-title" className="confirm-dialog-title">{local ? t(local.installed ? 'file.upgradeTitle' : 'file.installTitle', { name: entry.title }) : t('flow.confirmTitle', { name: entry.title })}</span>
|
||||
</div>
|
||||
<div className="confirm-dialog-body ad-confirm">
|
||||
{step === 'ask' && (
|
||||
<>
|
||||
<p>{t('flow.confirmBody', { version: entry.version, library: entry.sourceId })}</p>
|
||||
<EntryAdds entry={entry} />
|
||||
{local
|
||||
? <p>{t(local.installed ? 'file.confirmBodyUpgrade' : 'file.confirmBody', { version: local.version, name: local.name })}</p>
|
||||
: <p>{t('flow.confirmBody', { version: entry.version, library: entry.sourceId })}</p>}
|
||||
{local && (
|
||||
<Callout tone="warn" testId="install-unsigned" title={t('file.unsignedTitle')}>
|
||||
{t('file.unsignedNotice')}
|
||||
</Callout>
|
||||
)}
|
||||
{local && local.relation === 'upgrade' && (
|
||||
<p className="ad-quiet" data-testid="install-file-upgrade">{t('file.relation.upgrade', { installed: local.installedVersion, version: local.version })}</p>
|
||||
)}
|
||||
{local && local.relation === 'same' && (
|
||||
<Callout tone="error" role="alert" testId="install-file-same">{t('file.relation.same', { version: local.version })}</Callout>
|
||||
)}
|
||||
{local && local.relation === 'downgrade' && (
|
||||
<Callout tone="warn" testId="install-file-downgrade" title={t('file.relation.downgradeTitle')}>
|
||||
<p>{t('file.relation.downgrade', { installed: local.installedVersion, version: local.version })}</p>
|
||||
<label>
|
||||
<input type="checkbox" checked={downgrade} onChange={(e) => setDowngrade(e.target.checked)} data-testid="install-file-downgrade-confirm" />
|
||||
{' '}{t('file.relation.downgradeConfirm')}
|
||||
</label>
|
||||
</Callout>
|
||||
)}
|
||||
{!local && <EntryAdds entry={entry} />}
|
||||
{shadowConflicts(entry, installed).map(c => (
|
||||
<Callout key={c.tool} tone="warn" testId="install-shadow-warning" data-tool={c.tool}>
|
||||
{t('flow.shadowWarning', { tool: c.tool, name: c.name })}
|
||||
@@ -119,7 +153,7 @@ export default function InstallDialog({ entry, flow, actions, onClose, installed
|
||||
<section className="ad-hostaccess" aria-labelledby="install-host-title" data-testid="install-host-access">
|
||||
<h3 id="install-host-title" className="ad-hostaccess__title">{t('hostAccess.stepTitle')}</h3>
|
||||
<p>{t('hostAccess.stepIntro', { name: entry.title })}</p>
|
||||
{host.preview.official === false && (
|
||||
{!local && host.preview.official === false && (
|
||||
<p className="ad-quiet" data-testid="hostaccess-not-official">{t('hostAccess.notOfficial', { library: entry.sourceId })}</p>
|
||||
)}
|
||||
<HostAccessList ops={host.preview.ops} ticked={host.ticked} onTick={host.setTick} locked={host.preview.locked} />
|
||||
@@ -132,10 +166,21 @@ export default function InstallDialog({ entry, flow, actions, onClose, installed
|
||||
<p className="ad-quiet">{t('hostAccess.stepFooter')}</p>
|
||||
</section>
|
||||
)}
|
||||
<dl className="dk-kv">
|
||||
<dt>{t('trust.digest')}</dt><dd className="dk-mono">{shortDigest(entry.digest)}</dd>
|
||||
<dt>{t('trust.verification')}</dt><dd>{entry.policy.requireSignature ? t('trust.signatureRequired') : t('trust.digestOnly')}</dd>
|
||||
</dl>
|
||||
{local ? (
|
||||
<dl className="dk-kv" data-testid="install-file-facts">
|
||||
<dt>{t('file.facts.file')}</dt><dd className="ad-wrap">{local.name}</dd>
|
||||
<dt>{t('file.facts.sha256')}</dt><dd className="dk-mono ad-wrap" data-testid="install-file-sha">{local.sha256}</dd>
|
||||
<dt>{t('file.facts.addon')}</dt><dd>{local.addonId} {local.version}</dd>
|
||||
<dt>{t('file.facts.platform')}</dt><dd>{local.platform}</dd>
|
||||
<dt>{t('file.facts.api')}</dt><dd>{local.api.min === local.api.max ? local.api.min : `${local.api.min} - ${local.api.max}`}</dd>
|
||||
<dt>{t('trust.verification')}</dt><dd>{t('file.unsignedShort')}</dd>
|
||||
</dl>
|
||||
) : (
|
||||
<dl className="dk-kv">
|
||||
<dt>{t('trust.digest')}</dt><dd className="dk-mono">{shortDigest(entry.digest)}</dd>
|
||||
<dt>{t('trust.verification')}</dt><dd>{entry.policy.requireSignature ? t('trust.signatureRequired') : t('trust.digestOnly')}</dd>
|
||||
</dl>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{step !== 'ask' && (
|
||||
@@ -154,7 +199,7 @@ export default function InstallDialog({ entry, flow, actions, onClose, installed
|
||||
)}
|
||||
{step === 'done' && (
|
||||
<p className="ad-entry__note" data-tone="ok" role="status">
|
||||
{t('browse.installedOn', { name: entry.title })}
|
||||
{local && local.installed ? t('file.upgraded', { name: entry.title, version: local.version }) : t('browse.installedOn', { name: entry.title })}
|
||||
{entry.ui && entry.ui.mainPage && <> <Link to="/app/addons">{t('flow.seeInList')}</Link></>}
|
||||
</p>
|
||||
)}
|
||||
@@ -168,7 +213,7 @@ export default function InstallDialog({ entry, flow, actions, onClose, installed
|
||||
</div>
|
||||
<div className="confirm-dialog-actions">
|
||||
{step === 'ask' && <Button onClick={onClose}>{t('catalog.cancel')}</Button>}
|
||||
{step === 'ask' && <Button variant="primary" onClick={start} disabled={host.status === 'loading' || Boolean(block)} data-testid="install-confirm">{t('flow.installAndTurnOn')}</Button>}
|
||||
{step === 'ask' && <Button variant="primary" onClick={start} disabled={host.status === 'loading' || (Boolean(local) && host.status !== 'ready') || Boolean(block) || !gate.allowed} data-testid="install-confirm">{local && local.installed ? t('file.upgradeAction', { version: local.version }) : t('flow.installAndTurnOn')}</Button>}
|
||||
{running && <Button variant="primary" icon="download" busy disabled>{step === 'install' ? t('flow.installing', { name: entry.title }) : t('flow.turningOn', { name: entry.title })}</Button>}
|
||||
{step === 'failed' && <Button onClick={onClose}>{t('catalog.close')}</Button>}
|
||||
{step === 'failed' && !actions.unknown[entry.id] && <Button variant="primary" icon="refresh" onClick={retry}>{t('catalog.retry')}</Button>}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button, Callout } from '../ui'
|
||||
import InstallDialog from './InstallDialog'
|
||||
import { useAddonActions } from './useAddonActions'
|
||||
import { describeError } from './errors'
|
||||
import { addonsApi, addonsCatalog, addonFilesApi } from '../../utils/addonsClient'
|
||||
import { fileEntry, fileIdFromParams, normalizeFile } from './fileInstall'
|
||||
|
||||
// Install from a file. The button takes the add-on zip that `make addons-zip`
|
||||
// writes; the owner checks it and the person sees the same dialog as for a
|
||||
// library entry, with the host access step and the notice that nothing signs a
|
||||
// local file. A link from `local-ai addons install` carries ?file=<id> of a
|
||||
// file the command line staged, and opens the dialog straight away.
|
||||
export default function InstallFromFile({ onInstalled, filesApi = addonFilesApi }) {
|
||||
const { t } = useTranslation('addons')
|
||||
const [params, setParams] = useSearchParams()
|
||||
const input = useRef(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [entry, setEntry] = useState(null)
|
||||
const actions = useAddonActions(addonsApi, { onSettled: () => onInstalled && onInstalled(), adapter: addonsCatalog })
|
||||
const linked = fileIdFromParams(params)
|
||||
const asked = useRef('')
|
||||
|
||||
const show = useCallback((raw) => {
|
||||
const file = normalizeFile(raw)
|
||||
if (!file.fileId || !file.addonId) throw new Error('unexpected answer')
|
||||
setEntry(fileEntry(file))
|
||||
}, [])
|
||||
|
||||
const fail = useCallback((e) => {
|
||||
setError(e && e.type === 'not_found' ? t('file.gone') : describeError(e, t))
|
||||
}, [t])
|
||||
|
||||
const pick = async (e) => {
|
||||
const file = e.target.files && e.target.files[0]
|
||||
e.target.value = ''
|
||||
if (!file) return
|
||||
setError(null)
|
||||
setBusy(true)
|
||||
try { show(await filesApi.upload(file)) } catch (err) { fail(err) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
// A staged file the link names. Read once per id.
|
||||
useEffect(() => {
|
||||
if (!linked || asked.current === linked) return undefined
|
||||
asked.current = linked
|
||||
const ctl = new AbortController()
|
||||
setBusy(true)
|
||||
filesApi.describe(linked, ctl.signal)
|
||||
.then(show)
|
||||
.catch(err => { if (!err || err.name !== 'AbortError') fail(err) })
|
||||
.finally(() => setBusy(false))
|
||||
return () => ctl.abort()
|
||||
}, [linked, filesApi, show, fail])
|
||||
|
||||
const close = () => {
|
||||
const done = entry
|
||||
setEntry(null)
|
||||
if (linked) setParams(prev => { const out = new URLSearchParams(prev); out.delete('file'); return out }, { replace: true })
|
||||
// A file that was not installed is not kept for the next visit.
|
||||
const flow = done && actions.flows[`flow:${done.key}`]
|
||||
if (done && !(flow && flow.step === 'done')) filesApi.remove(done.local.fileId).catch(() => {})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<input ref={input} type="file" accept=".zip,application/zip" hidden onChange={pick} aria-label={t('file.pick')} data-testid="install-file-input" />
|
||||
<Button icon="upload" busy={busy} disabled={busy} onClick={() => input.current && input.current.click()} data-testid="addons-install-file">
|
||||
{busy ? t('file.checking') : t('file.button')}
|
||||
</Button>
|
||||
{error && (
|
||||
<div className="ad-file-error" role="alert" data-testid="install-file-error">
|
||||
<Callout tone="error" actions={<Button size="sm" onClick={() => setError(null)}>{t('catalog.close')}</Button>}>
|
||||
{t('file.rejected')} {error}
|
||||
</Callout>
|
||||
</div>
|
||||
)}
|
||||
<InstallDialog entry={entry} installed={{}} flow={entry ? actions.flows[`flow:${entry.key}`] : undefined} actions={actions} onClose={close} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { describeError } from './errors'
|
||||
import { updateView, formatWhen, upgradeAvailability } from './updateState'
|
||||
import { upgradeFailureWords } from './updateReview'
|
||||
import RecoveryPanel from './RecoveryPanel'
|
||||
import { isLocalFile } from './fileInstall'
|
||||
import { addonUpgrades } from '../../utils/addonUpgrades'
|
||||
import { reasonText } from './UpgradeControls'
|
||||
import { UpgradeReason } from '../upgrade/UpgradeBits'
|
||||
@@ -25,7 +26,7 @@ export function OperatorFacts({ addon, api, language, actions, check, upgraded,
|
||||
<h3 id="manage-trust" className="ad-section__title">{t('manage.trust')}</h3>
|
||||
<dl className="dk-kv ad-kv">
|
||||
<dt>{t('trust.verification')}</dt><dd>{t(`trust.mode.${addon.verification === 'sha256+sigstore' ? 'signed' : 'digest'}`)}</dd>
|
||||
<dt>{t('trust.library')}</dt><dd>{addon.sourceId || '—'}</dd>
|
||||
<dt>{t('trust.library')}</dt><dd data-testid="trust-library">{isLocalFile(addon.sourceId) ? t('file.sourceValue') : (addon.sourceId || '—')}</dd>
|
||||
{addon.channel && (<><dt>{t('trust.channel')}</dt><dd>{addon.channel}</dd></>)}
|
||||
{addon.selection && (<><dt>{t('trust.selection')}</dt><dd>{addon.selection}</dd></>)}
|
||||
<dt>{t('trust.digest')}</dt><dd className="dk-mono ad-wrap" title={addon.digest}>{shortDigest(addon.digest)}</dd>
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// Pure helpers of the install from a file: what the owner found in an
|
||||
// uploaded zip, shaped for the install dialog that library entries use.
|
||||
|
||||
// The source of a package that came from a file. Nothing signs it.
|
||||
export const LOCAL_FILE = 'local-file'
|
||||
|
||||
export function isLocalFile(sourceId) {
|
||||
return sourceId === LOCAL_FILE
|
||||
}
|
||||
|
||||
const RELATIONS = new Set(['new', 'upgrade', 'same', 'downgrade'])
|
||||
|
||||
const list = (v) => (Array.isArray(v) ? v.filter(x => typeof x === 'string') : [])
|
||||
const text = (v, max = 200) => (typeof v === 'string' ? v.slice(0, max) : '')
|
||||
|
||||
// The answer of the upload, checked field by field. Anything the owner did
|
||||
// not send in the expected shape is dropped, never guessed.
|
||||
export function normalizeFile(raw) {
|
||||
const v = raw && typeof raw === 'object' ? raw : {}
|
||||
const api = v.api && typeof v.api === 'object' ? v.api : {}
|
||||
return {
|
||||
fileId: text(v.fileId, 64),
|
||||
name: text(v.name),
|
||||
size: Number.isFinite(v.size) ? v.size : 0,
|
||||
sha256: text(v.sha256, 64).toLowerCase(),
|
||||
addonId: text(v.addonId, 64),
|
||||
title: text(v.title) || text(v.addonId, 64),
|
||||
version: text(v.version, 64),
|
||||
platform: text(v.platform, 32),
|
||||
api: { min: Number.isFinite(api.min) ? api.min : 0, max: Number.isFinite(api.max) ? api.max : 0 },
|
||||
required: list(v.required),
|
||||
optional: list(v.optional),
|
||||
installed: v.installed === true,
|
||||
installedVersion: text(v.installedVersion, 64),
|
||||
relation: RELATIONS.has(v.relation) ? v.relation : 'new',
|
||||
}
|
||||
}
|
||||
|
||||
// The entry the install dialog works on. The dialog asks the owner what the
|
||||
// file needs from the host with the same preview as for a library entry: the
|
||||
// source is local-file and the version is the id of the staged file.
|
||||
export function fileEntry(file) {
|
||||
return {
|
||||
key: `${LOCAL_FILE}/${file.addonId}/${file.fileId}`,
|
||||
id: file.addonId,
|
||||
title: file.title,
|
||||
sourceId: LOCAL_FILE,
|
||||
version: file.fileId,
|
||||
digest: `sha256:${file.sha256}`,
|
||||
policy: { requireSignature: false },
|
||||
shadows: [],
|
||||
local: file,
|
||||
}
|
||||
}
|
||||
|
||||
// Whether the file may be installed at all, and what the person must confirm
|
||||
// first. The same version is never installed again; an older one needs a tick.
|
||||
export function fileGate(file, { downgradeTicked = false } = {}) {
|
||||
if (!file || file.relation === 'same') return { allowed: false, reason: 'same' }
|
||||
if (file.relation === 'downgrade' && !downgradeTicked) return { allowed: false, reason: 'downgrade' }
|
||||
return { allowed: true, reason: '' }
|
||||
}
|
||||
|
||||
// The lifecycle action the file needs: an installed add-on is upgraded.
|
||||
export function fileAction(file) {
|
||||
return file && file.installed ? 'upgrade' : 'install'
|
||||
}
|
||||
|
||||
// The file id a link carries in ?file=, or '' when it has not the shape of one.
|
||||
export function fileIdFromParams(params) {
|
||||
const id = params && typeof params.get === 'function' ? params.get('file') : ''
|
||||
return /^[0-9a-f]{32}$/.test(id || '') ? id : ''
|
||||
}
|
||||
@@ -125,10 +125,11 @@ export function approvedOps(preview, ticked) {
|
||||
}
|
||||
|
||||
// The body of the install or upgrade that carries the approval.
|
||||
export function approvalBody(preview, ticked, { operationId, action = 'install', sourceId, addonId, version, blanketAck } = {}) {
|
||||
export function approvalBody(preview, ticked, { operationId, action = 'install', sourceId, addonId, version, blanketAck, downgrade } = {}) {
|
||||
const approve = { digest: preview.digest, ops: approvedOps(preview, ticked), revision: preview.revision, nonce: preview.nonce }
|
||||
if (blanketAck) approve.blanketAck = true
|
||||
return { operationId, action, sourceId, addonId, version, approve }
|
||||
// Only an install from a file can be a downgrade, and only after a tick.
|
||||
return { operationId, action, sourceId, addonId, version, ...(downgrade ? { downgrade: true } : {}), approve }
|
||||
}
|
||||
|
||||
// The operations grouped by family (the part before the first dot), in the
|
||||
|
||||
@@ -11,7 +11,7 @@ import { normalizeOperation } from '../../utils/addonsApi.js'
|
||||
// route, which grants only what the verified package declares; without it the
|
||||
// plain install runs, and the owner refuses it when the add-on needs a
|
||||
// host operation that is not held.
|
||||
export async function runInstallAndTurnOn({ run, api, adapter, hostGrants, mark }, entry, { skipInstall = false, approval = null } = {}) {
|
||||
export async function runInstallAndTurnOn({ run, api, adapter, hostGrants, mark }, entry, { skipInstall = false, approval = null, turnOn = true } = {}) {
|
||||
if (!skipInstall) {
|
||||
mark({ step: 'install' })
|
||||
const installed = await run(`install:${entry.key}`, {
|
||||
@@ -27,6 +27,8 @@ export async function runInstallAndTurnOn({ run, api, adapter, hostGrants, mark
|
||||
if (!installed) return null
|
||||
if (installed.outcome !== 'completed') { mark({ step: 'failed', failedStep: 'install', outcome: installed.outcome, error: installed.error }); return installed }
|
||||
}
|
||||
// An upgrade from a file keeps the add-on as it was: it does not turn it on.
|
||||
if (!turnOn) { mark({ step: 'done' }); return { outcome: 'completed' } }
|
||||
mark({ step: 'turn-on' })
|
||||
// Generation 0 sets no precondition: the add-on was just installed.
|
||||
const on = await run(`enable:${entry.id}:0`, {
|
||||
|
||||
@@ -9,6 +9,7 @@ import AppsView from '../components/addons/AppsView'
|
||||
import BrowseView from '../components/addons/BrowseView'
|
||||
import LibrariesView from '../components/addons/LibrariesView'
|
||||
import HostAccessView from '../components/addons/HostAccessView'
|
||||
import InstallFromFile from '../components/addons/InstallFromFile'
|
||||
import { useHostGrants } from '../components/addons/useHostGrants'
|
||||
import { pendingApprovals } from '../components/addons/hostGrantsState'
|
||||
import { resolveManage, resolveTab, visibleTabs } from '../components/addons/addonState'
|
||||
@@ -24,6 +25,8 @@ export default function Addons() {
|
||||
// The installed count for the tab and whether the list is a first run (the
|
||||
// empty state then carries the way to Find add-ons, so the header does not).
|
||||
const [summary, setSummary] = useState(null)
|
||||
// A file install changes the list; the list is read again.
|
||||
const [listKey, setListKey] = useState(0)
|
||||
const [params, setParams] = useSearchParams()
|
||||
const manage = resolveManage(params, isAdmin)
|
||||
const tab = manage ? 'apps' : resolveTab(params.get('tab'), isAdmin)
|
||||
@@ -68,7 +71,12 @@ export default function Addons() {
|
||||
<PageHeader
|
||||
title={t('title')}
|
||||
supporting={<>{t('supporting')}{pagesNote && <> {pagesNote}</>}</>}
|
||||
actions={showFind ? <Button variant="primary" icon="store" to="/app/addons?tab=browse" data-testid="addons-add">{t('drawer.add')}</Button> : null}
|
||||
actions={isAdmin && tab === 'apps' ? (
|
||||
<>
|
||||
{showFind && <Button variant="primary" icon="store" to="/app/addons?tab=browse" data-testid="addons-add">{t('drawer.add')}</Button>}
|
||||
<InstallFromFile onInstalled={() => setListKey(n => n + 1)} />
|
||||
</>
|
||||
) : null}
|
||||
/>
|
||||
|
||||
{tabs.length > 1 && (
|
||||
@@ -95,7 +103,7 @@ export default function Addons() {
|
||||
)}
|
||||
|
||||
<div role="tabpanel" id={`ad-panel-${tab}`} aria-labelledby={tabs.length > 1 ? `ad-tab-${tab}` : undefined} aria-label={tabs.length > 1 ? undefined : t('tabs.apps')} className="ad-panel">
|
||||
{tab === 'apps' && <AppsView api={addonsApi} adapter={addonsCatalog} onSummary={setSummary} grants={grants} />}
|
||||
{tab === 'apps' && <AppsView key={listKey} api={addonsApi} adapter={addonsCatalog} onSummary={setSummary} grants={grants} />}
|
||||
{tab === 'browse' && <BrowseView api={addonsApi} adapter={addonsCatalog} />}
|
||||
{tab === 'libraries' && <LibrariesView adapter={addonsCatalog} />}
|
||||
{tab === 'access' && <HostAccessView grants={grants} />}
|
||||
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
// Client of the add-on file routes: an add-on zip is staged on the owner first,
|
||||
// then installed through the host access install. Staging runs nothing; the
|
||||
// owner keeps the file for an hour, for the person who uploaded it.
|
||||
import { classifyHostGrants } from './hostGrantsApi.js'
|
||||
|
||||
// The id of a staged file: 32 lower-case hex characters.
|
||||
export const FILE_ID = /^[0-9a-f]{32}$/
|
||||
|
||||
// The rules a file can break, and the answers about a staged file. Each is a
|
||||
// final answer with words of its own from the owner.
|
||||
const TYPES = new Set([
|
||||
'bad_zip', 'unsafe_path', 'unsupported_entry', 'too_large', 'too_many_files', 'compression_ratio',
|
||||
'bad_manifest', 'digest_mismatch', 'wrong_platform', 'bad_mode', 'not_found', 'busy', 'invalid_request',
|
||||
])
|
||||
|
||||
export function classifyFiles(status, body) {
|
||||
const err = body && typeof body === 'object' ? body.error : null
|
||||
const type = err && typeof err === 'object' && typeof err.type === 'string' ? err.type : ''
|
||||
if (TYPES.has(type)) {
|
||||
return { kind: 'invalid', type, status, message: typeof err.message === 'string' ? err.message : '', details: err }
|
||||
}
|
||||
return classifyHostGrants(status, body)
|
||||
}
|
||||
|
||||
export function createAddonFilesApi(addonsApi) {
|
||||
const call = (path, opts = {}) => addonsApi.call(`/api/addons/files${path}`, { classify: classifyFiles, ...opts })
|
||||
return {
|
||||
// Sends the file and answers with what the owner found in it.
|
||||
upload(file, signal) {
|
||||
const form = new FormData()
|
||||
form.append('file', file, file.name)
|
||||
return call('', { method: 'POST', body: form, signal })
|
||||
},
|
||||
describe(id, signal) { return call(`/${encodeURIComponent(id)}`, { signal }) },
|
||||
async remove(id, signal) { await call(`/${encodeURIComponent(id)}`, { method: 'DELETE', signal }) },
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -59,11 +59,13 @@ export function createAddonsApi({ fetch: fetchImpl, base = p => p } = {}) {
|
||||
async function call(path, { method = 'GET', body, signal, headers: extra, classify = classifyResponse } = {}) {
|
||||
let response
|
||||
try {
|
||||
// A form goes as it is: the browser writes the multipart boundary.
|
||||
const form = typeof FormData !== 'undefined' && body instanceof FormData
|
||||
response = await doFetch(base(path), {
|
||||
method,
|
||||
signal,
|
||||
headers: { ...(body === undefined ? { Accept: 'application/json' } : { Accept: 'application/json', 'Content-Type': 'application/json' }), ...(extra || {}) },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
headers: { ...(body === undefined || form ? { Accept: 'application/json' } : { Accept: 'application/json', 'Content-Type': 'application/json' }), ...(extra || {}) },
|
||||
body: body === undefined ? undefined : form ? body : JSON.stringify(body),
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
} catch (e) {
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
import { apiUrl } from './basePath'
|
||||
import { createAddonsApi, createAddonsCatalogAdapter, normalizeOperation } from './addonsApi'
|
||||
import { createHostGrantsApi } from './hostGrantsApi'
|
||||
import { createAddonFilesApi } from './addonFilesApi'
|
||||
|
||||
export const addonsApi = createAddonsApi({ base: apiUrl })
|
||||
export const addonsCatalog = createAddonsCatalogAdapter(addonsApi)
|
||||
export const addonFilesApi = createAddonFilesApi(addonsApi)
|
||||
export const hostGrantsApi = createHostGrantsApi(addonsApi)
|
||||
export { normalizeOperation }
|
||||
@@ -0,0 +1,120 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { LOCAL_FILE, isLocalFile, normalizeFile, fileEntry, fileGate, fileAction, fileIdFromParams } from '../../src/components/addons/fileInstall.js'
|
||||
import { approvalBody } from '../../src/components/addons/hostGrantsState.js'
|
||||
import { createAddonFilesApi } from '../../src/utils/addonFilesApi.js'
|
||||
import { createAddonsApi, AddonsError } from '../../src/utils/addonsApi.js'
|
||||
import { runInstallAndTurnOn } from '../../src/components/addons/installAndTurnOn.js'
|
||||
|
||||
const ID = 'a'.repeat(32)
|
||||
const RAW = {
|
||||
fileId: ID, name: 'memory-0.2.0-linux-amd64.zip', size: 1234, sha256: 'AB'.repeat(32), addonId: 'memory', title: 'Short memory',
|
||||
version: '0.2.0', platform: 'linux/amd64', api: { min: 2, max: 2 }, required: ['workspace.materialize'], optional: ['egress.check'],
|
||||
installed: true, installedVersion: '0.1.0', relation: 'upgrade', origin: 'local file, unsigned',
|
||||
}
|
||||
|
||||
test('addonfile: the answer of the upload is checked field by field', () => {
|
||||
const f = normalizeFile(RAW)
|
||||
assert.equal(f.sha256, 'ab'.repeat(32))
|
||||
assert.deepEqual(f.api, { min: 2, max: 2 })
|
||||
assert.equal(f.relation, 'upgrade')
|
||||
assert.deepEqual(normalizeFile({ ...RAW, relation: 'weird', required: 'x', size: 'big' }).required, [])
|
||||
assert.equal(normalizeFile({ ...RAW, relation: 'weird' }).relation, 'new')
|
||||
assert.equal(normalizeFile(null).fileId, '')
|
||||
})
|
||||
|
||||
test('addonfile: the dialog entry names the local file source and the staged file id as the version', () => {
|
||||
const e = fileEntry(normalizeFile(RAW))
|
||||
assert.equal(e.sourceId, LOCAL_FILE)
|
||||
assert.equal(e.version, ID)
|
||||
assert.equal(e.id, 'memory')
|
||||
assert.equal(e.digest, 'sha256:' + 'ab'.repeat(32))
|
||||
assert.equal(e.policy.requireSignature, false)
|
||||
assert.equal(isLocalFile(e.sourceId), true)
|
||||
assert.equal(isLocalFile('official'), false)
|
||||
})
|
||||
|
||||
test('addonfile: the same version is never installed, an older one needs the tick', () => {
|
||||
const f = (relation) => normalizeFile({ ...RAW, relation })
|
||||
assert.deepEqual(fileGate(f('same'), { downgradeTicked: true }), { allowed: false, reason: 'same' })
|
||||
assert.deepEqual(fileGate(f('downgrade')), { allowed: false, reason: 'downgrade' })
|
||||
assert.equal(fileGate(f('downgrade'), { downgradeTicked: true }).allowed, true)
|
||||
assert.equal(fileGate(f('upgrade')).allowed, true)
|
||||
assert.equal(fileGate(f('new')).allowed, true)
|
||||
assert.equal(fileGate(null).allowed, false)
|
||||
})
|
||||
|
||||
test('addonfile: an installed add-on is upgraded and a new one installed', () => {
|
||||
assert.equal(fileAction(normalizeFile(RAW)), 'upgrade')
|
||||
assert.equal(fileAction(normalizeFile({ ...RAW, installed: false, relation: 'new' })), 'install')
|
||||
})
|
||||
|
||||
test('addonfile: only a well formed file id in the link opens the dialog', () => {
|
||||
assert.equal(fileIdFromParams(new URLSearchParams(`file=${ID}`)), ID)
|
||||
assert.equal(fileIdFromParams(new URLSearchParams('file=../x')), '')
|
||||
assert.equal(fileIdFromParams(new URLSearchParams('')), '')
|
||||
assert.equal(fileIdFromParams(null), '')
|
||||
})
|
||||
|
||||
test('addonfile: the approval carries the downgrade confirmation only when it was given', () => {
|
||||
const preview = { digest: 'sha256:aa', ops: [], revision: 3, nonce: 'n', blockedRequired: [] }
|
||||
const base = { action: 'upgrade', sourceId: LOCAL_FILE, addonId: 'memory', version: ID }
|
||||
assert.equal('downgrade' in approvalBody(preview, [], base), false)
|
||||
assert.equal(approvalBody(preview, [], { ...base, downgrade: true }).downgrade, true)
|
||||
assert.equal(approvalBody(preview, [], base).version, ID)
|
||||
})
|
||||
|
||||
test('addonfile: the upload is a multipart form with the file part, and the file routes are the documented ones', async () => {
|
||||
const seen = []
|
||||
const fetch = async (url, init) => {
|
||||
seen.push({ url, init })
|
||||
return { ok: true, status: 200, json: async () => RAW }
|
||||
}
|
||||
const api = createAddonFilesApi(createAddonsApi({ fetch }))
|
||||
const file = new File([new Uint8Array([80, 75])], 'x.zip')
|
||||
const out = await api.upload(file)
|
||||
assert.equal(out.fileId, ID)
|
||||
assert.equal(seen[0].url, '/api/addons/files')
|
||||
assert.equal(seen[0].init.method, 'POST')
|
||||
assert.ok(seen[0].init.body instanceof FormData)
|
||||
assert.equal(seen[0].init.body.get('file').name, 'x.zip')
|
||||
assert.equal(seen[0].init.headers['Content-Type'], undefined, 'the browser writes the boundary')
|
||||
await api.describe(ID)
|
||||
assert.equal(seen[1].url, `/api/addons/files/${ID}`)
|
||||
await api.remove(ID)
|
||||
assert.equal(seen[2].init.method, 'DELETE')
|
||||
})
|
||||
|
||||
test('addonfile: a refusal of the file keeps the reason the owner gave', async () => {
|
||||
const fetch = async () => ({ ok: false, status: 400, json: async () => ({ error: { type: 'wrong_platform', message: 'This file is for linux/arm64; this host is linux/amd64' } }) })
|
||||
const api = createAddonFilesApi(createAddonsApi({ fetch }))
|
||||
await assert.rejects(api.upload(new File([new Uint8Array([1])], 'x.zip')), (e) => {
|
||||
assert.ok(e instanceof AddonsError)
|
||||
assert.match(e.message, /linux\/arm64/)
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
test('addonfile: an upgrade from a file does not turn the add-on on, a new install does', async () => {
|
||||
const marks = []
|
||||
const ran = []
|
||||
const run = async (key, op) => { ran.push(key); await op.submit('op1', undefined); return { outcome: 'completed' } }
|
||||
const hostGrants = { install: async (body) => ({ payload: { OperationID: body.operationId, AddonID: body.addonId, Phase: 'completed', Generation: 2 } }) }
|
||||
const adapter = { submitLifecycle: async () => { throw new Error('plain install must not run') } }
|
||||
const api = { installed: async () => [{ id: 'memory', enabled: true }] }
|
||||
const entry = { key: 'k', id: 'memory', sourceId: LOCAL_FILE, version: ID }
|
||||
const approval = { action: 'upgrade', sourceId: LOCAL_FILE, addonId: 'memory', version: ID, approve: { digest: 'sha256:aa', ops: [], revision: 0, nonce: 'n' } }
|
||||
await runInstallAndTurnOn({ run, api, adapter, hostGrants, mark: v => marks.push(v.step) }, entry, { approval, turnOn: false })
|
||||
assert.deepEqual(ran, ['install:k'])
|
||||
assert.deepEqual(marks, ['install', 'done'])
|
||||
ran.length = 0; marks.length = 0
|
||||
adapter.submitLifecycle = async () => ({ payload: {} })
|
||||
await runInstallAndTurnOn({ run, api, adapter, hostGrants, mark: v => marks.push(v.step) }, entry, { approval })
|
||||
assert.deepEqual(ran, ['install:k', 'enable:memory:0'])
|
||||
})
|
||||
|
||||
test('addonfile: a gone file is a typed answer, not an outage', async () => {
|
||||
const fetch = async () => ({ ok: false, status: 404, json: async () => ({ error: { type: 'not_found', message: 'No such file' } }) })
|
||||
const api = createAddonFilesApi(createAddonsApi({ fetch }))
|
||||
await assert.rejects(api.describe(ID), (e) => e.kind === 'invalid' && e.type === 'not_found' && e.status === 404)
|
||||
})
|
||||
Reference in new issue
Block a user