From 66b834bd31f29665477b5f7310b4d793f8e29915 Mon Sep 17 00:00:00 2001 From: MathieuG-P <40181755+Zagrios@users.noreply.github.com> Date: Mon, 10 Apr 2023 23:23:02 +0200 Subject: [PATCH] [bugfix-203] move install folder more resilant + handle symlinks --- assets/jsons/translations/de.json | 8 +++ assets/jsons/translations/en.json | 8 +++ assets/jsons/translations/es.json | 8 +++ assets/jsons/translations/fr.json | 10 ++- src/main/helpers/fs.helpers.ts | 59 ++++++++++++----- src/main/ipcs/bs-version-ipcs.ts | 5 ++ src/main/services/bs-local-version.service.ts | 7 +-- src/main/services/folder-linker.service.ts | 17 +++-- .../services/installation-location.service.ts | 25 +++++--- .../services/version-folder-linker.service.ts | 27 +++++--- .../local-maps-list-panel.component.tsx | 2 - .../bsm-progress-bar.component.tsx | 2 +- .../pages/settings-page.component.tsx | 63 ++++++++++++------- .../services/bs-version-manager.service.ts | 4 +- .../services/version-folder-linker.service.ts | 4 ++ src/shared/models/bsm-exception.model.ts | 9 ++- 16 files changed, 180 insertions(+), 78 deletions(-) diff --git a/assets/jsons/translations/de.json b/assets/jsons/translations/de.json index d8d751af..65997534 100644 --- a/assets/jsons/translations/de.json +++ b/assets/jsons/translations/de.json @@ -269,12 +269,20 @@ "move-folder": { "success": { "titles": { + "transfer-started": "Übertragung läuft", "transfer-finished": "Transfer abgeschlossen" + }, + "descs": { + "transfer-started": "Die Übertragung hat begonnen und kann je nach Konfiguration mehrere Minuten dauern." } }, "errors": { "titles": { "transfer-failed": "Transfer fehlgeschlagen 😕" + }, + "descs": { + "COPY_TO_SUBPATH": "Der Zielordner darf kein Unterordner des Quellordners sein.", + "restore-linked-folders": "Beim Wiederherstellen von freigegebenen Ordnern ist ein Fehler aufgetreten. Sie können sie dennoch manuell über das Menü 'Freigegebene Ordner' auf der Versionsseite wiederherstellen." } } }, diff --git a/assets/jsons/translations/en.json b/assets/jsons/translations/en.json index d46dc2c1..f8cd62ee 100644 --- a/assets/jsons/translations/en.json +++ b/assets/jsons/translations/en.json @@ -269,12 +269,20 @@ "move-folder": { "success": { "titles": { + "transfer-started": "Transfer in progress", "transfer-finished": "Transfer complete" + }, + "descs": { + "transfer-started": "The transfer has started and may take several minutes depending on your configuration." } }, "errors": { "titles": { "transfer-failed": "Transfer failed 😕" + }, + "descs": { + "COPY_TO_SUBPATH": "The destination folder cannot be a subfolder of the source folder.", + "restore-linked-folders": "An error occurred while restoring shared folders. You can still manually restore them via the 'Shared Folders' menu on the versions page." } } }, diff --git a/assets/jsons/translations/es.json b/assets/jsons/translations/es.json index ff8e5aaa..d66c78bc 100644 --- a/assets/jsons/translations/es.json +++ b/assets/jsons/translations/es.json @@ -268,12 +268,20 @@ "move-folder": { "success": { "titles": { + "transfer-started": "Transferencia en curso", "transfer-finished": "Transferencia completa" + }, + "descs": { + "transfer-started": "La transferencia ha comenzado y puede tomar varios minutos dependiendo de su configuración." } }, "errors": { "titles": { "transfer-failed": "Transferencia fallida 😕" + }, + "descs": { + "COPY_TO_SUBPATH": "La carpeta de destino no puede ser una subcarpeta de la carpeta de origen.", + "restore-linked-folders": "Se produjo un error al restaurar las carpetas compartidas. Aún puede restaurarlas manualmente a través del menú 'Carpetas compartidas' en la página de versiones." } } }, diff --git a/assets/jsons/translations/fr.json b/assets/jsons/translations/fr.json index 15966f7e..0dd59d84 100644 --- a/assets/jsons/translations/fr.json +++ b/assets/jsons/translations/fr.json @@ -268,13 +268,21 @@ "move-folder": { "success": { "titles": { + "transfer-started": "Transfert en cours", "transfer-finished": "Transfert terminé 👌" + }, + "descs":{ + "transfer-started": "Le transfert a commencé, il peut durer plusieurs minutes selon votre configuration." } }, "errors": { "titles": { "transfer-failed": "Le transfert a échoué 😕" - } + }, + "descs": { + "COPY_TO_SUBPATH": "Le dossier de destination ne peut pas être un sous-dossier du dossier source.", + "restore-linked-folders": "Une erreur s'est produite lors de la restauration des dossiers partagés. Vous pouvez toujours les restaurer manuellement via le menu 'Dossiers partagés' sur la page des versions." + } } }, "steam": { diff --git a/src/main/helpers/fs.helpers.ts b/src/main/helpers/fs.helpers.ts index 4b2d0578..4dc75ebf 100644 --- a/src/main/helpers/fs.helpers.ts +++ b/src/main/helpers/fs.helpers.ts @@ -1,8 +1,9 @@ -import { copyFile, ensureDir, move, symlink } from "fs-extra"; +import { CopyOptions, copy, ensureDir, move, symlink } from "fs-extra"; import { access, mkdir, rm, readdir, unlink, lstat, readlink } from "fs/promises"; import path from "path"; import { Observable } from "rxjs"; import log from "electron-log" +import { BsmException } from "shared/models/bsm-exception.model"; export async function pathExist(path: string): Promise { try{ @@ -29,7 +30,7 @@ export async function unlinkPath(path: string): Promise{ return unlink(path); } -export async function getFoldersInFolder(folderPath: string): Promise { +export async function getFoldersInFolder(folderPath: string, opts?: {ignoreSymlinkTargetError?: boolean}): Promise { if(!(await pathExist(folderPath))){ return []; } const files = await readdir(folderPath, {withFileTypes: true}); @@ -40,7 +41,10 @@ export async function getFoldersInFolder(folderPath: string): Promise try{ const targetPath = await readlink(path.join(folderPath, file.name)); return (await lstat(targetPath)).isDirectory() ? path.join(folderPath, file.name) : undefined; - }catch(e){ + }catch(e: any){ + if(e.code === "ENOENT" && opts?.ignoreSymlinkTargetError === true && !path.extname(file.name)){ + return path.join(folderPath, file.name); + } return undefined; } }); @@ -81,23 +85,44 @@ export function moveFolderContent(src: string, dest: string): Observable { - - await ensureDir(destination); - const items = await readdir(source); +export function isSubdirectory(parent: string, child: string): boolean { + const parentNormalized = path.resolve(parent); + const childNormalized = path.resolve(child); + + if (parentNormalized === childNormalized) { return false; } + + const relativePath = path.relative(parentNormalized, childNormalized); + + if (path.parse(parentNormalized).root !== path.parse(childNormalized).root) { return false; } + + return relativePath && !relativePath.startsWith('..') && !path.isAbsolute(relativePath); +} + +export async function copyDirectoryWithJunctions(src: string, dest: string, options?: CopyOptions): Promise { + + if(isSubdirectory(src, dest)){ + throw {message: `Cannot copy directory '${src}' into itself '${dest}'.`, code: "COPY_TO_SUBPATH"} as BsmException; + } + + await ensureDir(dest); + const items = await readdir(src, { withFileTypes: true }); for (const item of items) { - const sourcePath = path.join(source, item); - const destinationPath = path.join(destination, item); - const stats = await lstat(sourcePath); - - if (stats.isDirectory()) { - await copyDirectoryWithJunctions(sourcePath, destinationPath); - } else if (stats.isFile()) { - await copyFile(sourcePath, destinationPath); - } else if (stats.isSymbolicLink()) { + const sourcePath = path.join(src, item.name); + const destinationPath = path.join(dest, item.name); + + if (item.isDirectory()) { + await copyDirectoryWithJunctions(sourcePath, destinationPath, options); + } else if (item.isFile()) { + await copy(sourcePath, destinationPath, options); + } else if (item.isSymbolicLink()) { + if(options?.overwrite){ + await unlinkPath(destinationPath); + } const symlinkTarget = await readlink(sourcePath); - await symlink(symlinkTarget, destinationPath, "junction"); + const relativePath = path.relative(src, symlinkTarget); + const newTarget = path.join(dest, relativePath); + await symlink(newTarget, destinationPath, 'junction'); } } } diff --git a/src/main/ipcs/bs-version-ipcs.ts b/src/main/ipcs/bs-version-ipcs.ts index 43637528..a04896f1 100644 --- a/src/main/ipcs/bs-version-ipcs.ts +++ b/src/main/ipcs/bs-version-ipcs.ts @@ -151,4 +151,9 @@ ipc.on("unlink-folder", async (req: IpcRequest<{ folder: string, options?: LinkO }); +ipc.on("relink-all-versions-folders", async (req: IpcRequest, reply) => { + const versionLinker = VersionFolderLinkerService.getInstance(); + reply(from(versionLinker.relinkAllVersionsFolders())); +}); + diff --git a/src/main/services/bs-local-version.service.ts b/src/main/services/bs-local-version.service.ts index d0360f8f..97f3184f 100644 --- a/src/main/services/bs-local-version.service.ts +++ b/src/main/services/bs-local-version.service.ts @@ -96,7 +96,6 @@ export class BSLocalVersionService{ public async getVersionPath(version: BSVersion): Promise{ if(version.steam){ return this.steamService.getGameFolder(BS_APP_ID, "Beat Saber") } if(version.oculus){ return this.oculusService.getGameFolder(OCULUS_BS_DIR); } - console.log("getVersionPath", this.getVersionFolder(version), version); return path.join( this.installLocationService.versionsDirectory, this.getVersionFolder(version) @@ -184,7 +183,7 @@ export class BSLocalVersionService{ } public async editVersion(version: BSVersion, name: string, color: string): Promise{ - if(version.steam || version.oculus){ throw {title: "CantEditSteam", msg: "CantEditSteam"} as BsmException; } + if(version.steam || version.oculus){ throw {title: "CantEditSteam", message: "CantEditSteam"} as BsmException; } const oldPath = await this.getVersionPath(version); const editedVersion: BSVersion = version.BSVersion === name ? {...version, name: undefined, color} @@ -205,7 +204,7 @@ export class BSLocalVersionService{ return editedVersion; }).catch((err: Error) => { log.error("edit version error", err, version, name, color); - throw {title: "CantRename", error: err} as BsmException; + throw {title: "CantRename", ...err} as BsmException; }); } @@ -228,7 +227,7 @@ export class BSLocalVersionService{ return cloneVersion; }).catch((err: Error) => { log.error("clone version error", err, version, name, color); - throw {title: "CantClone", error: err} as BsmException + throw {title: "CantClone", ...err} as BsmException }) } diff --git a/src/main/services/folder-linker.service.ts b/src/main/services/folder-linker.service.ts index c0fe772d..91512be6 100644 --- a/src/main/services/folder-linker.service.ts +++ b/src/main/services/folder-linker.service.ts @@ -3,7 +3,7 @@ import log from "electron-log"; import { deleteFolder, ensureFolderExist, moveFolderContent, pathExist, unlinkPath } from "../helpers/fs.helpers"; import { lstat, symlink } from "fs/promises"; import path from "path"; -import { copy } from "fs-extra"; +import { copy, readlink } from "fs-extra"; export class FolderLinkerService { @@ -18,12 +18,12 @@ export class FolderLinkerService { private readonly installLocationService = InstallationLocationService.getInstance(); - private readonly sharedFolder: string; - private constructor(){ this.installLocationService = InstallationLocationService.getInstance(); + } - this.sharedFolder = this.installLocationService.sharedContentPath; + private get sharedFolder(): string{ + return this.installLocationService.sharedContentPath; } private getSharedFolder(folderPath: string, intermediateFolder?: string): string { @@ -48,10 +48,15 @@ export class FolderLinkerService { public async linkFolder(folderPath: string, options?: LinkOptions): Promise { - if(await this.isFolderSymlink(folderPath)){ return; } - const sharedPath = this.getSharedFolder(folderPath, options?.intermediateFolder); + if(await this.isFolderSymlink(folderPath)){ + const isTargetedToSharedPath = await readlink(folderPath).then(target => target === sharedPath).catch(() => false); + if(isTargetedToSharedPath){ return; } + await unlinkPath(folderPath); + return symlink(sharedPath, folderPath, "junction"); + } + await ensureFolderExist(sharedPath); if(options?.backup === true){ diff --git a/src/main/services/installation-location.service.ts b/src/main/services/installation-location.service.ts index 317057ab..8e5c622e 100644 --- a/src/main/services/installation-location.service.ts +++ b/src/main/services/installation-location.service.ts @@ -1,10 +1,8 @@ import path from "path"; -import fs from 'fs-extra'; import log from "electron-log"; import { app } from "electron"; -import { BsmException } from "shared/models/bsm-exception.model"; import ElectronStore from "electron-store"; -import { ensureFolderExist, pathExist } from "../helpers/fs.helpers"; +import { copyDirectoryWithJunctions, deleteFolder, ensureFolderExist } from "../helpers/fs.helpers"; export class InstallationLocationService { @@ -48,15 +46,24 @@ export class InstallationLocationService { const oldDir = this.installationDirectory; const newDest = path.join(newDir, this.INSTALLATION_FOLDER); return new Promise(async (resolve, reject) => { - if(!(await pathExist(oldDir))){ ensureFolderExist(oldDir); } - fs.move(oldDir, newDest, { overwrite: true }).then(() => { + + await ensureFolderExist(oldDir); + + try{ + await copyDirectoryWithJunctions(oldDir, newDest, {overwrite: true}); + this._installationDirectory = newDir; this.installPathConfig.set(this.STORE_INSTALLATION_PATH_KEY, newDir); - resolve(this.installationDirectory); - }).catch((err: Error) => { - reject({title: "CantMoveFolder", error: err} as BsmException); + + deleteFolder(oldDir); + + return resolve(this.installationDirectory); + } + catch(err){ log.error(err); - }) + reject(err); + } + }); } diff --git a/src/main/services/version-folder-linker.service.ts b/src/main/services/version-folder-linker.service.ts index b540dd35..0f4f0281 100644 --- a/src/main/services/version-folder-linker.service.ts +++ b/src/main/services/version-folder-linker.service.ts @@ -43,7 +43,7 @@ export class VersionFolderLinkerService { for(const versionPath of versionPaths){ const folderPath = this.relativeToFullPath(versionPath, relativeFolder); if(folderPath === ignorePath){ continue; } - if(await this.folderLinker.isFolderSymlink(folderPath)){ console.log(folderPath); return true; } + if(await this.folderLinker.isFolderSymlink(folderPath)){ return true; } } return false; @@ -58,7 +58,7 @@ export class VersionFolderLinkerService { action.options = this.specialFolderOption(action.relativeFolder, action.options); const versionPath = await this.localVersion.getVersionPath(action.version); const folderPath = this.relativeToFullPath(versionPath, action.relativeFolder); - return this.folderLinker.linkFolder(folderPath, action.options).catch(() => false).then(() => true) + return this.folderLinker.linkFolder(folderPath, action.options).catch((err) => {console.log(err); return false}).then(() => true) } public async unlinkVersionFolder(action: VersionUnlinkFolderAction): Promise{ @@ -85,23 +85,34 @@ export class VersionFolderLinkerService { return this.folderLinker.isFolderSymlink(folderPath); } - public async getLinkedFolders(version: BSVersion, options?: { relative?: boolean }): Promise{ + public async getLinkedFolders(version: BSVersion, options?: { relative?: boolean, ignoreSymlinkTargetError?: boolean }): Promise{ const versionPath = await this.localVersion.getVersionPath(version); const [rootFolders, beatSaberDataFolders] = await Promise.all([ - getFoldersInFolder(versionPath), - getFoldersInFolder(path.join(versionPath, "Beat Saber_Data")) + getFoldersInFolder(versionPath, {ignoreSymlinkTargetError: options?.ignoreSymlinkTargetError}), + getFoldersInFolder(path.join(versionPath, "Beat Saber_Data"), {ignoreSymlinkTargetError: options?.ignoreSymlinkTargetError}) ]); - const linkedFolder = await Promise.all([...rootFolders, ...beatSaberDataFolders].map(async folder => { + const linkedFolders = await Promise.all([...rootFolders, ...beatSaberDataFolders].map(async folder => { if(!(await this.folderLinker.isFolderSymlink(folder))){ return null; } return folder; })); if(options?.relative){ - return linkedFolder.filter(folder => folder).map(folder => path.relative(versionPath, folder)); + return linkedFolders.filter(folder => folder).map(folder => path.relative(versionPath, folder)); } - return linkedFolder.filter(folder => folder); + return linkedFolders.filter(folder => folder); + } + + public async relinkAllVersionsFolders(): Promise{ + const versions = await this.localVersion.getInstalledVersions(); + + for(const version of versions){ + const linkedFolders = await this.getLinkedFolders(version, { relative: true, ignoreSymlinkTargetError: true }); + console.log(linkedFolders); + const actions = linkedFolders.map(folder => ({ type: "link", version, relativeFolder: folder } as VersionLinkFolderAction)); + await Promise.all(actions.map(action => this.doAction(action))); + } } } \ No newline at end of file diff --git a/src/renderer/components/maps-mangement-components/local-maps-list-panel.component.tsx b/src/renderer/components/maps-mangement-components/local-maps-list-panel.component.tsx index c9521b0a..92126e30 100644 --- a/src/renderer/components/maps-mangement-components/local-maps-list-panel.component.tsx +++ b/src/renderer/components/maps-mangement-components/local-maps-list-panel.component.tsx @@ -64,8 +64,6 @@ export const LocalMapsListPanel = forwardRef(({version, className, filter, searc useEffect(() => { - console.log(linked); - if(isVisible){ loadMaps(); mapsDownloader.addOnMapDownloadedListener((map, targerVersion) => { diff --git a/src/renderer/components/progress-bar/bsm-progress-bar.component.tsx b/src/renderer/components/progress-bar/bsm-progress-bar.component.tsx index d5150484..6fb231a8 100644 --- a/src/renderer/components/progress-bar/bsm-progress-bar.component.tsx +++ b/src/renderer/components/progress-bar/bsm-progress-bar.component.tsx @@ -24,7 +24,7 @@ export function BsmProgressBar() { return ( { visible && - +
{ !!progressValue && (
diff --git a/src/renderer/pages/settings-page.component.tsx b/src/renderer/pages/settings-page.component.tsx index 2647f603..6859d442 100644 --- a/src/renderer/pages/settings-page.component.tsx +++ b/src/renderer/pages/settings-page.component.tsx @@ -30,6 +30,7 @@ import { MapsManagerService } from "renderer/services/maps-manager.service"; import { PlaylistsManagerService } from "renderer/services/playlists-manager.service"; import { ModelsManagerService } from "renderer/services/models-manager.service"; import { useTranslation } from "renderer/hooks/use-translation.hook"; +import { VersionFolderLinkerService } from "renderer/services/version-folder-linker.service"; export function SettingsPage() { @@ -45,6 +46,7 @@ export function SettingsPage() { const mapsManager = MapsManagerService.getInstance(); const playlistsManager = PlaylistsManagerService.getInstance(); const modelsManager = ModelsManagerService.getInstance(); + const versionLinker = VersionFolderLinkerService.getInstance(); const {firstColor, secondColor} = useThemeColor(); @@ -102,33 +104,48 @@ export function SettingsPage() { setLanguageSelected(languagesItems.find(l => l.value === i18nService.currentLanguage).id); } - const setDefaultInstallationFolder = () => { - if(!progressBarService.require()){ return; } + const setDefaultInstallationFolder = () => { + if(!progressBarService.require()){ return; } - modalService.openModal(InstallationFolderModal).then(async res => { - if(res.exitCode !== ModalExitCode.COMPLETED){ return; } + modalService.openModal(InstallationFolderModal).then(async res => { + if(res.exitCode !== ModalExitCode.COMPLETED){ return; } - const fileChooserRes = await ipcService.sendV2<{canceled: boolean, filePaths: string[]}>("choose-folder").toPromise(); + const fileChooserRes = await ipcService.sendV2<{canceled: boolean, filePaths: string[]}>("choose-folder").toPromise(); - if(!fileChooserRes.canceled && fileChooserRes.filePaths?.length){ - progressBarService.showFake(.008); - downloaderService.setInstallationFolder(fileChooserRes.filePaths[0]).then(res => { - setTimeout(() => { - progressBarService.complete(); - setTimeout(() => progressBarService.hide(true), 1000); - }, 1000); - if(res.success){ - setInstallationFolder(res.data); - notificationService.notifySuccess({title: "notifications.settings.move-folder.success.titles.transfer-finished", duration: 3000}); - } - else{ - notificationService.notifyError({title: "notifications.settings.move-folder.errors.titles.transfer-failed"}); - } - }); - } + if(!fileChooserRes.canceled && fileChooserRes.filePaths?.length){ + progressBarService.showFake(.008); - }); - } + notificationService.notifySuccess({title: "notifications.settings.move-folder.success.titles.transfer-started", desc: "notifications.settings.move-folder.success.descs.transfer-started"}); + + downloaderService.setInstallationFolder(fileChooserRes.filePaths[0]).then(async res => { + setTimeout(() => { + progressBarService.complete(); + setTimeout(() => progressBarService.hide(true), 1000); + }, 1000); + + if(res.success){ + setInstallationFolder(res.data); + + notificationService.notifySuccess({title: "notifications.settings.move-folder.success.titles.transfer-finished", duration: 3000}); + + versionLinker.relinkAllVersionsFolders().toPromise().catch(() => { + notificationService.notifyError({title: "notifications.types.error", desc: "notifications.settings.move-folder.errors.descs.restore-linked-folders", duration: 15_000}); + }); + } + else{ + + if(res?.error?.code === "COPY_TO_SUBPATH"){ + notificationService.notifyError({title: "notifications.settings.move-folder.errors.titles.transfer-failed", desc: "notifications.settings.move-folder.errors.descs.COPY_TO_SUBPATH", duration: 10_000}); + return; + } + + notificationService.notifyError({title: "notifications.settings.move-folder.errors.titles.transfer-failed"}); + } + }); + } + + }); + } const toogleShowSupporters = () => { diff --git a/src/renderer/services/bs-version-manager.service.ts b/src/renderer/services/bs-version-manager.service.ts index 284413bf..dfc39821 100644 --- a/src/renderer/services/bs-version-manager.service.ts +++ b/src/renderer/services/bs-version-manager.service.ts @@ -84,7 +84,7 @@ export class BSVersionManagerService { if(!res.success){ this.notificationService.notifyError({ title: `notifications.custom-version.errors.titles.${res.error.title}`, - ...(res.error.msg && {desc: `notifications.custom-version.errors.msg.${res.error.msg}`}) + ...(res.error.message && {desc: `notifications.custom-version.errors.msg.${res.error.message}`}) }); return null; } @@ -104,7 +104,7 @@ export class BSVersionManagerService { if(!res.success){ this.notificationService.notifyError({ title: `notifications.custom-version.errors.titles.${res.error.title}`, - ...(res.error.msg && {desc: `notifications.custom-version.errors.msg.${res.error.msg}`}) + ...(res.error.message && {desc: `notifications.custom-version.errors.msg.${res.error.message}`}) }); return null; } diff --git a/src/renderer/services/version-folder-linker.service.ts b/src/renderer/services/version-folder-linker.service.ts index aaa2f61d..fd14fb2b 100644 --- a/src/renderer/services/version-folder-linker.service.ts +++ b/src/renderer/services/version-folder-linker.service.ts @@ -133,6 +133,10 @@ export class VersionFolderLinkerService{ return this.ipcService.sendV2("get-linked-folders", { args: { version, options } }); } + public relinkAllVersionsFolders(): Observable{ + return this.ipcService.sendV2("relink-all-versions-folders"); + } + public get currentAction$(): Observable{ return this._queue$.pipe(map(actions => actions.at(0)), distinctUntilChanged()); } diff --git a/src/shared/models/bsm-exception.model.ts b/src/shared/models/bsm-exception.model.ts index 2d251143..87e47a6f 100644 --- a/src/shared/models/bsm-exception.model.ts +++ b/src/shared/models/bsm-exception.model.ts @@ -1,5 +1,4 @@ -export interface BsmException { - title: string, - msg?: string, - error?: Error, -} \ No newline at end of file +export interface BsmException extends Readonly>{ + readonly title?: string, + readonly message?: string, +}; \ No newline at end of file