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..860844a2a 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -2,4 +2,21 @@ import { contextBridge, ipcRenderer } from 'electron' contextBridge.exposeInMainWorld('electronAPI', { getInfoOnSubnets: () => ipcRenderer.invoke('get-info-on-subnets'), + setItem: async (key: string, value: Blob, subFolders?: string[]) => { + const arrayBuffer = await value.arrayBuffer() + await ipcRenderer.invoke('setItem', { key, value: new Uint8Array(arrayBuffer), subFolders }) + }, + getItem: async (key: string, subFolders?: string[]) => { + const arrayBuffer = await ipcRenderer.invoke('getItem', { key, subFolders }) + return arrayBuffer ? new Blob([arrayBuffer]) : null + }, + removeItem: async (key: string, subFolders?: string[]) => { + await ipcRenderer.invoke('removeItem', { key, subFolders }) + }, + clear: async (subFolders?: string[]) => { + await ipcRenderer.invoke('clear', { subFolders }) + }, + keys: async (subFolders?: string[]) => { + return await ipcRenderer.invoke('keys', { subFolders }) + }, }) diff --git a/electron/services/storage.ts b/electron/services/storage.ts new file mode 100644 index 000000000..f005494df --- /dev/null +++ b/electron/services/storage.ts @@ -0,0 +1,61 @@ +import { ipcMain } from 'electron' +import { app } from 'electron' +import * as fs from 'fs/promises' +import { dirname, join } from 'path' + +// Create a new storage interface for filesystem +const cockpitFolderPath = join(app.getPath('home'), 'Cockpit') +fs.mkdir(cockpitFolderPath, { recursive: true }) + +export const filesystemStorage = { + async setItem(key: string, value: ArrayBuffer, subFolders?: string[]): Promise { + const buffer = Buffer.from(value) + const filePath = join(cockpitFolderPath, ...(subFolders ?? []), key) + await fs.mkdir(dirname(filePath), { recursive: true }) + await fs.writeFile(filePath, buffer) + }, + async getItem(key: string, subFolders?: string[]): Promise { + const filePath = join(cockpitFolderPath, ...(subFolders ?? []), key) + try { + return await fs.readFile(filePath) + } catch (error) { + if (error.code === 'ENOENT') return null + throw error + } + }, + async removeItem(key: string, subFolders?: string[]): Promise { + const filePath = join(cockpitFolderPath, ...(subFolders ?? []), key) + await fs.unlink(filePath) + }, + async clear(subFolders?: string[]): Promise { + const dirPath = join(cockpitFolderPath, ...(subFolders ?? [])) + await fs.rm(dirPath, { recursive: true }) + }, + async keys(subFolders?: string[]): Promise { + const dirPath = join(cockpitFolderPath, ...(subFolders ?? [])) + try { + return await fs.readdir(dirPath) + } catch (error) { + if (error.code === 'ENOENT') return [] + throw error + } + }, +} + +export const setupFilesystemStorage = (): void => { + ipcMain.handle('setItem', async (_, data) => { + await filesystemStorage.setItem(data.key, data.value, data.subFolders) + }) + ipcMain.handle('getItem', async (_, data) => { + return await filesystemStorage.getItem(data.key, data.subFolders) + }) + ipcMain.handle('removeItem', async (_, data) => { + await filesystemStorage.removeItem(data.key, data.subFolders) + }) + ipcMain.handle('clear', async (_, data) => { + await filesystemStorage.clear(data.subFolders) + }) + ipcMain.handle('keys', async (_, data) => { + return await filesystemStorage.keys(data.subFolders) + }) +} 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..9fa874c07 100644 --- a/src/libs/cosmos.ts +++ b/src/libs/cosmos.ts @@ -1,5 +1,6 @@ import { isBrowser } from 'browser-or-node' +import { ElectronStorageDB } from '@/types/general' import { NetworkInfo } from '@/types/network' import { @@ -107,7 +108,7 @@ declare global { /** * Electron API exposed through preload script */ - electronAPI?: { + electronAPI?: ElectronStorageDB & { /** * Get network information from the main process * @returns Promise containing subnet information diff --git a/src/libs/videoStorage.ts b/src/libs/videoStorage.ts new file mode 100644 index 000000000..2ffab2f90 --- /dev/null +++ b/src/libs/videoStorage.ts @@ -0,0 +1,127 @@ +import localforage from 'localforage' + +import type { ElectronStorageDB, 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.') + } +} + +/** + * Electron storage implementation. + * Uses the exposed IPC renderer API to store and retrieve data in the filesystem. + */ +class ElectronStorage implements ElectronStorageDB { + subFolders: string[] + electronAPI: ElectronStorageDB + + /** + * Creates a new instance of the ElectronStorage class. + * @param {string[]} subFolders - The subfolders to store the data in. + */ + constructor(subFolders: string[]) { + throwIfNotElectron() + + this.subFolders = subFolders + this.electronAPI = window.electronAPI as StorageDB + } + + setItem = async (key: string, value: Blob): Promise => { + throwIfNotElectron() + await this.electronAPI.setItem(key, value, this.subFolders) + } + + getItem = async (key: string): Promise => { + throwIfNotElectron() + return await this.electronAPI.getItem(key, this.subFolders) + } + + removeItem = async (key: string): Promise => { + throwIfNotElectron() + await this.electronAPI.removeItem(key, this.subFolders) + } + + clear = async (): Promise => { + throwIfNotElectron() + await this.electronAPI.clear(this.subFolders) + } + + keys = async (): Promise => { + throwIfNotElectron() + return await this.electronAPI.keys(this.subFolders) + } +} + +/** + * LocalForage storage implementation. + * Uses the localforage library to store and retrieve data in the IndexedDB. + */ +class LocalForageStorage implements StorageDB { + localForage: LocalForage + + /** + * Creates a new instance of the LocalForageStorage class. + * @param {string} name - The name of the localforage instance. + * @param {string} storeName - The name of the store to store the data in. + * @param {number} version - The version of the localforage instance. + * @param {string} description - The description of the localforage instance. + */ + constructor(name: string, storeName: string, version: number, description: string) { + this.localForage = localforage.createInstance({ + driver: localforage.INDEXEDDB, + name: name, + storeName: storeName, + version: version, + description: description, + }) + } + + setItem = async (key: string, value: Blob): Promise => { + await this.localForage.setItem(key, value) + } + + getItem = async (key: string): Promise => { + return await this.localForage.getItem(key) + } + + removeItem = async (key: string): Promise => { + await this.localForage.removeItem(key) + } + + clear = async (): Promise => { + await this.localForage.clear() + } + + keys = async (): Promise => { + return await this.localForage.keys() + } +} + +const tempVideoChunksIndexdedDB: StorageDB = new LocalForageStorage( + 'Cockpit - Temporary Video', + 'cockpit-temp-video-db', + 1.0, + 'Database for storing the chunks of an ongoing recording, to be merged afterwards.' +) + +const videoStoringIndexedDB: StorageDB = new LocalForageStorage( + 'Cockpit - Video Recovery', + 'cockpit-video-recovery-db', + 1.0, + 'Cockpit video recordings and their corresponding telemetry subtitles.' +) + +const electronVideoStorage = new ElectronStorage(['videos']) +const temporaryElectronVideoStorage = new ElectronStorage(['videos', 'temporary-video-chunks']) + +export const videoStorage = isElectron() ? electronVideoStorage : videoStoringIndexedDB +export const tempVideoStorage = isElectron() ? temporaryElectronVideoStorage : tempVideoChunksIndexdedDB diff --git a/src/stores/video.ts b/src/stores/video.ts index 34d53ff4b..0d8cb4946 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..f01f8ae57 100644 --- a/src/types/general.ts +++ b/src/types/general.ts @@ -33,3 +33,34 @@ export interface DialogActions { } export type ConfigComponent = DefineComponent, Record, unknown> | null + +export interface StorageDB { + getItem: (key: string) => Promise + setItem: (key: string, value: Blob) => Promise + removeItem: (key: string) => Promise + clear: () => Promise + keys: () => Promise +} + +export interface ElectronStorageDB { + /** + * Set an item in the filesystem storage + */ + setItem: (key: string, value: Blob, subFolders?: string[]) => Promise + /** + * Get an item from the filesystem storage + */ + getItem: (key: string, subFolders?: string[]) => Promise + /** + * Remove an item from the filesystem storage + */ + removeItem: (key: string, subFolders?: string[]) => Promise + /** + * Clear the filesystem storage + */ + clear: (subFolders?: string[]) => Promise + /** + * Get all keys from the filesystem storage + */ + keys: (subFolders?: string[]) => 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 {