From f9d48a15e519ce4f43c86e5e179af164198e1a31 Mon Sep 17 00:00:00 2001 From: Rafael Araujo Lehmkuhl Date: Thu, 26 Feb 2026 13:10:23 -0300 Subject: [PATCH 1/3] electron: Allow changing the Cockpit folder location --- src/electron/preload.ts | 4 +++ src/electron/services/config-store.ts | 7 +++++ src/electron/services/storage.ts | 37 ++++++++++++++++++++++-- src/electron/services/video-recording.ts | 10 +++---- src/libs/cosmos.ts | 21 ++++++++++++++ 5 files changed, 71 insertions(+), 8 deletions(-) diff --git a/src/electron/preload.ts b/src/electron/preload.ts index c95c3f3aed..8ad8bd1e56 100644 --- a/src/electron/preload.ts +++ b/src/electron/preload.ts @@ -39,6 +39,10 @@ contextBridge.exposeInMainWorld('electronAPI', { return await ipcRenderer.invoke('keys', { subFolders }) }, openCockpitFolder: () => ipcRenderer.invoke('open-cockpit-folder'), + getCockpitFolderPath: (): Promise => ipcRenderer.invoke('get-cockpit-folder-path'), + getDefaultCockpitFolderPath: (): Promise => ipcRenderer.invoke('get-default-cockpit-folder-path'), + setCockpitFolderPath: (newPath: string): Promise => ipcRenderer.invoke('set-cockpit-folder-path', newPath), + selectCockpitFolder: (): Promise => ipcRenderer.invoke('select-cockpit-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/config-store.ts b/src/electron/services/config-store.ts index 5f017b62bf..78b585e540 100644 --- a/src/electron/services/config-store.ts +++ b/src/electron/services/config-store.ts @@ -1,6 +1,9 @@ import Store from 'electron-store' const electronStoreSchema = { + cockpitFolderPath: { + type: 'string', + }, windowBounds: { type: 'object', properties: { @@ -25,6 +28,10 @@ const electronStoreSchema = { * Stores configuration data */ export interface ElectronStoreSchema { + /** + * Custom Cockpit folder path, overriding the default ~/Cockpit + */ + cockpitFolderPath: string | undefined /** * Window bounds */ diff --git a/src/electron/services/storage.ts b/src/electron/services/storage.ts index 8e1696e54e..b205654c19 100644 --- a/src/electron/services/storage.ts +++ b/src/electron/services/storage.ts @@ -1,13 +1,16 @@ import { dialog, ipcMain, shell } from 'electron' import { app } from 'electron' +import { mkdirSync } from 'fs' import * as fs from 'fs/promises' import { dirname, join } from 'path' import type { FileDialogOptions, FileStats } from '@/types/storage' -// Create a new storage interface for filesystem -export const cockpitFolderPath = join(app.getPath('home'), 'Cockpit') -fs.mkdir(cockpitFolderPath, { recursive: true }) +import store from './config-store' + +const defaultCockpitFolderPath = join(app.getPath('home'), 'Cockpit') +let cockpitFolderPath = store.get('cockpitFolderPath') ?? defaultCockpitFolderPath +mkdirSync(cockpitFolderPath, { recursive: true }) export const filesystemStorage = { async setItem(key: string, value: ArrayBuffer, subFolders?: string[]): Promise { @@ -88,6 +91,28 @@ export const setupFilesystemStorage = (): void => { await shell.openPath(tempChunksFolderPath) }) + ipcMain.handle('get-cockpit-folder-path', () => cockpitFolderPath) + + ipcMain.handle('get-default-cockpit-folder-path', () => defaultCockpitFolderPath) + + ipcMain.handle('set-cockpit-folder-path', async (_, newPath: string) => { + cockpitFolderPath = newPath + await fs.mkdir(cockpitFolderPath, { recursive: true }) + store.set('cockpitFolderPath', cockpitFolderPath) + }) + + ipcMain.handle('select-cockpit-folder', async () => { + const result = await dialog.showOpenDialog({ + properties: ['openDirectory', 'createDirectory'], + title: 'Select Cockpit folder', + defaultPath: cockpitFolderPath, + }) + if (result.canceled || result.filePaths.length === 0) { + return null + } + return result.filePaths[0] + }) + /** * Get file stats for a file * @param pathOrKey - Either a full file path, or a key (filename) if subFolders is provided @@ -135,3 +160,9 @@ export const setupFilesystemStorage = (): void => { return result.filePaths[0] }) } + +/** + * Returns the current Cockpit folder path + * @returns {string} The active Cockpit folder path + */ +export const getCockpitFolderPath = (): string => cockpitFolderPath diff --git a/src/electron/services/video-recording.ts b/src/electron/services/video-recording.ts index e0e7cda383..dd216ed862 100644 --- a/src/electron/services/video-recording.ts +++ b/src/electron/services/video-recording.ts @@ -13,7 +13,7 @@ import type { LiveConcatProcessResult, LiveStreamProcess, ZipExtractionResult } import { videoFilename, videoThumbnailFilename } from '../../utils/video' import { getFFmpegPath } from './ffmpeg-path' -import { cockpitFolderPath, filesystemStorage } from './storage' +import { filesystemStorage, getCockpitFolderPath } from './storage' /** * Live video streaming service for Electron @@ -72,7 +72,7 @@ const startVideoRecording = async ( const tempDir = await createTempDirectory(`cockpit_video_recording_${recordingHash}`) // Get video folder path and construct output path - const videosPath = join(cockpitFolderPath, 'videos') + const videosPath = join(getCockpitFolderPath(), 'videos') await fs.mkdir(videosPath, { recursive: true }) // Output directly as MP4 with fragmented format @@ -553,7 +553,7 @@ const createVideoChunksZip = async (hash: string): Promise => { console.debug(`Creating ZIP file for chunk group ${hash}`) // Find all chunk files for this hash - const tempChunksPath = join(cockpitFolderPath, 'videos', 'temporary-video-chunks') + const tempChunksPath = join(getCockpitFolderPath(), 'videos', 'temporary-video-chunks') let chunkFiles: string[] = [] try { @@ -603,7 +603,7 @@ const createVideoChunksZip = async (hash: string): Promise => { } // Generate ZIP filename - const tempChunksFolderPath = join(cockpitFolderPath, 'videos', 'temporary-video-chunks') + const tempChunksFolderPath = join(getCockpitFolderPath(), 'videos', 'temporary-video-chunks') const zipFilename = `${defaultFileName}.zip` const zipFilePath = join(tempChunksFolderPath, zipFilename) @@ -653,7 +653,7 @@ const createVideoChunksZip = async (hash: string): Promise => { // Add .ass telemetry file if found - ALWAYS process this regardless of chunk failures if (assFileName) { - const assFilePath = join(cockpitFolderPath, 'videos', assFileName) + const assFilePath = join(getCockpitFolderPath(), 'videos', assFileName) fs.access(assFilePath) .then(() => fs.stat(assFilePath)) diff --git a/src/libs/cosmos.ts b/src/libs/cosmos.ts index 0f006c7257..c560f87dd9 100644 --- a/src/libs/cosmos.ts +++ b/src/libs/cosmos.ts @@ -278,6 +278,27 @@ declare global { * Open cockpit folder */ openCockpitFolder: () => void + /** + * Get the current Cockpit folder path + * @returns {Promise} The current folder path + */ + getCockpitFolderPath: () => Promise + /** + * Get the default Cockpit folder path + * @returns {Promise} The default folder path + */ + getDefaultCockpitFolderPath: () => Promise + /** + * Set a new Cockpit folder path + * @param {string} newPath - The new folder path + * @returns {Promise} + */ + setCockpitFolderPath: (newPath: string) => Promise + /** + * Open a native folder picker to select a Cockpit folder + * @returns {Promise} The selected path, or null if cancelled + */ + selectCockpitFolder: () => Promise /** * Open video folder */ From fdd8f8f8a338f873d91bedce1222914284d2f61a Mon Sep 17 00:00:00 2001 From: Rafael Araujo Lehmkuhl Date: Thu, 26 Feb 2026 13:12:31 -0300 Subject: [PATCH 2/3] menu: settings: general: Add dedicated section and logic around the folder location --- src/views/ConfigurationGeneralView.vue | 120 +++++++++++++++++++++++-- 1 file changed, 111 insertions(+), 9 deletions(-) diff --git a/src/views/ConfigurationGeneralView.vue b/src/views/ConfigurationGeneralView.vue index dabb8edd06..23aaa98dac 100644 --- a/src/views/ConfigurationGeneralView.vue +++ b/src/views/ConfigurationGeneralView.vue @@ -44,15 +44,6 @@ Show tutorial - - Open Cockpit folder - + +
+ Cockpit folder location: +
+ + + + + Open folder + +
+
@@ -392,6 +428,7 @@ import { defaultGlobalAddress } from '@/assets/defaults' import ManageCockpitSettings from '@/components/configuration/CockpitSettingsManager.vue' import ExpansiblePanel from '@/components/ExpansiblePanel.vue' import VehicleDiscoveryDialog from '@/components/VehicleDiscoveryDialog.vue' +import { useInteractionDialog } from '@/composables/interactionDialog' import { useSnackbar } from '@/composables/snackbar' import * as Connection from '@/libs/connection/connection' import { ConnectionManager } from '@/libs/connection/connection-manager' @@ -416,12 +453,76 @@ const mainVehicleStore = useMainVehicleStore() const interfaceStore = useAppInterfaceStore() const missionStore = useMissionStore() const { openSnackbar } = useSnackbar() +const { showDialog, closeDialog } = useInteractionDialog() const globalAddressForm = ref() const globalAddressFormValid = ref(false) const newGlobalAddress = ref(mainVehicleStore.globalAddress) const showCockpitSettingsDialog = ref(false) +const cockpitFolderPath = ref('') +const defaultCockpitFolderPath = ref('') + +const loadCockpitFolderPath = async (): Promise => { + if (!isElectron() || !window.electronAPI) return + cockpitFolderPath.value = await window.electronAPI.getCockpitFolderPath() + defaultCockpitFolderPath.value = await window.electronAPI.getDefaultCockpitFolderPath() +} + +const applyFolderPath = async (path: string): Promise => { + if (!window.electronAPI) return + await window.electronAPI.setCockpitFolderPath(path) + cockpitFolderPath.value = path +} + +const browseCockpitFolder = async (): Promise => { + if (!window.electronAPI) return + const selected = await window.electronAPI.selectCockpitFolder() + if (!selected) return + + const folderName = selected.split(/[/\\]/).filter(Boolean).pop() + if (folderName === 'Cockpit') { + await applyFolderPath(selected) + return + } + + const selectedName = selected.split(/[/\\]/).filter(Boolean).pop() ?? '' + showDialog({ + title: 'Cockpit folder location', + message: + `The selected folder is not named "Cockpit". Would you like to use ` + + `${selected} directly, or create and use a Cockpit subfolder inside it?`, + variant: 'info', + persistent: true, + maxWidth: 700, + actions: [ + { text: 'Cancel', size: 'small', action: () => closeDialog() }, + { + text: `Use ${selectedName}`, + size: 'small', + action: () => { + closeDialog() + applyFolderPath(selected) + }, + }, + { + text: `Use ${selectedName}/Cockpit`, + size: 'small', + action: () => { + closeDialog() + applyFolderPath(`${selected}/Cockpit`) + }, + }, + ], + }) +} + +const resetCockpitFolderPath = async (): Promise => { + if (!window.electronAPI) return + await window.electronAPI.setCockpitFolderPath(defaultCockpitFolderPath.value) + cockpitFolderPath.value = defaultCockpitFolderPath.value +} + const setGlobalAddress = async (): Promise => { await globalAddressForm.value.validate() @@ -643,6 +744,7 @@ const newGenericWebSocketUrl = ref(exampleGenericWebSocketUrl) let unsubscribeGenericWebSocket: (() => void) | null = null onMounted(() => { + loadCockpitFolderPath() tryToPrettifyRtcConfig() unsubscribeGenericWebSocket = listenToGenericWebSocketConnections((connections) => { genericWebSocketConnections.value = connections From 0f6ef8947720311b6fe8711531b1a4a8b252aa2e Mon Sep 17 00:00:00 2001 From: Rafael Araujo Lehmkuhl Date: Fri, 27 Feb 2026 11:19:16 -0300 Subject: [PATCH 3/3] global: Use a consistent (and correct) restore/reset icon across the application --- src/views/ConfigurationGeneralView.vue | 6 +++--- src/views/ConfigurationLogsView.vue | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/views/ConfigurationGeneralView.vue b/src/views/ConfigurationGeneralView.vue index 23aaa98dac..52cbe366f1 100644 --- a/src/views/ConfigurationGeneralView.vue +++ b/src/views/ConfigurationGeneralView.vue @@ -153,7 +153,7 @@ > @@ -209,7 +209,7 @@ :disabled="!mainVehicleStore.customMAVLink2RestWebsocketURI.enabled" @click="resetMainVehicleConnectionURI" > - mdi-refresh + mdi-restore @@ -275,7 +275,7 @@ :disabled="!mainVehicleStore.customWebRTCSignallingURI.enabled" @click="resetWebRTCSignallingURI" > - mdi-refresh + mdi-restore diff --git a/src/views/ConfigurationLogsView.vue b/src/views/ConfigurationLogsView.vue index 114fe29f0e..e018b1c2fc 100644 --- a/src/views/ConfigurationLogsView.vue +++ b/src/views/ConfigurationLogsView.vue @@ -394,7 +394,7 @@
Reset Positions - mdi-refresh + mdi-restore