[bugfix-203] move install folder more resilant + handle symlinks

This commit is contained in:
MathieuG-P
2023-04-10 23:23:02 +02:00
parent 0214a518ee
commit 66b834bd31
16 changed files with 180 additions and 78 deletions
+8
View File
@@ -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."
}
}
},
+8
View File
@@ -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."
}
}
},
+8
View File
@@ -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."
}
}
},
+9 -1
View File
@@ -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": {
+42 -17
View File
@@ -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<boolean> {
try{
@@ -29,7 +30,7 @@ export async function unlinkPath(path: string): Promise<void>{
return unlink(path);
}
export async function getFoldersInFolder(folderPath: string): Promise<string[]> {
export async function getFoldersInFolder(folderPath: string, opts?: {ignoreSymlinkTargetError?: boolean}): Promise<string[]> {
if(!(await pathExist(folderPath))){ return []; }
const files = await readdir(folderPath, {withFileTypes: true});
@@ -40,7 +41,10 @@ export async function getFoldersInFolder(folderPath: string): Promise<string[]>
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<Progres
});
}
export async function copyDirectoryWithJunctions(source: string, destination: string): Promise<void> {
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<void> {
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');
}
}
}
+5
View File
@@ -151,4 +151,9 @@ ipc.on("unlink-folder", async (req: IpcRequest<{ folder: string, options?: LinkO
});
ipc.on("relink-all-versions-folders", async (req: IpcRequest<void>, reply) => {
const versionLinker = VersionFolderLinkerService.getInstance();
reply(from(versionLinker.relinkAllVersionsFolders()));
});
@@ -96,7 +96,6 @@ export class BSLocalVersionService{
public async getVersionPath(version: BSVersion): Promise<string>{
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<BSVersion>{
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
})
}
+11 -6
View File
@@ -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<void> {
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){
@@ -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<string>(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);
}
});
}
@@ -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<boolean>{
@@ -85,23 +85,34 @@ export class VersionFolderLinkerService {
return this.folderLinker.isFolderSymlink(folderPath);
}
public async getLinkedFolders(version: BSVersion, options?: { relative?: boolean }): Promise<string[]>{
public async getLinkedFolders(version: BSVersion, options?: { relative?: boolean, ignoreSymlinkTargetError?: boolean }): Promise<string[]>{
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<void>{
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)));
}
}
}
@@ -64,8 +64,6 @@ export const LocalMapsListPanel = forwardRef(({version, className, filter, searc
useEffect(() => {
console.log(linked);
if(isVisible){
loadMaps();
mapsDownloader.addOnMapDownloadedListener((map, targerVersion) => {
@@ -24,7 +24,7 @@ export function BsmProgressBar() {
return (
<AnimatePresence> { visible &&
<motion.div initial={{y: "120%"}} animate={{y:"0%"}} exit={{y:"120%"}} className="w-full absolute h-14 flex justify-center items-center bottom-2 pointer-events-none" style={style}>
<motion.div initial={{y: "120%"}} animate={{y:"0%"}} exit={{y:"120%"}} className="w-full absolute h-14 flex justify-center items-center bottom-2 pointer-events-none z-10" style={style}>
<div className={`flex items-center content-center justify-center bottom-9 z-10 rounded-full bg-light-main-color-2 dark:bg-main-color-2 shadow-center shadow-black transition-all duration-300 ${!progressValue && "h-14 w-14 "} ${!!progressValue && "h-5 w-3/4 p-[6px]"}`}>
{ !!progressValue && (
<div className="relative h-full w-full rounded-full bg-black">
+40 -23
View File
@@ -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 = () => {
@@ -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;
}
@@ -133,6 +133,10 @@ export class VersionFolderLinkerService{
return this.ipcService.sendV2("get-linked-folders", { args: { version, options } });
}
public relinkAllVersionsFolders(): Observable<void>{
return this.ipcService.sendV2("relink-all-versions-folders");
}
public get currentAction$(): Observable<VersionLinkerAction>{
return this._queue$.pipe(map(actions => actions.at(0)), distinctUntilChanged());
}
+4 -5
View File
@@ -1,5 +1,4 @@
export interface BsmException {
title: string,
msg?: string,
error?: Error,
}
export interface BsmException extends Readonly<Omit<NodeJS.ErrnoException, "message">>{
readonly title?: string,
readonly message?: string,
};