From c21cbe78ff268e688e6f189e7b2de29e81860334 Mon Sep 17 00:00:00 2001 From: Rafael Araujo Lehmkuhl Date: Wed, 27 Nov 2024 00:01:49 -0300 Subject: [PATCH] wip --- electron/main.ts | 36 +++++++++- electron/preload.ts | 12 ++++ src/components/VideoLibraryModal.vue | 6 +- .../mini-widgets/MiniVideoRecorder.vue | 2 +- src/libs/cosmos.ts | 31 ++++++++- src/libs/electron/filesystemStorage.ts | 65 ++++++++++++++++++ .../electron/filesystemStorageRendererAPI.ts | 45 +++++++++++++ src/libs/utils.ts | 9 +-- src/libs/videoStorage.ts | 25 +++++++ src/stores/video.ts | 66 +++++++------------ src/types/general.ts | 9 +++ src/types/video.ts | 4 -- 12 files changed, 250 insertions(+), 60 deletions(-) create mode 100644 src/libs/electron/filesystemStorage.ts create mode 100644 src/libs/electron/filesystemStorageRendererAPI.ts create mode 100644 src/libs/videoStorage.ts diff --git a/electron/main.ts b/electron/main.ts index b560ea0f6..a6a1eff54 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1,6 +1,8 @@ -import { app, BrowserWindow, protocol, screen } from 'electron' +import { app, BrowserWindow, ipcMain, protocol, screen } from 'electron' import { join } from 'path' +import { electronStorage } from '../src/libs/electron/filesystemStorageRendererAPI' + export const ROOT_PATH = { dist: join(__dirname, '..'), } @@ -59,7 +61,37 @@ protocol.registerSchemesAsPrivileged([ }, ]) -app.whenReady().then(createWindow) +app.whenReady().then(async () => { + console.log('Electron app is ready.') + console.log(`Cockpit version: ${app.getVersion()}`) + + console.log('Creating window...') + await createWindow() + + console.log('Setting up filesystem storage...') + setupFilesystemStorage() +}) + +const setupFilesystemStorage = (): void => { + ipcMain.on('setItem', (event, data) => { + electronStorage.setItem(data.key, data.value) + }) + ipcMain.on('getItem', (event, key) => { + return electronStorage.getItem(key) + }) + ipcMain.on('removeItem', (event, key) => { + electronStorage.removeItem(key) + }) + ipcMain.on('clear', () => { + electronStorage.clear() + }) + ipcMain.on('keys', () => { + return electronStorage.keys() + }) + ipcMain.on('iterate', (event, callback) => { + electronStorage.iterate(callback) + }) +} 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 e69de29bb..a9fe13359 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -0,0 +1,12 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { contextBridge, ipcRenderer } from 'electron' + +contextBridge.exposeInMainWorld('electronAPI', { + setItem: (key: string, value: Blob) => ipcRenderer.send('setItem', { key, value }), + getItem: (key: string) => ipcRenderer.invoke('getItem', key), + removeItem: (key: string) => ipcRenderer.send('removeItem', key), + clear: () => ipcRenderer.send('clear'), + keys: () => ipcRenderer.invoke('keys'), + iterate: (callback: (value: unknown, key: string, iterationNumber: number) => void) => + ipcRenderer.on('iterate', (_event, data) => callback(data.value, data.key, data.iterationNumber)), +}) diff --git a/src/components/VideoLibraryModal.vue b/src/components/VideoLibraryModal.vue index facff754d..c681f0919 100644 --- a/src/components/VideoLibraryModal.vue +++ b/src/components/VideoLibraryModal.vue @@ -889,11 +889,11 @@ const fetchVideosAndLogData = async (): Promise => { const logFileOperations: Promise[] = [] // Fetch processed videos and logs - await videoStore.videoStoringDB.iterate((value, key) => { + await videoStore.videoStorage.iterate((value, key) => { 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 +910,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) diff --git a/src/components/mini-widgets/MiniVideoRecorder.vue b/src/components/mini-widgets/MiniVideoRecorder.vue index 4991521ab..7e5712b26 100644 --- a/src/components/mini-widgets/MiniVideoRecorder.vue +++ b/src/components/mini-widgets/MiniVideoRecorder.vue @@ -188,7 +188,7 @@ 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 nProcessedVideos = (await videoStore.videoStorage.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 dc865c944..ed551a477 100644 --- a/src/libs/cosmos.ts +++ b/src/libs/cosmos.ts @@ -101,9 +101,38 @@ 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: unknown, key: string, iterationNumber: number) => void) => Promise } } - /* eslint-enable jsdoc/require-jsdoc */ } // Use global as window when running for browsers diff --git a/src/libs/electron/filesystemStorage.ts b/src/libs/electron/filesystemStorage.ts new file mode 100644 index 000000000..5135753a3 --- /dev/null +++ b/src/libs/electron/filesystemStorage.ts @@ -0,0 +1,65 @@ +import { app } from 'electron' +import fs from 'fs/promises' +import { dirname, join } from 'path' + +import { StorageDB } from '@/types/general' + +import { isElectron } from '../utils' + +// Create a new storage interface for filesystem +const cockpitFolderPath = join(app.getPath('userData'), 'Cockpit') +const filesystemOnlyInElectronErrorMsg = 'Filesystem storage is only available in Electron.' + +export const filesystemStorage: StorageDB = { + async setItem(key: string, value: Blob): Promise { + if (!isElectron()) throw new Error(filesystemOnlyInElectronErrorMsg) + console.log('setItem on electron', key) + const filePath = join(cockpitFolderPath, key) + await fs.mkdir(dirname(filePath), { recursive: true }) + await fs.writeFile(filePath, Buffer.from(await value.arrayBuffer())) + return value + }, + async getItem(key: string): Promise { + if (!isElectron()) throw new Error(filesystemOnlyInElectronErrorMsg) + console.log('getItem on electron', key) + const filePath = join(cockpitFolderPath, key) + try { + const buffer = await fs.readFile(filePath) + return new Blob([buffer]) + } catch (error) { + if (error.code === 'ENOENT') return null + throw error + } + }, + async removeItem(key: string): Promise { + if (!isElectron()) throw new Error(filesystemOnlyInElectronErrorMsg) + const filePath = join(cockpitFolderPath, key) + await fs.unlink(filePath) + }, + async clear(): Promise { + if (!isElectron()) throw new Error(filesystemOnlyInElectronErrorMsg) + 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 { + if (!isElectron()) throw new Error(filesystemOnlyInElectronErrorMsg) + 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 { + if (!isElectron()) throw new Error(filesystemOnlyInElectronErrorMsg) + const keys = await this.keys() + for (let i = 0; i < keys.length; i++) { + const key = keys[i] + const value = await this.getItem(key) + callback(value, key, i) + } + }, +} diff --git a/src/libs/electron/filesystemStorageRendererAPI.ts b/src/libs/electron/filesystemStorageRendererAPI.ts new file mode 100644 index 000000000..fddfe22b6 --- /dev/null +++ b/src/libs/electron/filesystemStorageRendererAPI.ts @@ -0,0 +1,45 @@ +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() + const arrayBuffer = await value.arrayBuffer() + await window.electronAPI.setItem(key, new Blob([arrayBuffer])) + return value + }, + getItem: async (key: string): Promise => { + throwIfNotElectron() + const arrayBuffer = await window.electronAPI.getItem(key) + return arrayBuffer ? new Blob([arrayBuffer]) : null + }, + 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: unknown, 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 f6bb3900f..343a24006 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' @@ -17,13 +16,14 @@ import { getIpsInformationFromVehicle } from '@/libs/blueos' 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, @@ -275,7 +275,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)) @@ -364,7 +364,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++ @@ -381,7 +381,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) @@ -430,13 +430,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] } } @@ -456,7 +456,7 @@ export const useVideoStore = defineStore('video', () => { } const downloadFiles = async ( - db: StorageDB, + db: StorageDB | LocalForage, keys: string[], shouldZip = false, zipFilenamePrefix = 'Cockpit-Video-Files', @@ -490,9 +490,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) } } @@ -500,48 +500,30 @@ 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) => { + await tempVideoStorage.iterate((chunk) => { totalSizeBytes += (chunk as 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() @@ -593,7 +575,7 @@ export const useVideoStore = defineStore('video', () => { const dateFinish = new Date(info.dateFinish!) debouncedUpdateFileProgress(info.fileName, 30, 'Grouping video chunks.') - await tempVideoChunksDB.iterate((videoChunk, chunkName) => { + await tempVideoStorage.iterate((videoChunk, chunkName) => { if (chunkName.includes(hash)) { chunks.push({ blob: videoChunk as Blob, name: chunkName }) } @@ -633,7 +615,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) @@ -647,7 +629,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) @@ -660,7 +642,7 @@ export const useVideoStore = defineStore('video', () => { // Remove temp chunks and video metadata from the database const cleanupProcessedData = async (recordingHash: string): Promise => { - await tempVideoChunksDB.removeItem(recordingHash) + await tempVideoStorage.removeItem(recordingHash) delete unprocessedVideos.value[recordingHash] } @@ -699,7 +681,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.') @@ -726,14 +708,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) } } @@ -916,8 +898,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..ff8137db0 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) => 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 {