diff --git a/electron/main.ts b/electron/main.ts index abe2a48d2..c63639aaf 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -2,6 +2,7 @@ import { app, BrowserWindow, protocol, screen } from 'electron' import { join } from 'path' import { setupNetworkService } from './services/network' +import { setupFilesystemStorage } from './services/storage' export const ROOT_PATH = { dist: join(__dirname, '..'), @@ -61,9 +62,16 @@ protocol.registerSchemesAsPrivileged([ }, ]) +setupFilesystemStorage() setupNetworkService() -app.whenReady().then(createWindow) +app.whenReady().then(async () => { + console.log('Electron app is ready.') + console.log(`Cockpit version: ${app.getVersion()}`) + + console.log('Creating window...') + createWindow() +}) app.on('before-quit', () => { // @ts-ignore: import.meta.env does not exist in the types diff --git a/electron/preload.ts b/electron/preload.ts index 4312004c1..11e7e63e6 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -2,4 +2,24 @@ import { contextBridge, ipcRenderer } from 'electron' contextBridge.exposeInMainWorld('electronAPI', { getInfoOnSubnets: () => ipcRenderer.invoke('get-info-on-subnets'), + setItem: async (key: string, value: Blob) => { + const arrayBuffer = await value.arrayBuffer() + await ipcRenderer.invoke('setItem', { key, value: new Uint8Array(arrayBuffer) }) + }, + getItem: async (key: string) => { + const arrayBuffer = await ipcRenderer.invoke('getItem', key) + return arrayBuffer ? new Blob([arrayBuffer]) : null + }, + removeItem: async (key: string) => { + await ipcRenderer.invoke('removeItem', key) + }, + clear: async () => { + await ipcRenderer.invoke('clear') + }, + keys: async () => { + return await ipcRenderer.invoke('keys') + }, + iterate: async (callback: (value: Blob, key: string, iterationNumber: number) => void) => { + await ipcRenderer.invoke('iterate', (_, data) => callback(data.value, data.key, data.iterationNumber)) + }, }) diff --git a/electron/services/storage.ts b/electron/services/storage.ts new file mode 100644 index 000000000..71d4a6495 --- /dev/null +++ b/electron/services/storage.ts @@ -0,0 +1,71 @@ +import { ipcMain } from 'electron' +import { app } from 'electron' +import * as fs from 'fs/promises' +import { dirname, join } from 'path' + +import { StorageDB } from '../../src/types/general' + +// Create a new storage interface for filesystem +const cockpitFolderPath = join(app.getPath('home'), 'Cockpit') +fs.mkdir(cockpitFolderPath, { recursive: true }) + +export const filesystemStorage: StorageDB = { + async setItem(key: string, value: ArrayBuffer): Promise { + const buffer = Buffer.from(value) + const filePath = join(cockpitFolderPath, key) + await fs.mkdir(dirname(filePath), { recursive: true }) + await fs.writeFile(filePath, buffer) + }, + async getItem(key: string): Promise { + const filePath = join(cockpitFolderPath, key) + try { + return await fs.readFile(filePath) + } catch (error) { + if (error.code === 'ENOENT') return null + throw error + } + }, + async removeItem(key: string): Promise { + const filePath = join(cockpitFolderPath, key) + await fs.unlink(filePath) + }, + async clear(): Promise { + throw new Error( + `Clear functionality is not available in the filesystem storage, so we don't risk losing important data. If you + want to clear the storage, please delete the Cockpit folder in your user data directory manually.` + ) + }, + async keys(): Promise { + const dirPath = cockpitFolderPath + try { + return await fs.readdir(dirPath) + } catch (error) { + if (error.code === 'ENOENT') return [] + throw error + } + }, + async iterate(callback: (value: unknown, key: string, iterationNumber: number) => void): Promise { + throw new Error('Iterate functionality is not available in the filesystem storage.') + }, +} + +export const setupFilesystemStorage = (): void => { + ipcMain.handle('setItem', async (_, data) => { + await filesystemStorage.setItem(data.key, data.value) + }) + ipcMain.handle('getItem', async (_, key) => { + return await filesystemStorage.getItem(key) + }) + ipcMain.handle('removeItem', async (_, key) => { + await filesystemStorage.removeItem(key) + }) + ipcMain.handle('clear', async () => { + await filesystemStorage.clear() + }) + ipcMain.handle('keys', async () => { + return await filesystemStorage.keys() + }) + ipcMain.handle('iterate', async (_, callback) => { + await filesystemStorage.iterate(callback) + }) +} diff --git a/src/components/VideoLibraryModal.vue b/src/components/VideoLibraryModal.vue index facff754d..2ea3d5379 100644 --- a/src/components/VideoLibraryModal.vue +++ b/src/components/VideoLibraryModal.vue @@ -889,11 +889,12 @@ const fetchVideosAndLogData = async (): Promise => { const logFileOperations: Promise[] = [] // Fetch processed videos and logs - await videoStore.videoStoringDB.iterate((value, key) => { + const keys = await videoStore.videoStorage.keys() + for (const key of keys) { if (videoStore.isVideoFilename(key)) { videoFilesOperations.push( (async () => { - const videoBlob = await videoStore.videoStoringDB.getItem(key) + const videoBlob = await videoStore.videoStorage.getItem(key) let url = '' let isProcessed = true if (videoBlob instanceof Blob) { @@ -910,7 +911,7 @@ const fetchVideosAndLogData = async (): Promise => { if (key.endsWith('.ass')) { logFileOperations.push( (async () => { - const videoBlob = await videoStore.videoStoringDB.getItem(key) + const videoBlob = await videoStore.videoStorage.getItem(key) let url = '' if (videoBlob instanceof Blob) { url = URL.createObjectURL(videoBlob) @@ -923,7 +924,7 @@ const fetchVideosAndLogData = async (): Promise => { })() ) } - }) + } // Fetch unprocessed videos const unprocessedVideos = await videoStore.unprocessedVideos diff --git a/src/components/mini-widgets/MiniVideoRecorder.vue b/src/components/mini-widgets/MiniVideoRecorder.vue index 4991521ab..c99ef9940 100644 --- a/src/components/mini-widgets/MiniVideoRecorder.vue +++ b/src/components/mini-widgets/MiniVideoRecorder.vue @@ -188,7 +188,8 @@ watch(nameSelectedStream, (newName) => { // Fetch number of temporary videos on storage const fetchNumberOfTempVideos = async (): Promise => { - const nProcessedVideos = (await videoStore.videoStoringDB.keys()).filter((k) => videoStore.isVideoFilename(k)).length + const keys = await videoStore.videoStorage.keys() + const nProcessedVideos = keys.filter((k) => videoStore.isVideoFilename(k)).length const nFailedUnprocessedVideos = Object.keys(videoStore.keysFailedUnprocessedVideos).length numberOfVideosOnDB.value = nProcessedVideos + nFailedUnprocessedVideos } diff --git a/src/libs/cosmos.ts b/src/libs/cosmos.ts index 41cc98050..0aec6a128 100644 --- a/src/libs/cosmos.ts +++ b/src/libs/cosmos.ts @@ -103,6 +103,36 @@ declare global { registerActionCallback: typeof registerActionCallback unregisterActionCallback: typeof unregisterActionCallback executeActionCallback: typeof executeActionCallback + /* eslint-enable jsdoc/require-jsdoc */ + } + /** + * Electron API for update management + */ + electronAPI: { + /** + * Set an item in the filesystem storage + */ + setItem: (key: string, value: Blob) => Promise + /** + * Get an item from the filesystem storage + */ + getItem: (key: string) => Promise + /** + * Remove an item from the filesystem storage + */ + removeItem: (key: string) => Promise + /** + * Clear the filesystem storage + */ + clear: () => Promise + /** + * Get all keys from the filesystem storage + */ + keys: () => Promise + /** + * Iterate over the items in the filesystem storage + */ + iterate: (callback: (value: Blob, key: string, iterationNumber: number) => void) => Promise } /** * Electron API exposed through preload script @@ -115,7 +145,6 @@ declare global { getInfoOnSubnets: () => Promise } } - /* eslint-enable jsdoc/require-jsdoc */ } // Use global as window when running for browsers diff --git a/src/libs/electron/filesystemStorageRendererAPI.ts b/src/libs/electron/filesystemStorageRendererAPI.ts new file mode 100644 index 000000000..15237c339 --- /dev/null +++ b/src/libs/electron/filesystemStorageRendererAPI.ts @@ -0,0 +1,42 @@ +import type { StorageDB } from '@/types/general' + +import { isElectron } from '../utils' + +const throwIfNotElectron = (): void => { + if (!isElectron()) { + console.warn('Filesystem storage is only available in Electron.') + return + } + if (!window.electronAPI) { + console.error('electronAPI is not available on window object') + console.debug('Available window properties:', Object.keys(window)) + throw new Error('Electron filesystem API is not properly initialized. This is likely a setup issue.') + } +} + +export const electronStorage: StorageDB = { + setItem: async (key: string, value: Blob): Promise => { + throwIfNotElectron() + await window.electronAPI.setItem(key, value) + }, + getItem: async (key: string): Promise => { + throwIfNotElectron() + return await window.electronAPI.getItem(key) + }, + removeItem: async (key: string): Promise => { + throwIfNotElectron() + await window.electronAPI.removeItem(key) + }, + clear: async (): Promise => { + throwIfNotElectron() + await window.electronAPI.clear() + }, + keys: async (): Promise => { + throwIfNotElectron() + return await window.electronAPI.keys() + }, + iterate: async (callback: (value: Blob, key: string, iterationNumber: number) => void): Promise => { + throwIfNotElectron() + await window.electronAPI.iterate(callback) + }, +} diff --git a/src/libs/utils.ts b/src/libs/utils.ts index eace84dbc..487419862 100644 --- a/src/libs/utils.ts +++ b/src/libs/utils.ts @@ -1,9 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { useInteractionDialog } from '@/composables/interactionDialog' - -const { showDialog } = useInteractionDialog() - export const constrain = (value: number, min: number, max: number): number => { return Math.max(Math.min(value, max), min) } @@ -135,7 +131,7 @@ export const tryOrAlert = async (tryFunction: () => Promise): Promise Promise): Promise { const restartMessage = `Restarting Cockpit in ${timeout / 1000} seconds...` - console.log(restartMessage) - showDialog({ message: restartMessage, variant: 'info', timer: timeout }) + console.info(restartMessage) setTimeout(() => location.reload(), timeout) } diff --git a/src/libs/videoStorage.ts b/src/libs/videoStorage.ts new file mode 100644 index 000000000..e81b37989 --- /dev/null +++ b/src/libs/videoStorage.ts @@ -0,0 +1,25 @@ +import localforage from 'localforage' + +import { electronStorage } from '@/libs/electron/filesystemStorageRendererAPI' +import { StorageDB } from '@/types/general' + +import { isElectron } from './utils' + +const tempVideoChunksIndexdedDB = localforage.createInstance({ + driver: localforage.INDEXEDDB, + name: 'Cockpit - Temporary Video', + storeName: 'cockpit-temp-video-db', + version: 1.0, + description: 'Database for storing the chunks of an ongoing recording, to be merged afterwards.', +}) + +const videoStoringIndexedDB = localforage.createInstance({ + driver: localforage.INDEXEDDB, + name: 'Cockpit - Video Recovery', + storeName: 'cockpit-video-recovery-db', + version: 1.0, + description: 'Local backups of Cockpit video recordings to be retrieved in case of failure.', +}) + +export const videoStorage: StorageDB = isElectron() ? electronStorage : videoStoringIndexedDB +export const tempVideoStorage: StorageDB = isElectron() ? electronStorage : tempVideoChunksIndexdedDB diff --git a/src/stores/video.ts b/src/stores/video.ts index 34d53ff4b..e30686ebd 100644 --- a/src/stores/video.ts +++ b/src/stores/video.ts @@ -2,7 +2,6 @@ import { useDebounceFn, useStorage, useThrottleFn, useTimestamp } from '@vueuse/ import { BlobReader, BlobWriter, ZipWriter } from '@zip.js/zip.js' import { differenceInSeconds, format } from 'date-fns' import { saveAs } from 'file-saver' -import localforage from 'localforage' import { defineStore } from 'pinia' import { v4 as uuid } from 'uuid' import { computed, ref, watch } from 'vue' @@ -18,13 +17,14 @@ import eventTracker from '@/libs/external-telemetry/event-tracking' import { availableCockpitActions, registerActionCallback } from '@/libs/joystick/protocols/cockpit-actions' import { datalogger } from '@/libs/sensors-logging' import { isEqual, sleep } from '@/libs/utils' +import { tempVideoStorage, videoStorage } from '@/libs/videoStorage' import { useMainVehicleStore } from '@/stores/mainVehicle' import { useMissionStore } from '@/stores/mission' import { Alert, AlertLevel } from '@/types/alert' +import { StorageDB } from '@/types/general' import { type DownloadProgressCallback, type FileDescriptor, - type StorageDB, type StreamData, type UnprocessedVideoInfo, type VideoProcessingDetails, @@ -281,7 +281,7 @@ export const useVideoStore = defineStore('video', () => { let recordingHash = '' let refreshHash = true - const namesCurrentChunksOnDB = await tempVideoChunksDB.keys() + const namesCurrentChunksOnDB = await tempVideoStorage.keys() while (refreshHash) { recordingHash = uuid().slice(0, 8) refreshHash = namesCurrentChunksOnDB.some((chunkName) => chunkName.includes(recordingHash)) @@ -370,7 +370,7 @@ export const useVideoStore = defineStore('video', () => { const chunkName = `${recordingHash}_${chunksCount}` try { - await tempVideoChunksDB.setItem(chunkName, e.data) + await tempVideoStorage.setItem(chunkName, e.data) sequentialLostChunks = 0 } catch { sequentialLostChunks++ @@ -387,7 +387,7 @@ export const useVideoStore = defineStore('video', () => { // Gets the thumbnail from the first chunk if (chunksCount === 0) { try { - const videoChunk = await tempVideoChunksDB.getItem(chunkName) + const videoChunk = await tempVideoStorage.getItem(chunkName) if (videoChunk) { const firstChunkBlob = new Blob([videoChunk as Blob]) const thumbnail = await extractThumbnailFromVideo(firstChunkBlob) @@ -436,13 +436,13 @@ export const useVideoStore = defineStore('video', () => { const discardProcessedFilesFromVideoDB = async (fileNames: string[]): Promise => { console.debug(`Discarding files from the video recovery database: ${fileNames.join(', ')}`) for (const filename of fileNames) { - await videoStoringDB.removeItem(filename) + await videoStorage.removeItem(filename) } } const discardUnprocessedFilesFromVideoDB = async (hashes: string[]): Promise => { for (const hash of hashes) { - await tempVideoChunksDB.removeItem(hash) + await tempVideoStorage.removeItem(hash) delete unprocessedVideos.value[hash] } } @@ -462,7 +462,7 @@ export const useVideoStore = defineStore('video', () => { } const downloadFiles = async ( - db: StorageDB, + db: StorageDB | LocalForage, keys: string[], shouldZip = false, zipFilenamePrefix = 'Cockpit-Video-Files', @@ -496,9 +496,9 @@ export const useVideoStore = defineStore('video', () => { console.debug(`Downloading files from the video recovery database: ${fileNames.join(', ')}`) if (zipMultipleFiles.value) { const ZipFilename = fileNames.length > 1 ? 'Cockpit-Video-Recordings' : 'Cockpit-Video-Recording' - await downloadFiles(videoStoringDB, fileNames, true, ZipFilename, progressCallback) + await downloadFiles(videoStorage, fileNames, true, ZipFilename, progressCallback) } else { - await downloadFiles(videoStoringDB, fileNames) + await downloadFiles(videoStorage, fileNames) } } @@ -506,48 +506,34 @@ export const useVideoStore = defineStore('video', () => { console.debug(`Downloading ${hashes.length} video chunks from the temporary database.`) for (const hash of hashes) { - const fileNames = (await tempVideoChunksDB.keys()).filter((filename) => filename.includes(hash)) + const fileNames = (await tempVideoStorage.keys()).filter((filename) => filename.includes(hash)) const zipFilenamePrefix = `Cockpit-Unprocessed-Video-Chunks-${hash}` - await downloadFiles(tempVideoChunksDB, fileNames, true, zipFilenamePrefix, progressCallback) + await downloadFiles(tempVideoStorage, fileNames, true, zipFilenamePrefix, progressCallback) } } // Used to clear the temporary video database const clearTemporaryVideoDB = async (): Promise => { - await tempVideoChunksDB.clear() + await tempVideoStorage.clear() } const temporaryVideoDBSize = async (): Promise => { let totalSizeBytes = 0 - await tempVideoChunksDB.iterate((chunk) => { - totalSizeBytes += (chunk as Blob).size - }) + const keys = await tempVideoStorage.keys() + for (const key of keys) { + const blob = await tempVideoStorage.getItem(key) + if (blob) { + totalSizeBytes += blob.size + } + } return totalSizeBytes } const videoStorageFileSize = async (filename: string): Promise => { - const file = await videoStoringDB.getItem(filename) + const file = await videoStorage.getItem(filename) return file ? (file as Blob).size : undefined } - // Used to store chunks of an ongoing recording, that will be merged into a video file when the recording is stopped - const tempVideoChunksDB = localforage.createInstance({ - driver: localforage.INDEXEDDB, - name: 'Cockpit - Temporary Video', - storeName: 'cockpit-temp-video-db', - version: 1.0, - description: 'Database for storing the chunks of an ongoing recording, to be merged afterwards.', - }) - - // Offer download of backuped videos - const videoStoringDB = localforage.createInstance({ - driver: localforage.INDEXEDDB, - name: 'Cockpit - Video Recovery', - storeName: 'cockpit-video-recovery-db', - version: 1.0, - description: 'Local backups of Cockpit video recordings to be retrieved in case of failure.', - }) - const updateLastProcessingUpdate = (recordingHash: string): void => { const info = unprocessedVideos.value[recordingHash] info.dateLastProcessingUpdate = new Date() @@ -599,11 +585,14 @@ export const useVideoStore = defineStore('video', () => { const dateFinish = new Date(info.dateFinish!) debouncedUpdateFileProgress(info.fileName, 30, 'Grouping video chunks.') - await tempVideoChunksDB.iterate((videoChunk, chunkName) => { - if (chunkName.includes(hash)) { - chunks.push({ blob: videoChunk as Blob, name: chunkName }) + const keys = await tempVideoStorage.keys() + const filteredKeys = keys.filter((key) => key.includes(hash)) + for (const key of filteredKeys) { + const blob = await tempVideoStorage.getItem(key) + if (blob && blob.size > 0) { + chunks.push({ blob, name: key }) } - }) + } // As we advance through the processing, we update the last processing update date, so consumers know this is ongoing updateLastProcessingUpdate(hash) @@ -639,7 +628,7 @@ export const useVideoStore = defineStore('video', () => { updateLastProcessingUpdate(hash) debouncedUpdateFileProgress(info.fileName, 75, `Saving video file.`) - await videoStoringDB.setItem(`${info.fileName}.${extensionContainer || '.webm'}`, durFixedBlob ?? mergedBlob) + await videoStorage.setItem(`${info.fileName}.${extensionContainer || '.webm'}`, durFixedBlob ?? mergedBlob) updateLastProcessingUpdate(hash) @@ -653,7 +642,7 @@ export const useVideoStore = defineStore('video', () => { const videoTelemetryLog = datalogger.getSlice(telemetryLog, dateStart, dateFinish) const assLog = datalogger.toAssOverlay(videoTelemetryLog, info.vWidth!, info.vHeight!, dateStart.getTime()) const logBlob = new Blob([assLog], { type: 'text/plain' }) - videoStoringDB.setItem(`${info.fileName}.ass`, logBlob) + videoStorage.setItem(`${info.fileName}.ass`, logBlob) updateLastProcessingUpdate(hash) @@ -666,7 +655,11 @@ export const useVideoStore = defineStore('video', () => { // Remove temp chunks and video metadata from the database const cleanupProcessedData = async (recordingHash: string): Promise => { - await tempVideoChunksDB.removeItem(recordingHash) + const keys = await tempVideoStorage.keys() + const filteredKeys = keys.filter((key) => key.includes(recordingHash) && key.includes('_')) + for (const key of filteredKeys) { + await tempVideoStorage.removeItem(key) + } delete unprocessedVideos.value[recordingHash] } @@ -705,7 +698,7 @@ export const useVideoStore = defineStore('video', () => { if (keysFailedUnprocessedVideos.value.isEmpty()) return console.log(`Processing unprocessed videos: ${keysFailedUnprocessedVideos.value.join(', ')}`) - const chunks = await tempVideoChunksDB.keys() + const chunks = await tempVideoStorage.keys() if (chunks.length === 0) { discardUnprocessedVideos() throw new Error('No video recording data found. Discarding leftover info.') @@ -732,14 +725,14 @@ export const useVideoStore = defineStore('video', () => { console.log('Discarding unprocessed videos.') const keysUnprocessedVideos = includeNotFailed ? keysAllUnprocessedVideos.value : keysFailedUnprocessedVideos.value - const currentChunks = await tempVideoChunksDB.keys() + const currentChunks = await tempVideoStorage.keys() const chunksUnprocessedVideos = currentChunks.filter((chunkName) => { return keysUnprocessedVideos.some((key) => chunkName.includes(key)) }) unprocessedVideos.value = {} for (const chunk of chunksUnprocessedVideos) { - tempVideoChunksDB.removeItem(chunk) + tempVideoStorage.removeItem(chunk) } } @@ -922,8 +915,8 @@ export const useVideoStore = defineStore('video', () => { jitterBufferTarget, zipMultipleFiles, namesAvailableStreams, - videoStoringDB, - tempVideoChunksDB, + videoStorage, + tempVideoStorage, streamsCorrespondency, namessAvailableAbstractedStreams, externalStreamId, diff --git a/src/types/general.ts b/src/types/general.ts index 0ba903c59..356569d7d 100644 --- a/src/types/general.ts +++ b/src/types/general.ts @@ -33,3 +33,12 @@ export interface DialogActions { } export type ConfigComponent = DefineComponent, Record, unknown> | null + +export interface StorageDB { + getItem: (key: string) => Promise + setItem: (key: string, value: Blob | ArrayBuffer) => Promise + removeItem: (key: string) => Promise + clear: () => Promise + keys: () => Promise + iterate: (callback: (value: unknown, key: string, iterationNumber: number) => void) => Promise +} diff --git a/src/types/video.ts b/src/types/video.ts index 5de9e0afc..5b58ba90d 100644 --- a/src/types/video.ts +++ b/src/types/video.ts @@ -108,10 +108,6 @@ export interface FileDescriptor { filename: string } -export interface StorageDB { - getItem: (key: string) => Promise -} - export type DownloadProgressCallback = (progress: number, total: number) => Promise export enum VideoExtensionContainer {