From c06b995148e5ec39d9481cad7539b7192671db68 Mon Sep 17 00:00:00 2001 From: Rafael Araujo Lehmkuhl Date: Tue, 5 May 2026 16:26:36 -0300 Subject: [PATCH 1/9] audio: Add types, filename helpers and storage backend for voice recordings Introduces the foundational pieces that the upcoming voice recording feature will build on: type definitions for recordings and metadata, date-based filename helpers that mirror the video naming convention, and a dedicated audio storage instance backed by the Cockpit folder under Electron and IndexedDB on the browser. --- src/libs/videoStorage.ts | 9 +++++ src/types/audio.ts | 85 ++++++++++++++++++++++++++++++++++++++++ src/utils/audio.ts | 58 +++++++++++++++++++++++++++ 3 files changed, 152 insertions(+) create mode 100644 src/types/audio.ts create mode 100644 src/utils/audio.ts diff --git a/src/libs/videoStorage.ts b/src/libs/videoStorage.ts index 1dcc40fce3..65ec00e8db 100644 --- a/src/libs/videoStorage.ts +++ b/src/libs/videoStorage.ts @@ -134,12 +134,21 @@ const snapshotsIndexedDB: StorageDB = new LocalForageStorage( 'Cockpit snapshots taken from video streams or workspace.' ) +const audioRecordingsIndexedDB: StorageDB = new LocalForageStorage( + 'Cockpit - Audio Recordings', + 'cockpit-audio-recordings-db', + 1.0, + 'Cockpit voice/audio recordings captured from the local microphone.' +) + const electronVideoStorage = new ElectronStorage(['videos']) const temporaryElectronVideoStorage = new ElectronStorage(['videos', 'temporary-video-chunks']) const electronSnapshotStorage = new ElectronStorage(['snapshots']) const snapshotThumbnailsStorage = new ElectronStorage(['snapshots', 'thumbs']) +const electronAudioStorage = new ElectronStorage(['audio']) export const videoStorage = isElectron() ? electronVideoStorage : videoStoringIndexedDB export const tempVideoStorage = isElectron() ? temporaryElectronVideoStorage : tempVideoChunksIndexdedDB export const snapshotStorage = isElectron() ? electronSnapshotStorage : snapshotsIndexedDB export const snapshotThumbStorage = isElectron() ? snapshotThumbnailsStorage : snapshotThumbnailsIndexedDB +export const audioStorage = isElectron() ? electronAudioStorage : audioRecordingsIndexedDB diff --git a/src/types/audio.ts b/src/types/audio.ts new file mode 100644 index 0000000000..74cdc6745d --- /dev/null +++ b/src/types/audio.ts @@ -0,0 +1,85 @@ +/** + * Common information shared by every audio recording entry, both ongoing and finalized. + */ +export interface CommonAudioInfo { + /** + * The name of the audio file (including extension) + */ + fileName: string + /** + * Recording hash that uniquely identifies the recording + */ + hash: string + /** + * The date the recording started + */ + dateStart?: Date + /** + * The date the recording finished. Undefined while the recording is ongoing. + */ + dateFinish?: Date +} + +/** + * Audio recording metadata persisted alongside the audio blob (sidecar JSON). + * @example + * { + * fileName: 'Cockpit (Apr 24, 2026 - 14꞉30꞉15 GMT-3) #abc12345.webm', + * hash: 'abc12345', + * dateStart: '2026-04-24T17:30:15.000Z', + * dateFinish: '2026-04-24T17:34:42.000Z', + * durationMs: 267000, + * mimeType: 'audio/webm;codecs=opus', + * missionName: 'Cockpit', + * cockpitVersion: '1.2.3', + * } + */ +export interface AudioRecordingMetadata extends CommonAudioInfo { + /** + * Date the recording started (ISO string when serialized to JSON) + */ + dateStart: Date + /** + * Date the recording finished (ISO string when serialized to JSON) + */ + dateFinish: Date + /** + * Duration of the recording in milliseconds + */ + durationMs: number + /** + * Container/codec of the recording, as reported by the MediaRecorder + */ + mimeType: string + /** + * Name of the active mission at the time of the recording + */ + missionName: string + /** + * Cockpit version that produced the recording + */ + cockpitVersion: string +} + +/** + * Library entry for an audio recording, including any metadata loaded from the sidecar JSON. + */ +export interface AudioLibraryFile extends CommonAudioInfo { + /** + * Duration of the recording in milliseconds, when known. + */ + durationMs?: number + /** + * Container/codec of the recording, when known. + */ + mimeType?: string +} + +/** + * Audio extension container types supported by the recorder. + */ +export enum AudioExtensionContainer { + WEBM = 'webm', + OGG = 'ogg', + MP4 = 'mp4', +} diff --git a/src/utils/audio.ts b/src/utils/audio.ts new file mode 100644 index 0000000000..a68a0a4853 --- /dev/null +++ b/src/utils/audio.ts @@ -0,0 +1,58 @@ +import { format } from 'date-fns' + +import { AudioExtensionContainer } from '@/types/audio' + +/** + * Returns the filename for an audio recording. + * Mirrors {@link import('./video').videoFilename} so audio and video files share a recognizable layout. + * @param {string} hash - The unique hash of the recording. + * @param {Date} creationDate - The creation date of the recording. + * @param {string} missionName - The name of the mission. Defaults to 'Cockpit'. + * @param {AudioExtensionContainer} extension - The extension/container to use. Defaults to WebM. + * @returns {string} The filename for the audio file. + */ +export const audioFilename = ( + hash: string, + creationDate: Date, + missionName = 'Cockpit', + extension: AudioExtensionContainer = AudioExtensionContainer.WEBM +): string => { + const timeString = format(creationDate, 'LLL dd, yyyy - HH꞉mm꞉ss O') + return `${missionName} (${timeString}) #${hash}.${extension}` +} + +/** + * Returns the filename without the extension. + * @param {string} audioFileName - The filename of the audio recording, with or without the extension. + * @returns {string} The filename without the extension. + */ +export const audioFilenameWithoutExtension = (audioFileName: string): string => { + return audioFileName.split('.').slice(0, -1).join('.') +} + +/** + * Returns the filename for the metadata sidecar of an audio recording. + * @param {string} audioFileName - The filename of the audio recording, with or without the extension. + * @returns {string} The filename for the metadata sidecar (.json). + */ +export const audioMetadataFilename = (audioFileName: string): string => { + return `${audioFilenameWithoutExtension(audioFileName)}.json` +} + +/** + * Tells whether a given filename corresponds to a Cockpit audio recording. + * @param {string} filename - The filename to check. + * @returns {boolean} Whether the filename is an audio recording. + */ +export const isAudioFilename = (filename: string): boolean => { + return Object.values(AudioExtensionContainer).some((ext) => filename.endsWith(`.${ext}`)) +} + +/** + * Tells whether a given filename corresponds to an audio metadata sidecar produced by Cockpit. + * @param {string} filename - The filename to check. + * @returns {boolean} Whether the filename is an audio metadata sidecar. + */ +export const isAudioMetadataFilename = (filename: string): boolean => { + return filename.endsWith('.json') +} From 6829b101700ef2a6ab0464976498992925469ba9 Mon Sep 17 00:00:00 2001 From: Rafael Araujo Lehmkuhl Date: Tue, 5 May 2026 16:26:41 -0300 Subject: [PATCH 2/9] electron: Expose audio recordings folder via IPC Adds an open-audio-folder IPC handler that opens the new audio/ subfolder of the Cockpit data directory in the system file manager, together with the matching preload binding and TypeScript declaration. This mirrors what is already done for the videos and snapshots folders. --- src/electron/preload.ts | 1 + src/electron/services/storage.ts | 5 +++++ src/libs/cosmos.ts | 4 ++++ 3 files changed, 10 insertions(+) diff --git a/src/electron/preload.ts b/src/electron/preload.ts index 6e7cc72b1d..69a155ad8e 100644 --- a/src/electron/preload.ts +++ b/src/electron/preload.ts @@ -44,6 +44,7 @@ contextBridge.exposeInMainWorld('electronAPI', { setCockpitFolderPath: (newPath: string): Promise => ipcRenderer.invoke('set-cockpit-folder-path', newPath), selectCockpitFolder: (): Promise => ipcRenderer.invoke('select-cockpit-folder'), openSnapshotFolder: () => ipcRenderer.invoke('open-snapshot-folder'), + openAudioFolder: () => ipcRenderer.invoke('open-audio-folder'), openVideoFolder: () => ipcRenderer.invoke('open-video-folder'), openVideoFile: (fileName: string) => ipcRenderer.invoke('open-video-file', fileName), openVideoChunksFolder: () => ipcRenderer.invoke('open-temp-video-chunks-folder'), diff --git a/src/electron/services/storage.ts b/src/electron/services/storage.ts index 18c8dea2fa..c33a8168b6 100644 --- a/src/electron/services/storage.ts +++ b/src/electron/services/storage.ts @@ -124,6 +124,11 @@ export const setupFilesystemStorage = (): void => { await fs.mkdir(snapshotFolderPath, { recursive: true }) await shell.openPath(snapshotFolderPath) }) + ipcMain.handle('open-audio-folder', async () => { + const audioFolderPath = join(cockpitFolderPath, 'audio') + await fs.mkdir(audioFolderPath, { recursive: true }) + await shell.openPath(audioFolderPath) + }) ipcMain.handle('open-video-file', async (_, fileName: string) => { const videoFolderPath = join(cockpitFolderPath, 'videos') const videoFilePath = join(videoFolderPath, fileName) diff --git a/src/libs/cosmos.ts b/src/libs/cosmos.ts index 0d0b74faec..6080703a06 100644 --- a/src/libs/cosmos.ts +++ b/src/libs/cosmos.ts @@ -304,6 +304,10 @@ declare global { * Open the snapshots folder in the system file manager */ openSnapshotFolder: () => void + /** + * Open the audio recordings folder in the system file manager + */ + openAudioFolder: () => void /** * Open video folder */ From 831285f0327093c866fd1b524d1d18db79656dd2 Mon Sep 17 00:00:00 2001 From: Rafael Araujo Lehmkuhl Date: Tue, 5 May 2026 16:26:48 -0300 Subject: [PATCH 3/9] stores: audio: Add Pinia store for microphone recording and persistence Wraps getUserMedia and MediaRecorder behind a small, video-store-shaped API: startRecording requests the local microphone, picks the best supported MIME type (Opus/WebM preferred, with OGG/MP4 fallbacks) and streams chunks into memory; stopRecording finalizes the recording, writes the audio blob to the audio storage and persists a sidecar JSON metadata file with the start/end timestamps, duration, mime type, mission name and Cockpit version. The metadata sidecar is what allows future work to align voice recordings with concurrent video recordings without coupling the two features at recording time. --- src/stores/audio.ts | 366 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 366 insertions(+) create mode 100644 src/stores/audio.ts diff --git a/src/stores/audio.ts b/src/stores/audio.ts new file mode 100644 index 0000000000..a3978fbe75 --- /dev/null +++ b/src/stores/audio.ts @@ -0,0 +1,366 @@ +import { saveAs } from 'file-saver' +import { defineStore } from 'pinia' +import { v4 as uuid } from 'uuid' +import { computed, ref } from 'vue' + +import { useInteractionDialog } from '@/composables/interactionDialog' +import { app_version } from '@/libs/cosmos' +import { isElectron, sanitizeFilenameComponent } from '@/libs/utils' +import { audioStorage } from '@/libs/videoStorage' +import { Alert, AlertLevel } from '@/types/alert' +import { AudioExtensionContainer, AudioLibraryFile, AudioRecordingMetadata, CommonAudioInfo } from '@/types/audio' +import { audioFilename, audioMetadataFilename, isAudioMetadataFilename } from '@/utils/audio' + +import { useAlertStore } from './alert' +import { useMissionStore } from './mission' + +/** + * Internal state of an active recording. + */ +interface ActiveAudioRecording extends CommonAudioInfo { + /** + * The full audio MediaStream backing the recording. + */ + mediaStream: MediaStream + /** + * The MediaRecorder collecting audio chunks for the recording. + */ + mediaRecorder: MediaRecorder + /** + * Audio chunks accumulated by the MediaRecorder while the recording is ongoing. + */ + chunks: Blob[] + /** + * MIME type that the MediaRecorder is producing for this recording. + */ + mimeType: string + /** + * Promise that resolves once the recording has been fully persisted to storage. + */ + finalization: Promise + /** + * Resolver for the finalization promise. + */ + resolveFinalization: () => void + /** + * Rejector for the finalization promise. + */ + rejectFinalization: (error: Error) => void +} + +/** + * MIME type candidates tried in order when configuring the MediaRecorder. + * The first supported by the runtime is used to encode the recording. + */ +const PREFERRED_AUDIO_MIME_TYPES: { + /** +cccccccccccccccccccccccccccccccccccc * +cccccccccccccccccccccccccccccccccccc + */ + mimeType: string + /** +mmmmmmmmmmmmmmmmmm * +mmmmmmmmmmmmmmmmmm + */ + extension: AudioExtensionContainer +}[] = [ + { mimeType: 'audio/webm;codecs=opus', extension: AudioExtensionContainer.WEBM }, + { mimeType: 'audio/webm', extension: AudioExtensionContainer.WEBM }, + { mimeType: 'audio/ogg;codecs=opus', extension: AudioExtensionContainer.OGG }, + { mimeType: 'audio/mp4', extension: AudioExtensionContainer.MP4 }, +] + +const pickSupportedAudioMime = (): { + /** +ccccccccccccccccccccccccccccccccccccc * +ccccccccccccccccccccccccccccccccccccc + */ + mimeType: string + /** +mmmmmmmmmmmmmmmmmm * +mmmmmmmmmmmmmmmmmm + */ + extension: AudioExtensionContainer +} => { + if (typeof MediaRecorder === 'undefined') { + throw new Error('MediaRecorder is not available in this environment.') + } + for (const candidate of PREFERRED_AUDIO_MIME_TYPES) { + if (MediaRecorder.isTypeSupported(candidate.mimeType)) return candidate + } + // Fall back to letting the runtime decide. Extension defaults to webm because that is what + // Chromium-based browsers use when no MIME type is forced. + return { mimeType: '', extension: AudioExtensionContainer.WEBM } +} + +export const useAudioStore = defineStore('audio', () => { + const missionStore = useMissionStore() + const alertStore = useAlertStore() + const { showDialog } = useInteractionDialog() + + const activeRecording = ref() + + const isRecording = computed(() => activeRecording.value !== undefined) + + const buildMetadata = (recording: ActiveAudioRecording): AudioRecordingMetadata => { + const dateStart = recording.dateStart ?? new Date() + const dateFinish = recording.dateFinish ?? new Date() + return { + fileName: recording.fileName, + hash: recording.hash, + dateStart, + dateFinish, + durationMs: Math.max(0, dateFinish.getTime() - dateStart.getTime()), + mimeType: recording.mimeType, + missionName: missionStore.missionName || 'Cockpit', + cockpitVersion: app_version.version, + } + } + + const persistRecording = async (recording: ActiveAudioRecording): Promise => { + const metadata = buildMetadata(recording) + + const audioBlob = new Blob(recording.chunks, { type: recording.mimeType || 'audio/webm' }) + if (audioBlob.size === 0) { + throw new Error('Recording produced no audio data.') + } + + await audioStorage.setItem(recording.fileName, audioBlob) + + const metadataBlob = new Blob([JSON.stringify(metadata, null, 2)], { type: 'application/json' }) + await audioStorage.setItem(audioMetadataFilename(recording.fileName), metadataBlob) + } + + const requestMicrophoneStream = async (): Promise => { + if (!navigator.mediaDevices?.getUserMedia) { + throw new Error('Microphone access is not available in this environment (insecure context?).') + } + return navigator.mediaDevices.getUserMedia({ audio: true, video: false }) + } + + /** + * Start a new audio recording from the local microphone. + * @returns {Promise} Promise that resolves when the recording has effectively started. + */ + const startRecording = async (): Promise => { + if (activeRecording.value !== undefined) { + showDialog({ message: 'A voice recording is already ongoing.', variant: 'warning' }) + return + } + + let mediaStream: MediaStream + try { + mediaStream = await requestMicrophoneStream() + } catch (error) { + const errorMsg = `Could not access the microphone: ${(error as Error).message ?? error!.toString()}` + console.error(errorMsg) + showDialog({ title: 'Cannot start voice recording.', message: errorMsg, variant: 'error' }) + return + } + + const { mimeType, extension } = pickSupportedAudioMime() + + let mediaRecorder: MediaRecorder + try { + mediaRecorder = mimeType ? new MediaRecorder(mediaStream, { mimeType }) : new MediaRecorder(mediaStream) + } catch (error) { + mediaStream.getTracks().forEach((track) => track.stop()) + const errorMsg = `Failed to start the audio recorder: ${(error as Error).message ?? error!.toString()}` + console.error(errorMsg) + showDialog({ title: 'Cannot start voice recording.', message: errorMsg, variant: 'error' }) + return + } + + const dateStart = new Date() + const recordingHash = uuid().slice(0, 8) + const safeMissionName = sanitizeFilenameComponent(missionStore.missionName) || 'Cockpit' + const fileName = audioFilename(recordingHash, dateStart, safeMissionName, extension) + + let resolveFinalization: () => void = () => undefined + let rejectFinalization: (error: Error) => void = () => undefined + const finalization = new Promise((resolve, reject) => { + resolveFinalization = resolve + rejectFinalization = reject + }) + + const recording: ActiveAudioRecording = { + hash: recordingHash, + fileName, + dateStart, + mediaStream, + mediaRecorder, + chunks: [], + mimeType: mediaRecorder.mimeType || mimeType || 'audio/webm', + finalization, + resolveFinalization, + rejectFinalization, + } + + mediaRecorder.ondataavailable = (event) => { + if (event.data && event.data.size > 0) { + recording.chunks.push(event.data) + } + } + + mediaRecorder.onerror = (event) => { + const errorMsg = `Audio recorder error: ${ + ( + event as unknown as { + /** + * + */ + error?: Error + } + ).error?.message ?? 'unknown' + }` + console.error(errorMsg) + alertStore.pushAlert(new Alert(AlertLevel.Error, errorMsg)) + } + + mediaRecorder.onstop = async () => { + recording.dateFinish = new Date() + mediaStream.getTracks().forEach((track) => track.stop()) + try { + await persistRecording(recording) + alertStore.pushAlert(new Alert(AlertLevel.Success, `Saved voice recording '${recording.fileName}'.`)) + resolveFinalization() + } catch (error) { + const errorMsg = `Failed to save voice recording: ${(error as Error).message ?? error!.toString()}` + console.error(errorMsg) + showDialog({ title: 'Voice recording failed to save.', message: errorMsg, variant: 'error' }) + rejectFinalization(error as Error) + } finally { + if (activeRecording.value?.hash === recording.hash) { + activeRecording.value = undefined + } + } + } + + // 1s timeslice keeps memory pressure bounded for long recordings without forcing chunked storage. + mediaRecorder.start(1000) + activeRecording.value = recording + + alertStore.pushAlert(new Alert(AlertLevel.Success, 'Started voice recording.')) + } + + /** + * Stop the ongoing audio recording, if any, and persist it to storage. + * @returns {Promise} Promise that resolves once the recording has been written to storage. + */ + const stopRecording = async (): Promise => { + const recording = activeRecording.value + if (recording === undefined) return + + if (recording.mediaRecorder.state !== 'inactive') { + recording.mediaRecorder.stop() + } + + try { + await recording.finalization + } catch { + // Errors are already surfaced through the dialog/alert in onstop. + } + } + + /** + * Discard the given audio files (and any matching metadata sidecars) from the audio storage. + * @param {string[]} fileNames - Names of the audio files to delete. + * @returns {Promise} Promise that resolves once the deletions are complete. + */ + const deleteAudioFiles = async (fileNames: string[]): Promise => { + for (const fileName of fileNames) { + await audioStorage.removeItem(fileName) + const metadataName = audioMetadataFilename(fileName) + if (metadataName !== fileName) { + await audioStorage.removeItem(metadataName) + } + } + } + + /** + * Download the given audio files (and any matching metadata sidecars) directly to the user's machine. + * On Electron the files already live on disk, so this is mainly useful in the browser version. + * @param {string[]} fileNames - Names of the audio files to download. + * @returns {Promise} Promise that resolves once all download triggers have been issued. + */ + const downloadAudioFiles = async (fileNames: string[]): Promise => { + for (const fileName of fileNames) { + const blob = await audioStorage.getItem(fileName) + if (blob) saveAs(blob, fileName) + const metadataName = audioMetadataFilename(fileName) + if (metadataName === fileName) continue + const metadataBlob = await audioStorage.getItem(metadataName) + if (metadataBlob) saveAs(metadataBlob, metadataName) + } + } + + /** + * Read the metadata sidecar for the given audio file, if it exists. + * @param {string} fileName - The audio filename. + * @returns {Promise} Parsed metadata, or undefined if absent/invalid. + */ + const getAudioMetadata = async (fileName: string): Promise => { + try { + const blob = await audioStorage.getItem(audioMetadataFilename(fileName)) + if (!blob) return undefined + const text = await blob.text() + const parsed = JSON.parse(text) as AudioRecordingMetadata + if (parsed.dateStart) parsed.dateStart = new Date(parsed.dateStart) + if (parsed.dateFinish) parsed.dateFinish = new Date(parsed.dateFinish) + return parsed + } catch (error) { + console.warn(`Failed to read audio metadata for "${fileName}":`, error) + return undefined + } + } + + /** + * List the audio recordings currently persisted in the audio storage, including metadata when available. + * @returns {Promise} Library entries sorted from most to least recent. + */ + const listAudioRecordings = async (): Promise => { + const keys = await audioStorage.keys() + const audioKeys = keys.filter((key) => !isAudioMetadataFilename(key)) + + const entries = await Promise.all( + audioKeys.map(async (fileName): Promise => { + const metadata = await getAudioMetadata(fileName) + const hashMatch = fileName.match(/#([a-z0-9]+)\./) + return { + fileName, + hash: metadata?.hash ?? hashMatch?.[1] ?? '', + dateStart: metadata?.dateStart, + dateFinish: metadata?.dateFinish, + durationMs: metadata?.durationMs, + mimeType: metadata?.mimeType, + } + }) + ) + + return entries.sort((a, b) => b.fileName.localeCompare(a.fileName)) + } + + /** + * Whether audio recording is supported in the current runtime. + * @returns {boolean} True if microphone capture and MediaRecorder are available. + */ + const isAudioRecordingSupported = (): boolean => { + if (typeof MediaRecorder === 'undefined') return false + if (typeof navigator === 'undefined') return false + return Boolean(navigator.mediaDevices?.getUserMedia) + } + + return { + audioStorage, + activeRecording, + isRecording, + isAudioRecordingSupported, + startRecording, + stopRecording, + listAudioRecordings, + getAudioMetadata, + deleteAudioFiles, + downloadAudioFiles, + isElectron, + } +}) From b75b635990c003dbe713424c4a793dc47d3da357 Mon Sep 17 00:00:00 2001 From: Rafael Araujo Lehmkuhl Date: Tue, 5 May 2026 16:26:55 -0300 Subject: [PATCH 4/9] widgets: voice-recorder: Add mini-widget to record from local microphone Introduces a new VoiceRecorder mini-widget that lets the user start and stop a microphone recording with a single click, mirroring the MiniVideoRecorder UX (pulsing red dot, live elapsed time, badge with the recording count opening the media library). Registers the new component in MiniWidgetType so it shows up in the widget palette. The widget is intentionally independent from the video recording flow, so users can record short voice notes alongside ongoing video captures without coupling the two features. --- src/components/mini-widgets/VoiceRecorder.vue | 179 ++++++++++++++++++ src/types/widgets.ts | 2 + 2 files changed, 181 insertions(+) create mode 100644 src/components/mini-widgets/VoiceRecorder.vue diff --git a/src/components/mini-widgets/VoiceRecorder.vue b/src/components/mini-widgets/VoiceRecorder.vue new file mode 100644 index 0000000000..ee07337861 --- /dev/null +++ b/src/components/mini-widgets/VoiceRecorder.vue @@ -0,0 +1,179 @@ + + + + + diff --git a/src/types/widgets.ts b/src/types/widgets.ts index 995c325e62..9854945b1a 100644 --- a/src/types/widgets.ts +++ b/src/types/widgets.ts @@ -127,6 +127,7 @@ export enum MiniWidgetType { TakeoffLandCommander = 'TakeoffLandCommander', VeryGenericIndicator = 'VeryGenericIndicator', ViewSelector = 'ViewSelector', + VoiceRecorder = 'VoiceRecorder', } /** @@ -891,6 +892,7 @@ export const isMiniWidgetConfigurable: Record = { [MiniWidgetType.ViewSelector]: false, [MiniWidgetType.SnapshotTool]: true, [MiniWidgetType.MiniMissionControlPanel]: false, + [MiniWidgetType.VoiceRecorder]: false, } export const widgetHasOwnContextMenu: Record = { From df06134a6627c5286e8b35fbbbf474d9e0bb3e8f Mon Sep 17 00:00:00 2001 From: Rafael Araujo Lehmkuhl Date: Tue, 5 May 2026 16:27:01 -0300 Subject: [PATCH 5/9] video-library: Add audio recordings tab Adds a third Audio entry to the left menu of the media library, next to Videos and Snapshots. The new tab lists every recording in the audio storage, surfaces duration and start time from the metadata sidecar when available, and exposes inline playback through a native audio element plus per-file and bulk download/delete actions. Under Electron a footer button reveals the new audio folder in the system file manager. --- src/components/VideoLibraryModal.vue | 239 +++++++++++++++++++++++++++ 1 file changed, 239 insertions(+) diff --git a/src/components/VideoLibraryModal.vue b/src/components/VideoLibraryModal.vue index e474269110..d0d31919e5 100644 --- a/src/components/VideoLibraryModal.vue +++ b/src/components/VideoLibraryModal.vue @@ -181,6 +181,111 @@ + diff --git a/src/components/blueos-cloud/BlueOsCloudMissionPicker.vue b/src/components/blueos-cloud/BlueOsCloudMissionPicker.vue new file mode 100644 index 0000000000..26950d2ba5 --- /dev/null +++ b/src/components/blueos-cloud/BlueOsCloudMissionPicker.vue @@ -0,0 +1,217 @@ + + + diff --git a/src/components/blueos-cloud/BlueOsCloudUploadProgress.vue b/src/components/blueos-cloud/BlueOsCloudUploadProgress.vue new file mode 100644 index 0000000000..3fa1340e9a --- /dev/null +++ b/src/components/blueos-cloud/BlueOsCloudUploadProgress.vue @@ -0,0 +1,78 @@ + + + diff --git a/src/components/mini-widgets/MissionIdentifier.vue b/src/components/mini-widgets/MissionIdentifier.vue index 2466f39f16..86529f41c4 100644 --- a/src/components/mini-widgets/MissionIdentifier.vue +++ b/src/components/mini-widgets/MissionIdentifier.vue @@ -35,14 +35,47 @@ -
-

Mission Name

- +
+
+

Mission Name

+ +
+ +
+
+
+ BlueOS Cloud mission + + Enable BlueOS Cloud integration in the General settings to use this feature. + + + Sign in to BlueOS Cloud to create missions. + + + Linked: {{ linkedCloudMission.title }} + + + Create a mission on BlueOS Cloud to log this session there. + +
+ + {{ linkedCloudMission ? 'Recreate on cloud' : 'Create on BlueOS Cloud' }} + +
+
@@ -51,10 +84,12 @@ diff --git a/src/libs/blueos-cloud/api.ts b/src/libs/blueos-cloud/api.ts new file mode 100644 index 0000000000..84fef0bf60 --- /dev/null +++ b/src/libs/blueos-cloud/api.ts @@ -0,0 +1,185 @@ +import { BlueOsCloudMission, BlueOsCloudPaginatedResponse, PresignedUpload } from './types' + +export const BLUEOS_CLOUD_API_BASE = 'https://app.blueos.cloud/api/v1' + +const authHeaders = (accessToken: string): Record => ({ + Authorization: accessToken, +}) + +const authJsonHeaders = (accessToken: string): Record => ({ + 'Authorization': accessToken, + 'Content-Type': 'application/json', +}) + +const fetchAllPages = async (initialUrl: string, accessToken: string): Promise => { + const all: T[] = [] + let nextUrl: string | null = initialUrl + + while (nextUrl) { + const res = await fetch(nextUrl.replace(/^http:\/\//, 'https://'), { headers: authHeaders(accessToken) }) + if (!res.ok) { + const text = await res.text().catch(() => '') + throw new Error(`BlueOS Cloud API error: ${res.status} ${text || res.statusText}`) + } + const data = (await res.json()) as BlueOsCloudPaginatedResponse | T[] + if (Array.isArray(data)) { + all.push(...data) + nextUrl = null + } else { + all.push(...data.results) + nextUrl = data.next + } + } + + return all +} + +/** + * Fetches every mission visible to the authenticated user, automatically following pagination. + * @param {string} accessToken - Valid BlueOS Cloud access token. + * @returns {Promise} List of missions sorted as returned by the API. + */ +export const fetchMissions = async (accessToken: string): Promise => { + return fetchAllPages(`${BLUEOS_CLOUD_API_BASE}/missions/`, accessToken) +} + +/** + * Creates a new mission in BlueOS Cloud. + * + * Latitude and longitude are formatted to 6 decimal places to satisfy the API contract. + * @param {object} input - Data describing the new mission. + * @param {string} input.name - Human-readable mission title. + * @param {string} [input.description] - Optional mission description. + * @param {number | null} [input.latitude] - Optional starting latitude in decimal degrees. + * @param {number | null} [input.longitude] - Optional starting longitude in decimal degrees. + * @param {string} accessToken - Valid BlueOS Cloud access token. + * @returns {Promise} Newly created mission as returned by the API. + */ +export const createMission = async ( + input: { + /** + * Human-readable mission title. + */ + name: string + /** + * Optional mission description. + */ + description?: string + /** + * Optional starting latitude in decimal degrees. + */ + latitude?: number | null + /** + * Optional starting longitude in decimal degrees. + */ + longitude?: number | null + }, + accessToken: string +): Promise => { + const body: Record = { + title: input.name, + start_time: new Date().toISOString(), + } + if (input.description) body.description = input.description + if (input.latitude != null) body.start_latitude = input.latitude.toFixed(6) + if (input.longitude != null) body.start_longitude = input.longitude.toFixed(6) + + const res = await fetch(`${BLUEOS_CLOUD_API_BASE}/missions/`, { + method: 'POST', + headers: authJsonHeaders(accessToken), + body: JSON.stringify(body), + }) + if (!res.ok) { + const text = await res.text().catch(() => '') + throw new Error(`Failed to create BlueOS Cloud mission: ${res.status} ${text || res.statusText}`) + } + + return res.json() +} + +/** + * Requests a pre-signed S3 upload URL for adding an attachment to a mission. + * + * The returned `url` and `fields` must be sent together as a multipart form body, with the file appended last. + * @param {string} missionId - Identifier of the mission that should receive the attachment. + * @param {string} fileName - Name to use for the file once it is stored on the cloud. + * @param {string} accessToken - Valid BlueOS Cloud access token. + * @param {string} [capturedAt] - Optional ISO timestamp describing when the file was originally captured. + * @returns {Promise} Pre-signed upload payload that must be POSTed to S3. + */ +export const getPresignedUpload = async ( + missionId: string, + fileName: string, + accessToken: string, + capturedAt?: string +): Promise => { + const body = new URLSearchParams() + body.append('file_name', fileName) + if (capturedAt) body.append('captured_at', capturedAt) + + const res = await fetch(`${BLUEOS_CLOUD_API_BASE}/missions/${missionId}/new_attachment/`, { + method: 'POST', + headers: { + 'Authorization': accessToken, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: body.toString(), + }) + if (!res.ok) { + const text = await res.text().catch(() => '') + throw new Error(`Failed to request BlueOS Cloud upload: ${res.status} ${text || res.statusText}`) + } + + return res.json() +} + +/** + * Uploads a Blob to a pre-signed S3 URL using the standard multipart form contract. + * + * The function reports progress via the optional `onProgress` callback so callers can render a progress bar. + * @param {PresignedUpload} presigned - Pre-signed payload returned by {@link getPresignedUpload}. + * @param {Blob} fileBlob - File contents to be uploaded. + * @param {string} fileName - File name to register on S3 (must match the one used in the pre-signed request). + * @param {(progress: number) => void} [onProgress] - Optional callback invoked with progress in the 0-100 range. + * @param {AbortSignal} [signal] - Optional signal that aborts the upload (e.g. when the user cancels). + * @returns {Promise} Resolves once the upload has been accepted by S3. + */ +export const uploadFileToPresignedUrl = ( + presigned: PresignedUpload, + fileBlob: Blob, + fileName: string, + onProgress?: (progress: number) => void, + signal?: AbortSignal +): Promise => { + return new Promise((resolve, reject) => { + const formData = new FormData() + for (const [key, value] of Object.entries(presigned.fields)) { + formData.append(key, value) + } + formData.append('file', fileBlob, fileName) + + const xhr = new XMLHttpRequest() + xhr.open('POST', presigned.url) + + xhr.upload.onprogress = (event): void => { + if (event.lengthComputable && event.total > 0) { + const pct = (event.loaded / event.total) * 100 + onProgress?.(Math.min(pct, 99)) + } + } + + xhr.onload = (): void => { + if (xhr.status >= 200 && xhr.status < 300) { + onProgress?.(100) + resolve() + return + } + reject(new Error(`S3 upload failed: ${xhr.status} ${xhr.statusText}`)) + } + xhr.onerror = (): void => reject(new Error('Network error during BlueOS Cloud upload')) + xhr.onabort = (): void => reject(new Error('BlueOS Cloud upload aborted')) + + signal?.addEventListener('abort', () => xhr.abort(), { once: true }) + xhr.send(formData) + }) +} diff --git a/src/libs/blueos-cloud/auth.ts b/src/libs/blueos-cloud/auth.ts new file mode 100644 index 0000000000..a957985133 --- /dev/null +++ b/src/libs/blueos-cloud/auth.ts @@ -0,0 +1,213 @@ +import { BlueOsCloudTokens, BlueOsCloudUser, DeviceAuthorizationResponse, TokenResponse } from './types' + +export const BLUEOS_CLOUD_AUTH0_DOMAIN = 'bcloud-prod.us.auth0.com' +export const BLUEOS_CLOUD_AUTH0_CLIENT_ID = '9gVAeMgG9STSliyvBZDqiNqBmN76g5jr' +export const BLUEOS_CLOUD_AUTH0_AUDIENCE = 'UXVpt5UzHP7v58VeyXl3IHLMSQloBufr' +export const BLUEOS_CLOUD_AUTH0_SCOPE = 'openid profile email offline_access' + +const TOKEN_REFRESH_SAFETY_MARGIN_MS = 60_000 + +/** + * Custom error thrown when the user explicitly cancels or rejects the device authorization request. + * Use it to differentiate user-driven aborts from generic network failures in the wizard UI. + */ +export class DeviceAuthorizationCancelled extends Error { + /** + * Creates a new DeviceAuthorizationCancelled error. + * @param {string} message - Human readable description of why the flow was cancelled. + */ + constructor(message = 'Device authorization cancelled') { + super(message) + this.name = 'DeviceAuthorizationCancelled' + } +} + +/** + * Starts the Auth0 Device Authorization Flow by requesting a device & user code pair. + * + * The returned `verification_uri_complete` URL must be opened by the user in a browser to authorize the application. + * Once the user finishes the browser flow, call {@link pollForDeviceAuthorizationToken} with the returned `device_code` + * to exchange it for an access token. + * @returns {Promise} The device authorization payload returned by Auth0. + */ +export const requestDeviceAuthorization = async (): Promise => { + const body = new URLSearchParams({ + client_id: BLUEOS_CLOUD_AUTH0_CLIENT_ID, + scope: BLUEOS_CLOUD_AUTH0_SCOPE, + audience: BLUEOS_CLOUD_AUTH0_AUDIENCE, + }) + + const res = await fetch(`https://${BLUEOS_CLOUD_AUTH0_DOMAIN}/oauth/device/code`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString(), + }) + + if (!res.ok) { + const text = await res.text().catch(() => '') + throw new Error(`Failed to start BlueOS Cloud login: ${res.status} ${text || res.statusText}`) + } + + return res.json() +} + +/** + * Builds a {@link BlueOsCloudTokens} object from an Auth0 token response. + * + * Calculates the absolute expiration timestamp (`expiresAt`) from the relative `expires_in` value so it can be checked + * against `Date.now()` later without keeping track of when the response was received. + * @param {TokenResponse} response - Token payload returned by Auth0. + * @param {string | null | undefined} fallbackRefreshToken - Refresh token from a previous response, used when the + * current response does not include one. + * @returns {BlueOsCloudTokens} Token bundle ready to be persisted. + */ +const buildTokensFromResponse = ( + response: TokenResponse, + fallbackRefreshToken: string | null | undefined +): BlueOsCloudTokens => ({ + accessToken: response.access_token, + refreshToken: response.refresh_token ?? fallbackRefreshToken ?? null, + expiresAt: Date.now() + response.expires_in * 1000, +}) + +/** + * Polls the Auth0 token endpoint until the user completes the browser-side authorization flow. + * + * The polling cadence respects the `interval` returned by Auth0, automatically slowing down when the server responds + * with `slow_down`, and stops after `expires_in` seconds with a clear error. + * @param {DeviceAuthorizationResponse} authorization - Payload returned by {@link requestDeviceAuthorization}. + * @param {AbortSignal} [signal] - Optional signal that allows cancelling the polling loop (e.g. when the user closes + * the wizard dialog). + * @returns {Promise} Tokens obtained once the user authorizes the application. + */ +export const pollForDeviceAuthorizationToken = async ( + authorization: DeviceAuthorizationResponse, + signal?: AbortSignal +): Promise => { + let intervalSeconds = authorization.interval > 0 ? authorization.interval : 5 + const expiresAt = Date.now() + authorization.expires_in * 1000 + + while (Date.now() < expiresAt) { + if (signal?.aborted) throw new DeviceAuthorizationCancelled('Login wizard was closed') + + await new Promise((resolve, reject) => { + const timeoutId = setTimeout(resolve, intervalSeconds * 1000) + signal?.addEventListener( + 'abort', + () => { + clearTimeout(timeoutId) + reject(new DeviceAuthorizationCancelled('Login wizard was closed')) + }, + { once: true } + ) + }) + + const body = new URLSearchParams({ + grant_type: 'urn:ietf:params:oauth:grant-type:device_code', + device_code: authorization.device_code, + client_id: BLUEOS_CLOUD_AUTH0_CLIENT_ID, + }) + const res = await fetch(`https://${BLUEOS_CLOUD_AUTH0_DOMAIN}/oauth/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString(), + }) + + if (res.ok) { + const data: TokenResponse = await res.json() + return buildTokensFromResponse(data, null) + } + + const errorPayload = (await res.json().catch(() => ({}))) as { + /** + * + */ + error?: string + /** + * + */ + error_description?: string + } + + if (errorPayload.error === 'authorization_pending') continue + if (errorPayload.error === 'slow_down') { + intervalSeconds += 5 + continue + } + if (errorPayload.error === 'access_denied') { + throw new DeviceAuthorizationCancelled('Authorization was denied on the BlueOS Cloud login page') + } + if (errorPayload.error === 'expired_token') { + throw new Error('BlueOS Cloud login code expired. Please try again.') + } + + throw new Error(`BlueOS Cloud login failed: ${errorPayload.error_description || errorPayload.error || res.status}`) + } + + throw new Error('BlueOS Cloud login timed out. Please try again.') +} + +/** + * Refreshes the access token using a previously issued refresh token. + * @param {string} refreshToken - Refresh token returned during the original device flow. + * @returns {Promise} New token bundle reusing the input refresh token when a new one is not issued. + */ +export const refreshAccessToken = async (refreshToken: string): Promise => { + const body = new URLSearchParams({ + grant_type: 'refresh_token', + client_id: BLUEOS_CLOUD_AUTH0_CLIENT_ID, + refresh_token: refreshToken, + }) + + const res = await fetch(`https://${BLUEOS_CLOUD_AUTH0_DOMAIN}/oauth/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString(), + }) + + if (!res.ok) { + const text = await res.text().catch(() => '') + throw new Error(`Failed to refresh BlueOS Cloud session: ${res.status} ${text || res.statusText}`) + } + + const data: TokenResponse = await res.json() + return buildTokensFromResponse(data, refreshToken) +} + +/** + * Determines whether a token bundle is still valid. + * + * A small safety margin is subtracted from the absolute expiration time so callers don't try to use a token that is + * about to expire mid-flight. + * @param {BlueOsCloudTokens | null} tokens - Token bundle to check, or `null` when no user is logged in. + * @returns {boolean} `true` when the access token is present and not within the safety margin of expiring. + */ +export const isTokenValid = (tokens: BlueOsCloudTokens | null): boolean => { + if (!tokens?.accessToken) return false + return Date.now() < tokens.expiresAt - TOKEN_REFRESH_SAFETY_MARGIN_MS +} + +/** + * Fetches the authenticated user profile from Auth0. + * @param {string} accessToken - Valid Auth0 access token. + * @returns {Promise} Profile data describing the authenticated user. + */ +export const fetchAuthenticatedUser = async (accessToken: string): Promise => { + const res = await fetch(`https://${BLUEOS_CLOUD_AUTH0_DOMAIN}/userinfo`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }) + + if (!res.ok) { + const text = await res.text().catch(() => '') + throw new Error(`Failed to fetch BlueOS Cloud user info: ${res.status} ${text || res.statusText}`) + } + + const profile = (await res.json()) as Record + return { + sub: String(profile.sub ?? ''), + name: typeof profile.name === 'string' ? profile.name : undefined, + email: typeof profile.email === 'string' ? profile.email : undefined, + picture: typeof profile.picture === 'string' ? profile.picture : undefined, + nickname: typeof profile.nickname === 'string' ? profile.nickname : undefined, + } +} diff --git a/src/libs/blueos-cloud/types.ts b/src/libs/blueos-cloud/types.ts new file mode 100644 index 0000000000..5064883a49 --- /dev/null +++ b/src/libs/blueos-cloud/types.ts @@ -0,0 +1,173 @@ +/** + * Authenticated BlueOS Cloud user, derived from the Auth0 `/userinfo` endpoint. + */ +export interface BlueOsCloudUser { + /** + * Auth0 subject identifier (e.g. `auth0|abc123`). + */ + sub: string + /** + * Display name returned by Auth0. + */ + name?: string + /** + * E-mail address returned by Auth0. + */ + email?: string + /** + * Profile picture URL returned by Auth0. + */ + picture?: string + /** + * Auth0 nickname / username. + */ + nickname?: string +} + +/** + * Token bundle persisted locally after a successful login. + */ +export interface BlueOsCloudTokens { + /** + * Auth0 access token used to call the BlueOS Cloud API. + */ + accessToken: string + /** + * Optional refresh token used to obtain a new access token when it expires. + */ + refreshToken: string | null + /** + * Epoch milliseconds at which the access token expires. + */ + expiresAt: number +} + +/** + * Successful response payload from `POST /oauth/device/code` (Auth0 Device Authorization Flow). + */ +export interface DeviceAuthorizationResponse { + /** + * Opaque code that the application uses to poll the token endpoint. + */ + device_code: string + /** + * Short, human-readable code that the user must enter on the verification page. + */ + user_code: string + /** + * Base verification URL (without the `user_code` query parameter). + */ + verification_uri: string + /** + * Verification URL that already contains the `user_code` query parameter. + */ + verification_uri_complete: string + /** + * Seconds before the device code expires. + */ + expires_in: number + /** + * Recommended polling interval in seconds. + */ + interval: number +} + +/** + * Successful response payload from `POST /oauth/token` (Auth0 token exchange). + */ +export interface TokenResponse { + /** + * Access token used to authenticate API calls. + */ + access_token: string + /** + * Refresh token (only returned when the `offline_access` scope is requested). + */ + refresh_token?: string + /** + * OpenID Connect ID token (JWT) describing the authenticated user. + */ + id_token?: string + /** + * Token type returned by the authorization server (typically `Bearer`). + */ + token_type: string + /** + * Seconds until the access token expires. + */ + expires_in: number +} + +/** + * Mission representation as returned by the BlueOS Cloud API. + */ +export interface BlueOsCloudMission { + /** + * Unique mission identifier. + */ + id: string + /** + * Mission title shown on the cloud UI. + */ + title: string + /** + * Optional mission description. + */ + description: string + /** + * ISO timestamp of when the mission started. + */ + start_time: string | null + /** + * ISO timestamp of when the mission ended. + */ + end_time: string | null + /** + * Identifier of the user that created the mission. + */ + created_by: number | null + /** + * Decimal latitude of the mission start (string to preserve precision). + */ + start_latitude: string | null + /** + * Decimal longitude of the mission start (string to preserve precision). + */ + start_longitude: string | null +} + +/** + * Generic paginated payload used by the BlueOS Cloud API. + */ +export interface BlueOsCloudPaginatedResponse { + /** + * Total number of items across all pages. + */ + count: number + /** + * URL of the next page, or `null` when there are no more pages. + */ + next: string | null + /** + * URL of the previous page, or `null` when on the first page. + */ + previous: string | null + /** + * Items contained in the current page. + */ + results: T[] +} + +/** + * Pre-signed S3 upload payload returned by `POST /missions/{id}/new_attachment/`. + */ +export interface PresignedUpload { + /** + * Target URL where the file must be POSTed. + */ + url: string + /** + * Form fields that must be sent alongside the file in the multipart body. + */ + fields: Record +} diff --git a/src/stores/blueOsCloud.ts b/src/stores/blueOsCloud.ts new file mode 100644 index 0000000000..ec8eab1e61 --- /dev/null +++ b/src/stores/blueOsCloud.ts @@ -0,0 +1,160 @@ +import { useStorage } from '@vueuse/core' +import { defineStore } from 'pinia' +import { computed, ref } from 'vue' + +import { useBlueOsStorage } from '@/composables/settingsSyncer' +import { createMission, fetchMissions } from '@/libs/blueos-cloud/api' +import { fetchAuthenticatedUser, isTokenValid, refreshAccessToken } from '@/libs/blueos-cloud/auth' +import { BlueOsCloudMission, BlueOsCloudTokens, BlueOsCloudUser } from '@/libs/blueos-cloud/types' + +export const useBlueOsCloudStore = defineStore('blueOsCloud', () => { + const isIntegrationEnabled = useBlueOsStorage('cockpit-blueos-cloud-enabled', false) + const tokens = useStorage('cockpit-blueos-cloud-tokens', null, undefined, { + serializer: { + read: (raw) => { + if (!raw) return null + try { + return JSON.parse(raw) as BlueOsCloudTokens + } catch { + return null + } + }, + write: (value) => (value ? JSON.stringify(value) : ''), + }, + }) + const user = useStorage('cockpit-blueos-cloud-user', null, undefined, { + serializer: { + read: (raw) => { + if (!raw) return null + try { + return JSON.parse(raw) as BlueOsCloudUser + } catch { + return null + } + }, + write: (value) => (value ? JSON.stringify(value) : ''), + }, + }) + + const missions = ref([]) + const isLoadingMissions = ref(false) + const lastError = ref(null) + + const isAuthenticated = computed(() => !!tokens.value && !!user.value) + + /** + * Clears persisted tokens and the cached user profile, effectively logging the user out locally. + */ + const clearSession = (): void => { + tokens.value = null + user.value = null + missions.value = [] + } + + /** + * Stores a freshly issued token bundle and refreshes the cached user profile from Auth0. + * + * Used both at the end of the device-flow login wizard and after a successful refresh-token exchange. + * @param {BlueOsCloudTokens} newTokens - Token bundle to persist. + */ + const persistSession = async (newTokens: BlueOsCloudTokens): Promise => { + tokens.value = newTokens + user.value = await fetchAuthenticatedUser(newTokens.accessToken) + } + + /** + * Returns a valid access token, refreshing it on the fly when it has expired. + * + * Throws when there is no active session or when the refresh attempt fails, so callers can show a re-login prompt. + * @returns {Promise} An access token guaranteed to be valid for the next API call. + */ + const ensureValidAccessToken = async (): Promise => { + if (!tokens.value) throw new Error('Not signed in to BlueOS Cloud') + + if (isTokenValid(tokens.value)) return tokens.value.accessToken + + if (!tokens.value.refreshToken) { + clearSession() + throw new Error('BlueOS Cloud session expired. Please sign in again.') + } + + try { + const refreshed = await refreshAccessToken(tokens.value.refreshToken) + await persistSession(refreshed) + return refreshed.accessToken + } catch (error) { + clearSession() + throw error + } + } + + /** + * Refreshes the cached list of cloud missions. + * + * Mutates `missions`, `isLoadingMissions` and `lastError` so the UI can react to the load lifecycle. + * @returns {Promise} The updated list of missions. + */ + const refreshMissions = async (): Promise => { + isLoadingMissions.value = true + lastError.value = null + try { + const accessToken = await ensureValidAccessToken() + const fetched = await fetchMissions(accessToken) + missions.value = fetched + return fetched + } catch (error) { + lastError.value = (error as Error).message + throw error + } finally { + isLoadingMissions.value = false + } + } + + /** + * Creates a mission in BlueOS Cloud and prepends it to the local cache. + * @param {object} input - Mission data. + * @param {string} input.name - Title for the new mission. + * @param {string} [input.description] - Optional mission description. + * @param {number | null} [input.latitude] - Optional starting latitude in decimal degrees. + * @param {number | null} [input.longitude] - Optional starting longitude in decimal degrees. + * @returns {Promise} The newly created mission. + */ + const createCloudMission = async (input: { + /** + * Title for the new mission. + */ + name: string + /** + * Optional mission description. + */ + description?: string + /** + * Optional starting latitude in decimal degrees. + */ + latitude?: number | null + /** + * Optional starting longitude in decimal degrees. + */ + longitude?: number | null + }): Promise => { + const accessToken = await ensureValidAccessToken() + const created = await createMission(input, accessToken) + missions.value = [created, ...missions.value] + return created + } + + return { + isIntegrationEnabled, + tokens, + user, + missions, + isLoadingMissions, + lastError, + isAuthenticated, + persistSession, + clearSession, + ensureValidAccessToken, + refreshMissions, + createCloudMission, + } +}) diff --git a/src/views/ConfigurationGeneralView.vue b/src/views/ConfigurationGeneralView.vue index 57b494a76e..f829abf84b 100644 --- a/src/views/ConfigurationGeneralView.vue +++ b/src/views/ConfigurationGeneralView.vue @@ -364,6 +364,64 @@
+ + + + + + + + diff --git a/src/components/blueos-cloud/BlueOsCloudLoginDialog.vue b/src/components/blueos-cloud/BlueOsCloudLoginDialog.vue index a19fb7c255..d0477ab399 100644 --- a/src/components/blueos-cloud/BlueOsCloudLoginDialog.vue +++ b/src/components/blueos-cloud/BlueOsCloudLoginDialog.vue @@ -23,7 +23,7 @@
  • Open the link, sign in to BlueOS Cloud and confirm the code.
  • Cockpit will detect the authorization automatically.
  • -

    {{ errorMessage }}

    +

    {{ errorMessage }}

    @@ -48,7 +48,7 @@ Waiting for authorization...
    -

    {{ errorMessage }}

    +

    {{ errorMessage }}

    diff --git a/src/components/blueos-cloud/BlueOsCloudMissionPicker.vue b/src/components/blueos-cloud/BlueOsCloudMissionPicker.vue index 26950d2ba5..b508926c7f 100644 --- a/src/components/blueos-cloud/BlueOsCloudMissionPicker.vue +++ b/src/components/blueos-cloud/BlueOsCloudMissionPicker.vue @@ -31,10 +31,10 @@ No missions yet on your BlueOS Cloud account.
    - + + View on BlueOS Cloud + mdi-open-in-new + +
    {{ cloudStore.lastError }}
    -
    Or create a new mission
    +
    + Or create a new mission + + {{ showCreateForm ? 'Hide' : 'Show' }} options + +
    +
    + +
    @@ -95,10 +115,13 @@ diff --git a/src/components/blueos-cloud/BlueOsCloudUploadProgress.vue b/src/components/blueos-cloud/BlueOsCloudUploadProgress.vue index 3fa1340e9a..5e54e39c98 100644 --- a/src/components/blueos-cloud/BlueOsCloudUploadProgress.vue +++ b/src/components/blueos-cloud/BlueOsCloudUploadProgress.vue @@ -25,6 +25,16 @@

    {{ Math.round(progress) }}%

    {{ errorMessage }}

    Upload complete!

    + + mdi-open-in-new + View mission on BlueOS Cloud + @@ -40,9 +50,12 @@ diff --git a/src/components/mini-widgets/MissionIdentifier.vue b/src/components/mini-widgets/MissionIdentifier.vue index 86529f41c4..d32a2e0cbc 100644 --- a/src/components/mini-widgets/MissionIdentifier.vue +++ b/src/components/mini-widgets/MissionIdentifier.vue @@ -40,9 +40,9 @@

    Mission Name

    @@ -57,14 +57,23 @@ Sign in to BlueOS Cloud to create missions. - Linked: {{ linkedCloudMission.title }} + Linked: + + {{ linkedCloudMission.title }} + mdi-open-in-new + Create a mission on BlueOS Cloud to log this session there. - {{ linkedCloudMission ? 'Recreate on cloud' : 'Create on BlueOS Cloud' }} + Create on BlueOS Cloud +
    + +
    @@ -86,12 +98,15 @@