diff --git a/localtypings/pxtarget.d.ts b/localtypings/pxtarget.d.ts index bfc8141d9c7a..d405e8754369 100644 --- a/localtypings/pxtarget.d.ts +++ b/localtypings/pxtarget.d.ts @@ -525,6 +525,7 @@ declare namespace pxt { timeMachineDiffInterval?: number; // An interval in milliseconds at which to take diffs to store in project history. Defaults to 5 minutes timeMachineSnapshotInterval?: number; // An interval in milliseconds at which to take full project snapshots in project history. Defaults to 15 minutes adjustBlockContrast?: boolean; // If set to true, all block colors will automatically be adjusted to have a contrast ratio of 4.5 with text + ipcIdentityProxy?: boolean; // for use with the in game minecraft experience only. If true, proxies all identity API requests through the ipc channel feedbackEnabled?: boolean; // allow feedback to be shown on a target ocvAppId?: number; // the app id needed to attach to the OCV service ocvFrameUrl?: string; // the base url for the OCV service diff --git a/localtypings/pxteditor.d.ts b/localtypings/pxteditor.d.ts index 0d031970141a..3cb7bf2f9483 100644 --- a/localtypings/pxteditor.d.ts +++ b/localtypings/pxteditor.d.ts @@ -104,6 +104,7 @@ declare namespace pxt.editor { | "serviceworkerregistered" | "runeval" | "precachetutorial" + | "cloudproxy" // package extension messasges | ExtInitializeType @@ -1158,7 +1159,7 @@ declare namespace pxt.editor { perfMeasurementThresholdMs?: number; onPerfMilestone?: (payload: { milestone: string, time: number, params?: Map }) => void; onPerfMeasurement?: (payload: { name: string, start: number, duration: number, params?: Map }) => void; - + // Used with the @tutorialCompleted macro. See docs/writing-docs/tutorials.md for more info onTutorialCompleted?: () => void; onMarkdownActivityLoad?: (path: string, title?: string, editorProjectName?: string) => Promise; @@ -1381,6 +1382,92 @@ declare namespace pxt.editor { } type AssetEditorEvent = AssetEditorRequestSaveEvent | AssetEditorReadyEvent; + + type CloudProject = { + id: string; + shareId?: string; + header: string; + text: string; + version: string; + + // minecraft specific + driveItemId?: string; + }; + + interface BaseCloudProxyRequest extends EditorMessageRequest { + action: "cloudproxy"; + operation: string; + response: true; + } + + interface CloudProxyUserRequest extends BaseCloudProxyRequest { + operation: "user"; + } + + interface CloudProxyListRequest extends BaseCloudProxyRequest { + operation: "list"; + headerIds?: string[]; + } + + interface CloudProxyGetRequest extends BaseCloudProxyRequest { + operation: "get"; + headerId: string; + } + + interface CloudProxySetRequest extends BaseCloudProxyRequest { + operation: "set"; + project: CloudProject; + } + + interface CloudProxyDeleteRequest extends BaseCloudProxyRequest { + operation: "delete"; + headerId: string; + } + + type CloudProxyRequest = + | CloudProxyUserRequest + | CloudProxyListRequest + | CloudProxyGetRequest + | CloudProxySetRequest + | CloudProxyDeleteRequest; + + + interface BaseCloudProxyResponse extends EditorMessageResponse { + action: "cloudproxy"; + operation: string; + resp: pxt.auth.ApiResult; + } + + interface CloudProxyUserResponse extends BaseCloudProxyResponse { + operation: "user"; + } + + interface CloudProxyListResponse extends BaseCloudProxyResponse { + operation: "list"; + resp: pxt.auth.ApiResult; + } + + interface CloudProxyGetResponse extends BaseCloudProxyResponse { + operation: "get"; + resp: pxt.auth.ApiResult; + } + + interface CloudProxySetResponse extends BaseCloudProxyResponse { + operation: "set"; + resp: pxt.auth.ApiResult; + } + + interface CloudProxyDeleteResponse extends BaseCloudProxyResponse { + operation: "delete"; + resp: pxt.auth.ApiResult; + } + + type CloudProxyResponse = + | CloudProxyUserResponse + | CloudProxyListResponse + | CloudProxyGetResponse + | CloudProxySetResponse + | CloudProxyDeleteResponse; } declare namespace pxt.workspace { diff --git a/pxtlib/auth.ts b/pxtlib/auth.ts index 234f281ee2c7..01eaf2993e16 100644 --- a/pxtlib/auth.ts +++ b/pxtlib/auth.ts @@ -116,6 +116,10 @@ namespace pxt.auth { return await setLocalStorageValueAsync(CSRF_TOKEN_KEY, token); } export async function hasAuthTokenAsync(): Promise { + if (proxyIdentityThroughIPC()) { + cachedHasAuthToken = true; + return true; + } return !!(await getAuthTokenAsync()); } async function delAuthTokenAsync(): Promise { @@ -748,9 +752,16 @@ namespace pxt.auth { } export function hasIdentity(): boolean { + if (proxyIdentityThroughIPC()) { + return true; + } return !authDisabled && !pxt.BrowserUtils.isPxtElectron() && identityProviders().length > 0; } + export function proxyIdentityThroughIPC(): boolean { + return pxt.appTarget.appTheme.ipcIdentityProxy; + } + function idpEnabled(idp: pxt.IdentityProviderId): boolean { return identityProviders().filter(prov => prov.id === idp).length > 0; } diff --git a/webapp/public/cloudframe.html b/webapp/public/cloudframe.html new file mode 100644 index 000000000000..0e91a7ba18af --- /dev/null +++ b/webapp/public/cloudframe.html @@ -0,0 +1,249 @@ + + + + + + + + + + + + +
+

+    
+ + + + diff --git a/webapp/src/app.tsx b/webapp/src/app.tsx index 5e7e6d40e92c..570236dffdc3 100644 --- a/webapp/src/app.tsx +++ b/webapp/src/app.tsx @@ -83,6 +83,7 @@ import { Tour } from "./components/onboarding/Tour"; import { parseTourStepsAsync } from "./onboarding"; import { initGitHubDb } from "./idbworkspace"; import { BlockDefinition, CategoryNameID } from "./toolbox"; +import { MinecraftAuthClient } from "./minecraftAuthClient"; import { Feedback} from "../../react-common/components/controls/Feedback/Feedback"; pxt.blocks.requirePxtBlockly = () => pxtblockly as any; @@ -6012,6 +6013,10 @@ document.addEventListener("DOMContentLoaded", async () => { initGitHubDb(); + if (pxt.auth.proxyIdentityThroughIPC()) { + auth.overrideAuthClient(() => new MinecraftAuthClient()); + } + // DO NOT put any async code before this line! The serviceworker must be initialized before // the window load event fires appcache.init(() => theEditor.reloadEditor()); diff --git a/webapp/src/auth.ts b/webapp/src/auth.ts index b735f55f2288..9417d3234b1c 100644 --- a/webapp/src/auth.ts +++ b/webapp/src/auth.ts @@ -2,6 +2,7 @@ import * as core from "./core"; import * as data from "./data"; import * as cloud from "./cloud"; import * as workspace from "./workspace"; +import { MinecraftAuthClient } from "./minecraftAuthClient"; /** * Virtual API keys @@ -35,7 +36,7 @@ export class Component extends data.Component { } } -class AuthClient extends pxt.auth.AuthClient { +export class AuthClient extends pxt.auth.AuthClient { protected async onSignedIn(): Promise { const state = await pxt.auth.getUserStateAsync(); core.infoNotification(lf("Signed in: {0}", pxt.auth.userName(state.profile))); @@ -151,12 +152,13 @@ function initVirtualApi() { } let authClientPromise: Promise; +let authClientFactory = () => new AuthClient(); async function clientAsync(): Promise { if (!pxt.auth.hasIdentity()) { return undefined; } if (authClientPromise) return authClientPromise; authClientPromise = new Promise(async (resolve, reject) => { - const cli = new AuthClient(); + const cli = authClientFactory(); await cli.initAsync(); await cli.authCheckAsync(); await cli.initialUserPreferencesAsync(); @@ -165,6 +167,10 @@ async function clientAsync(): Promise { return authClientPromise; } +export function overrideAuthClient(factory: () => AuthClient) { + authClientFactory = factory; +} + export function hasIdentity(): boolean { return pxt.auth.hasIdentity(); } diff --git a/webapp/src/headerbar.tsx b/webapp/src/headerbar.tsx index d873f3d8e90c..5ca7ef056c35 100644 --- a/webapp/src/headerbar.tsx +++ b/webapp/src/headerbar.tsx @@ -239,7 +239,7 @@ export class HeaderBar extends data.Component { const { home, header, tutorialOptions } = this.props.parent.state; const isController = pxt.shell.isControllerMode(); const isNativeHost = cmds.isNativeHost(); - const hasIdentity = auth.hasIdentity(); + const hasIdentity = auth.hasIdentity() && !pxt.auth.proxyIdentityThroughIPC(); const activeEditor = this.props.parent.isPythonActive() ? "Python" : (this.props.parent.isJavaScriptActive() ? "JavaScript" : "Blocks"); diff --git a/webapp/src/minecraftAuthClient.ts b/webapp/src/minecraftAuthClient.ts new file mode 100644 index 000000000000..b8d9cc39bac7 --- /dev/null +++ b/webapp/src/minecraftAuthClient.ts @@ -0,0 +1,420 @@ +import { AuthClient } from "./auth"; + +interface IPCRenderer { + on(event: "responseFromApp", handler: (event: any, message: string) => void): void; + sendToHost(messageType: "sendToApp", message: IPCMessage): void; +} + +interface IPCHeader { + requestId: string; + messagePurpose: "cloud"; + version: 1; +} + +interface IPCMessage { + header: IPCHeader; + body: + | CloudProxyUserRequest + | CloudProxyListRequest + | CloudProxyGetRequest + | CloudProxySetRequest + | CloudProxyDeleteRequest; +} + +interface IPCResponse { + header: IPCHeader; + body: + | CloudProxyUserResponse + | CloudProxyListResponse + | CloudProxyGetResponse + | CloudProxySetResponse + | CloudProxyDeleteResponse; +} + +/*************************** + * Requests * + ***************************/ + +interface CloudProxyUserRequest { + operation: "user"; +} + +interface CloudProxyListRequest { + operation: "list"; + headerIds?: string[]; +} + +interface CloudProxyGetRequest { + operation: "get"; + headerId: string; +} + +interface CloudProxySetRequest { + operation: "set"; + project: pxt.editor.CloudProject; +} + +interface CloudProxyDeleteRequest { + operation: "delete"; + headerId: string; +} + +/*************************** + * Responses * + ***************************/ + +interface CloudProxyUserResponse { + operation: "user"; + status: number; + resp: string; // a unique string identifying the user +} + +interface CloudProxyListResponse { + operation: "list"; + status: number; + resp: pxt.editor.CloudProject[]; +} + +interface CloudProxyGetResponse { + operation: "get"; + status: number; + resp: pxt.editor.CloudProject; +} + +interface CloudProxySetResponse { + operation: "set"; + status: number; + resp: string; +} + +interface CloudProxyDeleteResponse { + operation: "delete"; + status: number; + resp: undefined; +} + +interface DriveDBEntry { + id: string; + driveItemId: string; +} + +const DRIVE_ID_DB_NAME = "__minecraft_userdriveid" +const DRIVE_ID_TABLE = "userdrive"; +const DRIVE_ID_KEYPATH = "id"; + +export class MinecraftAuthClient extends AuthClient { + protected pendingMessages: pxt.Map<(response: pxt.editor.CloudProxyResponse) => void> = {}; + protected preferences: pxt.auth.UserPreferences = {}; + protected ipc?: IPCRenderer; + + constructor() { + super(); + this.init(); + } + + async apiAsync(url: string, data?: any, method?: string, authToken?: string): Promise> { + try { + return this.apiAsyncCore(url, data, method, authToken); + } + catch (e) { + return { + success: false, + statusCode: 500, + resp: undefined, + err: e + }; + } + } + + protected async apiAsyncCore(url: string, data?: any, method?: string, authToken?: string): Promise> { + const match = /((?:\/[^\?\/]+)*)(\?.*)?/.exec(url); + + if (!match) { + throw new Error("Bad API format"); + } + + const path = match[1]; + const query = match[2]; + + if (!method) { + if (data) { + method = "POST"; + } + else { + method = "GET"; + } + } + + if (path === "/api/user") { + if (method === "DELETE") { + return ( + { + success: true, + statusCode: 200, + resp: undefined, + err: undefined + } + ); + } + } + else if (path === "/api/user/profile") { + return this.userAsync() as Promise>; + } + else if (path === "/api/user/preferences") { + if (method === "POST") { + this.preferences = { + ...this.preferences, + ...data + }; + } + return ( + { + success: true, + statusCode: 200, + resp: {...this.preferences} as T, + err: undefined + } + ); + } + else if (path === "/api/auth/login") { + return ( + { + success: false, + statusCode: 500, + resp: undefined, + err: "Not supported" + } + ); + } + else if (path === "/api/auth/logout") { + return ( + { + success: true, + statusCode: 200, + resp: {} as T, + err: undefined + } + ); + } + else if (path === "/api/user/project") { + if (method === "GET") { + // LIST + let headerIds: string[]; + + if (query) { + const parsed = new URLSearchParams(query); + const list = parsed.get("projectIds"); + if (list) { + headerIds = list.split(","); + } + } + + return this.listAsync(headerIds) as Promise>; + } + else { + // SET + return this.setAsync(data) as Promise>; + } + } + else if (path === "/api/user/project/share") { + // TODO + } + else if (path.startsWith("/api/user/project/")) { + const headerId = path.substring(18); + + if (method === "GET") { + return this.getAsync(headerId) as Promise>; + } + } + + return ( + { + success: false, + statusCode: 500, + resp: undefined, + err: "Not supported" + } + ); + } + + protected postMessageAsync(message: Partial): Promise { + if (this.ipc) { + return this.postMessageToIPC(message); + } + else { + return this.postMessageToParentFrame(message); + } + } + + protected postMessageToIPC(message: Partial): Promise { + return new Promise((resolve, reject) => { + const requestId = "cloudproxy-" + crypto.randomUUID(); + const toSend: IPCMessage = { + header: { + requestId: requestId, + messagePurpose: "cloud", + version: 1 + }, + body: message as pxt.editor.CloudProxyRequest + } + + this.pendingMessages[requestId] = resolve as any; + + this.ipc.sendToHost("sendToApp", toSend); + }) + } + + protected postMessageToParentFrame(message: Partial): Promise { + return new Promise((resolve, reject) => { + if (window.parent === window) { + reject("No IPC renderer and not embeded in iframe"); + return; + } + const toPost = { + ...message, + type: "pxthost", + action: "cloudproxy", + reponse: true, + id: "cloudproxy-" + crypto.randomUUID() + }; + + this.pendingMessages[toPost.id] = resolve as any; + + // TODO: send over ipc channel + window.parent.postMessage(toPost, "*"); + }); + } + + protected init() { + this.ipc = (window as any).ipcRenderer; + + if (!this.ipc) { + pxt.warn("No ipcRenderer detected. Using iframe cloud proxy instead"); + this.initIFrame(); + return; + } + + this.ipc.on("responseFromApp", (_, message) => { + const parsed = pxt.U.jsonTryParse(message) as IPCResponse; + + if (!parsed) return; + + if (parsed.header.messagePurpose !== "cloud") return; + + let resp = parsed.body.resp as any; + if (parsed.body.operation === "user") { + resp = { id: parsed.body.resp } + } + + const response: pxt.editor.CloudProxyResponse = { + type: "pxteditor", + action: "cloudproxy", + id: parsed.header.requestId, + operation: parsed.body.operation, + success: parsed.body.status === 200, + resp: { + statusCode: parsed.body.status, + success: parsed.body.status === 200, + resp: resp, + err: undefined + } + }; + + this.handleResponse(response); + }); + } + + protected initIFrame() { + window.addEventListener("message", ev => { + const message = ev.data; + + if (message.action === "cloudproxy") { + this.handleResponse(message); + } + }); + } + + protected handleResponse(response: pxt.editor.CloudProxyResponse) { + if (this.pendingMessages[response.id]) { + this.pendingMessages[response.id](response); + delete this.pendingMessages[response.id]; + } + } + + protected async listAsync(headerIds?: string[]): Promise> { + const resp = await this.postMessageAsync({ + operation: "list", + headerIds + }); + + if (resp.resp.success) { + for (const project of resp.resp.resp) { + await this.updateProjectDriveId(project); + } + } + + return resp.resp; + } + + protected async getAsync(headerId: string): Promise> { + const resp = await this.postMessageAsync({ + operation: "get", + headerId + }); + + if (resp.resp.success) { + await this.updateProjectDriveId(resp.resp.resp); + } + + return resp.resp; + } + + protected async setAsync(project: pxt.editor.CloudProject): Promise> { + const id = await this.lookupProjectDriveId(project.id); + project.driveItemId = id; + + const resp = await this.postMessageAsync({ + operation: "set", + project + }); + + return resp.resp; + } + + protected async userAsync(): Promise> { + const resp = await this.postMessageAsync({ + operation: "user", + }); + + return resp.resp; + } + + protected async updateProjectDriveId(project: pxt.editor.CloudProject) { + if (!project.driveItemId) return; + + const db = getUserDriveDb(); + await db.openAsync(); + + await db.setAsync(DRIVE_ID_TABLE, { + id: project.id, + driveItemId: project.driveItemId + } as DriveDBEntry); + } + + protected async lookupProjectDriveId(headerId: string) { + const db = getUserDriveDb(); + await db.openAsync(); + + const entry = await db.getAsync(DRIVE_ID_TABLE, headerId); + return entry?.driveItemId; + } +} + +function getUserDriveDb() { + const db = new pxt.BrowserUtils.IDBWrapper(DRIVE_ID_DB_NAME, 1, (ev, r) => { + const db = r.result as IDBDatabase; + db.createObjectStore(DRIVE_ID_TABLE, { keyPath: DRIVE_ID_KEYPATH }); + }); + + return db; +} \ No newline at end of file