From 3afeeef848e505a18704cd855a093c0122d0c671 Mon Sep 17 00:00:00 2001 From: MathieuG-P <40181755+Zagrios@users.noreply.github.com> Date: Mon, 2 Oct 2023 13:04:28 +0200 Subject: [PATCH] [feature-274] the script for download and verify oculus game works --- package-lock.json | 34 ++- package.json | 2 + src/main/helpers/fs.helpers.ts | 13 +- src/main/models/oculus-downloader.class.ts | 199 ++++++++++++++++++ .../services/bs-oculus-downloader.service.ts | 20 +- .../models/exceptions/custom-error.class.ts | 25 +++ 6 files changed, 282 insertions(+), 11 deletions(-) create mode 100644 src/main/models/oculus-downloader.class.ts create mode 100644 src/shared/models/exceptions/custom-error.class.ts diff --git a/package-lock.json b/package-lock.json index 087e4a2a..28273ab2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@electron/fuses": "^1.6.2", "@node-steam/vdf": "^2.2.0", "@tippyjs/react": "^4.2.6", + "@types/pako": "^2.0.1", "archiver": "^6.0.1", "color": "^4.2.3", "dateformat": "^5.0.3", @@ -28,6 +29,7 @@ "node-abi": "^3.47.0", "node-fetch": "^2.6.7", "node-stream-zip": "^1.15.0", + "pako": "^2.1.0", "ps-list": "^7.2.0", "qrcode.react": "^3.1.0", "react": "^18.2.0", @@ -2744,6 +2746,11 @@ "node": ">= 6" } }, + "node_modules/@types/pako": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.1.tgz", + "integrity": "sha512-fXhui1fHdLrUR0KEyQsBzqdi3Z+MitnRcpI2eeFJyzaRdqO2miX/BDz2Hh0VdkBbrWprgcQ+ItFmbdKYdbMjvg==" + }, "node_modules/@types/parse-json": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", @@ -12486,6 +12493,11 @@ "setimmediate": "^1.0.5" } }, + "node_modules/jszip/node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" + }, "node_modules/jszip/node_modules/readable-stream": { "version": "2.3.7", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", @@ -14101,9 +14113,9 @@ } }, "node_modules/pako": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", - "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==" }, "node_modules/param-case": { "version": "3.0.4", @@ -21298,6 +21310,11 @@ } } }, + "@types/pako": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.1.tgz", + "integrity": "sha512-fXhui1fHdLrUR0KEyQsBzqdi3Z+MitnRcpI2eeFJyzaRdqO2miX/BDz2Hh0VdkBbrWprgcQ+ItFmbdKYdbMjvg==" + }, "@types/parse-json": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", @@ -28750,6 +28767,11 @@ "setimmediate": "^1.0.5" }, "dependencies": { + "pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" + }, "readable-stream": { "version": "2.3.7", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", @@ -29967,9 +29989,9 @@ "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==" }, "pako": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", - "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==" }, "param-case": { "version": "3.0.4", diff --git a/package.json b/package.json index 79f20ef5..c3dfcfd9 100644 --- a/package.json +++ b/package.json @@ -232,6 +232,7 @@ "@electron/fuses": "^1.6.2", "@node-steam/vdf": "^2.2.0", "@tippyjs/react": "^4.2.6", + "@types/pako": "^2.0.1", "archiver": "^6.0.1", "color": "^4.2.3", "dateformat": "^5.0.3", @@ -249,6 +250,7 @@ "node-abi": "^3.47.0", "node-fetch": "^2.6.7", "node-stream-zip": "^1.15.0", + "pako": "^2.1.0", "ps-list": "^7.2.0", "qrcode.react": "^3.1.0", "react": "^18.2.0", diff --git a/src/main/helpers/fs.helpers.ts b/src/main/helpers/fs.helpers.ts index 832b05c0..abb0324b 100644 --- a/src/main/helpers/fs.helpers.ts +++ b/src/main/helpers/fs.helpers.ts @@ -1,9 +1,10 @@ -import { CopyOptions, copy, ensureDir, move, symlink } from "fs-extra"; +import { CopyOptions, copy, createReadStream, 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"; +import crypto from "crypto"; export async function pathExist(path: string): Promise { try { @@ -153,6 +154,16 @@ export async function copyDirectoryWithJunctions(src: string, dest: string, opti } } +export function hashFile(filePath: string, algorithm = "sha256"): Promise { + return new Promise((resolve, reject) => { + const shasum = crypto.createHash(algorithm); + const stream = createReadStream(filePath); + stream.on("data", data => shasum.update(data)); + stream.on("error", reject); + stream.on("close", () => resolve(shasum.digest("hex"))); + }); +} + export interface Progression { total: number; current: number; diff --git a/src/main/models/oculus-downloader.class.ts b/src/main/models/oculus-downloader.class.ts new file mode 100644 index 00000000..9b1ed208 --- /dev/null +++ b/src/main/models/oculus-downloader.class.ts @@ -0,0 +1,199 @@ +import JSZip from "jszip"; +import fetch from "node-fetch"; +import { CustomError } from "../../shared/models/exceptions/custom-error.class"; +import { mkdirs, createWriteStream, pathExists } from "fs-extra"; +import path from "path"; +import { inflate } from "pako" +import { Observable, lastValueFrom } from "rxjs"; +import { Progression, hashFile } from "../helpers/fs.helpers"; + +export class OculusDownloader { + + private options: OculusDownloaderOptions; + private isDownloading: boolean; + + private getDownloadManifestUrl(token: string, binaryId: string): string { + return `https://securecdn.oculus.com/binaries/download/?id=${binaryId}&access_token=${token}&get_manifest=1`; + } + + private getDownloadSegmentUrl(token: string, binaryId: string, segmentSha256: string): string { + return `https://securecdn.oculus.com/binaries/segment/?access_token=${token}&binary_id=${binaryId}&segment_sha256=${segmentSha256}`; + } + + private async downloadManifestZip(manifestUrl: string): Promise { + const response = await fetch(manifestUrl); + const arrBuffer = await response.arrayBuffer(); + return JSZip.loadAsync(arrBuffer); + } + + private async getManifest(): Promise { + const downloadUrl = this.getDownloadManifestUrl(this.options.accessToken, this.options.binaryId); + const manifestZip = await this.downloadManifestZip(downloadUrl).catch(err => CustomError.throw(err, "DOWNLOAD_MANIFEST_ZIP_FAILED")); + const manifestFile = manifestZip.file("manifest.json"); + + if(!manifestFile){ + throw new CustomError("Manifest file not found", "MANIFEST_FILE_NOT_FOUND"); + } + + return manifestFile.async("text").then(JSON.parse).catch(err => CustomError.throw(err, "PARSE_MANIFEST_FILE_FAILED")); + } + + private async downloadManifestFile(file: OculusManifestFile, destination: string): Promise { + + const downloadSegment = async (segment: OculusManifestFileSegment): Promise => { + const segmentUrl = this.getDownloadSegmentUrl(this.options.accessToken, this.options.binaryId, segment[1]); + const response = await fetch(segmentUrl); + return response.arrayBuffer(); + } + + await mkdirs(path.dirname(destination)); + const writeStream = createWriteStream(destination); + + for (const segment of file.segments) { + const arrBuffer = await downloadSegment(segment); + await writeStream.write(inflate(arrBuffer)); + } + + writeStream.close(); + + return file; + } + + private verifyIntegrity(manifest: OculusManifest, folder: string): Observable> { + return new Observable>(sub => { + + const files = Object.entries(manifest.files); + const progress: Progression = { current: 0, total: files.length, data: [] }; + const wrongFiles: OculusManifestFile[] = []; + let canceled = false; + + (async () => { + + for(const [fileName, file] of files){ + + if(canceled){ return; } + + const destination = path.join(folder, fileName); + + if(!pathExists(destination)){ + wrongFiles.push(file); + progress.current++; + sub.next(progress); + continue; + } + + const fileSha256 = await hashFile(destination, "sha256"); + + if(fileSha256 !== file.sha256){ + wrongFiles.push(file); + } + + progress.current++; + sub.next(progress); + } + + progress.data = wrongFiles; + sub.next(progress); + + })().then(() => sub.complete()).catch(err => sub.error(err)); + + return () => { + canceled = true; + } + }); + } + + public downloadApp(options: OculusDownloaderOptions): Observable{ + + return new Observable(subscriber => { + + if(this.isDownloading){ + throw new CustomError("Already downloading", "ALREADY_DOWNLOADING"); + } + + this.options = options; + this.isDownloading = true; + + const progress: Progression = { current: 0, total: 0 }; + + (async () => { + + const manifest = await this.getManifest().catch(err => CustomError.throw(err, "UNABLE_TO_GET_MANIFEST")); + const files = Object.entries(manifest.files); + + progress.total = files.reduce((acc, file) => { return acc + file[1].size }, 0) + subscriber.next(progress); + + for(const file of files){ + + if(this.isDownloading === false){ return; } + + const destination = path.join("C:", "test", "test", file[0]); + await this.downloadManifestFile(file[1], destination).catch(err => CustomError.throw(err, "DOWNLOAD_FILE_FAILED")); + progress.current += file[1].size; + subscriber.next(progress); + } + + const integrity = await lastValueFrom(this.verifyIntegrity(manifest, path.join("C:", "test", "test"))).catch(err => CustomError.throw(err, "VERIFY_INTEGRITY_FAILED")); + + if(integrity.data.length > 0){ + throw new CustomError("Some files failed to download", "SOME_FILES_FAILED_TO_DOWNLOAD", integrity.data); + } + + })().then(() => subscriber.complete()).catch(err => subscriber.error(err)); + + return () => { + this.isDownloading = false; + } + }); + } + + public async verifyApp(options: OculusDownloaderOptions){ + throw new CustomError("Not implemented", "NOT_IMPLEMENTED"); + } + + public stopDownload(){ + this.isDownloading = false; + } + +} + +interface OculusDownloaderOptions { + binaryId: Readonly; + accessToken: Readonly; + destination: Readonly; + logger?: Readonly; +} + +interface OculusManifest { + appId: Readonly; + canonicalName: Readonly; + isCore: Readonly; + packageType: Readonly; + launchFile: Readonly; + launchParameters: Readonly; + launchFile2D: Readonly; + launchParameters2D: Readonly; + version: Readonly; + versionCode: Readonly; + redistributables: Readonly; + files: Readonly>; + firewallExceptionsRequired: Readonly; + parentCanonicalName: Readonly; + manifestVersion: Readonly; +}; + +interface OculusManifestFile { + sha256: Readonly; + size: number; + segmentSize: number; + segments: OculusManifestFileSegment[]; +} + +type OculusManifestFileSegment = [number, string, number]; + +interface Logger { + info: (...args: unknown[]) => void; + warn: (...args: unknown[]) => void; + error: (...args: unknown[]) => void; +} \ No newline at end of file diff --git a/src/main/services/bs-oculus-downloader.service.ts b/src/main/services/bs-oculus-downloader.service.ts index d44efc2e..ff0638e4 100644 --- a/src/main/services/bs-oculus-downloader.service.ts +++ b/src/main/services/bs-oculus-downloader.service.ts @@ -2,6 +2,8 @@ import { BSVersion } from "../../shared/bs-version.interface"; import { WindowManagerService } from "./window-manager.service"; import { minToMs } from "../../shared/helpers/time.helpers"; import log from "electron-log"; +import { CustomError } from "../../shared/models/exceptions/custom-error.class"; +import { OculusDownloader } from "../models/oculus-downloader.class"; export class BsOculusDownloaderService { @@ -17,8 +19,12 @@ export class BsOculusDownloaderService { private readonly windows: WindowManagerService; + private readonly oculusDownloader: OculusDownloader; + private constructor() { this.windows = WindowManagerService.getInstance(); + + this.oculusDownloader = new OculusDownloader(); } private isUserTokenValid(token: string): boolean{ @@ -61,7 +67,7 @@ export class BsOculusDownloaderService { const promise = new Promise((resolve, reject) => { timout = setTimeout(() => { - reject(new Error("Trying to get Oculus user token timed out")); + reject(new CustomError("Trying to get Oculus user token timed out", "OCULUS_LOGIN_TIMED_OUT")); window.close(); }, minToMs(5)); @@ -78,7 +84,7 @@ export class BsOculusDownloaderService { }); window.on("closed", () => { - reject(new Error("Oculus login window closed by user")); + reject(new CustomError("Oculus login window closed by user", "OCULUS_LOGIN_WINDOW_CLOSED_BY_USER")); }); }).finally(() => { if(!window.isDestroyed() && window.isClosable()){ @@ -90,8 +96,14 @@ export class BsOculusDownloaderService { return promise; } - public downloadVersion(version: BSVersion){ - return this.getUserToken(); + public async downloadVersion(version: BSVersion){ + const token = await this.getUserToken(); + console.log(token); + const s = this.oculusDownloader.downloadApp({ accessToken: token, binaryId: "5074476459318759", destination: "" }).subscribe({ + next: a => console.log(a), + error: a => console.error(a), + complete: () => console.log("complete") + }); } } \ No newline at end of file diff --git a/src/shared/models/exceptions/custom-error.class.ts b/src/shared/models/exceptions/custom-error.class.ts new file mode 100644 index 00000000..bdc18c8a --- /dev/null +++ b/src/shared/models/exceptions/custom-error.class.ts @@ -0,0 +1,25 @@ +export class CustomError extends Error { + + private readonly _code: string; + private readonly _data?: unknown; + + constructor(message: string, code: string, data?: unknown){ + super(message); + this._code = code; + this._data = data; + } + + public get code(): string { return this._code; } + public get data(): unknown { return this._data; } + + public static fromError(error: Error, code: string, data?: unknown): CustomError{ + const customError = new CustomError(error.message ?? error.toString(), code, data); + customError.stack = error.stack; + return customError; + } + + public static throw(error: Error, code: string, data?: unknown): never{ + throw CustomError.fromError(error, code, data); + } + +} \ No newline at end of file