From 6749f3019d0da1641c7dd4e0ae57897efa6ff691 Mon Sep 17 00:00:00 2001 From: jabrailkhalil Date: Thu, 4 Jun 2026 18:15:43 +0300 Subject: [PATCH 1/4] Add Windows portable release support --- apps/desktop/electron-builder.yml | 5 + apps/desktop/src/main/index.ts | 16 +++- .../src/main/ipc/services/update-service.ts | 21 ++++- apps/desktop/src/main/portable.ts | 92 +++++++++++++++++++ apps/desktop/src/main/settings.ts | 47 ++++++++-- apps/desktop/src/main/utils/auto-launch.ts | 6 ++ 6 files changed, 175 insertions(+), 12 deletions(-) create mode 100644 apps/desktop/src/main/portable.ts diff --git a/apps/desktop/electron-builder.yml b/apps/desktop/electron-builder.yml index 0d30bbd4..66e2fbc8 100644 --- a/apps/desktop/electron-builder.yml +++ b/apps/desktop/electron-builder.yml @@ -32,11 +32,16 @@ extraResources: # `npm install -g @vidbee/cli` (or one of the other channels). win: executableName: vidbee + target: + - nsis + - portable nsis: artifactName: ${name}-${version}-setup.${ext} shortcutName: ${productName} uninstallDisplayName: ${productName} createDesktopShortcut: always +portable: + artifactName: ${name}-${version}-portable.${ext} mac: hardenedRuntime: true entitlements: build/entitlements.mac.plist diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 0e509b4a..ff3b6e64 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -42,6 +42,7 @@ import { import { runDesktopTaskQueueMigration } from './lib/task-queue-migrate' import { ytdlpManager } from './lib/ytdlp-manager' import { startExtensionApiServer, stopExtensionApiServer } from './local-api' +import { isPortableMode } from './portable' import { settingsManager } from './settings' import { createTray, destroyTray } from './tray' import { applyAutoLaunchSetting } from './utils/auto-launch' @@ -654,6 +655,11 @@ function registerVidbeeProtocol(): void { } function initAutoUpdater(): void { + if (isPortableMode) { + log.info('Portable mode is active, skipping auto-updater initialization') + return + } + try { log.info('Initializing auto-updater...') @@ -745,9 +751,13 @@ app.whenReady().then(async () => { registerVidbeeProtocol() - const registered = app.setAsDefaultProtocolClient(APP_PROTOCOL) - if (!registered) { - log.warn(`Failed to register ${APP_PROTOCOL} protocol handler`) + if (isPortableMode) { + log.info(`Portable mode is active, skipping ${APP_PROTOCOL} protocol handler registration`) + } else { + const registered = app.setAsDefaultProtocolClient(APP_PROTOCOL) + if (!registered) { + log.warn(`Failed to register ${APP_PROTOCOL} protocol handler`) + } } // Default open or close DevTools by F12 in development diff --git a/apps/desktop/src/main/ipc/services/update-service.ts b/apps/desktop/src/main/ipc/services/update-service.ts index a61a49b6..3fdf2e31 100644 --- a/apps/desktop/src/main/ipc/services/update-service.ts +++ b/apps/desktop/src/main/ipc/services/update-service.ts @@ -1,6 +1,7 @@ import { app } from 'electron' import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator' import { autoUpdater } from 'electron-updater' +import { isPortableMode } from '../../portable' const isNewerVersion = (latest: string, current: string): boolean => { const toSegments = (version: string) => @@ -36,6 +37,13 @@ class UpdateService extends IpcService { async checkForUpdates( _context: IpcContext ): Promise<{ available: boolean; version?: string; error?: string }> { + if (isPortableMode) { + return { + available: false, + version: app.getVersion() + } + } + try { const currentVersion = app.getVersion() const result = await autoUpdater.checkForUpdates() @@ -62,6 +70,13 @@ class UpdateService extends IpcService { @IpcMethod() async downloadUpdate(_context: IpcContext): Promise<{ success: boolean; error?: string }> { + if (isPortableMode) { + return { + success: false, + error: 'Auto-update is disabled in portable mode' + } + } + try { await autoUpdater.downloadUpdate() return { success: true } @@ -75,6 +90,10 @@ class UpdateService extends IpcService { @IpcMethod() quitAndInstall(_context: IpcContext): void { + if (isPortableMode) { + return + } + autoUpdater.quitAndInstall() } @@ -85,7 +104,7 @@ class UpdateService extends IpcService { @IpcMethod() isAutoUpdateEnabled(_context: IpcContext): boolean { - return true + return !isPortableMode } } diff --git a/apps/desktop/src/main/portable.ts b/apps/desktop/src/main/portable.ts new file mode 100644 index 00000000..57f92a61 --- /dev/null +++ b/apps/desktop/src/main/portable.ts @@ -0,0 +1,92 @@ +import fs from 'node:fs' +import path from 'node:path' +import { app } from 'electron' + +export const portableRoot = + process.env.PORTABLE_EXECUTABLE_DIR || process.env.VIDBEE_PORTABLE_DIR || '' + +export const isPortableMode = portableRoot.length > 0 + +const ensureDirectoryExists = (dir: string): void => { + fs.mkdirSync(dir, { recursive: true }) +} + +const setEnvPath = (key: string, value: string): void => { + process.env[key] = value +} + +const setAppPath = (name: Parameters[0], value: string): void => { + try { + app.setPath(name, value) + } catch { + // Some Electron path names can be platform-specific. + } +} + +export const getPortablePath = (...segments: string[]): string => { + return path.join(portableRoot, ...segments) +} + +export const getPortableDownloadsPath = (): string => { + return getPortablePath('Downloads') +} + +export const configurePortableMode = (): void => { + if (!isPortableMode) { + return + } + + const roamingDir = getPortablePath('Data', 'Roaming') + const localDir = getPortablePath('Data', 'Local') + const userDataDir = getPortablePath('Data', 'UserData') + const sessionDataDir = getPortablePath('Data', 'SessionData') + const homeDir = getPortablePath('Data', 'Home') + const cacheDir = getPortablePath('Data', 'Cache') + const configDir = getPortablePath('Data', 'Config') + const localShareDir = getPortablePath('Data', 'LocalShare') + const denoDir = getPortablePath('Data', 'Deno') + const logsDir = getPortablePath('Data', 'Logs') + const crashDumpsDir = getPortablePath('Data', 'CrashDumps') + const downloadsDir = getPortableDownloadsPath() + const tempDir = getPortablePath('Temp') + + for (const dir of [ + roamingDir, + localDir, + userDataDir, + sessionDataDir, + homeDir, + cacheDir, + configDir, + localShareDir, + denoDir, + logsDir, + crashDumpsDir, + downloadsDir, + tempDir + ]) { + ensureDirectoryExists(dir) + } + + setEnvPath('APPDATA', roamingDir) + setEnvPath('LOCALAPPDATA', localDir) + setEnvPath('USERPROFILE', homeDir) + setEnvPath('HOME', homeDir) + setEnvPath('XDG_CACHE_HOME', cacheDir) + setEnvPath('XDG_CONFIG_HOME', configDir) + setEnvPath('XDG_DATA_HOME', localShareDir) + setEnvPath('DENO_DIR', denoDir) + setEnvPath('TEMP', tempDir) + setEnvPath('TMP', tempDir) + setEnvPath('VIDBEE_PORTABLE', '1') + + setAppPath('appData', roamingDir) + setAppPath('userData', userDataDir) + setAppPath('sessionData', sessionDataDir) + setAppPath('temp', tempDir) + setAppPath('downloads', downloadsDir) + setAppPath('logs', logsDir) + setAppPath('crashDumps', crashDumpsDir) +} + +configurePortableMode() diff --git a/apps/desktop/src/main/settings.ts b/apps/desktop/src/main/settings.ts index bf8ffc93..64b52a92 100644 --- a/apps/desktop/src/main/settings.ts +++ b/apps/desktop/src/main/settings.ts @@ -3,6 +3,7 @@ import os from 'node:os' import path from 'node:path' import type { AppSettings } from '../shared/types' import { defaultSettings } from '../shared/types' +import { getPortableDownloadsPath, isPortableMode } from './portable' import { scopedLoggers } from './utils/logger' // Use require for electron-store to avoid CommonJS/ESM issues @@ -20,10 +21,16 @@ const ensureDirectoryExists = (dir: string) => { } const resolveDefaultDownloadPath = () => { + if (isPortableMode) { + return getPortableDownloadsPath() + } + return path.join(os.homedir(), 'Downloads', 'VidBee') } const DEFAULT_DOWNLOAD_PATH = resolveDefaultDownloadPath() +const REQUIRED_AUTO_UPDATE = !isPortableMode +const REQUIRED_LAUNCH_AT_LOGIN = false class SettingsManager { // biome-ignore lint/suspicious/noExplicitAny: electron-store requires dynamic import @@ -33,7 +40,9 @@ class SettingsManager { this.store = new Store({ defaults: { ...defaultSettings, - downloadPath: DEFAULT_DOWNLOAD_PATH + downloadPath: DEFAULT_DOWNLOAD_PATH, + autoUpdate: REQUIRED_AUTO_UPDATE, + launchAtLogin: isPortableMode ? REQUIRED_LAUNCH_AT_LOGIN : defaultSettings.launchAtLogin } }) this.ensureDownloadDirectory() @@ -42,7 +51,11 @@ class SettingsManager { get(key: K): AppSettings[K] { if (key === 'autoUpdate') { - return true as AppSettings[K] + return REQUIRED_AUTO_UPDATE as AppSettings[K] + } + + if (isPortableMode && key === 'launchAtLogin') { + return REQUIRED_LAUNCH_AT_LOGIN as AppSettings[K] } return this.store.get(key) @@ -50,7 +63,12 @@ class SettingsManager { set(key: K, value: AppSettings[K]): void { if (key === 'autoUpdate') { - this.store.set(key, true) + this.store.set(key, REQUIRED_AUTO_UPDATE) + return + } + + if (isPortableMode && key === 'launchAtLogin') { + this.store.set(key, REQUIRED_LAUNCH_AT_LOGIN) return } @@ -65,14 +83,22 @@ class SettingsManager { ...defaultSettings, downloadPath: DEFAULT_DOWNLOAD_PATH, ...this.store.store, - autoUpdate: true + autoUpdate: REQUIRED_AUTO_UPDATE, + launchAtLogin: isPortableMode + ? REQUIRED_LAUNCH_AT_LOGIN + : (this.store.store.launchAtLogin ?? defaultSettings.launchAtLogin) } } setAll(settings: Partial): void { for (const [key, value] of Object.entries(settings)) { if (key === 'autoUpdate') { - this.store.set(key, true) + this.store.set(key, REQUIRED_AUTO_UPDATE) + continue + } + + if (isPortableMode && key === 'launchAtLogin') { + this.store.set(key, REQUIRED_LAUNCH_AT_LOGIN) continue } @@ -87,7 +113,9 @@ class SettingsManager { this.store.clear() this.store.set({ ...defaultSettings, - downloadPath: DEFAULT_DOWNLOAD_PATH + downloadPath: DEFAULT_DOWNLOAD_PATH, + autoUpdate: REQUIRED_AUTO_UPDATE, + launchAtLogin: isPortableMode ? REQUIRED_LAUNCH_AT_LOGIN : defaultSettings.launchAtLogin }) } @@ -108,8 +136,11 @@ class SettingsManager { private ensureRequiredSettings(): void { try { - if (this.store.get('autoUpdate') !== true) { - this.store.set('autoUpdate', true) + if (this.store.get('autoUpdate') !== REQUIRED_AUTO_UPDATE) { + this.store.set('autoUpdate', REQUIRED_AUTO_UPDATE) + } + if (isPortableMode && this.store.get('launchAtLogin') !== REQUIRED_LAUNCH_AT_LOGIN) { + this.store.set('launchAtLogin', REQUIRED_LAUNCH_AT_LOGIN) } } catch (error) { scopedLoggers.system.error('Failed to enforce required settings:', error) diff --git a/apps/desktop/src/main/utils/auto-launch.ts b/apps/desktop/src/main/utils/auto-launch.ts index 5f1e179f..220ff2cd 100644 --- a/apps/desktop/src/main/utils/auto-launch.ts +++ b/apps/desktop/src/main/utils/auto-launch.ts @@ -1,5 +1,6 @@ import { app } from 'electron' import log from 'electron-log/main' +import { isPortableMode } from '../portable' const SUPPORTED_PLATFORMS = new Set(['darwin', 'win32']) @@ -8,6 +9,11 @@ export function isAutoLaunchSupported(): boolean { } export function applyAutoLaunchSetting(enabled: boolean): void { + if (isPortableMode) { + log.info('Portable mode is active, skipping auto launch setting update') + return + } + if (!isAutoLaunchSupported()) { log.info('Auto launch is not supported on this platform, skipping setting update') return From 2f88402bd7ee4e38df31433e61d3a2cc6d37ab0c Mon Sep 17 00:00:00 2001 From: jabrailkhalil Date: Thu, 4 Jun 2026 19:53:27 +0300 Subject: [PATCH 2/4] Improve portable startup and profile paths --- apps/desktop/src/main/index.ts | 53 ++++++++++++++++--- .../main/ipc/services/file-system-service.ts | 5 ++ apps/desktop/src/main/portable.ts | 18 +++++++ apps/desktop/src/renderer/src/App.tsx | 4 ++ 4 files changed, 72 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index ff3b6e64..115c8bac 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -109,6 +109,8 @@ interface DeepLinkData { const pendingDeepLinkUrls: DeepLinkData[] = [] const pendingOneClickDownloads: DeepLinkData[] = [] let isRendererReady = false +let isMainWindowReadyToShow = false +let shouldKeepMainWindowHiddenAtStartup = false const getActiveMainWindow = (): BrowserWindow | null => { if (!mainWindow || mainWindow.isDestroyed()) { @@ -132,6 +134,33 @@ const sendToRenderer = (channel: string, ...args: unknown[]): void => { } } +const showMainWindowWhenReady = (): void => { + const window = getActiveMainWindow() + if (!window) { + return + } + + if (shouldKeepMainWindowHiddenAtStartup) { + applyDockVisibility(settingsManager.get('hideDockIcon')) + return + } + + if (isMainWindowReadyToShow && isRendererReady && !window.isVisible()) { + window.show() + } +} + +ipcMain.on('app:renderer-ready', (event) => { + const window = getActiveMainWindow() + if (!window || event.sender !== window.webContents) { + return + } + + isRendererReady = true + showMainWindowWhenReady() + flushPendingDeepLinks() +}) + const parseDownloadDeepLink = (rawUrl: string): DeepLinkData | null => { try { const parsed = new URL(rawUrl) @@ -232,8 +261,10 @@ getDesktopSubscriptions().on('changed', () => { export function createWindow(): void { const isMac = process.platform === 'darwin' const isWindows = process.platform === 'win32' - const shouldStartHidden = + shouldKeepMainWindowHiddenAtStartup = BACKGROUND_MODE || (isWindows && app.getLoginItemSettings().wasOpenedAtLogin) + isMainWindowReadyToShow = false + isRendererReady = false const windowOptions: BrowserWindowConstructorOptions = { width: 1200, @@ -287,11 +318,8 @@ export function createWindow(): void { }) mainWindow.on('ready-to-show', () => { - if (shouldStartHidden) { - applyDockVisibility(settingsManager.get('hideDockIcon')) - return - } - mainWindow?.show() + isMainWindowReadyToShow = true + showMainWindowWhenReady() }) mainWindow.webContents.setWindowOpenHandler((details) => { @@ -311,8 +339,17 @@ export function createWindow(): void { void listDesktopSubscriptionsSnapshot() .then((snapshot) => sendToRenderer('subscriptions:updated', snapshot)) .catch((err) => log.warn('Failed to send initial subscriptions snapshot:', err)) - isRendererReady = true - flushPendingDeepLinks() + + setTimeout(() => { + if (isRendererReady || shouldKeepMainWindowHiddenAtStartup) { + return + } + + log.warn('Renderer ready signal was not received; showing window via fallback') + isRendererReady = true + showMainWindowWhenReady() + flushPendingDeepLinks() + }, 5000) }) // Setup error handling for renderer process diff --git a/apps/desktop/src/main/ipc/services/file-system-service.ts b/apps/desktop/src/main/ipc/services/file-system-service.ts index d9747477..27f81919 100644 --- a/apps/desktop/src/main/ipc/services/file-system-service.ts +++ b/apps/desktop/src/main/ipc/services/file-system-service.ts @@ -6,6 +6,7 @@ import { pathToFileURL } from 'node:url' import { promisify } from 'node:util' import { clipboard, dialog, Notification, shell } from 'electron' import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator' +import { getPortableDownloadsPath, isPortableMode } from '../../portable' import { scopedLoggers } from '../../utils/logger' const execFileAsync = promisify(execFile) @@ -99,6 +100,10 @@ class FileSystemService extends IpcService { @IpcMethod() getDefaultDownloadPath(_context: IpcContext): string { + if (isPortableMode) { + return getPortableDownloadsPath() + } + const fallbackPath = path.join(os.homedir(), 'Downloads') if (process.platform === 'linux' || process.platform === 'freebsd') { diff --git a/apps/desktop/src/main/portable.ts b/apps/desktop/src/main/portable.ts index 57f92a61..580b8a11 100644 --- a/apps/desktop/src/main/portable.ts +++ b/apps/desktop/src/main/portable.ts @@ -41,6 +41,12 @@ export const configurePortableMode = (): void => { const userDataDir = getPortablePath('Data', 'UserData') const sessionDataDir = getPortablePath('Data', 'SessionData') const homeDir = getPortablePath('Data', 'Home') + const desktopDir = path.join(homeDir, 'Desktop') + const documentsDir = path.join(homeDir, 'Documents') + const homeDownloadsDir = path.join(homeDir, 'Downloads') + const musicDir = path.join(homeDir, 'Music') + const picturesDir = path.join(homeDir, 'Pictures') + const videosDir = path.join(homeDir, 'Videos') const cacheDir = getPortablePath('Data', 'Cache') const configDir = getPortablePath('Data', 'Config') const localShareDir = getPortablePath('Data', 'LocalShare') @@ -56,6 +62,12 @@ export const configurePortableMode = (): void => { userDataDir, sessionDataDir, homeDir, + desktopDir, + documentsDir, + homeDownloadsDir, + musicDir, + picturesDir, + videosDir, cacheDir, configDir, localShareDir, @@ -83,8 +95,14 @@ export const configurePortableMode = (): void => { setAppPath('appData', roamingDir) setAppPath('userData', userDataDir) setAppPath('sessionData', sessionDataDir) + setAppPath('home', homeDir) + setAppPath('desktop', desktopDir) + setAppPath('documents', documentsDir) setAppPath('temp', tempDir) setAppPath('downloads', downloadsDir) + setAppPath('music', musicDir) + setAppPath('pictures', picturesDir) + setAppPath('videos', videosDir) setAppPath('logs', logsDir) setAppPath('crashDumps', crashDumpsDir) } diff --git a/apps/desktop/src/renderer/src/App.tsx b/apps/desktop/src/renderer/src/App.tsx index 74184225..28bf98cb 100644 --- a/apps/desktop/src/renderer/src/App.tsx +++ b/apps/desktop/src/renderer/src/App.tsx @@ -79,6 +79,10 @@ function AppContent() { version: appVersion }) + useEffect(() => { + window.api?.send('app:renderer-ready') + }, []) + const handlePageChange = useCallback( (page: Page) => { const targetPath = pageToPath[page] ?? '/' From c75d1e9e93eaae3ca3236bcb8092a2c14e2bad60 Mon Sep 17 00:00:00 2001 From: jabrailkhalil Date: Thu, 4 Jun 2026 23:25:59 +0300 Subject: [PATCH 3/4] Verify Windows portable release artifact --- .github/workflows/build.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 54fb1f51..6fb97098 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -121,6 +121,23 @@ jobs: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} run: ${{ matrix.build_script }} + - name: Verify Windows release artifacts + if: matrix.platform == 'windows' + shell: pwsh + run: | + $dist = "apps/desktop/dist" + $requiredPatterns = @("*-setup.exe", "*-portable.exe") + foreach ($pattern in $requiredPatterns) { + $matches = Get-ChildItem -Path $dist -Filter $pattern -File + if ($matches.Count -eq 0) { + Write-Error "Missing Windows release artifact matching $pattern" + exit 1 + } + $matches | ForEach-Object { + Write-Host "Found Windows release artifact: $($_.Name)" + } + } + - name: Verify macOS codesign and notarization if: matrix.platform == 'macos' && env.SIGNING_AVAILABLE == 'true' shell: bash @@ -173,3 +190,4 @@ jobs: apps/desktop/dist/*.yml apps/desktop/dist/*.blockmap retention-days: 1 + if-no-files-found: error From 9ad0b16cac24652aa8da22af039d19b66423c9c2 Mon Sep 17 00:00:00 2001 From: jabrailkhalil Date: Thu, 4 Jun 2026 23:33:58 +0300 Subject: [PATCH 4/4] Add Windows portable folder release archive --- .github/workflows/build.yml | 2 +- apps/desktop/package.json | 2 +- .../create-windows-portable-folder.ps1 | 234 ++++++++++++++++++ 3 files changed, 236 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/scripts/create-windows-portable-folder.ps1 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6fb97098..0bedfd48 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -126,7 +126,7 @@ jobs: shell: pwsh run: | $dist = "apps/desktop/dist" - $requiredPatterns = @("*-setup.exe", "*-portable.exe") + $requiredPatterns = @("*-setup.exe", "*-portable.exe", "*-windows-portable.zip") foreach ($pattern in $requiredPatterns) { $matches = Get-ChildItem -Path $dist -Filter $pattern -File if ($matches.Count -eq 0) { diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 470341c5..d9eb7714 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -20,7 +20,7 @@ "setup": "node scripts/setup-dev-binaries.js", "postinstall": "node scripts/postinstall.mjs", "build:unpack": "pnpm run setup && pnpm run build && electron-builder --dir", - "build:win": "pnpm run setup && node scripts/check-ytdlp.js win && pnpm run build && electron-builder --win", + "build:win": "pnpm run setup && node scripts/check-ytdlp.js win && pnpm run build && electron-builder --win && powershell -NoProfile -ExecutionPolicy Bypass -File scripts/create-windows-portable-folder.ps1", "build:mac": "pnpm run setup && node scripts/check-ytdlp.js mac && pnpm run build && electron-builder --mac --x64 --arm64", "build:linux": "pnpm run setup && node scripts/check-ytdlp.js linux && pnpm run build && electron-builder --linux", "release": "git checkout main && git pull && pnpm run check && bumpp", diff --git a/apps/desktop/scripts/create-windows-portable-folder.ps1 b/apps/desktop/scripts/create-windows-portable-folder.ps1 new file mode 100644 index 00000000..a172a0c5 --- /dev/null +++ b/apps/desktop/scripts/create-windows-portable-folder.ps1 @@ -0,0 +1,234 @@ +$ErrorActionPreference = 'Stop' + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$AppDir = Resolve-Path -LiteralPath (Join-Path $ScriptDir '..') +$DistDir = Join-Path $AppDir 'dist' +$PackageJson = Get-Content -LiteralPath (Join-Path $AppDir 'package.json') -Raw | ConvertFrom-Json +$Version = $PackageJson.version +$WinUnpackedDir = Join-Path $DistDir 'win-unpacked' +$PortableDir = Join-Path $DistDir 'VidBee-portable' +$AppOutputDir = Join-Path $PortableDir 'app' +$ZipPath = Join-Path $DistDir "vidbee-$Version-windows-portable.zip" + +if (-not (Test-Path -LiteralPath $WinUnpackedDir)) { + throw "Missing win-unpacked build output: $WinUnpackedDir" +} + +Remove-Item -LiteralPath $PortableDir -Recurse -Force -ErrorAction SilentlyContinue +Remove-Item -LiteralPath $ZipPath -Force -ErrorAction SilentlyContinue + +New-Item -ItemType Directory -Path $AppOutputDir -Force | Out-Null +Copy-Item -Path (Join-Path $WinUnpackedDir '*') -Destination $AppOutputDir -Recurse -Force + +$DataDir = Join-Path $PortableDir 'Data' +$TempDir = Join-Path $PortableDir 'Temp' +$DownloadsDir = Join-Path $PortableDir 'Downloads' +$HomeDir = Join-Path $DataDir 'Home' +$LauncherDirs = @( + (Join-Path $DataDir 'Roaming'), + (Join-Path $DataDir 'Local'), + (Join-Path $DataDir 'UserData'), + (Join-Path $DataDir 'SessionData'), + $HomeDir, + (Join-Path $HomeDir 'Desktop'), + (Join-Path $HomeDir 'Documents'), + (Join-Path $HomeDir 'Downloads'), + (Join-Path $HomeDir 'Music'), + (Join-Path $HomeDir 'Pictures'), + (Join-Path $HomeDir 'Videos'), + (Join-Path $DataDir 'Cache'), + (Join-Path $DataDir 'Config'), + (Join-Path $DataDir 'LocalShare'), + (Join-Path $DataDir 'Deno'), + $TempDir, + $DownloadsDir +) + +foreach ($dir in $LauncherDirs) { + New-Item -ItemType Directory -Path $dir -Force | Out-Null +} + +$PowerShellLauncher = @' +$ErrorActionPreference = 'Stop' + +$PortableRoot = Split-Path -Parent $MyInvocation.MyCommand.Path +$AppDir = Join-Path $PortableRoot 'app' +$DataDir = Join-Path $PortableRoot 'Data' +$TempDir = Join-Path $PortableRoot 'Temp' +$DownloadsDir = Join-Path $PortableRoot 'Downloads' +$HomeDir = Join-Path $DataDir 'Home' +$ExePath = Join-Path $AppDir 'vidbee.exe' + +$paths = @( + (Join-Path $DataDir 'Roaming'), + (Join-Path $DataDir 'Local'), + (Join-Path $DataDir 'UserData'), + (Join-Path $DataDir 'SessionData'), + $HomeDir, + (Join-Path $HomeDir 'Desktop'), + (Join-Path $HomeDir 'Documents'), + (Join-Path $HomeDir 'Downloads'), + (Join-Path $HomeDir 'Music'), + (Join-Path $HomeDir 'Pictures'), + (Join-Path $HomeDir 'Videos'), + (Join-Path $DataDir 'Cache'), + (Join-Path $DataDir 'Config'), + (Join-Path $DataDir 'LocalShare'), + (Join-Path $DataDir 'Deno'), + $TempDir, + $DownloadsDir +) + +foreach ($path in $paths) { + if (-not (Test-Path -LiteralPath $path)) { + New-Item -ItemType Directory -Path $path -Force | Out-Null + } +} + +$env:APPDATA = Join-Path $DataDir 'Roaming' +$env:LOCALAPPDATA = Join-Path $DataDir 'Local' +$env:USERPROFILE = $HomeDir +$env:HOME = $HomeDir +$env:XDG_CACHE_HOME = Join-Path $DataDir 'Cache' +$env:XDG_CONFIG_HOME = Join-Path $DataDir 'Config' +$env:XDG_DATA_HOME = Join-Path $DataDir 'LocalShare' +$env:DENO_DIR = Join-Path $DataDir 'Deno' +$env:TEMP = $TempDir +$env:TMP = $TempDir +$env:VIDBEE_PORTABLE = '1' +$env:VIDBEE_PORTABLE_DIR = $PortableRoot +$env:NO_UPDATE_NOTIFIER = '1' +$env:SENTRYCLI_SKIP_DOWNLOAD = '1' + +$UserDataDir = Join-Path $DataDir 'UserData' +Start-Process -FilePath $ExePath -WorkingDirectory $AppDir -ArgumentList ('--user-data-dir="{0}"' -f $UserDataDir) +'@ + +$VbsLauncher = @' +Option Explicit + +Dim shell, fso, scriptDir, ps1, command + +Set shell = CreateObject("WScript.Shell") +Set fso = CreateObject("Scripting.FileSystemObject") + +scriptDir = fso.GetParentFolderName(WScript.ScriptFullName) +ps1 = fso.BuildPath(scriptDir, "Start-VidBee-Portable.ps1") + +command = "powershell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File " & Chr(34) & ps1 & Chr(34) +shell.Run command, 0, False +'@ + +$Readme = @" +VidBee portable folder +====================== + +Run VidBee Portable.lnk from this folder. + +Do not run app\vidbee.exe directly if you want the app data to stay portable. +The shortcut starts a hidden WSH launcher, which starts a hidden PowerShell +launcher. The launcher redirects APPDATA, LOCALAPPDATA, USERPROFILE, HOME, XDG +paths, DENO_DIR, TEMP, and TMP into this folder, and also passes a local +Chromium --user-data-dir path. + +Default download folder: +Downloads + +Move this folder only while VidBee is closed. Keep the path reasonably short; +very deep Windows paths can fail when browser profile/cache files exist under +Data. +"@ + +[System.IO.File]::WriteAllText( + (Join-Path $PortableDir 'Start-VidBee-Portable.ps1'), + $PowerShellLauncher, + [System.Text.UTF8Encoding]::new($false) +) +[System.IO.File]::WriteAllText( + (Join-Path $PortableDir 'Start-VidBee-Portable.vbs'), + $VbsLauncher, + [System.Text.Encoding]::ASCII +) +[System.IO.File]::WriteAllText( + (Join-Path $PortableDir 'README-portable.txt'), + $Readme, + [System.Text.UTF8Encoding]::new($false) +) + +$ShellLinkCode = @' +using System; +using System.Runtime.InteropServices; +using System.Text; + +[ComImport, Guid("00021401-0000-0000-C000-000000000046")] +public class ShellLink { } + +[ComImport, InterfaceType(ComInterfaceType.InterfaceIsIUnknown), Guid("000214F9-0000-0000-C000-000000000046")] +public interface IShellLinkW { + void GetPath([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszFile, int cchMaxPath, IntPtr pfd, uint fFlags); + void GetIDList(out IntPtr ppidl); + void SetIDList(IntPtr pidl); + void GetDescription([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszName, int cchMaxName); + void SetDescription([MarshalAs(UnmanagedType.LPWStr)] string pszName); + void GetWorkingDirectory([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszDir, int cchMaxPath); + void SetWorkingDirectory([MarshalAs(UnmanagedType.LPWStr)] string pszDir); + void GetArguments([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszArgs, int cchMaxPath); + void SetArguments([MarshalAs(UnmanagedType.LPWStr)] string pszArgs); + void GetHotkey(out short pwHotkey); + void SetHotkey(short wHotkey); + void GetShowCmd(out int piShowCmd); + void SetShowCmd(int iShowCmd); + void GetIconLocation([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszIconPath, int cchIconPath, out int piIcon); + void SetIconLocation([MarshalAs(UnmanagedType.LPWStr)] string pszIconPath, int iIcon); + void SetRelativePath([MarshalAs(UnmanagedType.LPWStr)] string pszPathRel, uint dwReserved); + void Resolve(IntPtr hwnd, uint fFlags); + void SetPath([MarshalAs(UnmanagedType.LPWStr)] string pszFile); +} + +[ComImport, InterfaceType(ComInterfaceType.InterfaceIsIUnknown), Guid("0000010b-0000-0000-C000-000000000046")] +public interface IPersistFile { + void GetClassID(out Guid pClassID); + void IsDirty(); + void Load([MarshalAs(UnmanagedType.LPWStr)] string pszFileName, uint dwMode); + void Save([MarshalAs(UnmanagedType.LPWStr)] string pszFileName, bool fRemember); + void SaveCompleted([MarshalAs(UnmanagedType.LPWStr)] string pszFileName); + void GetCurFile([MarshalAs(UnmanagedType.LPWStr)] out string ppszFileName); +} + +public static class PortableShortcutMaker { + public static void Create(string linkPath, string targetPath, string workDir, string iconPath) { + var link = (IShellLinkW)new ShellLink(); + link.SetPath(targetPath); + link.SetWorkingDirectory(workDir); + link.SetRelativePath(linkPath, 0); + link.SetIconLocation(iconPath, 0); + link.SetDescription("VidBee Portable"); + ((IPersistFile)link).Save(linkPath, true); + } +} +'@ + +Add-Type -TypeDefinition $ShellLinkCode +$ShortcutPath = Join-Path $PortableDir 'VidBee Portable.lnk' +$VbsPath = Join-Path $PortableDir 'Start-VidBee-Portable.vbs' +$IconPath = Join-Path $AppOutputDir 'vidbee.exe' +[PortableShortcutMaker]::Create($ShortcutPath, $VbsPath, $PortableDir, $IconPath) + +$RequiredFiles = @( + 'VidBee Portable.lnk', + 'Start-VidBee-Portable.vbs', + 'Start-VidBee-Portable.ps1', + 'README-portable.txt', + 'app\vidbee.exe', + 'app\resources\app.asar' +) + +foreach ($relativePath in $RequiredFiles) { + $candidate = Join-Path $PortableDir $relativePath + if (-not (Test-Path -LiteralPath $candidate)) { + throw "Missing portable folder file: $relativePath" + } +} + +Compress-Archive -LiteralPath $PortableDir -DestinationPath $ZipPath -CompressionLevel Optimal -Force +Write-Host "Created Windows portable folder archive: $ZipPath"