From 1f374048d00b0ac4ab1ab5e9f93012bbd6b9e77d Mon Sep 17 00:00:00 2001 From: Vitali Date: Tue, 16 Dec 2025 18:17:58 -0500 Subject: [PATCH 1/3] feat: Add custom folder mounts configuration Allow users to mount additional folders from the Linux filesystem into Windows, accessible via \host.lan\Data\. Users can add, remove, and enable/disable mounts through the Configuration panel. - Add CustomVolumeMounts.vue component with folder browser - Add volumes.ts with path validation and compose utilities - Add CustomVolumeMount type to types.ts - Integrate custom mounts into Config view --- .../components/CustomVolumeMounts.vue | 206 ++++++++++++++++++ src/renderer/lib/config.ts | 33 ++- src/renderer/lib/volumes.ts | 126 +++++++++++ src/renderer/lib/winboat.ts | 29 +++ src/renderer/views/Config.vue | 19 +- src/types.ts | 6 + 6 files changed, 409 insertions(+), 10 deletions(-) create mode 100644 src/renderer/components/CustomVolumeMounts.vue create mode 100644 src/renderer/lib/volumes.ts diff --git a/src/renderer/components/CustomVolumeMounts.vue b/src/renderer/components/CustomVolumeMounts.vue new file mode 100644 index 00000000..e179de8b --- /dev/null +++ b/src/renderer/components/CustomVolumeMounts.vue @@ -0,0 +1,206 @@ + + + + + diff --git a/src/renderer/lib/config.ts b/src/renderer/lib/config.ts index 7342eccf..f96bdac5 100644 --- a/src/renderer/lib/config.ts +++ b/src/renderer/lib/config.ts @@ -1,6 +1,6 @@ const fs: typeof import("fs") = require("node:fs"); const path: typeof import("path") = require("node:path"); -import { type WinApp } from "../../types"; +import { type WinApp, type CustomVolumeMount } from "../../types"; import { WINBOAT_DIR } from "./constants"; import { type PTSerializableDeviceInfo } from "./usbmanager"; import { ContainerRuntimes } from "./containers/common"; @@ -69,6 +69,7 @@ export type WinboatConfigObj = { rdpArgs: RdpArg[]; disableAnimations: boolean; containerRuntime: ContainerRuntimes; + customVolumeMounts: CustomVolumeMount[]; versionData: WinboatVersionData; appsSortOrder: string; }; @@ -89,6 +90,7 @@ const defaultConfig: WinboatConfigObj = { disableAnimations: false, // TODO: Ideally should be podman once we flesh out everything containerRuntime: ContainerRuntimes.DOCKER, + customVolumeMounts: [], versionData: { previous: currentVersion, // As of 0.9.0 this won't exist on the filesystem, so we just set it to the current version current: currentVersion @@ -175,25 +177,38 @@ export class WinboatConfig { console.log("Successfully read the config file"); // Some fields might be missing after an update, so we merge them with the default config + let configModified = false; for (const key in defaultConfig) { - let hasMissing = false; if (!(key in configObj)) { // @ts-expect-error This is valid configObj[key] = defaultConfig[key]; - hasMissing = true; + configModified = true; console.log( `Added missing config key: ${key} with default value: ${ JSON.stringify(defaultConfig[key as keyof WinboatConfigObj]) }`, ); } + } - // If we have any missing keys, we should just write the config back to disk so those new keys are saved - // We cannot use this.writeConfig() here since #configData is not populated yet - if (hasMissing) { - fs.writeFileSync(WinboatConfig.configPath, JSON.stringify(configObj, null, 4), "utf-8"); - console.log("Wrote updated config with missing keys to disk"); - } + // Migrate old customVolumeMounts format (containerPath -> shareName) + if (configObj.customVolumeMounts) { + configObj.customVolumeMounts = configObj.customVolumeMounts.map((mount: any) => { + if ('containerPath' in mount && !('shareName' in mount)) { + // Extract share name from containerPath (e.g., "/gamez" -> "gamez") + const shareName = mount.containerPath.replace(/^\//, '').replace(/[^a-zA-Z0-9_-]/g, ''); + console.log(`Migrated volume mount containerPath '${mount.containerPath}' to shareName '${shareName}'`); + configModified = true; + return { hostPath: mount.hostPath, shareName, enabled: mount.enabled }; + } + return mount; + }); + } + + // If config was modified, write it back to disk + if (configModified) { + fs.writeFileSync(WinboatConfig.configPath, JSON.stringify(configObj, null, 4), "utf-8"); + console.log("Wrote updated config to disk"); } return { ...configObj }; diff --git a/src/renderer/lib/volumes.ts b/src/renderer/lib/volumes.ts new file mode 100644 index 00000000..4c3cebd9 --- /dev/null +++ b/src/renderer/lib/volumes.ts @@ -0,0 +1,126 @@ +const fs: typeof import("fs") = require("node:fs"); +const path: typeof import("path") = require("node:path"); + +import type { CustomVolumeMount, ComposeConfig } from "../../types"; + +/** + * Validates a host path exists and is accessible + */ +export function validateHostPath(hostPath: string): { valid: boolean; error?: string } { + try { + if (!hostPath) { + return { valid: false, error: "Path is required" }; + } + if (!path.isAbsolute(hostPath)) { + return { valid: false, error: "Path must be absolute" }; + } + if (!fs.existsSync(hostPath)) { + return { valid: false, error: "Path does not exist" }; + } + const stats = fs.statSync(hostPath); + if (!stats.isDirectory()) { + return { valid: false, error: "Path is not a directory" }; + } + fs.accessSync(hostPath, fs.constants.R_OK); + return { valid: true }; + } catch { + return { valid: false, error: "Path is not accessible" }; + } +} + +/** + * Validates a share name (folder name visible in Windows) + */ +export function validateShareName(name: string): { valid: boolean; error?: string } { + if (!name) { + return { valid: false, error: "Name is required" }; + } + if (!/^[a-zA-Z0-9_-]+$/.test(name)) { + return { valid: false, error: "Only letters, numbers, underscores, hyphens" }; + } + if (name.length > 32) { + return { valid: false, error: "Max 32 characters" }; + } + return { valid: true }; +} + +// Base path for custom mounts (avoids /tmp/smb which gets cleaned on container start) +export const CUSTOM_MOUNT_BASE = "/mnt/winboat"; + +/** + * Converts a CustomVolumeMount to compose volume string format + * Mounts under /mnt/winboat/ - symlinks are created in /tmp/smb/ after container starts + */ +export function mountToVolumeString(mount: CustomVolumeMount): string { + return `${mount.hostPath}:${CUSTOM_MOUNT_BASE}/${mount.shareName}`; +} + +/** + * Identifies if a volume string is a custom user mount (not system) + * Custom mounts are under /mnt/winboat/ (or legacy paths /tmp/smb/, or bare paths like /gamez) + */ +export function isCustomMount(volumeStr: string): boolean { + const parts = volumeStr.split(":"); + const containerPath = parts.length >= 2 ? parts[parts.length - 1] : ""; + + // System paths that should NOT be considered custom mounts + const systemPaths = ["/storage", "/shared", "/oem", "/dev/bus/usb", "/dev"]; + + // If it's a system path, it's not custom + if (systemPaths.some(p => containerPath === p || containerPath.startsWith(p + "/"))) { + return false; + } + + // Current format: /mnt/winboat/ + if (containerPath.startsWith(CUSTOM_MOUNT_BASE + "/")) { + return true; + } + + // Legacy format: /tmp/smb/ + if (containerPath.startsWith("/tmp/smb/")) { + return true; + } + + // Very old format: bare path like /gamez (not a system path, likely custom) + // Only match single-level paths that aren't system + if (containerPath.match(/^\/[a-zA-Z0-9_-]+$/)) { + return true; + } + + return false; +} + +/** + * Apply custom mounts to a compose config + * Removes existing custom mounts and adds enabled ones + */ +export function applyCustomMounts( + compose: ComposeConfig, + mounts: CustomVolumeMount[] +): void { + // Remove existing custom mounts (keep system volumes) + compose.services.windows.volumes = compose.services.windows.volumes.filter( + vol => !isCustomMount(vol) + ); + + // Add enabled custom mounts + const enabledMounts = mounts + .filter(m => m.enabled) + .map(mountToVolumeString); + + compose.services.windows.volumes.push(...enabledMounts); +} + +/** + * Creates symlinks in /tmp/smb/ pointing to /mnt/winboat/ mounts + * This makes custom mounts accessible via \\host.lan\Data\ + * Returns shell commands to execute inside the container + */ +export function getSymlinkCommands(mounts: CustomVolumeMount[]): string[] { + const enabledMounts = mounts.filter(m => m.enabled); + if (enabledMounts.length === 0) return []; + + return enabledMounts.map(mount => + `ln -sfn ${CUSTOM_MOUNT_BASE}/${mount.shareName} /tmp/smb/${mount.shareName}` + ); +} diff --git a/src/renderer/lib/winboat.ts b/src/renderer/lib/winboat.ts index d6d52f19..acbbfa72 100644 --- a/src/renderer/lib/winboat.ts +++ b/src/renderer/lib/winboat.ts @@ -21,6 +21,7 @@ import { setIntervalImmediately } from "../utils/interval"; import { ExecFileAsyncError } from "./exec-helper"; import { ContainerManager, ContainerStatus } from "./containers/container"; import { CommonPorts, ContainerRuntimes, createContainer, getActiveHostPort } from "./containers/common"; +import { getSymlinkCommands } from "./volumes"; const nodeFetch: typeof import("node-fetch").default = require("node-fetch"); const fs: typeof import("fs") = require("node:fs"); @@ -307,6 +308,8 @@ export class Winboat { logger.info(`Winboat Guest API went ${this.isOnline ? "online" : "offline"}`); if (this.isOnline.value) { + // Create symlinks for custom volume mounts + await this.createCustomMountSymlinks(); await this.checkVersionAndUpdateGuestServer(); } } @@ -442,6 +445,32 @@ export class Winboat { }; } + /** + * Creates symlinks in /tmp/smb/ for custom volume mounts + * This allows Windows to access them via \\host.lan\Data\ + */ + async createCustomMountSymlinks() { + const mounts = this.#wbConfig?.config.customVolumeMounts || []; + const commands = getSymlinkCommands(mounts); + + if (commands.length === 0) { + logger.info("No custom volume mounts to symlink"); + return; + } + + logger.info(`Creating symlinks for ${commands.length} custom volume mount(s)`); + + for (const cmd of commands) { + try { + await execAsync(`docker exec WinBoat ${cmd}`); + logger.info(`Created symlink: ${cmd}`); + } catch (e) { + logger.error(`Failed to create symlink: ${cmd}`); + logger.error(e); + } + } + } + async #connectQMPManager() { try { this.qmpMgr = await QMPManager.createConnection( diff --git a/src/renderer/views/Config.vue b/src/renderer/views/Config.vue index 8ffb1e84..551e3ac0 100644 --- a/src/renderer/views/Config.vue +++ b/src/renderer/views/Config.vue @@ -60,6 +60,9 @@ + + + ([]); +const origCustomVolumeMounts = ref([]); + // For USB Devices const availableDevices = ref([]); @@ -554,6 +563,9 @@ async function assignValues() { freerdpPort.value = (portMapper.value.getShortPortMapping(GUEST_RDP_PORT)?.host as number) ?? GUEST_RDP_PORT; origFreerdpPort.value = freerdpPort.value; + customVolumeMounts.value = [...wbConfig.config.customVolumeMounts]; + origCustomVolumeMounts.value = [...wbConfig.config.customVolumeMounts]; + const specs = await getSpecs(); maxRamGB.value = specs.ramGB; maxNumCores.value = specs.cpuCores; @@ -585,6 +597,10 @@ async function saveCompose() { compose.value!.services.windows.restart = autoStartContainer.value ? RESTART_ON_FAILURE : RESTART_NO; + // Apply custom volume mounts + applyCustomMounts(compose.value!, customVolumeMounts.value); + wbConfig.config.customVolumeMounts = [...customVolumeMounts.value]; + portMapper.value!.setShortPortMapping(GUEST_RDP_PORT, freerdpPort.value, { protocol: "tcp", hostIP: "127.0.0.1", @@ -725,7 +741,8 @@ const saveButtonDisabled = computed(() => { shareFolder.value !== origShareFolder.value || sharedFolderPath.value !== origSharedFolderPath.value || (!Number.isNaN(freerdpPort.value) && freerdpPort.value !== origFreerdpPort.value) || - autoStartContainer.value !== origAutoStartContainer.value; + autoStartContainer.value !== origAutoStartContainer.value || + JSON.stringify(customVolumeMounts.value) !== JSON.stringify(origCustomVolumeMounts.value); const shouldBeDisabled = errors.value?.length || !hasResourceChanges || isApplyingChanges.value; diff --git a/src/types.ts b/src/types.ts index 21784796..a29b7306 100644 --- a/src/types.ts +++ b/src/types.ts @@ -122,3 +122,9 @@ export type USBDevice = { productID: string; alias: string; }; + +export type CustomVolumeMount = { + hostPath: string; + shareName: string; + enabled: boolean; +}; From 6b504b096e1d07aa8c3168bafcba5c92f121269d Mon Sep 17 00:00:00 2001 From: Vitali Date: Wed, 11 Feb 2026 21:15:16 -0500 Subject: [PATCH 2/3] fix: Address PR review feedback on custom volume mounts - Switch validation to throw-based pattern matching ComposePortEntry convention, with contextual values in error messages - Replace hardcoded `docker exec` with `containerMgr.executableAlias` to support both Docker and Podman runtimes Tested manually on Podman: - Validation errors display correctly with input context - Custom mount symlinks created via `podman exec` - Mounted folder accessible in Windows at \\host.lan\Data\ Co-Authored-By: Claude Opus 4.6 --- .../components/CustomVolumeMounts.vue | 23 +++++++--- src/renderer/lib/volumes.ts | 46 +++++++------------ src/renderer/lib/winboat.ts | 2 +- 3 files changed, 34 insertions(+), 37 deletions(-) diff --git a/src/renderer/components/CustomVolumeMounts.vue b/src/renderer/components/CustomVolumeMounts.vue index e179de8b..cd145ecc 100644 --- a/src/renderer/components/CustomVolumeMounts.vue +++ b/src/renderer/components/CustomVolumeMounts.vue @@ -121,9 +121,12 @@ const newMount = ref({ // Validate existing mounts (host path may have been deleted) const mountErrors = computed(() => { return props.modelValue.map(mount => { - const hostValidation = validateHostPath(mount.hostPath); - if (!hostValidation.valid) return hostValidation.error; - return null; + try { + validateHostPath(mount.hostPath); + return null; + } catch (e) { + return (e as Error).message; + } }); }); @@ -132,13 +135,19 @@ const newMountError = computed(() => { if (!newMount.value.hostPath && !newMount.value.shareName) return null; if (newMount.value.hostPath) { - const hostValidation = validateHostPath(newMount.value.hostPath); - if (!hostValidation.valid) return `Host: ${hostValidation.error}`; + try { + validateHostPath(newMount.value.hostPath); + } catch (e) { + return `Host: ${(e as Error).message}`; + } } if (newMount.value.shareName) { - const shareValidation = validateShareName(newMount.value.shareName); - if (!shareValidation.valid) return `Share: ${shareValidation.error}`; + try { + validateShareName(newMount.value.shareName); + } catch (e) { + return `Share: ${(e as Error).message}`; + } // Check for duplicates const isDuplicate = props.modelValue.some( diff --git a/src/renderer/lib/volumes.ts b/src/renderer/lib/volumes.ts index 4c3cebd9..0f15bc3b 100644 --- a/src/renderer/lib/volumes.ts +++ b/src/renderer/lib/volumes.ts @@ -4,44 +4,32 @@ const path: typeof import("path") = require("node:path"); import type { CustomVolumeMount, ComposeConfig } from "../../types"; /** - * Validates a host path exists and is accessible + * Validates a host path exists and is accessible. + * Throws an Error if the path is invalid. */ -export function validateHostPath(hostPath: string): { valid: boolean; error?: string } { +export function validateHostPath(hostPath: string): void { + if (!hostPath) throw new Error("Path is required"); + if (!path.isAbsolute(hostPath)) throw new Error(`Path must be absolute: '${hostPath}'`); + if (!fs.existsSync(hostPath)) throw new Error(`Path does not exist: '${hostPath}'`); + + const stats = fs.statSync(hostPath); + if (!stats.isDirectory()) throw new Error(`Path is not a directory: '${hostPath}'`); + try { - if (!hostPath) { - return { valid: false, error: "Path is required" }; - } - if (!path.isAbsolute(hostPath)) { - return { valid: false, error: "Path must be absolute" }; - } - if (!fs.existsSync(hostPath)) { - return { valid: false, error: "Path does not exist" }; - } - const stats = fs.statSync(hostPath); - if (!stats.isDirectory()) { - return { valid: false, error: "Path is not a directory" }; - } fs.accessSync(hostPath, fs.constants.R_OK); - return { valid: true }; } catch { - return { valid: false, error: "Path is not accessible" }; + throw new Error(`Path is not accessible: '${hostPath}'`); } } /** - * Validates a share name (folder name visible in Windows) + * Validates a share name (folder name visible in Windows). + * Throws an Error if the name is invalid. */ -export function validateShareName(name: string): { valid: boolean; error?: string } { - if (!name) { - return { valid: false, error: "Name is required" }; - } - if (!/^[a-zA-Z0-9_-]+$/.test(name)) { - return { valid: false, error: "Only letters, numbers, underscores, hyphens" }; - } - if (name.length > 32) { - return { valid: false, error: "Max 32 characters" }; - } - return { valid: true }; +export function validateShareName(name: string): void { + if (!name) throw new Error("Name is required"); + if (!/^[a-zA-Z0-9_-]+$/.test(name)) throw new Error(`Name '${name}' contains invalid characters, only letters, numbers, underscores, and hyphens are allowed`); + if (name.length > 32) throw new Error(`Name '${name}' exceeds maximum length of 32 characters`); } // Base path for custom mounts (avoids /tmp/smb which gets cleaned on container start) diff --git a/src/renderer/lib/winboat.ts b/src/renderer/lib/winboat.ts index acbbfa72..2ea4328c 100644 --- a/src/renderer/lib/winboat.ts +++ b/src/renderer/lib/winboat.ts @@ -462,7 +462,7 @@ export class Winboat { for (const cmd of commands) { try { - await execAsync(`docker exec WinBoat ${cmd}`); + await execAsync(`${this.containerMgr!.executableAlias} exec WinBoat ${cmd}`); logger.info(`Created symlink: ${cmd}`); } catch (e) { logger.error(`Failed to create symlink: ${cmd}`); From 3e6589e3c761764eb97b5d1c9c2191b63e4afee1 Mon Sep 17 00:00:00 2001 From: Vitali Date: Tue, 12 May 2026 19:03:28 -0400 Subject: [PATCH 3/3] fix: move custom folder mounts to Data2 Expose custom folder mounts through Dockur's Data2 share instead of Data. Keep the home share under Data and custom mounts under Data2 to avoid stale folders appearing inside the shared home path. AI-Assisted: OpenAI GPT-5.4 (xhigh) --- src/renderer/components/ConfigCard.vue | 7 +- .../components/CustomVolumeMounts.vue | 2 +- src/renderer/lib/install.ts | 3 +- src/renderer/lib/volumes.ts | 120 +++++++++++------- src/renderer/lib/winboat.ts | 29 ----- src/renderer/views/Config.vue | 25 ++-- 6 files changed, 96 insertions(+), 90 deletions(-) diff --git a/src/renderer/components/ConfigCard.vue b/src/renderer/components/ConfigCard.vue index 82a10329..98cd25d6 100644 --- a/src/renderer/components/ConfigCard.vue +++ b/src/renderer/components/ConfigCard.vue @@ -58,7 +58,7 @@ @@ -124,6 +124,9 @@ type PropsType = { }; const props = defineProps(); +const emit = defineEmits<{ + (e: "toggle"): void; +}>(); const value = defineModel("value"); function ensureNumericInput(e: any) { @@ -150,4 +153,4 @@ function applyStep(step: number) { value.value = Math.min(Math.max(props.min ?? Number.MIN_SAFE_INTEGER, tmp), props.max ?? Number.MAX_SAFE_INTEGER); } - \ No newline at end of file + diff --git a/src/renderer/components/CustomVolumeMounts.vue b/src/renderer/components/CustomVolumeMounts.vue index cd145ecc..8462f866 100644 --- a/src/renderer/components/CustomVolumeMounts.vue +++ b/src/renderer/components/CustomVolumeMounts.vue @@ -6,7 +6,7 @@

Mount additional folders from your Linux filesystem into Windows. - They appear under \\host.lan\Data\<name> + They appear under \\host.lan\Data2\<name>

diff --git a/src/renderer/lib/install.ts b/src/renderer/lib/install.ts index 515e982f..d4bb7653 100644 --- a/src/renderer/lib/install.ts +++ b/src/renderer/lib/install.ts @@ -6,6 +6,7 @@ import { Winboat } from "./winboat"; import { ContainerManager } from "./containers/container"; import { WinboatConfig } from "./config"; import { CommonPorts, createContainer, getActiveHostPort } from "./containers/common"; +import { isRootSharedFolderMount } from "./volumes"; const fs: typeof import("fs") = require("fs"); const path: typeof import("path") = require("path"); @@ -106,7 +107,7 @@ export class InstallManager { } // Shared folder mapping - const sharedFolderIdx = composeContent.services.windows.volumes.findIndex(vol => vol.includes("/shared")); + const sharedFolderIdx = composeContent.services.windows.volumes.findIndex(isRootSharedFolderMount); if (!this.conf.sharedFolderPath) { // Remove shared folder if not enabled diff --git a/src/renderer/lib/volumes.ts b/src/renderer/lib/volumes.ts index 0f15bc3b..a438747d 100644 --- a/src/renderer/lib/volumes.ts +++ b/src/renderer/lib/volumes.ts @@ -1,8 +1,17 @@ const fs: typeof import("fs") = require("node:fs"); +const os: typeof import("os") = require("node:os"); const path: typeof import("path") = require("node:path"); import type { CustomVolumeMount, ComposeConfig } from "../../types"; +const SHARED_FOLDER_ROOT = "/shared"; +const CUSTOM_SHARE_ROOT = "/shared2"; +const STORAGE_SHARED_FOLDER_ROOT = "/storage/shared"; +const LEGACY_CUSTOM_DATA_ROOT = "/data2"; +const LEGACY_CUSTOM_MOUNT_BASE = "/mnt/winboat"; +const LEGACY_SAMBA_MOUNT_BASE = "/tmp/smb"; +const LEGACY_SINGLE_PATH_MOUNT = /^\/[a-zA-Z0-9_-]+$/; + /** * Validates a host path exists and is accessible. * Throws an Error if the path is invalid. @@ -32,50 +41,89 @@ export function validateShareName(name: string): void { if (name.length > 32) throw new Error(`Name '${name}' exceeds maximum length of 32 characters`); } -// Base path for custom mounts (avoids /tmp/smb which gets cleaned on container start) -export const CUSTOM_MOUNT_BASE = "/mnt/winboat"; +function resolveComposeHostPath(hostPath: string): string { + return hostPath.replace("${HOME}", os.homedir()); +} + +function getVolumePaths(volumeStr: string): [hostPath: string, containerPath: string] | null { + const parts = volumeStr.split(":"); + if (parts.length < 2) { + return null; + } + + const lastPart = parts.at(-1)!; + const containerPathIndex = lastPart.startsWith("/") ? parts.length - 1 : parts.length - 2; + const containerPath = parts[containerPathIndex]; + if (!containerPath?.startsWith("/")) { + return null; + } + + return [parts.slice(0, containerPathIndex).join(":"), containerPath]; +} + +export function isRootSharedFolderMount(volumeStr: string): boolean { + return getVolumePaths(volumeStr)?.[1] === SHARED_FOLDER_ROOT; +} + +function getSharedFolderVolume(compose: ComposeConfig): string | undefined { + return compose.services.windows.volumes.find(isRootSharedFolderMount); +} + +export function getSharedFolderHostPath(compose: ComposeConfig): string | null { + const sharedFolderVolume = getSharedFolderVolume(compose); + const hostPath = sharedFolderVolume ? getVolumePaths(sharedFolderVolume)?.[0] : null; + return hostPath ? resolveComposeHostPath(hostPath) : null; +} /** - * Converts a CustomVolumeMount to compose volume string format - * Mounts under /mnt/winboat/ - symlinks are created in /tmp/smb/ after container starts + * Converts a CustomVolumeMount to compose volume string format. + * Custom mounts always live under Dockur's Data2 share root. */ -export function mountToVolumeString(mount: CustomVolumeMount): string { - return `${mount.hostPath}:${CUSTOM_MOUNT_BASE}/${mount.shareName}`; +function mountToVolumeString(mount: CustomVolumeMount): string { + return `${mount.hostPath}:${CUSTOM_SHARE_ROOT}/${mount.shareName}`; +} + +function isManagedCustomMountPath(containerPath: string): boolean { + return ( + containerPath.startsWith(`${CUSTOM_SHARE_ROOT}/`) || + containerPath.startsWith(`${SHARED_FOLDER_ROOT}/`) || + containerPath.startsWith(`${STORAGE_SHARED_FOLDER_ROOT}/`) || + containerPath.startsWith(`${LEGACY_CUSTOM_DATA_ROOT}/`) || + containerPath.startsWith(`${LEGACY_CUSTOM_MOUNT_BASE}/`) || + containerPath.startsWith(`${LEGACY_SAMBA_MOUNT_BASE}/`) || + LEGACY_SINGLE_PATH_MOUNT.test(containerPath) + ); } /** - * Identifies if a volume string is a custom user mount (not system) - * Custom mounts are under /mnt/winboat/ (or legacy paths /tmp/smb/, or bare paths like /gamez) + * Identifies if a volume string is a custom user mount (not system). + * Custom mounts live under Data2, with cleanup for older mount layouts. */ export function isCustomMount(volumeStr: string): boolean { - const parts = volumeStr.split(":"); - const containerPath = parts.length >= 2 ? parts[parts.length - 1] : ""; - - // System paths that should NOT be considered custom mounts - const systemPaths = ["/storage", "/shared", "/oem", "/dev/bus/usb", "/dev"]; - - // If it's a system path, it's not custom - if (systemPaths.some(p => containerPath === p || containerPath.startsWith(p + "/"))) { + const containerPath = getVolumePaths(volumeStr)?.[1]; + if (!containerPath) { return false; } - // Current format: /mnt/winboat/ - if (containerPath.startsWith(CUSTOM_MOUNT_BASE + "/")) { - return true; - } - - // Legacy format: /tmp/smb/ - if (containerPath.startsWith("/tmp/smb/")) { - return true; + const systemPaths = [ + "/storage", + SHARED_FOLDER_ROOT, + CUSTOM_SHARE_ROOT, + STORAGE_SHARED_FOLDER_ROOT, + LEGACY_CUSTOM_DATA_ROOT, + "/oem", + "/dev", + "/dev/bus/usb", + ]; + if (systemPaths.includes(containerPath)) { + return false; } - // Very old format: bare path like /gamez (not a system path, likely custom) - // Only match single-level paths that aren't system - if (containerPath.match(/^\/[a-zA-Z0-9_-]+$/)) { - return true; + if (containerPath.startsWith("/dev/")) { + return false; } - return false; + return isManagedCustomMountPath(containerPath); } /** @@ -98,17 +146,3 @@ export function applyCustomMounts( compose.services.windows.volumes.push(...enabledMounts); } - -/** - * Creates symlinks in /tmp/smb/ pointing to /mnt/winboat/ mounts - * This makes custom mounts accessible via \\host.lan\Data\ - * Returns shell commands to execute inside the container - */ -export function getSymlinkCommands(mounts: CustomVolumeMount[]): string[] { - const enabledMounts = mounts.filter(m => m.enabled); - if (enabledMounts.length === 0) return []; - - return enabledMounts.map(mount => - `ln -sfn ${CUSTOM_MOUNT_BASE}/${mount.shareName} /tmp/smb/${mount.shareName}` - ); -} diff --git a/src/renderer/lib/winboat.ts b/src/renderer/lib/winboat.ts index 2ea4328c..d6d52f19 100644 --- a/src/renderer/lib/winboat.ts +++ b/src/renderer/lib/winboat.ts @@ -21,7 +21,6 @@ import { setIntervalImmediately } from "../utils/interval"; import { ExecFileAsyncError } from "./exec-helper"; import { ContainerManager, ContainerStatus } from "./containers/container"; import { CommonPorts, ContainerRuntimes, createContainer, getActiveHostPort } from "./containers/common"; -import { getSymlinkCommands } from "./volumes"; const nodeFetch: typeof import("node-fetch").default = require("node-fetch"); const fs: typeof import("fs") = require("node:fs"); @@ -308,8 +307,6 @@ export class Winboat { logger.info(`Winboat Guest API went ${this.isOnline ? "online" : "offline"}`); if (this.isOnline.value) { - // Create symlinks for custom volume mounts - await this.createCustomMountSymlinks(); await this.checkVersionAndUpdateGuestServer(); } } @@ -445,32 +442,6 @@ export class Winboat { }; } - /** - * Creates symlinks in /tmp/smb/ for custom volume mounts - * This allows Windows to access them via \\host.lan\Data\ - */ - async createCustomMountSymlinks() { - const mounts = this.#wbConfig?.config.customVolumeMounts || []; - const commands = getSymlinkCommands(mounts); - - if (commands.length === 0) { - logger.info("No custom volume mounts to symlink"); - return; - } - - logger.info(`Creating symlinks for ${commands.length} custom volume mount(s)`); - - for (const cmd of commands) { - try { - await execAsync(`${this.containerMgr!.executableAlias} exec WinBoat ${cmd}`); - logger.info(`Created symlink: ${cmd}`); - } catch (e) { - logger.error(`Failed to create symlink: ${cmd}`); - logger.error(e); - } - } - } - async #connectQMPManager() { try { this.qmpMgr = await QMPManager.createConnection( diff --git a/src/renderer/views/Config.vue b/src/renderer/views/Config.vue index 551e3ac0..f2a832b4 100644 --- a/src/renderer/views/Config.vue +++ b/src/renderer/views/Config.vue @@ -479,7 +479,11 @@ import { import { ComposePortEntry, ComposePortMapper, Range } from "../utils/port"; import CustomVolumeMounts from "../components/CustomVolumeMounts.vue"; import type { CustomVolumeMount } from "../../types"; -import { applyCustomMounts } from "../lib/volumes"; +import { + applyCustomMounts, + getSharedFolderHostPath, + isRootSharedFolderMount, +} from "../lib/volumes"; const { app }: typeof import("@electron/remote") = require("@electron/remote"); const electron: typeof import("electron") = require("electron").remote || require("@electron/remote"); const os: typeof import("os") = require("node:os"); @@ -543,13 +547,10 @@ async function assignValues() { ramGB.value = Number(compose.value.services.windows.environment.RAM_SIZE.split("G")[0]); origRamGB.value = ramGB.value; - // Find any volume that ends with /shared - const sharedVolume = compose.value.services.windows.volumes.find(v => v.includes("/shared")); - if (sharedVolume) { + const sharedFolderHostPath = getSharedFolderHostPath(compose.value); + if (sharedFolderHostPath) { shareFolder.value = true; - // Extract the path before :/shared - const [hostPath] = sharedVolume.split(":"); - sharedFolderPath.value = hostPath.replace("${HOME}", os.homedir()); + sharedFolderPath.value = sharedFolderHostPath; } else { shareFolder.value = false; sharedFolderPath.value = ""; @@ -581,13 +582,9 @@ async function saveCompose() { compose.value!.services.windows.environment.RAM_SIZE = `${ramGB.value}G`; compose.value!.services.windows.environment.CPU_CORES = `${numCores.value}`; - // Remove any existing shared volume - const existingSharedVolume = compose.value!.services.windows.volumes.find(v => v.includes("/shared")); - if (existingSharedVolume) { - compose.value!.services.windows.volumes = compose.value!.services.windows.volumes.filter( - v => !v.includes("/shared"), - ); - } + compose.value!.services.windows.volumes = compose.value!.services.windows.volumes.filter( + volume => !isRootSharedFolderMount(volume), + ); // Add the new shared volume if enabled if (shareFolder.value && sharedFolderPath.value) {