Skip to content

Commit

Permalink
Merge pull request #210 from Zagrios/bugfix/move-install-directory-fa…
Browse files Browse the repository at this point in the history
…iled/203

[bugfix-203] move install folder more resilant + handle symlinks
  • Loading branch information
Zagrios authored Apr 10, 2023
2 parents 0214a51 + 66b834b commit 962f1d8
Show file tree
Hide file tree
Showing 16 changed files with 184 additions and 82 deletions.
8 changes: 8 additions & 0 deletions assets/jsons/translations/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
}
},
Expand Down
8 changes: 8 additions & 0 deletions assets/jsons/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
}
},
Expand Down
8 changes: 8 additions & 0 deletions assets/jsons/translations/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
}
},
Expand Down
10 changes: 9 additions & 1 deletion assets/jsons/translations/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
59 changes: 42 additions & 17 deletions src/main/helpers/fs.helpers.ts
Original file line number Diff line number Diff line change
@@ -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{
Expand All @@ -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});
Expand All @@ -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;
}
});
Expand Down Expand Up @@ -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');
}
}
}
Expand Down
5 changes: 5 additions & 0 deletions src/main/ipcs/bs-version-ipcs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()));
});


7 changes: 3 additions & 4 deletions src/main/services/bs-local-version.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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}
Expand All @@ -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;
});
}

Expand All @@ -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
})
}

Expand Down
17 changes: 11 additions & 6 deletions src/main/services/folder-linker.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand All @@ -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 {
Expand All @@ -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){
Expand Down
25 changes: 16 additions & 9 deletions src/main/services/installation-location.service.ts
Original file line number Diff line number Diff line change
@@ -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 {

Expand Down Expand Up @@ -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);
}

});
}

Expand Down
27 changes: 19 additions & 8 deletions src/main/services/version-folder-linker.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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>{
Expand All @@ -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)));
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,6 @@ export const LocalMapsListPanel = forwardRef(({version, className, filter, searc

useEffect(() => {

console.log(linked);

if(isVisible){
loadMaps();
mapsDownloader.addOnMapDownloadedListener((map, targerVersion) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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">
Expand Down
Loading

0 comments on commit 962f1d8

Please sign in to comment.