diff --git a/.env b/.env index 99108e92..de8517c7 100644 --- a/.env +++ b/.env @@ -17,6 +17,7 @@ PUBLIC_SITE_URL=https://openshock.app PUBLIC_SITE_SHORT_URL=https://openshock.app PUBLIC_BACKEND_API_URL=https://api.openshock.app PUBLIC_GATEWAY_CSP_WILDCARD=https://*.openshock.app +PUBLIC_FIRMWARE_REPO_URL=https://repo.openshock.org # Server-side only (Node adapter). When set to `true`, disables TLS certificate # validation for the server's own outgoing requests, allowing SSR/API calls to a @@ -41,4 +42,4 @@ PUBLIC_SIGNOZ_TRACE_PROPAGATION=false PUBLIC_SIGNOZ_DEPLOYMENT_ENVIRONMENT= # Extra OTel resource attributes, comma-separated key=value pairs (same format as the standard # OTEL_RESOURCE_ATTRIBUTES env var). Example: deployment.region=eu,team=frontend -PUBLIC_SIGNOZ_RESOURCE_ATTRIBUTES= \ No newline at end of file +PUBLIC_SIGNOZ_RESOURCE_ATTRIBUTES= diff --git a/.env.development b/.env.development index 15730ea9..3cbdbe48 100644 --- a/.env.development +++ b/.env.development @@ -2,6 +2,7 @@ PUBLIC_SITE_URL=https://openshock.dev PUBLIC_SITE_SHORT_URL=https://openshock.dev PUBLIC_BACKEND_API_URL=https://api.openshock.dev PUBLIC_GATEWAY_CSP_WILDCARD=https://*.openshock.dev +PUBLIC_FIRMWARE_REPO_URL=https://repo.openshock.dev PUBLIC_TURNSTILE_DEV_BYPASS_VALUE=dev-bypass PUBLIC_DEVELOPMENT_BANNER=true \ No newline at end of file diff --git a/src/lib/api/firmwareCDN.ts b/src/lib/api/firmwareCDN.ts deleted file mode 100644 index 84a0b518..00000000 --- a/src/lib/api/firmwareCDN.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { HashBuffer } from '@openshock/svelte-core/utils'; - -export const FirmwareChannels = ['stable', 'beta', 'develop'] as const; -export type FirmwareChannel = (typeof FirmwareChannels)[number]; - -const BASE_URL = 'https://firmware.openshock.org'; - -const versionUrl = (channel: string) => `${BASE_URL}/version-${channel}.txt`; -const boardsListUrl = (version: string) => `${BASE_URL}/${version}/boards.txt`; -const boardFileUrl = (version: string, board: string, fileName: string) => - `${BASE_URL}/${version}/${board}/${fileName}`; - -async function DownloadText(url: string) { - const response = await fetch(url); - if (!response.ok) - throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`); - const text = await response.text(); - return text.trim(); -} -async function DownloadLines(url: string) { - const text = await DownloadText(url); - return text.split('\n').map((x) => x.trim()); -} -async function DownloadBinary(url: string) { - const response = await fetch(url); - if (!response.ok) - throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`); - return await response.bytes(); -} - -export function FetchChannelVersion(channel: FirmwareChannel) { - return DownloadText(versionUrl(channel)); -} - -export function FetchVersionBoards(version: string) { - return DownloadLines(boardsListUrl(version)); -} - -export function DownloadBoardBinary(version: string, board: string, filename: string) { - return DownloadBinary(boardFileUrl(version, board, filename)); -} - -export async function GetBoardBinaryHashes( - version: string, - board: string, - hashType: 'md5' | 'sha256' = 'sha256' -) { - const lines = await DownloadLines(boardFileUrl(version, board, `hashes.${hashType}.txt`)); - - let hashLength: number; - switch (hashType) { - case 'md5': - hashLength = 32; - break; - case 'sha256': - hashLength = 64; - break; - default: - throw new Error(`Unsupported hash type: ${hashType}`); - } - - const hashes: Record = {}; - for (const line of lines) { - const parts = line.split(' '); - if (parts.length < 2) throw new Error(`Invalid hash line: ${line}`); - const hash = parts[0].trim(); - if (hash.length !== hashLength) throw new Error(`Invalid hash length in line: ${line}`); - if (!/^[a-f0-9]+$/i.test(hash)) throw new Error(`Invalid hash format in line: ${line}`); - - let filename = parts.slice(1).join(' ').trim(); // Join the rest in case filename has spaces - if (!filename) throw new Error(`Invalid filename in line: ${line}`); - - if (filename.startsWith('./')) { - // Remove leading './' if present - filename = filename.slice(2); - } - - hashes[filename] = hash; - } - - return hashes; -} -export async function GetBoardBinaryHash( - version: string, - board: string, - filename: string, - hashType: 'md5' | 'sha256' -) { - if (filename.startsWith('./')) { - filename = filename.slice(2); // Remove leading './' if present - } - - const hashes = await GetBoardBinaryHashes(version, board, hashType); - if (filename in hashes) { - return hashes[filename]; - } - - return null; -} - -export async function DownloadAndVerifyBoardBinary( - version: string, - board: string, - filename: string -) { - // Download the binary and its hash in parallel - const [binary, hash] = await Promise.all([ - DownloadBinary(boardFileUrl(version, board, filename)), - GetBoardBinaryHash(version, board, filename, 'sha256'), - ]); - - if (!hash) { - throw new Error(`No hash found for ${filename} in board ${board} version ${version}`); - } - - // Calculate the hash of the downloaded binary - const calculatedHash = await HashBuffer(binary, 'SHA-256'); - if (calculatedHash !== hash) { - throw new Error( - `Hash mismatch for ${filename} in board ${board} version ${version}: expected ${hash}, got ${calculatedHash}` - ); - } - - return binary; -} diff --git a/src/lib/api/firmwareRepo/ResponseError.ts b/src/lib/api/firmwareRepo/ResponseError.ts new file mode 100644 index 00000000..f5ea047d --- /dev/null +++ b/src/lib/api/firmwareRepo/ResponseError.ts @@ -0,0 +1,9 @@ +export class ResponseError extends Error { + override name = 'ResponseError'; + constructor( + public response: Response, + msg?: string + ) { + super(msg ?? `HTTP ${response.status} ${response.statusText}`); + } +} diff --git a/src/lib/api/firmwareRepo/TransformError.ts b/src/lib/api/firmwareRepo/TransformError.ts new file mode 100644 index 00000000..f52dba14 --- /dev/null +++ b/src/lib/api/firmwareRepo/TransformError.ts @@ -0,0 +1,6 @@ +export class TransformError extends Error { + override name = 'TransformError'; + constructor(message: string) { + super(message); + } +} diff --git a/src/lib/api/firmwareRepo/artifacts.ts b/src/lib/api/firmwareRepo/artifacts.ts new file mode 100644 index 00000000..2927989d --- /dev/null +++ b/src/lib/api/firmwareRepo/artifacts.ts @@ -0,0 +1,37 @@ +import { HashBuffer } from '@openshock/svelte-core/utils'; +import type { FirmwareArtifact, FirmwareRelease } from './models'; + +export function FindArtifact( + release: FirmwareRelease, + board: string, + type: string +): FirmwareArtifact | null { + const boardInfo = release.boards[board]; + if (!boardInfo) return null; + return boardInfo.artifacts.find((a) => a.type === type) ?? null; +} + +async function DownloadBinary(url: string): Promise { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`); + } + + if ('bytes' in Response.prototype) { + return await response.bytes(); + } + + const buf = await response.arrayBuffer(); + return new Uint8Array(buf); +} + +export async function DownloadAndVerifyArtifact(artifact: FirmwareArtifact): Promise { + const binary = await DownloadBinary(artifact.url); + + const calculatedHash = await HashBuffer(binary.buffer as ArrayBuffer, 'SHA-256'); + if (calculatedHash.toUpperCase() !== artifact.sha256Hash.toUpperCase()) { + throw new Error(`Hash mismatch: expected ${artifact.sha256Hash}, got ${calculatedHash}`); + } + + return binary; +} diff --git a/src/lib/api/firmwareRepo/base.ts b/src/lib/api/firmwareRepo/base.ts new file mode 100644 index 00000000..f4477ecf --- /dev/null +++ b/src/lib/api/firmwareRepo/base.ts @@ -0,0 +1,38 @@ +import { getFirmwareRepoURL, type FirmwareRepoPath } from '$lib/utils/url'; +import { ResponseError } from './ResponseError'; + +/** + * Fetches JSON from the firmware repository server. + * + * Unlike the backend client this sends no credentials — the repository server is a public, + * cross-origin, read-only service. + * + * Pass a `URL` instead of a path when the request needs query parameters; build it with + * {@link getFirmwareRepoURL} and set them via `searchParams`. + */ +export async function GetJson( + path: FirmwareRepoPath | URL, + expectedStatus = 200, + transformer: (data: unknown) => T +): Promise { + const url = path instanceof URL ? path : getFirmwareRepoURL(path); + + const res = await fetch(url, { + method: 'GET', + headers: { accept: 'application/json' }, + redirect: 'error', + }); + + if (res.status !== expectedStatus) { + throw new ResponseError(res, `Unexpected status ${res.status} for GET ${url.href}`); + } + + const contentType = res.headers.get('content-type') ?? ''; + if (!contentType.includes('application/json')) { + throw new ResponseError(res, `Expected JSON but got ${contentType}`); + } + + const data = await res.json(); + + return transformer(data); +} diff --git a/src/lib/api/firmwareRepo/boards.ts b/src/lib/api/firmwareRepo/boards.ts new file mode 100644 index 00000000..8ce15693 --- /dev/null +++ b/src/lib/api/firmwareRepo/boards.ts @@ -0,0 +1,21 @@ +import type { FirmwareRelease } from './models'; + +/** + * Board names available in a release, optionally narrowed to a chip. + * + * `chip` is matched against the chip's esptool-js name, which is what a connected-device + * detection returns. + */ +export function ExtractBoards( + release: FirmwareRelease, + chip?: string | null, + includeDiscontinued = false +): string[] { + const entries = Object.entries(release.boards); + const filtered = entries.filter(([, board]) => { + if (!includeDiscontinued && board.discontinued) return false; + if (chip && board.chip.name !== chip) return false; + return true; + }); + return filtered.map(([name]) => name).sort(); +} diff --git a/src/lib/api/firmwareRepo/firmware.ts b/src/lib/api/firmwareRepo/firmware.ts new file mode 100644 index 00000000..3a6acd0a --- /dev/null +++ b/src/lib/api/firmwareRepo/firmware.ts @@ -0,0 +1,57 @@ +import { getFirmwareRepoURL } from '$lib/utils/url'; +import { GetJson } from './base'; +import type { + FirmwareBoardRelease, + FirmwareChannel, + FirmwareRelease, + FirmwareVersionHistory, +} from './models'; +import { + TransformFirmwareBoardRelease, + TransformFirmwareRelease, + TransformFirmwareVersionHistory, +} from './transformers'; + +/** Most recent published release for a channel, with every board. */ +export async function FetchLatest(channel: FirmwareChannel): Promise { + return GetJson(`2/firmware/latest/${channel}`, 200, TransformFirmwareRelease); +} + +/** + * Full release details for one version. Versions are globally unique, so this is not + * scoped by channel. + */ +export async function FetchVersion(version: string): Promise { + return GetJson( + `2/firmware/versions/${encodeURIComponent(version)}`, + 200, + TransformFirmwareRelease + ); +} + +/** Artifacts for a single board in a single version. `board` is the board name. */ +export async function FetchBoardRelease( + version: string, + board: string +): Promise { + return GetJson( + `2/firmware/versions/${encodeURIComponent(version)}/${encodeURIComponent(board)}`, + 200, + TransformFirmwareBoardRelease + ); +} + +/** Paginated version history. Channel is an optional filter, passed as a query parameter. */ +export async function FetchVersionHistory( + channel?: FirmwareChannel | null, + limit = 20, + offset = 0 +): Promise { + const url = getFirmwareRepoURL('2/firmware/versions'); + + url.searchParams.set('limit', String(limit)); + url.searchParams.set('offset', String(offset)); + if (channel) url.searchParams.set('channel', channel); + + return GetJson(url, 200, TransformFirmwareVersionHistory); +} diff --git a/src/lib/api/firmwareRepo/models/FirmwareArtifact.ts b/src/lib/api/firmwareRepo/models/FirmwareArtifact.ts new file mode 100644 index 00000000..4727b7a6 --- /dev/null +++ b/src/lib/api/firmwareRepo/models/FirmwareArtifact.ts @@ -0,0 +1,6 @@ +export interface FirmwareArtifact { + type: string; + url: string; + sha256Hash: string; + fileSize: number; +} diff --git a/src/lib/api/firmwareRepo/models/FirmwareBoard.ts b/src/lib/api/firmwareRepo/models/FirmwareBoard.ts new file mode 100644 index 00000000..910b91a0 --- /dev/null +++ b/src/lib/api/firmwareRepo/models/FirmwareBoard.ts @@ -0,0 +1,8 @@ +import type { FirmwareArtifact } from './FirmwareArtifact'; +import type { FirmwareChipRef } from './FirmwareChipRef'; + +export interface FirmwareBoard { + chip: FirmwareChipRef; + discontinued: boolean; + artifacts: FirmwareArtifact[]; +} diff --git a/src/lib/api/firmwareRepo/models/FirmwareBoardRelease.ts b/src/lib/api/firmwareRepo/models/FirmwareBoardRelease.ts new file mode 100644 index 00000000..737005e9 --- /dev/null +++ b/src/lib/api/firmwareRepo/models/FirmwareBoardRelease.ts @@ -0,0 +1,8 @@ +import type { FirmwareArtifact } from './FirmwareArtifact'; + +/** Minimal single-board response. `boardId` is the canonical board name. */ +export interface FirmwareBoardRelease { + version: string; + boardId: string; + artifacts: FirmwareArtifact[]; +} diff --git a/src/lib/api/firmwareRepo/models/FirmwareChannel.ts b/src/lib/api/firmwareRepo/models/FirmwareChannel.ts new file mode 100644 index 00000000..b7bd75a2 --- /dev/null +++ b/src/lib/api/firmwareRepo/models/FirmwareChannel.ts @@ -0,0 +1,3 @@ +export const FirmwareChannels = ['stable', 'beta', 'develop'] as const; + +export type FirmwareChannel = (typeof FirmwareChannels)[number]; diff --git a/src/lib/api/firmwareRepo/models/FirmwareChipRef.ts b/src/lib/api/firmwareRepo/models/FirmwareChipRef.ts new file mode 100644 index 00000000..ce9d6390 --- /dev/null +++ b/src/lib/api/firmwareRepo/models/FirmwareChipRef.ts @@ -0,0 +1,7 @@ +/** + * Chip reference. `name` is the public identifier and matches esptool-js chip identifiers exactly + * (e.g. "ESP32-S3") — pass it straight to esptool-js. The chip's UUID is server-internal. + */ +export interface FirmwareChipRef { + name: string; +} diff --git a/src/lib/api/firmwareRepo/models/FirmwareRelease.ts b/src/lib/api/firmwareRepo/models/FirmwareRelease.ts new file mode 100644 index 00000000..116ba822 --- /dev/null +++ b/src/lib/api/firmwareRepo/models/FirmwareRelease.ts @@ -0,0 +1,13 @@ +import type { FirmwareBoard } from './FirmwareBoard'; +import type { FirmwareReleaseNote } from './FirmwareReleaseNote'; +import type { FirmwareSource } from './FirmwareSource'; + +export interface FirmwareRelease { + version: string; + channel: string; + releaseDate: Temporal.Instant; + source: FirmwareSource; + releaseNotes: FirmwareReleaseNote[]; + /** Keyed by canonical board name, e.g. "Wemos-D1-Mini-ESP32". */ + boards: Record; +} diff --git a/src/lib/api/firmwareRepo/models/FirmwareReleaseNote.ts b/src/lib/api/firmwareRepo/models/FirmwareReleaseNote.ts new file mode 100644 index 00000000..e70549ba --- /dev/null +++ b/src/lib/api/firmwareRepo/models/FirmwareReleaseNote.ts @@ -0,0 +1,7 @@ +import type { FirmwareReleaseNoteType } from './FirmwareReleaseNoteType'; + +export interface FirmwareReleaseNote { + type: FirmwareReleaseNoteType; + title: string | null; + content: string; +} diff --git a/src/lib/api/firmwareRepo/models/FirmwareReleaseNoteType.ts b/src/lib/api/firmwareRepo/models/FirmwareReleaseNoteType.ts new file mode 100644 index 00000000..cd23697e --- /dev/null +++ b/src/lib/api/firmwareRepo/models/FirmwareReleaseNoteType.ts @@ -0,0 +1,3 @@ +export const FirmwareReleaseNoteTypes = ['breaking', 'warning', 'info', 'section'] as const; + +export type FirmwareReleaseNoteType = (typeof FirmwareReleaseNoteTypes)[number]; diff --git a/src/lib/api/firmwareRepo/models/FirmwareRepository.ts b/src/lib/api/firmwareRepo/models/FirmwareRepository.ts new file mode 100644 index 00000000..34e476f5 --- /dev/null +++ b/src/lib/api/firmwareRepo/models/FirmwareRepository.ts @@ -0,0 +1,6 @@ +export interface FirmwareRepository { + id: string; + provider: string; + owner: string; + repo: string; +} diff --git a/src/lib/api/firmwareRepo/models/FirmwareSource.ts b/src/lib/api/firmwareRepo/models/FirmwareSource.ts new file mode 100644 index 00000000..d30886b9 --- /dev/null +++ b/src/lib/api/firmwareRepo/models/FirmwareSource.ts @@ -0,0 +1,12 @@ +import type { FirmwareRepository } from './FirmwareRepository'; + +/** Source traceability. URLs are constructed server-side per provider. */ +export interface FirmwareSource { + repository: FirmwareRepository; + commitHash: string; + ref: string | null; + runId: string | null; + commitUrl: string; + refUrl: string | null; + runUrl: string | null; +} diff --git a/src/lib/api/firmwareRepo/models/FirmwareVersionHistory.ts b/src/lib/api/firmwareRepo/models/FirmwareVersionHistory.ts new file mode 100644 index 00000000..6c0ecdac --- /dev/null +++ b/src/lib/api/firmwareRepo/models/FirmwareVersionHistory.ts @@ -0,0 +1,7 @@ +import type { FirmwareVersionSummary } from './FirmwareVersionSummary'; + +/** One page of version history. `total` counts every version matching the filter, not just this page. */ +export interface FirmwareVersionHistory { + versions: FirmwareVersionSummary[]; + total: number; +} diff --git a/src/lib/api/firmwareRepo/models/FirmwareVersionSummary.ts b/src/lib/api/firmwareRepo/models/FirmwareVersionSummary.ts new file mode 100644 index 00000000..bb8d09de --- /dev/null +++ b/src/lib/api/firmwareRepo/models/FirmwareVersionSummary.ts @@ -0,0 +1,10 @@ +import type { FirmwareReleaseNote } from './FirmwareReleaseNote'; +import type { FirmwareSource } from './FirmwareSource'; + +export interface FirmwareVersionSummary { + version: string; + channel: string; + releaseDate: Temporal.Instant; + source: FirmwareSource; + releaseNotes: FirmwareReleaseNote[]; +} diff --git a/src/lib/api/firmwareRepo/models/index.ts b/src/lib/api/firmwareRepo/models/index.ts new file mode 100644 index 00000000..70d8f5ef --- /dev/null +++ b/src/lib/api/firmwareRepo/models/index.ts @@ -0,0 +1,12 @@ +export * from './FirmwareArtifact'; +export * from './FirmwareBoard'; +export * from './FirmwareBoardRelease'; +export * from './FirmwareChannel'; +export * from './FirmwareChipRef'; +export * from './FirmwareRelease'; +export * from './FirmwareReleaseNote'; +export * from './FirmwareReleaseNoteType'; +export * from './FirmwareRepository'; +export * from './FirmwareSource'; +export * from './FirmwareVersionHistory'; +export * from './FirmwareVersionSummary'; diff --git a/src/lib/api/firmwareRepo/transformers/FirmwareArtifact.ts b/src/lib/api/firmwareRepo/transformers/FirmwareArtifact.ts new file mode 100644 index 00000000..db0d4168 --- /dev/null +++ b/src/lib/api/firmwareRepo/transformers/FirmwareArtifact.ts @@ -0,0 +1,21 @@ +import { HasNumber, HasString, isObject } from '@openshock/svelte-core/typeguards'; +import { TransformError } from '../TransformError'; +import type { FirmwareArtifact } from '../models'; + +export function TransformFirmwareArtifact(data: unknown): FirmwareArtifact { + if (!isObject(data)) { + throw new TransformError('Expected object for FirmwareArtifact'); + } + + if (!HasString(data, 'type')) throw new TransformError('Expected string: type'); + if (!HasString(data, 'url')) throw new TransformError('Expected string: url'); + if (!HasString(data, 'sha256Hash')) throw new TransformError('Expected string: sha256Hash'); + if (!HasNumber(data, 'fileSize')) throw new TransformError('Expected number: fileSize'); + + return { + type: data.type, + url: data.url, + sha256Hash: data.sha256Hash, + fileSize: data.fileSize, + }; +} diff --git a/src/lib/api/firmwareRepo/transformers/FirmwareBoard.ts b/src/lib/api/firmwareRepo/transformers/FirmwareBoard.ts new file mode 100644 index 00000000..a9fc9627 --- /dev/null +++ b/src/lib/api/firmwareRepo/transformers/FirmwareBoard.ts @@ -0,0 +1,23 @@ +import { HasBoolean, isObject } from '@openshock/svelte-core/typeguards'; +import { TransformError } from '../TransformError'; +import type { FirmwareBoard } from '../models'; +import { TransformFirmwareArtifact } from './FirmwareArtifact'; +import { TransformFirmwareChipRef } from './FirmwareChipRef'; +import { RequiredArray } from './props'; + +export function TransformFirmwareBoard(data: unknown): FirmwareBoard { + if (!isObject(data)) { + throw new TransformError('Expected object for FirmwareBoard'); + } + + // Captured before the guards below narrow `data` and drop its index signature. + const chip = data.chip; + + if (!HasBoolean(data, 'discontinued')) throw new TransformError('Expected boolean: discontinued'); + + return { + chip: TransformFirmwareChipRef(chip), + discontinued: data.discontinued, + artifacts: RequiredArray(data, 'artifacts').map(TransformFirmwareArtifact), + }; +} diff --git a/src/lib/api/firmwareRepo/transformers/FirmwareBoardRelease.ts b/src/lib/api/firmwareRepo/transformers/FirmwareBoardRelease.ts new file mode 100644 index 00000000..5e27d8fd --- /dev/null +++ b/src/lib/api/firmwareRepo/transformers/FirmwareBoardRelease.ts @@ -0,0 +1,20 @@ +import { HasString, isObject } from '@openshock/svelte-core/typeguards'; +import { TransformError } from '../TransformError'; +import type { FirmwareBoardRelease } from '../models'; +import { TransformFirmwareArtifact } from './FirmwareArtifact'; +import { RequiredArray } from './props'; + +export function TransformFirmwareBoardRelease(data: unknown): FirmwareBoardRelease { + if (!isObject(data)) { + throw new TransformError('Expected object for FirmwareBoardRelease'); + } + + if (!HasString(data, 'version')) throw new TransformError('Expected string: version'); + if (!HasString(data, 'boardId')) throw new TransformError('Expected string: boardId'); + + return { + version: data.version, + boardId: data.boardId, + artifacts: RequiredArray(data, 'artifacts').map(TransformFirmwareArtifact), + }; +} diff --git a/src/lib/api/firmwareRepo/transformers/FirmwareChannel.ts b/src/lib/api/firmwareRepo/transformers/FirmwareChannel.ts new file mode 100644 index 00000000..d8c63fd3 --- /dev/null +++ b/src/lib/api/firmwareRepo/transformers/FirmwareChannel.ts @@ -0,0 +1,5 @@ +import { FirmwareChannels, type FirmwareChannel } from '../models'; + +export function IsFirmwareChannel(value: string): value is FirmwareChannel { + return (FirmwareChannels as readonly string[]).includes(value); +} diff --git a/src/lib/api/firmwareRepo/transformers/FirmwareChipRef.ts b/src/lib/api/firmwareRepo/transformers/FirmwareChipRef.ts new file mode 100644 index 00000000..8526ac25 --- /dev/null +++ b/src/lib/api/firmwareRepo/transformers/FirmwareChipRef.ts @@ -0,0 +1,15 @@ +import { HasString, isObject } from '@openshock/svelte-core/typeguards'; +import { TransformError } from '../TransformError'; +import type { FirmwareChipRef } from '../models'; + +export function TransformFirmwareChipRef(data: unknown): FirmwareChipRef { + if (!isObject(data)) { + throw new TransformError('Expected object for FirmwareChipRef'); + } + + if (!HasString(data, 'name')) throw new TransformError('Expected string: name'); + + return { + name: data.name, + }; +} diff --git a/src/lib/api/firmwareRepo/transformers/FirmwareRelease.ts b/src/lib/api/firmwareRepo/transformers/FirmwareRelease.ts new file mode 100644 index 00000000..d72cbff5 --- /dev/null +++ b/src/lib/api/firmwareRepo/transformers/FirmwareRelease.ts @@ -0,0 +1,44 @@ +import { HasObject, HasString, isObject } from '@openshock/svelte-core/typeguards'; +import { TransformError } from '../TransformError'; +import type { FirmwareBoard, FirmwareRelease } from '../models'; +import { TransformFirmwareBoard } from './FirmwareBoard'; +import { TransformFirmwareReleaseNote } from './FirmwareReleaseNote'; +import { TransformFirmwareSource } from './FirmwareSource'; +import { RequiredArray } from './props'; + +export function TransformFirmwareRelease(data: unknown): FirmwareRelease { + if (!isObject(data)) { + throw new TransformError('Expected object for FirmwareRelease'); + } + + // Captured before the guards below narrow `data` and drop its index signature. + const source = data.source; + + if (!HasString(data, 'version')) throw new TransformError('Expected string: version'); + // Deliberately not narrowed to FirmwareChannel — the server may publish channels this + // client does not know about yet, and an unknown one must not fail the whole response. + if (!HasString(data, 'channel')) throw new TransformError('Expected string: channel'); + if (!HasString(data, 'releaseDate')) throw new TransformError('Expected string: releaseDate'); + if (!HasObject(data, 'boards')) throw new TransformError('Expected object: boards'); + + let releaseDate: Temporal.Instant; + try { + releaseDate = Temporal.Instant.from(data.releaseDate); + } catch { + throw new TransformError('Invalid date: releaseDate'); + } + + const boards: Record = {}; + for (const [name, board] of Object.entries(data.boards)) { + boards[name] = TransformFirmwareBoard(board); + } + + return { + version: data.version, + channel: data.channel, + releaseDate, + source: TransformFirmwareSource(source), + releaseNotes: RequiredArray(data, 'releaseNotes').map(TransformFirmwareReleaseNote), + boards, + }; +} diff --git a/src/lib/api/firmwareRepo/transformers/FirmwareReleaseNote.ts b/src/lib/api/firmwareRepo/transformers/FirmwareReleaseNote.ts new file mode 100644 index 00000000..354b0116 --- /dev/null +++ b/src/lib/api/firmwareRepo/transformers/FirmwareReleaseNote.ts @@ -0,0 +1,22 @@ +import { HasString, isObject } from '@openshock/svelte-core/typeguards'; +import { TransformError } from '../TransformError'; +import type { FirmwareReleaseNote } from '../models'; +import { IsFirmwareReleaseNoteType } from './FirmwareReleaseNoteType'; +import { OptionalStringOrNull } from './props'; + +export function TransformFirmwareReleaseNote(data: unknown): FirmwareReleaseNote { + if (!isObject(data)) { + throw new TransformError('Expected object for FirmwareReleaseNote'); + } + + if (!HasString(data, 'type') || !IsFirmwareReleaseNoteType(data.type)) { + throw new TransformError('Expected FirmwareReleaseNoteType: type'); + } + if (!HasString(data, 'content')) throw new TransformError('Expected string: content'); + + return { + type: data.type, + title: OptionalStringOrNull(data, 'title'), + content: data.content, + }; +} diff --git a/src/lib/api/firmwareRepo/transformers/FirmwareReleaseNoteType.ts b/src/lib/api/firmwareRepo/transformers/FirmwareReleaseNoteType.ts new file mode 100644 index 00000000..e5f1104f --- /dev/null +++ b/src/lib/api/firmwareRepo/transformers/FirmwareReleaseNoteType.ts @@ -0,0 +1,5 @@ +import { FirmwareReleaseNoteTypes, type FirmwareReleaseNoteType } from '../models'; + +export function IsFirmwareReleaseNoteType(value: string): value is FirmwareReleaseNoteType { + return (FirmwareReleaseNoteTypes as readonly string[]).includes(value); +} diff --git a/src/lib/api/firmwareRepo/transformers/FirmwareRepository.ts b/src/lib/api/firmwareRepo/transformers/FirmwareRepository.ts new file mode 100644 index 00000000..d1b739ea --- /dev/null +++ b/src/lib/api/firmwareRepo/transformers/FirmwareRepository.ts @@ -0,0 +1,21 @@ +import { HasString, isObject } from '@openshock/svelte-core/typeguards'; +import { TransformError } from '../TransformError'; +import type { FirmwareRepository } from '../models'; + +export function TransformFirmwareRepository(data: unknown): FirmwareRepository { + if (!isObject(data)) { + throw new TransformError('Expected object for FirmwareRepository'); + } + + if (!HasString(data, 'id')) throw new TransformError('Expected string: id'); + if (!HasString(data, 'provider')) throw new TransformError('Expected string: provider'); + if (!HasString(data, 'owner')) throw new TransformError('Expected string: owner'); + if (!HasString(data, 'repo')) throw new TransformError('Expected string: repo'); + + return { + id: data.id, + provider: data.provider, + owner: data.owner, + repo: data.repo, + }; +} diff --git a/src/lib/api/firmwareRepo/transformers/FirmwareSource.ts b/src/lib/api/firmwareRepo/transformers/FirmwareSource.ts new file mode 100644 index 00000000..a954b03f --- /dev/null +++ b/src/lib/api/firmwareRepo/transformers/FirmwareSource.ts @@ -0,0 +1,27 @@ +import { HasString, isObject } from '@openshock/svelte-core/typeguards'; +import { TransformError } from '../TransformError'; +import type { FirmwareSource } from '../models'; +import { TransformFirmwareRepository } from './FirmwareRepository'; +import { OptionalStringOrNull } from './props'; + +export function TransformFirmwareSource(data: unknown): FirmwareSource { + if (!isObject(data)) { + throw new TransformError('Expected object for FirmwareSource'); + } + + // Captured before the guards below narrow `data` and drop its index signature. + const repository = data.repository; + + if (!HasString(data, 'commitHash')) throw new TransformError('Expected string: commitHash'); + if (!HasString(data, 'commitUrl')) throw new TransformError('Expected string: commitUrl'); + + return { + repository: TransformFirmwareRepository(repository), + commitHash: data.commitHash, + ref: OptionalStringOrNull(data, 'ref'), + runId: OptionalStringOrNull(data, 'runId'), + commitUrl: data.commitUrl, + refUrl: OptionalStringOrNull(data, 'refUrl'), + runUrl: OptionalStringOrNull(data, 'runUrl'), + }; +} diff --git a/src/lib/api/firmwareRepo/transformers/FirmwareVersionHistory.ts b/src/lib/api/firmwareRepo/transformers/FirmwareVersionHistory.ts new file mode 100644 index 00000000..7bfdd818 --- /dev/null +++ b/src/lib/api/firmwareRepo/transformers/FirmwareVersionHistory.ts @@ -0,0 +1,18 @@ +import { HasNumber, isObject } from '@openshock/svelte-core/typeguards'; +import { TransformError } from '../TransformError'; +import type { FirmwareVersionHistory } from '../models'; +import { TransformFirmwareVersionSummary } from './FirmwareVersionSummary'; +import { RequiredArray } from './props'; + +export function TransformFirmwareVersionHistory(data: unknown): FirmwareVersionHistory { + if (!isObject(data)) { + throw new TransformError('Expected object for FirmwareVersionHistory'); + } + + if (!HasNumber(data, 'total')) throw new TransformError('Expected number: total'); + + return { + versions: RequiredArray(data, 'versions').map(TransformFirmwareVersionSummary), + total: data.total, + }; +} diff --git a/src/lib/api/firmwareRepo/transformers/FirmwareVersionSummary.ts b/src/lib/api/firmwareRepo/transformers/FirmwareVersionSummary.ts new file mode 100644 index 00000000..8fc0d219 --- /dev/null +++ b/src/lib/api/firmwareRepo/transformers/FirmwareVersionSummary.ts @@ -0,0 +1,34 @@ +import { HasString, isObject } from '@openshock/svelte-core/typeguards'; +import { TransformError } from '../TransformError'; +import type { FirmwareVersionSummary } from '../models'; +import { TransformFirmwareReleaseNote } from './FirmwareReleaseNote'; +import { TransformFirmwareSource } from './FirmwareSource'; +import { RequiredArray } from './props'; + +export function TransformFirmwareVersionSummary(data: unknown): FirmwareVersionSummary { + if (!isObject(data)) { + throw new TransformError('Expected object for FirmwareVersionSummary'); + } + + // Captured before the guards below narrow `data` and drop its index signature. + const source = data.source; + + if (!HasString(data, 'version')) throw new TransformError('Expected string: version'); + if (!HasString(data, 'channel')) throw new TransformError('Expected string: channel'); + if (!HasString(data, 'releaseDate')) throw new TransformError('Expected string: releaseDate'); + + let releaseDate: Temporal.Instant; + try { + releaseDate = Temporal.Instant.from(data.releaseDate); + } catch { + throw new TransformError('Invalid date: releaseDate'); + } + + return { + version: data.version, + channel: data.channel, + releaseDate, + source: TransformFirmwareSource(source), + releaseNotes: RequiredArray(data, 'releaseNotes').map(TransformFirmwareReleaseNote), + }; +} diff --git a/src/lib/api/firmwareRepo/transformers/index.ts b/src/lib/api/firmwareRepo/transformers/index.ts new file mode 100644 index 00000000..42ef4f8d --- /dev/null +++ b/src/lib/api/firmwareRepo/transformers/index.ts @@ -0,0 +1,3 @@ +export * from './FirmwareBoardRelease'; +export * from './FirmwareRelease'; +export * from './FirmwareVersionHistory'; diff --git a/src/lib/api/firmwareRepo/transformers/props.ts b/src/lib/api/firmwareRepo/transformers/props.ts new file mode 100644 index 00000000..1e480e7b --- /dev/null +++ b/src/lib/api/firmwareRepo/transformers/props.ts @@ -0,0 +1,27 @@ +import { isString } from '@openshock/svelte-core/typeguards'; +import { TransformError } from '../TransformError'; + +/** + * Reads a field the repository server may omit entirely rather than send as `null`, + * normalising both cases to `null`. + * + * The `Has*` guards from svelte-core require the key to be present, so they can't express + * "absent or null" on their own. + */ +export function OptionalStringOrNull(data: Record, key: string): string | null { + const value = data[key]; + + if (value === undefined || value === null) return null; + if (!isString(value)) throw new TransformError(`Expected string|null: ${key}`); + + return value; +} + +/** Reads a required array field, leaving element validation to the caller. */ +export function RequiredArray(data: Record, key: string): unknown[] { + const value = data[key]; + + if (!Array.isArray(value)) throw new TransformError(`Expected array: ${key}`); + + return value; +} diff --git a/src/lib/components/FirmwareChannelSelector.svelte b/src/lib/components/FirmwareChannelSelector.svelte index 563e1031..4b61454f 100644 --- a/src/lib/components/FirmwareChannelSelector.svelte +++ b/src/lib/components/FirmwareChannelSelector.svelte @@ -1,28 +1,31 @@ diff --git a/src/lib/errorhandling/apiErrorHandling.ts b/src/lib/errorhandling/apiErrorHandling.ts index 82fe1b65..ca29f185 100644 --- a/src/lib/errorhandling/apiErrorHandling.ts +++ b/src/lib/errorhandling/apiErrorHandling.ts @@ -32,8 +32,12 @@ async function handleResponseError( // `response.json()` (which would throw "Body has already been consumed"). const problem = error.body; if (!isProblemDetails(problem)) { + // Not every service the frontend talks to speaks ProblemDetails — the firmware + // repository server returns plain HTTP errors with no body on the error object. + // Fall back to a generic toast so the failure still reaches the user. console.error('Content json is not a valid problemdetails object', problem); - return null; + toast.error('Internal error occured'); + return; } if (dev) { diff --git a/src/lib/utils/url.ts b/src/lib/utils/url.ts index 022833b0..e4a81c15 100644 --- a/src/lib/utils/url.ts +++ b/src/lib/utils/url.ts @@ -2,7 +2,12 @@ import { afterNavigate, goto, replaceState } from '$app/navigation'; import { asset, base, match } from '$app/paths'; import { page } from '$app/state'; import type { Asset, Pathname } from '$app/types'; -import { PUBLIC_BACKEND_API_URL, PUBLIC_SITE_SHORT_URL, PUBLIC_SITE_URL } from '$env/static/public'; +import { + PUBLIC_BACKEND_API_URL, + PUBLIC_FIRMWARE_REPO_URL, + PUBLIC_SITE_SHORT_URL, + PUBLIC_SITE_URL, +} from '$env/static/public'; import { tick } from 'svelte'; // --------------------------------------------------------------------------- // Constants @@ -25,6 +30,16 @@ type BackendApiVersion = 1 | 2; */ export type BackendPath = `${BackendApiVersion}` | `${BackendApiVersion}/${string}`; +type FirmwareRepoApiVersion = 2; + +/** + * A path segment for the firmware repository server, always prefixed with a version number. + * + * @example "2" + * @example "2/firmware/latest/stable" + */ +export type FirmwareRepoPath = `${FirmwareRepoApiVersion}` | `${FirmwareRepoApiVersion}/${string}`; + // --------------------------------------------------------------------------- // Backend // --------------------------------------------------------------------------- @@ -68,6 +83,53 @@ export function getBackendURL(path?: BackendPath): URL { return url; } +// --------------------------------------------------------------------------- +// Firmware repository +// --------------------------------------------------------------------------- + +/** + * Builds a fully-qualified URL for the firmware repository server. + * + * The base URL is read from `PUBLIC_FIRMWARE_REPO_URL`. An optional + * version-prefixed path is appended when provided. + * + * @param path - Version-prefixed resource path (e.g. `"2/firmware/latest/stable"`) + * @returns A `URL` pointing at the firmware repository resource + * + * @throws {Error} If `PUBLIC_FIRMWARE_REPO_URL` is not HTTPS + * @throws {Error} If `PUBLIC_FIRMWARE_REPO_URL` contains query params or a hash + * + * @example + * ```ts + * getFirmwareRepoURL(); // https://repo.example.com/ + * getFirmwareRepoURL("2/firmware/latest/stable") // https://repo.example.com/2/firmware/latest/stable + * ``` + */ +export function getFirmwareRepoURL(path?: FirmwareRepoPath): URL { + const url = new URL(PUBLIC_FIRMWARE_REPO_URL); + + if (url.protocol !== 'https:') { + throw new Error('PUBLIC_FIRMWARE_REPO_URL must be an HTTPS URL'); + } + + if (url.search || url.hash) { + throw new Error('PUBLIC_FIRMWARE_REPO_URL must not contain query parameters or hash fragments'); + } + + if (path !== undefined) { + // Ensure exactly one slash between the base pathname and the appended path + const base = url.pathname.endsWith('/') ? url.pathname : `${url.pathname}/`; + const suffix = path.startsWith('/') ? path.slice(1) : path; + url.pathname = base + suffix; + } + + return url; +} + +// --------------------------------------------------------------------------- +// Gateway +// --------------------------------------------------------------------------- + /** * Builds the live-control-gateway WebSocket URL from the parts the backend advertises * (`host`, `port`, and a full `path` that already includes the gateway's path prefix and the diff --git a/src/routes/(app)/hubs/[hubId=guid]/update/+page.svelte b/src/routes/(app)/hubs/[hubId=guid]/update/+page.svelte index 13be05f4..cbda1425 100644 --- a/src/routes/(app)/hubs/[hubId=guid]/update/+page.svelte +++ b/src/routes/(app)/hubs/[hubId=guid]/update/+page.svelte @@ -77,7 +77,7 @@ } from '$lib/state/hubs-state.svelte'; import { cn, NumberToHexPadded } from '@openshock/svelte-core/utils'; import { onMount } from 'svelte'; - import type { FirmwareChannel } from '$lib/api/firmwareCDN'; + import type { FirmwareChannel } from '$lib/api/firmwareRepo/models'; import { PageHeader } from '@openshock/svelte-core/components'; let hubLoaded = $state(false); diff --git a/src/routes/terminal/+page.svelte b/src/routes/terminal/+page.svelte index 74030df8..a5d342c9 100644 --- a/src/routes/terminal/+page.svelte +++ b/src/routes/terminal/+page.svelte @@ -8,7 +8,7 @@ Zap, } from '@lucide/svelte'; import { browser } from '$app/env'; - import type { FirmwareChannel } from '$lib/api/firmwareCDN'; + import type { FirmwareChannel, FirmwareRelease } from '$lib/api/firmwareRepo/models'; import { Container } from '@openshock/svelte-core/components'; import FirmwareChannelSelector from '$lib/components/FirmwareChannelSelector.svelte'; import { ChromeLogo, EdgeLogo, OperaLogo } from '@openshock/svelte-core/components/svg'; @@ -44,6 +44,7 @@ let channel = $state('stable'); let version = $state(null); + let latestResponse = $state(null); // Tracks which channel+version the user explicitly confirmed. Changing either invalidates it. let confirmedChannel = $state(null); let confirmedVersion = $state(null); @@ -323,6 +324,7 @@ {#if version} @@ -350,7 +352,7 @@ {#if isCurrent}
@@ -379,9 +381,9 @@ {#if i === 2} - {#if isCurrent && version && board && connection} + {#if isCurrent && latestResponse && board && connection} import { Check, ChevronsUpDown } from '@lucide/svelte'; - import { FetchVersionBoards } from '$lib/api/firmwareCDN'; + import { ExtractBoards } from '$lib/api/firmwareRepo/boards'; + import type { FirmwareRelease } from '$lib/api/firmwareRepo/models'; import { Button } from '@openshock/svelte-core/components/ui/button'; import * as Command from '@openshock/svelte-core/components/ui/command'; import { @@ -10,29 +11,23 @@ } from '@openshock/svelte-core/components/ui/popover'; import { cn } from '@openshock/svelte-core/utils'; - /** Optional chip to constrain the list of boards to */ - //export let chip: string | null = null; interface Props { - version: string | null; + latestResponse: FirmwareRelease | null; + chip?: string | null; selectedBoard?: string | null; disabled?: boolean; } - let { version, selectedBoard = $bindable(null), disabled = false }: Props = $props(); + let { + latestResponse, + chip = null, + selectedBoard = $bindable(null), + disabled = false, + }: Props = $props(); - let boardsCache = $state<{ [key: string]: string[] }>({}); + let boards = $derived(latestResponse ? ExtractBoards(latestResponse, chip) : []); $effect(() => { - if (version && !(version in boardsCache)) { - let requestedVersion = version; - FetchVersionBoards(version).then((b) => { - boardsCache = { ...boardsCache, [requestedVersion]: b ?? [] }; - }); - } - }); - - let boards = $derived(version ? (boardsCache[version] ?? []) : []); - $effect(() => { - if (boards.length === 0) { + if (selectedBoard && !boards.includes(selectedBoard)) { selectedBoard = null; } }); diff --git a/src/routes/terminal/FirmwareFlasher.svelte b/src/routes/terminal/FirmwareFlasher.svelte index da374940..96c8ba47 100644 --- a/src/routes/terminal/FirmwareFlasher.svelte +++ b/src/routes/terminal/FirmwareFlasher.svelte @@ -1,13 +1,14 @@