diff --git a/localtypings/pxtarget.d.ts b/localtypings/pxtarget.d.ts index d0f1f29e7f15..10a3931fcbc2 100644 --- a/localtypings/pxtarget.d.ts +++ b/localtypings/pxtarget.d.ts @@ -524,6 +524,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 } interface DownloadDialogTheme { diff --git a/localtypings/pxteditor.d.ts b/localtypings/pxteditor.d.ts index 26dd274455a3..0f458610f539 100644 --- a/localtypings/pxteditor.d.ts +++ b/localtypings/pxteditor.d.ts @@ -103,6 +103,7 @@ declare namespace pxt.editor { | "serviceworkerregistered" | "runeval" | "precachetutorial" + | "cloudproxy" // package extension messasges | ExtInitializeType @@ -1368,6 +1369,89 @@ declare namespace pxt.editor { } type AssetEditorEvent = AssetEditorRequestSaveEvent | AssetEditorReadyEvent; + + type CloudProject = { + id: string; + shareId?: string; + header: string; + text: string; + version: 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 eab841f53d32..a87f2a8d09b1 100644 --- a/webapp/src/app.tsx +++ b/webapp/src/app.tsx @@ -82,6 +82,7 @@ import { Tour } from "./components/onboarding/Tour"; import { parseTourStepsAsync } from "./onboarding"; import { initGitHubDb } from "./idbworkspace"; import { BlockDefinition, CategoryNameID } from "./toolbox"; +import { MinecraftAuthClient } from "./minecraftAuthClient"; pxt.blocks.requirePxtBlockly = () => pxtblockly as any; pxt.blocks.requireBlockly = () => Blockly; @@ -5963,11 +5964,15 @@ document.addEventListener("DOMContentLoaded", async () => { pxt.blocks.showBlockIdInTooltip = true; } - initGitHubDb(); - pxt.perf.measureStart("setAppTarget"); pkg.setupAppTarget((window as any).pxtTargetBundle); + 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..d056b874a54b --- /dev/null +++ b/webapp/src/minecraftAuthClient.ts @@ -0,0 +1,190 @@ +import { AuthClient } from "./auth"; + + +export class MinecraftAuthClient extends AuthClient { + protected pendingMessages: pxt.Map<(response: pxt.editor.CloudProxyResponse) => void> = {}; + protected preferences: pxt.auth.UserPreferences = {}; + + private pendingAuthCheck: Promise> = undefined; + + constructor() { + super(); + + window.addEventListener("message", ev => { + const message = ev.data; + + if (message.action === "cloudproxy") { + if (this.pendingMessages[message.id]) { + const response = message as pxt.editor.CloudProxyResponse; + this.pendingMessages[message.id](response); + delete this.pendingMessages[message.id]; + } + } + }); + } + + async apiAsync(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 { + return new Promise((resolve, reject) => { + 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 async listAsync(headerIds?: string[]): Promise> { + const resp = await this.postMessageAsync({ + operation: "list", + headerIds + }); + + return resp.resp; + } + + protected async getAsync(headerId: string): Promise> { + const resp = await this.postMessageAsync({ + operation: "get", + headerId + }); + + return resp.resp; + } + + protected async setAsync(project: pxt.editor.CloudProject): Promise> { + 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; + } +} +