diff --git a/index.html b/index.html index e122ba8..acfd01c 100644 --- a/index.html +++ b/index.html @@ -16,8 +16,8 @@ - Borg Games - Play Windows games in your browser - + Borg Demo + @@ -31,7 +31,7 @@ gtag('config', 'AW-16572194743'); - +

DEV MODE - Play in your browser

@@ -56,251 +56,33 @@

-

Factorio

- -
-
-

No nodes available - ask a friend to run one!

-

- Know a friend with a gaming PC? Ask them to run a node so you could play! -

- - -
-
- Use in-game login instead - - -
- -

- -
- -

Minecraft

- - - -
-

GOG

- -
- - - -
- -
- - -
-
- -
- - GOG.comCONNECT -
- -
- GOG.com - CONNECTED -

- DISCONNECT -

-
- -
-

Connect your GOG.com account

- -

Currently supported: -

- Divinity: Original Sin 2 - Definitive Edition -
-

-
- -

 

- -
- -
- -

 

- - Securely rent your PC out for others to play on - -

 

+

WIM uri

+ +

Run

+ + 🐞 Report an issue 🐞
- - - - - - - - - + - + @@ -331,5 +108,4 @@

© Borg Queen, LLC 2024

window.location.reload(); } - \ No newline at end of file diff --git a/ts/auth/gog.ts b/ts/auth/gog.ts deleted file mode 100644 index 069c0ea..0000000 --- a/ts/auth/gog.ts +++ /dev/null @@ -1,174 +0,0 @@ -import {SYNC} from "../onedrive.js"; -import {devMode} from "../dev.js"; - -const AUTH_ENDPOINT = devMode() - ? 'https://localhost:7173/cors/gog/' - : 'https://borg-ephemeral.azurewebsites.net/cors/gog/'; - -const TOKENS_URL = 'special/approot:/Stores/gog-tokens.json'; -const TOKENS_KEY = 'gog-tokens'; - -export async function getToken() { - let tokens = JSON.parse(localStorage.getItem(TOKENS_KEY)!); - - if (tokens === null) { - const response = await SYNC.download(TOKENS_URL); - if (response === null) { - return null; - } - tokens = await response.json(); - } - - // TODO skip refresh if token is still valid - try { - const refreshUrl = AUTH_ENDPOINT + 'refresh'; - const refresh = await fetch(refreshUrl, { - method: 'POST', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify(tokens), - }); - if (!refresh.ok) { - let error = ""; - try { - error = await refresh.text(); - } catch (e) { - } - if (refresh.status === 401 && error) - throw new Error(error); - if (error) - error = "\r\n" + error; - throw new Error(`HTTP ${refresh.status}: ${refresh.statusText} ${error}`); - } - tokens = await refresh.json(); - } catch (e) { - console.error('error refreshing token', e); - } - - localStorage.setItem(TOKENS_KEY, JSON.stringify(tokens)); - try { - await saveTokens(); - } catch (e) { - console.error('error saving tokens', e); - } - - document.body.classList.add('gog'); - document.body.classList.remove('gog-pending'); - - return tokens.access_token; -} - -export class GogAuth { - channel: RTCDataChannel; - private _messageHandler: (event: any) => Promise; - - constructor(channel: RTCDataChannel, game: string) { - this.channel = channel; - this._messageHandler = this.onMessage.bind(this); - channel.addEventListener('message', this._messageHandler); - } - - async onMessage(event: MessageEvent) { - try { - const token = await getToken(); - this.channel.send(JSON.stringify({token})); - } catch (e: any) { - console.error('error getting GOG token', e); - this.channel.send(JSON.stringify({error: e.name, message: e.message})); - } - } - - destroy() { - this.channel.removeEventListener('message', this._messageHandler); - } -} - -export async function handleLogin() { - try { - const query = new URLSearchParams(window.location.search); - if (!query.has('gog_code')) - return; - const code = query.get('gog_code')!; - query.delete('gog_code'); - window.history.replaceState({}, document.title, window.location.pathname + '?' + query.toString()); - await completeLogin(code); - } catch (e) { - console.error('error handling GOG login', e); - } -} - -async function completeLogin(code: string) { - const url = AUTH_ENDPOINT + 'code2token?code=' + encodeURIComponent(code); - const response = await fetch(url, { - method: 'POST', - }); - if (!response.ok) { - let error = ""; - try { - error = await response.text(); - } catch (e) { - } - if (response.status === 401 && error) - throw new Error(error); - if (error) - error = "\r\n" + error; - throw new Error(`HTTP ${response.status}: ${response.statusText} ${error}`); - } - const tokens = await response.json(); - localStorage.setItem(TOKENS_KEY, JSON.stringify(tokens)); - - console.log('GOG.com logged in'); - - gogLogin.style.display = 'none'; - document.body.classList.remove('gog-pending'); - document.body.classList.add('gog'); -} - -async function saveTokens() { - const tokens = localStorage.getItem(TOKENS_KEY); - const save = tokens !== null && tokens !== '' - ? await SYNC.makeRequest(TOKENS_URL + ':/content', { - method: 'PUT', - headers: {'Content-Type': 'application/json'}, - body: localStorage.getItem(TOKENS_KEY), - }) : await SYNC.makeRequest(TOKENS_URL, { - method: 'DELETE', - }); - if (!save.ok) - throw new Error(`Failed to save GOG account: HTTP ${save.status}: ${save.statusText}`); -} - -const codeInput = document.getElementById('gog-code')!; -const gogLogin = document.getElementById('gog-login-dialog')!; -codeInput.addEventListener('input', async event => { - if (codeInput.value.startsWith('https://embed.gog.com/on_login_success?')) { - const params = new URLSearchParams(codeInput.value); - const code = params.get('code'); - if (!code) - return; - - console.log('GOG code:', code); - codeInput.classList.remove('bad'); - codeInput.disabled = true; - try { - await completeLogin(code); - } catch (e) { - codeInput.classList.add('bad'); - throw e; - } finally { - codeInput.disabled = false; - } - } -}); -const disconnectGog = document.getElementById('disconnect-gog'); -disconnectGog!.addEventListener('click', async event => { - event.preventDefault(); - - if (!confirm('Disconnect GOG.com?')) - return; - - document.body.classList.remove('gog'); - document.body.classList.add('gog-pending'); - - localStorage.removeItem(TOKENS_KEY); - await saveTokens(); -}); \ No newline at end of file diff --git a/ts/auth/ms.ts b/ts/auth/ms.ts deleted file mode 100644 index 387dc8a..0000000 --- a/ts/auth/ms.ts +++ /dev/null @@ -1,95 +0,0 @@ -declare var msal: any; - -export function makeApp(clientID: string) { - const msalConfig = { - auth: { - clientId: clientID, - authority: 'https://login.microsoftonline.com/consumers', - }, - cache: { - cacheLocation: 'localStorage' - } - }; - - return new msal.PublicClientApplication(msalConfig); -} -export async function login(clientApp: any, scopes: string[], loud?: boolean, partial?: { account?: any }) { - partial = partial || {}; - loud = loud || false; - const loginRequest = { scopes }; - - const redirectResult = await clientApp.handleRedirectPromise(); - console.debug('redirectResult', redirectResult); - if (redirectResult && window.location.hash.length > 0) { - try { - const hashQuery = new URLSearchParams(window.location.hash.substring(1)); - hashQuery.delete('code'); - hashQuery.delete('client_info'); - window.history.replaceState({}, document.title, - window.location.pathname + window.location.search + '#' + hashQuery.toString()); - } catch (e){ - console.warn('Failed to parse hash', e); - } - } - - if (!clientApp.getActiveAccount()) { - const currentAccounts = clientApp.getAllAccounts(); - if (currentAccounts.length !== 0) { - if (currentAccounts.length > 1) - console.warn('More than one account detected, logging in with the first account'); - clientApp.setActiveAccount(currentAccounts[0]); - console.log('There is an active account'); - } else { - if (!loud) { - console.log('No active accounts, not logging in'); - return null; - } - console.log('No active accounts, logging in'); - try { - const loginResponse = await clientApp.loginRedirect(loginRequest); - } catch (err) { - if (err instanceof msal.InteractionRequiredAuthError) { - document.write('Sign in required: session expired'); - } else { - throw err; - } - } - const newAccounts = clientApp.getAllAccounts(); - console.log('got ' + newAccounts.length + ' accounts'); - clientApp.setActiveAccount(newAccounts[0]); - } - } else { - console.debug('using existing account'); - } - - partial.account = clientApp.getActiveAccount(); - - const cooldownKey = 'ms-login-cooldown-' + clientApp.config.auth.clientId; - - let tokenResponse; - try { - tokenResponse = await clientApp.acquireTokenSilent(loginRequest); - } catch (err) { - if (err instanceof msal.InteractionRequiredAuthError) { - if (!loud) { - console.log('interactive login required'); - return null; - } - tokenResponse = await clientApp.acquireTokenRedirect(loginRequest); - } else if (err instanceof msal.BrowserAuthError && (err).errorCode === 'monitor_window_timeout' - && +localStorage.getItem(cooldownKey)! < new Date().getTime()) { - if (!loud) { - console.log('interactive login required'); - return null; - } - localStorage.setItem(cooldownKey, String(new Date().getTime() + 90 * 1000)); - tokenResponse = await clientApp.acquireTokenRedirect(loginRequest); - } else { - throw err; - } - } - - localStorage.removeItem(cooldownKey); - - return tokenResponse.accessToken; -} \ No newline at end of file diff --git a/ts/auth/steam.ts b/ts/auth/steam.ts deleted file mode 100644 index 9734ab7..0000000 --- a/ts/auth/steam.ts +++ /dev/null @@ -1,202 +0,0 @@ -import {SYNC} from "../onedrive.js"; -import {ConduitService} from "../conduit.js"; -import {timeout} from "../../js/streaming-client/built/util.js"; -import {showLoginDialog} from "../home.js"; - -declare const QRCode: any; - -let licenseBlob: ISignedLicenseList | null = null; -let instance: Promise | null = null; - -export function login(host?: string) { - if (host === undefined) - host = window.location.host; - const hostUrl = `https://${host}`; - const hostArg = encodeURIComponent(hostUrl); - window.location.href = `https://steamcommunity.com/openid/login?openid.ns=http://specs.openid.net/auth/2.0&openid.claimed_id=http://specs.openid.net/auth/2.0/identifier_select&openid.identity=http://specs.openid.net/auth/2.0/identifier_select&openid.return_to=${hostArg}&openid.realm=${hostArg}&openid.mode=checkid_setup`; -} - -export function loginRedirected() { - const query = new URLSearchParams(window.location.search); - return query.get('openid.op_endpoint') === 'https://steamcommunity.com/openid/login'; -} - -async function getSteamTask() { - const steamTimeout = timeout(60000); - const steam = await ConduitService.connect('borg:cube:steam', { - verMin: '1.1', - verMax: '2.0' - }, steamTimeout); - if (steam) { - steam.events.subscribe('close', () => { - instance = null - }); - } - return steam; -} - -export async function getSteam() { - if (instance === null) - instance = getSteamTask(); - return await instance; -} - -export async function getSignedLicenses() { - if (licenseBlob === null) { - if (!SYNC.isLoggedIn()) - return null; - const response = await SYNC.download('special/approot:/Games/Steam.json'); - if (response === null) { - const stored = localStorage.getItem("STEAM_LICENSES"); - if (stored) { - licenseBlob = JSON.parse(stored); - await saveLicenses(licenseBlob!); - return licenseBlob; - } - return null; - } - - try { - licenseBlob = await response.json(); - } catch (e) { - console.error('corrupted Steam license list file Games/Steam.json', e); - return null; - } - } - - return licenseBlob; -} - -export async function hasLicenseToAny(appIDs: number[], packageIDs: number[]) { - const blob = await getSignedLicenses(); - if (blob === null) - return null; - let licenses = null; - try { - licenses = JSON.parse(atob(blob.LicensesUtf8)); - if (typeof licenses !== 'object') - throw new RangeError('invalid license list'); - } catch (e) { - console.error('unable to process Steam license list', e); - licenseBlob = null; - return null; - } - // AppID; PackageID - for (const license of licenses) { - if (appIDs.includes(license.AppID)) - return true; - if (packageIDs.includes(license.PackageID)) - return true; - } - return false; -} - -export async function onLogin(): Promise { - const steam = await getSteam(); - if (steam === null) { - console.warn('Steam login failed'); - return null; - } - - const openID = >{}; - - const currentUrl = new URL(window.location.href); - const searchParams = currentUrl.searchParams; - for (const key of [...searchParams.keys()]) { - if (key.startsWith("openid.")) { - console.debug(key.substring(7), searchParams.get(key)); - openID[key.substring(7)] = searchParams.get(key)!; - searchParams.delete(key); - } - } - currentUrl.search = searchParams.toString(); - window.history.replaceState({}, document.title, currentUrl.href); - - delete openID["mode"]; - - let result: ISignedLicenseList; - try { - result = await steam.call('LoginWithOpenID', openID); - } catch (e: any) { - if (e.data.type === "System.InvalidOperationException") { - alert("QR code login required."); - return null; - } - console.error(e); - return null; - } - - return await saveLicenses(result); -} - -export async function loginWithQR(challengeURL: string) { - const steam = await getSteam(); - if (steam === null) - throw new Error('Steam login failed'); - const qrElement = document.getElementById('steam-qr')!; - const steamQR = new QRCode(qrElement, 'https://borg.games'); - steamQR.makeCode(challengeURL); - let result: ILoginResponse | null = null; - while (true) { - try { - result = await steam.call('LoginWithQR', [challengeURL]); - if (result.ChallengeURL) { - challengeURL = result.ChallengeURL; - qrElement.style.opacity = "1"; - steamQR.makeCode(challengeURL); - } else { - if (!SYNC.isLoggedIn()) { - localStorage.setItem("STEAM_LICENSES", JSON.stringify(result.Licenses)); - if (!await showLoginDialog(true)) - window.location.reload(); - } - return await saveLicenses(result.Licenses!); - } - } catch (e: any) { - const err = e.data ?? e; - if (err.type === "System.Runtime.InteropServices.ExternalException") { - if (err.message.includes("TryAnotherCM")) { - challengeURL = null!; - qrElement.style.opacity = "0.5"; - continue; - } - } - - console.error(e); - return; - } - } -} - -async function saveLicenses(signedLicenseList: ISignedLicenseList): Promise { - const licenses = JSON.parse(atob(signedLicenseList.LicensesUtf8)); - - const saveResponse = await SYNC.makeRequest('special/approot:/Games/Steam.json:/content', { - method: 'PUT', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify(signedLicenseList), - }); - if (!saveResponse.ok) - console.warn('Failed to save Steam login: ', saveResponse.status, saveResponse.statusText); - else - licenseBlob = signedLicenseList; - - localStorage.removeItem("STEAM_LICENSES"); - - return licenses; -} - -export interface ISteamLicense { - AppID?: number; - PackageID?: number; -} - -interface ISignedLicenseList { - LicensesUtf8: string; - Signature: string; -} - -interface ILoginResponse { - Licenses?: ISignedLicenseList; - ChallengeURL?: string; -} \ No newline at end of file diff --git a/ts/drive-gamefs.ts b/ts/drive-gamefs.ts deleted file mode 100644 index 0af217d..0000000 --- a/ts/drive-gamefs.ts +++ /dev/null @@ -1,131 +0,0 @@ -import {MY} from "./onedrive.js"; -import {wait} from "../js/streaming-client/built/util.js"; - -export interface IRunningGame { - image?: string; - running?: boolean; -} - -export class OneDriveRunningGames { - static async launch(pc: string, gameID: string, sessionID: string, timeout: Promise) { - let cancelled = false; - timeout.catch(() => cancelled = true); - const gamePath = 'special/approot:/PCs/' + pc + '/running/' + gameID; - const sessionPath = gamePath + '/' + sessionID; - const response = await MY.makeRequest(sessionPath + '.game:/content', { - method: 'PUT', - headers: { 'Content-Type': 'text/plain' }, - body: '' + gameID - }); - - if (response.status !== 201) - throw new Error('Failed to request game launch'); - - var result: string | null = null; - let shouldCancel = () => !!result || cancelled; - let restartDelay = async (link: any) => { - await wait(5000); - return link; - }; - - const responsePath = sessionPath + '.launch'; - await MY.deltaStream(gamePath, async (candidate) => { - if (!candidate.hasOwnProperty('file')) return; - if (candidate.name !== sessionID + '.launch') return; - const launchResponse = await fetch(candidate['@microsoft.graph.downloadUrl']); - if (!launchResponse.ok) - throw new Error('Failed to fetch launch response'); - - const responseText = await launchResponse.text(); - if (responseText === 'OK') { - result = responseText; - } else { - throw new Error('Failed to launch game: ' + responseText); - } - }, restartDelay, shouldCancel); - - if (result === null && cancelled) - throw new Error('game launch cancelled'); - - return result; - } - - static async isRunning(pc: string, gameID: string, sessionID: string, timeout: Promise) { - const running = await OneDriveRunningGames.getRunning(pc, gameID, timeout); - return running && running.hasOwnProperty(sessionID); - } - - static async assertRunning(pc: string, gameID: string, sessionID: string, timeout: Promise) { - const isRunning = await OneDriveRunningGames.isRunning(pc, gameID, sessionID, timeout); - if (!isRunning) - throw new Error('Game exited'); - } - - static async getRunning(pc: string, gameID: string, timeout: Promise) { - if (gameID.indexOf('/') !== -1) - throw new Error('Invalid game ID: ' + gameID); - let cancelled = false; - timeout.catch(() => cancelled = true); - const gamePath = 'special/approot:/PCs/' + pc + '/running/' + gameID; - const response = await MY.makeRequest(gamePath - + ':/children?filter=file ne null and (endswith(name,\'.game\') or endswith(name, \'.jpg\'))'); - - if (response.status === 404) - return {}; - - if (response.status !== 200) - throw new Error('Failed to request game launch'); - - const items = await response.json(); - const result = >{}; - for (const item of items.value) { - const session: string = item.name.substring(0, item.name.lastIndexOf('.')); - if (!result.hasOwnProperty(session)) - result[session] = {}; - if (item.name.endsWith('.jpg')) - result[session].image = item['@microsoft.graph.downloadUrl']; - if (item.name.endsWith('.game')) - result[session].running = true; - } - for (const [session, info] of Object.entries(result)) { - if (!info.hasOwnProperty('running')) - delete result[session]; - } - return result; - } - - static async stop(pc: string, gameID: string, sessionID: string) { - const gamePath = 'special/approot:/PCs/' + pc + '/running/' + gameID; - const sessionPath = gamePath + '/' + sessionID; - const response = await MY.makeRequest(sessionPath + '.game', { - method: 'DELETE', - }); - - if (response.status !== 204) - throw new Error('Failed to request game stop'); - } - - static async waitForStop(pc: string, gameID: string, sessionID: string, timeout: Promise) { - const gamePath = 'special/approot:/PCs/' + pc + '/running/' + gameID; - - let cancelled = false; - timeout.catch(() => cancelled = true); - while (!cancelled) { - const response = await MY.makeRequest(gamePath - + ':/children?filter=file ne null and endswith(name,\'.exit\')'); - - if (!response.ok) - throw new Error('Failed to check exit status'); - - const items = await response.json(); - for (const item of items.value) { - if (item.name === sessionID + '.exit') - return true; - } - - await wait(500); - } - - return false; - } -} diff --git a/ts/drive-link.ts b/ts/drive-link.ts deleted file mode 100644 index 0c51977..0000000 --- a/ts/drive-link.ts +++ /dev/null @@ -1,138 +0,0 @@ -import {MY} from "./onedrive.js"; -import {ISignal} from "../js/streaming-client/built/signal.js"; -import {wait} from "../js/streaming-client/built/util.js"; -// TODO reduce traffic by using https://learn.microsoft.com/en-us/graph/query-parameters -export class OneDriveSignal implements ISignal { - stopCode: number; - candidateIndex: number; - sessionId?: string; - answer?: RTCLocalSessionDescriptionInit; - onCandidate: any; - pc: string; - onFatal: any; - - async connect(cfg: any, sessionId: string, answer: RTCLocalSessionDescriptionInit, onCandidate: any) { - if (this.answer) - throw new Error('already connected'); - this.onCandidate = onCandidate; - this.sessionId = sessionId; - this.answer = answer; - - this.submitAnswer().then(r => console.debug('answer submitted')); - - this.fetchCandidates().then(r => console.debug('candidates fetch stopped')); - } - - sessionPath() { - return 'special/approot:/PCs/' + this.pc + '/connections/' + this.sessionId; - } - - async submitAnswer() { - const response = await MY.makeRequest('special/approot:/PCs/' - + this.pc + '/connections/' + this.sessionId + '.sdp.client:/content', { - method: 'PUT', - headers: {'Content-Type': 'text/plain'}, - body: JSON.stringify(this.answer) - }); - - if (!response.ok) - throw new Error('Failed to submit answer'); - } - - async sendCandidate(candidateJSON: string) { - const response = await MY.makeRequest(this.sessionPath() + '/ice/' - + this.candidateIndex++ + '.ice.client:/content', { - method: 'PUT', - headers: {'Content-Type': 'text/plain'}, - body: candidateJSON - }); - - if (!response.ok) - throw new Error('Failed to submit ICE candidate'); - } - - async fetchCandidates() { - let shouldCancel = () => this.stopCode !== 0; - const begun_ms = performance.now(); - const seen = new Set(); - let restartDelay = async (link: any) => { - if (seen.size > 1) { - await wait(3 * 60000); - return link; - } - const elapsed_ms = performance.now() - begun_ms; - const delay = elapsed_ms < 5000 ? 1000 - : elapsed_ms < 60000 ? 5000 - : 3 * 60000; - await wait(delay); - return link; - }; - await MY.deltaStream(this.sessionPath() + '/ice', async (candidate) => { - if (!candidate.hasOwnProperty('file')) return; - if (!candidate.name.endsWith('.ice')) return; - const downloadUrl = candidate['@microsoft.graph.downloadUrl']; - if (!downloadUrl) return; - const candidateResponse = await fetch(downloadUrl); - if (!candidateResponse.ok) - console.error('Failed to fetch ICE candidate', candidateResponse.status, candidateResponse.statusText); - const jsonText = await candidateResponse.text(); - seen.add(candidate.name); - this.onCandidate(jsonText, null); - }, restartDelay, shouldCancel); - } - - static async getServerOffer(pc: string, timeout: Promise) { - let cancelled = false; - timeout.catch(() => cancelled = true); - - const offer = { - sdp: null, - session: null - }; - let shouldCancel = () => !!offer.sdp || cancelled; - let restartDelay = async (link: any) => { - await wait(1000); - return link; - }; - - const connections = 'special/approot:/PCs/' + pc + '/connections'; - let info = await (await MY.makeRequest(connections)).json(); - - await MY.deltaStream(connections, async (candidate: any) => { - if (!candidate.hasOwnProperty('file')) return; - if (!candidate.name.endsWith('.sdp')) return; - if (candidate.parentReference.id !== info.id) return; - const sdpResponse = await fetch(candidate['@microsoft.graph.downloadUrl']); - if (sdpResponse.ok) { - offer.sdp = JSON.parse(await sdpResponse.text()); - offer.session = candidate.name.substring(0, candidate.name.length - '.sdp'.length); - } - }, restartDelay, shouldCancel); - - if (offer.sdp === null && cancelled) - throw new Error('cancelled'); - - return offer; - } - - cfgDefaults(cfg: any) { - if (!cfg) cfg = {}; - return structuredClone(cfg); - } - - getAttemptId() { - console.warn('getAttemptId stub'); - return "41"; - } - - constructor(pc: string, onFatal?: any) { - this.pc = pc; - this.onFatal = onFatal; - this.candidateIndex = 0; - this.stopCode = 0; - } - - close(code: number) { - this.stopCode = code; - } -} \ No newline at end of file diff --git a/ts/drive-persistence.ts b/ts/drive-persistence.ts deleted file mode 100644 index 81aff70..0000000 --- a/ts/drive-persistence.ts +++ /dev/null @@ -1,246 +0,0 @@ -import {SYNC} from "./onedrive.js"; -const MONITOR_PREFIX = "https://api.onedrive.com/v1.0/monitor/"; -const MAX_CONTENT_SIZE = 128*1024; - -function codeResponse(channel: RTCDataChannel, prefix: string, code: number, text: string) { - channel.send(prefix + JSON.stringify({ - status: code, - body: btoa(text), - })); -} - -const forbidden = (channel: RTCDataChannel, prefix: string, text: string) => codeResponse(channel, prefix, 403, text); -const unauthorized = (channel: RTCDataChannel, prefix: string, text: string) => codeResponse(channel, prefix, 401, text); -const invalidArg = (channel: RTCDataChannel, prefix: string, text: string) => codeResponse(channel, prefix, 400, text); - -function odataError(channel: RTCDataChannel, prefix: string, httpCode: number, errorCode: string, message: string) { - channel.send(prefix + JSON.stringify({ - status: httpCode, - contentType: "application/json", - body: strToBase64(JSON.stringify({ - error: { - code: errorCode, - message - } - })), - })); -} - -const canonicalize = (id: string) => decodeURIComponent(id); - -function blobToBase64(blob: Blob) { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onloadend = () => { - const val = reader.result as string; - return resolve(val.substring(val.indexOf(',') + 1)); - }; - reader.onerror = reject; - reader.readAsDataURL(blob); - }); -} - -async function blobToObject(blob: Blob) { - const json = await blob.text(); - return JSON.parse(json); -} - -async function base64ToArrayBuffer(base64: string) { - const dataUrl = "data:application/octet-stream;base64," + base64; - const result = await fetch(dataUrl); - return await result.arrayBuffer(); -} - -function bytesToBase64(bytes: Uint8Array) { - const binString = Array.from(bytes, (x) => String.fromCodePoint(x)).join(""); - return btoa(binString); -} - -function strToBase64(str: string) { - return bytesToBase64(new TextEncoder().encode(str)); -} - -const isTypeJson = (contentType: string | null) => contentType && contentType.startsWith("application/json"); - -export class OneDrivePersistence { - items: Set; - channel: RTCDataChannel; - private _messageHandler: (event: any) => Promise; - - async forward(event: MessageEvent) { - const channel = event.target; - const jsonStart = event.data.indexOf('{'); - const prefix = event.data.substring(0, jsonStart); - const json = event.data.substring(jsonStart); - const request = JSON.parse(json); - try { - if (request.options?.body) - request.options.body = await base64ToArrayBuffer(request.options.body); - const method = request.options?.method?.trim().toUpperCase() ?? "GET"; - let u = new URL(request.uri); - console.debug(method, request.uri); - let call; - switch (u.protocol) { - case "bcd:": - call = parseRequest(u.pathname, method); - break; - case "https:": - if (request.uri.startsWith(MONITOR_PREFIX)) - call = new Request(request.uri, true); - else - return unauthorized(channel, prefix, "globalAccess"); - break; - default: - return invalidArg(channel, prefix, "protocol"); - } - if (call?.drive && !this.allowed(call.drive) || call?.item && !this.allowed(call.item)) - return forbidden(channel, prefix, "drive or item"); - - const response = await SYNC.makeRequest(call.url, request.options); - const location = response.headers.get('Location'); - const contentType = response.headers.get('Content-Type'); - const body = await response.blob(); - - if (body.size > MAX_CONTENT_SIZE) { - console.error(request.uri, "content too large", body.size); - return odataError(channel, prefix, 502, "responseTooLarge", - `The ${body.size} bytes response body is too large. The maximum size is ${MAX_CONTENT_SIZE} bytes.`); - } - - if (response.ok && call.returnItems) - await this.updateMapper(location, isTypeJson(contentType) ? body : null); - - const result = { - status: response.status, - body: await blobToBase64(body), - location, - contentType, - }; - channel.send(prefix + JSON.stringify(result)); - console.debug(method, request.uri, response.status) - } catch (e) { - console.error(request.uri, e); - channel.send(prefix + JSON.stringify({ - status: 502, - contentType: "application/json", - body: strToBase64(JSON.stringify(e)), - })); - } - } - - async updateMapper(location: string | null, bytes: Blob | null) { - if (location) { - console.error("Unexpected location", location); - } - try { - if (bytes === null) - return; - const object = await blobToObject(bytes); - - if ("id" in object) - this.addItem(object.id); - if ("resourceId" in object) - this.addItem(object.resourceId); - if ("value" in object) - for (const item of object.value) - if ("id" in item) - this.addItem(item.id); - } catch (e) { - console.warn("Failed to parse response", e); - } - } - - addItem(id: string) { - const canonical = canonicalize(id); - this.items.add(canonical); - } - - allowed(id: string) { - const canonical = canonicalize(id); - return this.items.has(canonical); - } - - constructor(channel: RTCDataChannel, allowed?: string[]) { - this.channel = channel; - this.items = new Set(); - this._messageHandler = this.forward.bind(this); - if (allowed) { - for (const id of allowed) - this.addItem(id); - } - channel.addEventListener('message', this._messageHandler); - } - - destroy() { - this.channel.removeEventListener('message', this._messageHandler); - } -} - -function parseRequest(path: string, method: string) { - const parts = path.substring(1).split('/'); - if (parts[0] !== "") - throw new RangeError("Invalid path"); - - let url = parts.slice(3).join("/"); - - if (parts.length === 3 && parts[1] === "me" && parts[2] === "drive") - return new Request("", true); - - if (parts.length < 5 || parts[1] !== "drives" || parts[3] !== "items") - throw new RangeError("Invalid path"); - let [, , drive, , item] = parts; - let request = new Request(url, false); - request.drive = drive; - request.item = item; - - if (parts.length === 5) { - request.returnItems = true; - return request; - } - - if (parts.length === 6) { - switch (parts[5]) { - case "children": - case "copy": - request.returnItems = true; - return request; - case "content": - case "createUploadSession": - return request; - default: - throw new RangeError("Invalid path"); - } - } - - if (parts.length === 7) { - if (!request.item.endsWith(":")) - throw new RangeError("Invalid path"); - request.item = request.item.substring(0, request.item.length - 1); - let nameColon = parts[5]; - if (!nameColon.endsWith(":")) - throw new RangeError("Invalid path"); - switch (parts[6]) { - case "content": - request.returnItems = method.toUpperCase() === "PUT"; - return request; - case "createUploadSession": - return request; - default: - throw new RangeError("Invalid path"); - } - } - - throw new RangeError("Invalid path"); -} - -class Request { - url: string; - returnItems: boolean; - drive?: string; - item?: string; - - constructor(url: string, returnItems: boolean) { - this.url = url; - this.returnItems = returnItems; - } -} \ No newline at end of file diff --git a/ts/games/factorio.ts b/ts/games/factorio.ts deleted file mode 100644 index a1454d0..0000000 --- a/ts/games/factorio.ts +++ /dev/null @@ -1,107 +0,0 @@ -import * as Steam from "../auth/steam.js"; - -import {SYNC} from "../onedrive.js"; - -import {devMode} from "../dev.js"; -import {promiseOr} from "../util.js"; - -export const REPRESENTATIVE_PACKAGE_IDS = [88199]; -export const APP_ID = 427520; -export const LOCAL_DATA = "Games/Factorio"; -const LOCAL_DATA_URL = `special/approot:/${LOCAL_DATA}`; -const PLAYER_DATA_URL = LOCAL_DATA_URL + "/player-data.json"; -const playFull = document.getElementById('factorio'); -const loginContainer = document.getElementById('factorio-login-container')!; - -async function getPlayerData() { - const response = await SYNC.download(PLAYER_DATA_URL); - if (response === null) - return {}; - return await response.json(); -} - -async function credsMissing() { - try { - var player = await getPlayerData(); - } catch (e) { - console.error('loginRequired', e); - return true; - } - const user = player["service-username"]; - const token = player["service-token"]; - const required = !user || !token; - if (!required) - localStorage.removeItem('factorio-creds'); - return required; -} - -export async function loginRequired() { - const creds = credsMissing().then(has => !has); - const steam = Steam.hasLicenseToAny([APP_ID], REPRESENTATIVE_PACKAGE_IDS); - return !await promiseOr([creds, steam]); -} - -let loginCheck: Promise | null = null; - -const playFactorio = document.getElementById('factorio-play')!; -playFactorio.addEventListener('click', expand); - -export async function expand() { - playFactorio.style.display = 'none'; - document.getElementById('factorio-login')!.style.display = 'inline-block'; - const needsLogin = await checkLogin(); - if (needsLogin) { - const steam = await Steam.getSteam(); - console.log('conduit connected. querying about Steam...'); - try { - const result: any = await steam!.call('LoginWithQR', [null]); - if (await Steam.loginWithQR(result.ChallengeURL)) - if (await checkLogin()) - alert("You don't have Factorio on Steam, login with username and password instead"); - } catch (e) { - console.log('unable to initiate Steam QR login: ', e); - } - } -} - -playFactorio.addEventListener('mouseenter', checkLogin); - -async function checkLogin() { - if (loginCheck) - return await loginCheck; - - Steam.getSteam(); - - loginCheck = (async () => { - const needsLogin = await loginRequired(); - playFull.disabled = needsLogin && modeSwitch.dataset['mode'] === 'steam'; - console.log('Factorio needs login', needsLogin); - loginContainer.classList.toggle('needs-login', needsLogin); - return needsLogin; - })(); - const result = await loginCheck; - loginCheck = null; - return result; -} - -const modeSwitch = document.getElementById('mode-switch')!; -modeSwitch.addEventListener('click', e => switchLoginMode(e)); -function switchLoginMode(e: MouseEvent) { - e?.preventDefault(); - - const mode = modeSwitch.dataset['mode'] === 'steam' ? 'password' : 'steam'; - modeSwitch.dataset['mode'] = mode; - modeSwitch.innerText = mode === 'steam' - ? 'Use in-game login instead' - : 'Login with Steam instead'; - - for(const mode of document.querySelectorAll('.login-mode')) { - mode.classList.toggle('selected'); - } - - if (mode === 'steam') { - checkLogin(); - } else { - playFull.disabled = false; - } -} diff --git a/ts/games/mc.ts b/ts/games/mc.ts deleted file mode 100644 index 70d5049..0000000 --- a/ts/games/mc.ts +++ /dev/null @@ -1,85 +0,0 @@ -import {SYNC} from "../onedrive.js"; - -const AUTH_ENDPOINT = 'https://borg-ephemeral.azurewebsites.net/cors/minecraft/'; -export const LOCAL_DATA = "Games/Minecraft"; -const LOCAL_DATA_URL = `special/approot:/${LOCAL_DATA}`; -const CREDS_URL = LOCAL_DATA_URL + "/cml-creds.json"; - -export interface IMinecraftLoginInit { - code: string; - location: string; -} - -export async function beginLogin(): Promise { - const response = await fetch(AUTH_ENDPOINT + 'login', {method: 'POST'}); - const code = await response.text(); - const location = response.headers.get('Location')!; - return {code, location}; -} - -export async function completeLogin(code: string, signal: AbortSignal | undefined) { - const completionUrl = AUTH_ENDPOINT + 'await/' + encodeURIComponent(code); - const completion = await fetch(completionUrl, {method: 'POST', signal}); - if (!completion.ok) { - let error = ""; - try { - error = await completion.text(); - } catch (e) {} - if (completion.status === 401 && error) - throw new Error(error); - if (error) - error = "\r\n" + error; - throw new Error(`HTTP ${completion.status}: ${completion.statusText} ${error}`); - } - const session = await completion.json(); - const save = await SYNC.makeRequest(CREDS_URL + ':/content', { - method: 'PUT', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify(session), - }); - if (!save.ok) - throw new Error(`Failed to save MC account: HTTP ${save.status}: ${save.statusText}`); - - console.log('Minecraft logged in'); -} - -export async function loginRequired() { - try { - var creds = await getCreds(); - if (creds === null) - return true; - } catch (e) { - console.error('MC loginRequired', e); - return true; - } - - try { - const profile = await getProfile(creds.accessToken); - console.log('MC profile', profile); - return false; - } catch (e) { - console.error('MC loginRequired', e); - return true; - } -} - -async function getProfile(accessToken: string) { - const profileUrl = AUTH_ENDPOINT + 'check/' + encodeURIComponent(accessToken); - const profile = await fetch(profileUrl, {method: 'POST'}); - if (profile.status === 401) - return new Error('Unauthorized'); - return await profile.json(); -} - -export async function getCreds() { - const response = await SYNC.download(CREDS_URL); - if (response === null) - return null; - - try { - return await response.json(); - } catch (e) { - console.error('mc-creds', e); - return null; - } -} \ No newline at end of file diff --git a/ts/home.ts b/ts/home.ts index a07ba3b..96c9009 100644 --- a/ts/home.ts +++ b/ts/home.ts @@ -1,19 +1,13 @@ import * as util from "../js/streaming-client/built/util.js"; -import * as GOG from "./auth/gog.js"; -import * as Factorio from './games/factorio.js'; -import * as Minecraft from "./games/mc.js"; import * as Msg from '../js/streaming-client/built/msg.js'; -import * as Steam from "./auth/steam.js"; import {Client, IExitEvent} from '../js/streaming-client/built/client.js'; import {ClientAPI} from "./client-api.js"; import {Ephemeral, IBorgNode, INodeFilter} from "./ephemeral.js"; -import {OneDrivePersistence} from "./drive-persistence.js"; import {Session} from "./session.js"; import {getNetworkStatistics} from "./connectivity-check.js"; import {devMode} from "./dev.js"; -import {SYNC} from "./onedrive.js"; import {notify} from "./notifications.js"; import {configureInput} from "./borg-input.js"; @@ -30,50 +24,16 @@ let controlChannel: RTCDataChannel | null = null; const resume = document.getElementById('video-resume')!; resume.onclick = () => video.play(); -const mcLoginDialog = document.getElementById('mc-login-dialog')!; -let mcLoginAbort = 'AbortController' in window ? new AbortController() : null; -const modeSwitch = document.getElementById('mode-switch'); -const inviteButtons = document.querySelectorAll('button.invite'); -const inviteText = 'Join Borg P2P Cloud Gaming network to play remotely or rent your PC out.' + - ' You will need to install the Borg software on a gaming PC under Windows Pro.' + - ' You can download the Borg node software from the Microsoft Store.'; -const invite = { - title: 'Setup Borg node', - text: inviteText, - uri: 'https://borg.games/setup', -}; -const emailInvite = "mailto:" - + "?subject=" + encodeURIComponent('Invite: Join Borg P2P Cloud Gaming') - + "&body=" + encodeURIComponent(inviteText - + '\n\nDownload Borg app from Microsoft Store: https://www.microsoft.com/store/apps/9NTDRRR4814S' - + '\n\nSetup instructions: https://borg.games/setup' - ); - export class Home { static async init() { const networkText = document.getElementById('network')!; networkText.innerText = NETWORK || ''; - const steamLogin = document.getElementById('steam-login')!; - steamLogin.addEventListener('click', () => Steam.login()); const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); if (isSafari) setInterval(safariHack, 3000); - let loginPromise = Home.login(); videoBitrate.addEventListener('input', changeBitrate); - for (const button of inviteButtons) { - button.addEventListener('click', () => { - if (navigator.canShare && navigator.canShare(invite)) { - navigator.share(invite) - .then(() => console.log('Share was successful.')) - .catch((error) => console.log('Sharing failed', error)); - } else { - window.open(emailInvite); - } - }) - } - function changeBitrate() { const value = +videoBitrate.value; const short = value < 4 ? "low" @@ -89,61 +49,6 @@ export class Home { videoBitrate.value = String(parseInt(localStorage.getItem('encoder_bitrate')!) || 2); changeBitrate(); - - const loginButton = document.getElementById('loginButton'); - loginButton.addEventListener('click', () => { - Home.login(true); - }); - let loggedIn = await loginPromise; - if (!loggedIn && SYNC.account) - loggedIn = await Home.login(true); - - loginButton.disabled = loggedIn; - - if (Steam.loginRedirected()) - await handleSteamLogin(); - - // updates .gog-pending/.gog - if (loggedIn) { - await GOG.handleLogin(); - await GOG.getToken(); - } - } - - static async login(loud?: boolean) { - let token; - try { - token = await SYNC.login(loud); - } catch (e) { - console.error(e); - } - if (token) - await Home.showStorage(); - else if (!SYNC.account || loud) - Home.showLogin(); - - return !!token; - } - - static async showStorage() { - const response = await SYNC.makeRequest(''); - if (!response.ok) { - console.error(response); - Home.showLogin(); - return; - } - const items = await response.json(); - const progress = document.getElementById('space')!; - progress.max = items.quota.total; - progress.value = items.quota.used; - const GB = 1024 * 1024 * 1024; - progress.innerText = progress.title = `${Math.round(items.quota.used / GB)} GB / ${Math.round(items.quota.total / GB)} GB`; - document.body.classList.add('sync'); - document.body.classList.remove('sync-pending'); - } - - static showLogin() { - document.body.classList.remove('sync-pending'); } static runClient(nodes: IBorgNode[], persistenceID: string | null | undefined, @@ -168,15 +73,12 @@ export class Home { const offer = nodes[i]; let stall = 0; let stall_reset = 0; - let auth: GOG.GogAuth | null = null; //set up client object with an event callback: gets connect, status, chat, and shutter events const client = new Client(clientApi, signalFactory, videoContainer, (event) => { console.log('EVENT', i, event); switch (event.type) { case 'exit': - if (auth !== null) - auth.destroy(); const exitCode = (event as IExitEvent).code; document.removeEventListener('keydown', hotkeys, true); if (exitCode !== Client.StopCodes.CONCURRENT_SESSION) @@ -240,31 +142,21 @@ export class Home { if (client.exited()) break; const launch = { - Launch: "borg:games/" + config.game, - PersistenceRoot: SYNC.isLoggedIn() ? persistenceID : undefined, - SteamLicenses: SYNC.isLoggedIn() ? await Steam.getSignedLicenses() : undefined, - GogToken: SYNC.isLoggedIn() ? await GOG.getToken() : undefined, - Cml: SYNC.isLoggedIn() ? await Minecraft.getCreds() : undefined, + Launch: "borg:demo/" + config.game, + PersistenceRoot: undefined, + SteamLicenses: undefined, + GogToken: undefined, + Cml: undefined, }; channel.send("\x15" + JSON.stringify(launch)); await Session.waitForCommandRequest(channel); controlChannel = channel; break; case 'persistence': - if (SYNC.isLoggedIn() && persistenceID) { - const persistence = new OneDrivePersistence(channel, [persistenceID]); - console.log('persistence enabled'); - } else { - console.warn('persistence not available'); - } + console.warn('persistence not available'); break; case 'auth': - if (SYNC.isLoggedIn()) { - auth = new GOG.GogAuth(channel, config.game); - console.log('auth enabled'); - } else { - console.warn('auth not available'); - } + console.warn('auth not available'); break; } }); @@ -320,12 +212,7 @@ export class Home { if (!config.sessionId) config.sessionId = crypto.randomUUID(); - if ((config.game === 'factorio' || config.game === 'minecraft') && !SYNC.isLoggedIn()) { - if (!await showLoginDialog()) - return; - } - - const uri = new URL('borg:games/' + config.game); + const uri = new URL('borg:demo/' + config.game); const gameName = config.game === 'minecraft' || config.game.startsWith("minecraft?") ? 'Minecraft' : 'Factorio'; @@ -333,27 +220,6 @@ export class Home { document.body.classList.add('video'); let persistenceID: string | undefined = undefined; - if (SYNC.isLoggedIn()) - persistenceID = await ensureSyncFolders(uri); - - switch (config.game) { - case 'factorio': - break; - case 'minecraft': - if (await Minecraft.loginRequired()) { - const login = await Minecraft.beginLogin(); - showMinecraftLogin(login); - try { - await Minecraft.completeLogin(login.code, mcLoginAbort?.signal); - } catch (e) { - if (mcLoginAbort !== null && e instanceof DOMException && e.name === 'AbortError') - return; - } finally { - mcLoginDialog.style.display = 'none'; - } - } - break; - } if (uri.searchParams.get('trial') === '1') notify('Trial mode: 5 minutes', 30000); @@ -390,122 +256,6 @@ export class Home { } } -async function handleSteamLogin() { - if (!SYNC.isLoggedIn()) - if (!await showLoginDialog()) - return; - const licenses = await Steam.onLogin(); - if (licenses === null) { - alert("Steam login failed."); - return; - } - const factorioLicense = licenses.find(l => l.AppID === Factorio.APP_ID || Factorio.REPRESENTATIVE_PACKAGE_IDS.includes(l.PackageID!)); - Factorio.expand(); - if (!factorioLicense) - alert("Factorio license not found."); -} - -export async function showLoginDialog(disableCancel?: boolean) { - const dialog = document.getElementById('login-dialog')!; - const cancel = document.getElementById('cancelLogin')!; - cancel.style.display = !!disableCancel ? 'none' : 'inline-block'; - dialog.style.display = 'flex'; - const promise = new Promise(async (resolve) => { - const doLogin = async () => { - try { - resolve(await Home.login(true)); - } catch (e) { - resolve(false); - } - }; - // if (SYNC.account) - // await doLogin(); - document.getElementById('onedriveLogin')!.onclick = doLogin; - cancel.onclick = () => resolve(false); - }); - try { - return await promise; - } finally { - dialog.style.display = 'none'; - } -} - -function showMinecraftLogin(login: Minecraft.IMinecraftLoginInit) { - document.getElementById('mc-code')!.innerText = login.code; - const loginLink = document.getElementById('mc-login-link'); - loginLink.href = login.location; - mcLoginDialog.style.display = 'flex'; -} - -export async function abortMinecraftLogin() { - if (mcLoginAbort !== null) { - mcLoginAbort.abort(); - mcLoginAbort = new AbortController(); - } - - await util.wait(1000); -} - -async function ensureSyncFolders(game: URL): Promise { - let gamePathParts = game.pathname.split('/').slice(1); - if (gamePathParts.length === 0) - throw new Error('Invalid game path'); - - let gameDir = gamePathParts[0]; - let platform = null; - - if (gamePathParts.length === 1) { - if (gameDir == 'minecraft') { - gameDir = 'Minecraft'; - // workaround for https://github.com/BorgGames/Drone/issues/20 - await SYNC.makeRequest('special/approot:/Games/Minecraft/saves', { - method: 'PUT', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({folder: {}}) - }); - await SYNC.makeRequest('special/approot:/Games/Minecraft/saves/.keep:/content', { - method: 'PUT', - headers: {'Content-Type': 'text/plain'}, - body: 'https://github.com/BorgGames/Drone/issues/21' - }); - } - else if (gameDir == 'factorio') - gameDir = 'Factorio'; - } else { - switch (gameDir) { - case 'gog': case 'GOG': - gameDir = 'GOG/' + gamePathParts[1]; - platform = 'GOG'; - break; - default: - throw new Error('Invalid game path'); - } - } - - let url = 'special/approot:/Games/' + gameDir; - let response = await SYNC.makeRequest(url, { - method: 'PUT', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({folder: {}}) - }); - - if (response.status === 409) - response = await SYNC.makeRequest(url); - - if (!response.ok) - throw new Error(`Failed to create Sync folder: HTTP ${response.status}: ${response.statusText}`); - - await SYNC.makeRequest(url + '/.keep:/content', { - method: 'PUT', - headers: {'Content-Type': 'text/plain'}, - body: 'https://github.com/BorgGames/Drone/issues/21' - }); - - const item = await response.json(); - - return item.id; -} - function safariHack() { if (!controlChannel) return; diff --git a/ts/launcher.ts b/ts/launcher.ts deleted file mode 100644 index d70450b..0000000 --- a/ts/launcher.ts +++ /dev/null @@ -1,175 +0,0 @@ -import {OneDriveRunningGames as GameFS} from "./drive-gamefs.js"; -import * as util from "../js/streaming-client/built/util.js"; -import {GameID} from "./gid.js"; - -const gameList = document.getElementById('game-list')!; -const details = document.getElementById('game-details')!; -const titleUI = document.getElementById('game-title')!; -const launchButton = document.getElementById('launch-button')!; -const gamePC = document.getElementById('game-pc'); -const runningUI = document.getElementById('game-running')!; -const supportStatus = document.getElementById('support-status')!; - -interface IMyGame { - title: string; - offers: any[]; -} - -export class Launcher { - static selectedGame: IMyGame | null; - static games: {[uri: string]: IMyGame}; - static launch: (pc: string, exe: string, session?: string) => void; - - static async selectGame(uri: string | null) { - if (uri === null) { - details.style.display = 'none'; - Launcher.selectedGame = null; - return; - } - - supportStatus.style.display = "borg://exe/factorio" === uri ? "none" : ""; - details.style.display = 'block'; - const game = Launcher.games[uri]; - titleUI.innerText = game.title; - while (gamePC.options.length > 0) { - gamePC.options[0].remove(); - } - gamePC.removeEventListener('change', pcChanged); - - const running = document.createElement('div'); - runningUI.innerHTML = ''; - runningUI.appendChild(running); - - let preferred = localStorage.getItem('lastGamePC-' + uri); - for (const offer of game.offers) { - const option = document.createElement("option"); - option.value = offer.pc; - option.dataset.offer = JSON.stringify(offer); - option.innerText = offer.pc; - option.defaultSelected = offer.pc === preferred; - gamePC.appendChild(option); - const exe = GameID.tryGetExe(offer.Uri); - if (offer.Support === "OK") { - supportStatus.style.display = "none"; - } - if (exe === null) { - console.warn("No executable found in game URI", offer.Uri); - continue; - } - loadRunning(running, offer.pc, exe); - } - gamePC.disabled = game.offers.length < 2; - gamePC.addEventListener('change', pcChanged); - - Launcher.selectedGame = game; - } - - static initialize(games: {[uri: string]: IMyGame}) { - Launcher.games = games; - gameList.addEventListener('change', gameSelected); - launchButton.addEventListener('click', launchRequested) - } -} - -async function loadRunning(to: Node, pc: string, exe: string) { - const timeout = util.timeout(3000); - const instances = Object.entries(await GameFS.getRunning(pc, exe, timeout)); - - if (instances.length === 0) - return; - - const pcUI = document.createElement('div'); - const head = document.createElement('h4'); - head.innerText = pc; - pcUI.appendChild(head); - for (const [session, info] of instances) { - const container = document.createElement('fieldset'); - container.className = "game-connect"; - - const ui = document.createElement('button'); - ui.title = "Click to connect"; - ui.className = "connect"; - ui.type = "button"; - ui.dataset.exe = exe; - ui.dataset.session = session; - ui.dataset.pc = pc; - ui.addEventListener('click', connectRequested); - - const thumbnail = document.createElement('img'); - thumbnail.alt = 'Session ' + session; - thumbnail.className = 'game-stream-thumbnail'; - const placeholder = "img/placeholder.png"; - if (info.hasOwnProperty('image')) { - thumbnail.src = info.image!; - thumbnail.onerror = () => { - if (thumbnail.src !== placeholder) - thumbnail.src = "img/placeholder.png"; - }; - } else - thumbnail.src = placeholder; - ui.appendChild(thumbnail); - - const stop = document.createElement('button'); - stop.innerText = '✖'; - stop.className = 'stop'; - stop.title = 'Stop'; - stop.type = 'button'; - Object.assign(stop.dataset, ui.dataset); - stop.addEventListener('click', stopRequested); - ui.appendChild(stop); - - container.appendChild(ui); - - pcUI.appendChild(container); - } - to.appendChild(pcUI); -} - -function gameSelected(e: Event) { - Launcher.selectGame(gameList.value); -} - -function pcChanged(e: Event) { - let offer = JSON.parse(gamePC.options[gamePC.selectedIndex].dataset.offer!); - localStorage.setItem('lastGamePC-' + offer.Uri, offer.pc); -} - -function connectRequested(e: MouseEvent) { - e.preventDefault(); - const button = e.currentTarget; - Launcher.launch(button.dataset.pc!, button.dataset.exe!, button.dataset.session); -} - -function launchRequested(e: MouseEvent) { - let offer = JSON.parse(gamePC.options[gamePC.selectedIndex].dataset.offer!); - const exe = GameID.tryGetExe(offer.Uri); - if (exe === null) { - alert("No executable found in game URI"); - return; - } - Launcher.launch(offer.pc, exe); -} - -async function stopRequested(e: Event) { - e.preventDefault(); - e.stopImmediatePropagation(); - const stop = e.target; - const exe = stop.dataset.exe; - const session = stop.dataset.session; - const pc = stop.dataset.pc; - const node = stop.closest('fieldset')!; - node.disabled = true; - - try { - await GameFS.stop(pc!, exe!, session!); - try{ - await GameFS.waitForStop(pc!, exe!, session!, util.timeout(60000)); - } catch (e){ - console.error(e); - } - node.remove(); - } catch (e) { - node.disabled = false; - throw e; - } -} diff --git a/ts/my.ts b/ts/my.ts deleted file mode 100644 index d695949..0000000 --- a/ts/my.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { MY as OneDrive } from "./onedrive.js"; -import { GameID } from "./gid.js"; - -interface IMyGame { - title: string; - offers: any[]; -} - -export class Games { - static games: Record = {}; -} -export async function getGames(pc: string, list: HTMLSelectElement) { - let json = null; - try { - json = await OneDrive.download(`special/approot:/PCs/${pc}.games.json`); - } catch (e) { - console.warn('Failed to fetch game list from ' + pc, e); - return; - } - if (json === null) - return; - const items = await json.json(); - for (const item of items) { - item.pc = pc; - const exe = GameID.tryGetExe(item.Uri); - if (exe === null) { - console.warn("Unsupported uri: " + item.Uri); - continue; - } - if (Games.games.hasOwnProperty(item.Uri)) { - Games.games[item.Uri].offers.push(item); - continue; - } - Games.games[item.Uri] = { - offers: [item], - title: item.Title, - }; - - const gameItem = document.createElement('option'); - gameItem.innerText = item.Title; - gameItem.value = item.Uri; - list.size = list.size + 1; - list.appendChild(gameItem); - } -} - diff --git a/ts/onedrive.ts b/ts/onedrive.ts deleted file mode 100644 index 8d09abc..0000000 --- a/ts/onedrive.ts +++ /dev/null @@ -1,140 +0,0 @@ -import * as ms from './auth/ms.js'; -import {wait} from "../js/streaming-client/built/util.js"; - -const driveUrl = 'https://graph.microsoft.com/v1.0/me/drive/'; -const scopes = ['user.read', 'files.readwrite.appfolder']; - -export class OneDrive { - clientID; - auth; - accessToken = null; - account: any; - - constructor(clientID: string) { - this.clientID = clientID; - this.auth = ms.makeApp(clientID); - } - - async makeRequest(url: string, options?: RequestInit) { - if (!this.accessToken) throw new Error(NOT_LOGGED_IN); - - try { - this.accessToken = (await this.auth.acquireTokenSilent({ scopes })).accessToken; - } catch (e) { - console.warn('Failed to update token silently'); - } - - if (!options) options = {}; - options.headers = new Headers(options.headers); - options.headers.set('Authorization', 'Bearer ' + this.accessToken); - if (!url.startsWith('https://')) - url = driveUrl + url; - let triedLogin = false; - while (true) { - let result = await fetch(url, options); - if (result.ok) return result; - switch (result.status) { - case 503: case 429: - const defaultDelayS = Math.random() * 0.3 + 0.3; - const retryS = parseInt(result.headers.get('Retry-After')!) || defaultDelayS; - console.warn('Retry after, sec: ', retryS); - await wait(retryS * 1000); - continue; - case 401: - if (triedLogin) - return result; - triedLogin = true; - await this.login(); - continue; - default: - return result; - } - } - } - - async download(url: string) { - // workaround for https://github.com/microsoftgraph/msgraph-sdk-javascript/issues/388 - if (url.endsWith(':/content')) - throw new RangeError(); - const response = await this.makeRequest(url + '?select=id,@microsoft.graph.downloadUrl'); - if (response.status === 404) - return null; - if (!response.ok) - throw new Error('Failed to download ' + url + ': ' + response.status + ': ' + response.statusText); - const item = await response.json(); - const realUrl = item['@microsoft.graph.downloadUrl']; - return await fetch(realUrl); - } - - async login(loud?: boolean) { - const partial = { account: null }; - const token = await ms.login(this.auth, scopes, loud, partial); - this.account = partial.account; - if (!token) return false; - this.accessToken = token; - console.log('Logged in'); - return true; - } - - async ensureBorgTag() { - const ensureAppFolder = await this.makeRequest('special/approot', {}); - console.log('ensure: ', ensureAppFolder); - - const exists = await this.makeRequest('special/approot:/' + this.clientID, {}); - if (exists.status === 404 || (await exists.json()).size !== 73) { - const response = await this.makeRequest('special/approot:/' + this.clientID + ':/content', { - method: 'PUT', - headers: {'Content-Type': 'text/plain'}, - body: `${crypto.randomUUID()}+${crypto.randomUUID()}` - }); - - if (response.status !== 201) - throw new Error(`Failed to create tag file: HTTP ${response.status}: ${response.statusText}`); - - console.log('PUT Borg tag: ', response); - } else { - console.log('Borg tag already exists: ', exists); - } - } - - async deltaStream(resource: string, - handler: (item: any) => Promise, - restartDelay: (v: string) => Promise, - shouldCancel: () => boolean) { - let link = resource + ':/delta'; - while (!shouldCancel()) { - const response = await this.makeRequest(link); - - if (!response.ok) { - if (response.status === 404) { - link = await restartDelay(link); - continue; - } - console.error('delta stream error', response.status, response.statusText); - throw new Error('delta stream HTTP ' + response.status + ': ' + response.statusText); - } - - const delta = await response.json(); - for (const item of delta.value) { - await handler(item); - } - - if (delta.hasOwnProperty('@odata.deltaLink')) { - link = await restartDelay(delta['@odata.deltaLink']); - } else if (delta.hasOwnProperty('@odata.nextLink')) { - link = delta['@odata.nextLink']; - } else { - console.warn('No delta link found'); - return; - } - } - } - - isLoggedIn() { - return !!this.accessToken; - } -} - -export const NOT_LOGGED_IN = "Not logged in"; -export const SYNC = new OneDrive('4c1b168d-3889-494d-a1ea-1a95c3ecda51'); -export const MY = new OneDrive('c516d4c8-2391-481d-a098-b66382079a38'); \ No newline at end of file