From ba9db4251de0f47499f0eee5172f1c082af178c7 Mon Sep 17 00:00:00 2001 From: Richard Knoll Date: Wed, 20 Nov 2024 15:59:47 -0800 Subject: [PATCH 01/10] beginnings of minecraft cloud proxy work --- localtypings/pxteditor.d.ts | 84 ++++++++++++ pxtlib/auth.ts | 11 ++ webapp/public/cloudframe.html | 205 ++++++++++++++++++++++++++++++ webapp/src/app.tsx | 7 +- webapp/src/auth.ts | 10 +- webapp/src/headerbar.tsx | 2 +- webapp/src/minecraftAuthClient.ts | 204 +++++++++++++++++++++++++++++ 7 files changed, 518 insertions(+), 5 deletions(-) create mode 100644 webapp/public/cloudframe.html create mode 100644 webapp/src/minecraftAuthClient.ts 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..0e7481c9fbda 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 (hasCloudProxyIdentity()) { + cachedHasAuthToken = true; + return true; + } return !!(await getAuthTokenAsync()); } async function delAuthTokenAsync(): Promise { @@ -748,9 +752,16 @@ namespace pxt.auth { } export function hasIdentity(): boolean { + if (hasCloudProxyIdentity()) { + return true; + } return !authDisabled && !pxt.BrowserUtils.isPxtElectron() && identityProviders().length > 0; } + export function hasCloudProxyIdentity(): boolean { + return true; + } + 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..2c661ac6193d --- /dev/null +++ b/webapp/public/cloudframe.html @@ -0,0 +1,205 @@ + + + + + + + + + + + +
+

+    
+ + + + diff --git a/webapp/src/app.tsx b/webapp/src/app.tsx index eab841f53d32..2eb88f39a763 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; @@ -5940,6 +5941,8 @@ document.addEventListener("DOMContentLoaded", async () => { pxt.setupWebConfig((window as any).pxtConfig); const config = pxt.webConfig + auth.overrideAuthClient(() => new MinecraftAuthClient()); + const optsQuery = Util.parseQueryString(window.location.href.toLowerCase()); if (optsQuery["dbg"] == "1") { pxt.setLogLevel(pxt.LogLevel.Debug); @@ -5963,11 +5966,11 @@ document.addEventListener("DOMContentLoaded", async () => { pxt.blocks.showBlockIdInTooltip = true; } - initGitHubDb(); - pxt.perf.measureStart("setAppTarget"); pkg.setupAppTarget((window as any).pxtTargetBundle); + initGitHubDb(); + // 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..a123223813cb 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.hasCloudProxyIdentity(); 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..396a01954551 --- /dev/null +++ b/webapp/src/minecraftAuthClient.ts @@ -0,0 +1,204 @@ +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 === "POST") { + return this.setAsync(data) as Promise>; + } + else if (method === "GET") { + return this.getAsync(headerId) as Promise>; + } + } + + return ( + { + success: false, + statusCode: 500, + resp: undefined, + err: "Not supported" + } + ); + } + + // public async authCheckAsync(): Promise { + // if (!this.pendingAuthCheck) { + // this.pendingAuthCheck = this.userAsync(); + // } + // const user = await this.pendingAuthCheck; + + // if (user.success) { + // return user.resp; + // } + // return undefined; + // } + + 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; + + 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; + } +} + From 5b48293494f7c2a6d4e61b11b3b89649c58097ba Mon Sep 17 00:00:00 2001 From: Richard Knoll Date: Thu, 21 Nov 2024 12:41:34 -0800 Subject: [PATCH 02/10] more cloudframe stuff --- webapp/public/cloudframe.html | 44 +++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/webapp/public/cloudframe.html b/webapp/public/cloudframe.html index 2c661ac6193d..0e91a7ba18af 100644 --- a/webapp/public/cloudframe.html +++ b/webapp/public/cloudframe.html @@ -36,6 +36,7 @@ +

     
From d540f89c2312c86bdd1f2c6e2bce0f54f044e776 Mon Sep 17 00:00:00 2001 From: Richard Knoll Date: Thu, 21 Nov 2024 14:06:18 -0800 Subject: [PATCH 03/10] add target flag --- localtypings/pxtarget.d.ts | 1 + pxtlib/auth.ts | 8 ++++---- webapp/src/app.tsx | 6 ++++-- webapp/src/headerbar.tsx | 2 +- webapp/src/minecraftAuthClient.ts | 18 ++---------------- 5 files changed, 12 insertions(+), 23 deletions(-) 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/pxtlib/auth.ts b/pxtlib/auth.ts index 0e7481c9fbda..01eaf2993e16 100644 --- a/pxtlib/auth.ts +++ b/pxtlib/auth.ts @@ -116,7 +116,7 @@ namespace pxt.auth { return await setLocalStorageValueAsync(CSRF_TOKEN_KEY, token); } export async function hasAuthTokenAsync(): Promise { - if (hasCloudProxyIdentity()) { + if (proxyIdentityThroughIPC()) { cachedHasAuthToken = true; return true; } @@ -752,14 +752,14 @@ namespace pxt.auth { } export function hasIdentity(): boolean { - if (hasCloudProxyIdentity()) { + if (proxyIdentityThroughIPC()) { return true; } return !authDisabled && !pxt.BrowserUtils.isPxtElectron() && identityProviders().length > 0; } - export function hasCloudProxyIdentity(): boolean { - return true; + export function proxyIdentityThroughIPC(): boolean { + return pxt.appTarget.appTheme.ipcIdentityProxy; } function idpEnabled(idp: pxt.IdentityProviderId): boolean { diff --git a/webapp/src/app.tsx b/webapp/src/app.tsx index 2eb88f39a763..a87f2a8d09b1 100644 --- a/webapp/src/app.tsx +++ b/webapp/src/app.tsx @@ -5941,8 +5941,6 @@ document.addEventListener("DOMContentLoaded", async () => { pxt.setupWebConfig((window as any).pxtConfig); const config = pxt.webConfig - auth.overrideAuthClient(() => new MinecraftAuthClient()); - const optsQuery = Util.parseQueryString(window.location.href.toLowerCase()); if (optsQuery["dbg"] == "1") { pxt.setLogLevel(pxt.LogLevel.Debug); @@ -5971,6 +5969,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/headerbar.tsx b/webapp/src/headerbar.tsx index a123223813cb..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() && !pxt.auth.hasCloudProxyIdentity(); + 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 index 396a01954551..d056b874a54b 100644 --- a/webapp/src/minecraftAuthClient.ts +++ b/webapp/src/minecraftAuthClient.ts @@ -120,10 +120,7 @@ export class MinecraftAuthClient extends AuthClient { else if (path.startsWith("/api/user/project/")) { const headerId = path.substring(18); - if (method === "POST") { - return this.setAsync(data) as Promise>; - } - else if (method === "GET") { + if (method === "GET") { return this.getAsync(headerId) as Promise>; } } @@ -138,18 +135,6 @@ export class MinecraftAuthClient extends AuthClient { ); } - // public async authCheckAsync(): Promise { - // if (!this.pendingAuthCheck) { - // this.pendingAuthCheck = this.userAsync(); - // } - // const user = await this.pendingAuthCheck; - - // if (user.success) { - // return user.resp; - // } - // return undefined; - // } - protected postMessageAsync(message: Partial): Promise { return new Promise((resolve, reject) => { const toPost = { @@ -162,6 +147,7 @@ export class MinecraftAuthClient extends AuthClient { this.pendingMessages[toPost.id] = resolve as any; + // TODO: send over ipc channel window.parent.postMessage(toPost, "*"); }) } From 6c9e84a95600c1a93024787c91b9e10746ae8c75 Mon Sep 17 00:00:00 2001 From: Richard Knoll Date: Wed, 15 Jan 2025 15:13:14 -0800 Subject: [PATCH 04/10] actually send proxy messages over ipc channel --- localtypings/pxteditor.d.ts | 2 +- webapp/src/minecraftAuthClient.ts | 192 +++++++++++++++++++++++++++--- 2 files changed, 178 insertions(+), 16 deletions(-) diff --git a/localtypings/pxteditor.d.ts b/localtypings/pxteditor.d.ts index a3df562db204..e8b0115ba9fa 100644 --- a/localtypings/pxteditor.d.ts +++ b/localtypings/pxteditor.d.ts @@ -1149,7 +1149,7 @@ declare namespace pxt.editor { onPostHostMessage?: (msg: pxt.editor.EditorMessageRequest) => void; 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; diff --git a/webapp/src/minecraftAuthClient.ts b/webapp/src/minecraftAuthClient.ts index d056b874a54b..805a89f0b1c8 100644 --- a/webapp/src/minecraftAuthClient.ts +++ b/webapp/src/minecraftAuthClient.ts @@ -1,26 +1,106 @@ 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; +} export class MinecraftAuthClient extends AuthClient { protected pendingMessages: pxt.Map<(response: pxt.editor.CloudProxyResponse) => void> = {}; protected preferences: pxt.auth.UserPreferences = {}; - - private pendingAuthCheck: Promise> = undefined; + protected ipc?: IPCRenderer; 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]; - } - } - }); + this.init(); } async apiAsync(url: string, data?: any, method?: string, authToken?: string): Promise> { @@ -136,6 +216,33 @@ export class MinecraftAuthClient extends AuthClient { } 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) => { const toPost = { ...message, @@ -149,7 +256,62 @@ export class MinecraftAuthClient extends AuthClient { // TODO: send over ipc channel window.parent.postMessage(toPost, "*"); - }) + }); + } + + protected init() { + this.ipc = (window as any).ipcRenderer; + + if (!this.ipc) { + 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> { From 5008ac17d2db81181352b57a37f6acdc3cc6b57f Mon Sep 17 00:00:00 2001 From: Richard Knoll Date: Wed, 15 Jan 2025 15:35:03 -0800 Subject: [PATCH 05/10] error handling --- webapp/src/minecraftAuthClient.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/webapp/src/minecraftAuthClient.ts b/webapp/src/minecraftAuthClient.ts index 805a89f0b1c8..f848eb757c56 100644 --- a/webapp/src/minecraftAuthClient.ts +++ b/webapp/src/minecraftAuthClient.ts @@ -104,6 +104,20 @@ export class MinecraftAuthClient extends AuthClient { } 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) { @@ -244,6 +258,10 @@ export class MinecraftAuthClient extends AuthClient { 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", @@ -263,6 +281,7 @@ export class MinecraftAuthClient extends AuthClient { this.ipc = (window as any).ipcRenderer; if (!this.ipc) { + pxt.warn("No ipcRenderer detected. Using iframe cloud proxy instead"); this.initIFrame(); return; } From 7b3ef305baf60eb86144887b571ed4dfa0c80e90 Mon Sep 17 00:00:00 2001 From: Richard Knoll Date: Mon, 24 Feb 2025 11:41:21 -0800 Subject: [PATCH 06/10] store onedrive id --- localtypings/pxteditor.d.ts | 3 ++ webapp/src/minecraftAuthClient.ts | 51 ++++++++++++++++++++++++++++++- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/localtypings/pxteditor.d.ts b/localtypings/pxteditor.d.ts index 73539581fbd3..3cb7bf2f9483 100644 --- a/localtypings/pxteditor.d.ts +++ b/localtypings/pxteditor.d.ts @@ -1389,6 +1389,9 @@ declare namespace pxt.editor { header: string; text: string; version: string; + + // minecraft specific + driveItemId?: string; }; interface BaseCloudProxyRequest extends EditorMessageRequest { diff --git a/webapp/src/minecraftAuthClient.ts b/webapp/src/minecraftAuthClient.ts index f848eb757c56..b8d9cc39bac7 100644 --- a/webapp/src/minecraftAuthClient.ts +++ b/webapp/src/minecraftAuthClient.ts @@ -93,6 +93,15 @@ interface CloudProxyDeleteResponse { 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 = {}; @@ -188,7 +197,6 @@ export class MinecraftAuthClient extends AuthClient { ); } else if (path === "/api/user/project") { - if (method === "GET") { // LIST let headerIds: string[]; @@ -339,6 +347,12 @@ export class MinecraftAuthClient extends AuthClient { headerIds }); + if (resp.resp.success) { + for (const project of resp.resp.resp) { + await this.updateProjectDriveId(project); + } + } + return resp.resp; } @@ -348,10 +362,17 @@ export class MinecraftAuthClient extends AuthClient { 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 @@ -367,5 +388,33 @@ export class MinecraftAuthClient extends AuthClient { 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 From 705cb67686a52ff90d9c03b0c88d795fa6a14f0b Mon Sep 17 00:00:00 2001 From: Richard Knoll Date: Thu, 10 Apr 2025 10:30:12 -0700 Subject: [PATCH 07/10] handle 503 responses as offline --- pxtlib/auth.ts | 1 + webapp/public/cloudframe.html | 41 ++++++++++++++++++++++++++++++- webapp/src/auth.ts | 7 ++++++ webapp/src/cloud.ts | 2 +- webapp/src/minecraftAuthClient.ts | 14 +++++++++-- 5 files changed, 61 insertions(+), 4 deletions(-) diff --git a/pxtlib/auth.ts b/pxtlib/auth.ts index cd5911a9be2d..d326037d69ca 100644 --- a/pxtlib/auth.ts +++ b/pxtlib/auth.ts @@ -104,6 +104,7 @@ namespace pxt.auth { // Last known auth token state. This is provided as a convenience for legacy methods that cannot be made async. // Preference hasAuthTokenAsync() over taking a dependency on this cached value. export let cachedHasAuthToken = false; + export let cachedAuthOffline = false; async function setLocalStorageValueAsync(key: string, value: string | undefined): Promise { if (!!value) diff --git a/webapp/public/cloudframe.html b/webapp/public/cloudframe.html index 0e91a7ba18af..f0bebc3c41c4 100644 --- a/webapp/public/cloudframe.html +++ b/webapp/public/cloudframe.html @@ -36,6 +36,7 @@ - +
+ + + +

     
diff --git a/webapp/src/auth.ts b/webapp/src/auth.ts index b4805bfe5385..d376dcec0f41 100644 --- a/webapp/src/auth.ts +++ b/webapp/src/auth.ts @@ -10,8 +10,10 @@ import { MinecraftAuthClient } from "./minecraftAuthClient"; const MODULE = "auth"; const FIELD_USER_PROFILE = "profile"; const FIELD_LOGGED_IN = "logged-in"; +const FIELD_OFFLINE = "offline"; export const USER_PROFILE = `${MODULE}:${FIELD_USER_PROFILE}`; export const LOGGED_IN = `${MODULE}:${FIELD_LOGGED_IN}`; +export const OFFLINE = `${MODULE}:${FIELD_OFFLINE}`; const USER_PREF_MODULE = "user-pref"; const FIELD_USER_PREFERENCES = "preferences"; @@ -99,6 +101,7 @@ export class AuthClient extends pxt.auth.AuthClient { switch (field) { case FIELD_USER_PROFILE: return hasToken ? { ...state?.profile } : null; case FIELD_LOGGED_IN: return hasToken && state?.profile != null; + case FIELD_OFFLINE: return pxt.auth.cachedAuthOffline; } return null; } @@ -184,6 +187,10 @@ export function loggedIn(): boolean { return data.getData(LOGGED_IN); } +export function isOffline(): boolean { + return data.getData(OFFLINE); +} + export function userProfile(): pxt.auth.UserProfile { return data.getData(USER_PROFILE); } diff --git a/webapp/src/cloud.ts b/webapp/src/cloud.ts index 4548c215b172..136fe7fe429b 100644 --- a/webapp/src/cloud.ts +++ b/webapp/src/cloud.ts @@ -168,7 +168,7 @@ export class CloudTempMetadata { const h = workspace.getHeader(this.headerId); if (!h || !h.cloudUserId) return undefined; - if (!auth.loggedIn()) + if (!auth.loggedIn() || auth.isOffline()) return pxt.cloud.cloudStatus["offline"]; if (this._syncStartTime > 0) return pxt.cloud.cloudStatus["syncing"]; diff --git a/webapp/src/minecraftAuthClient.ts b/webapp/src/minecraftAuthClient.ts index b8d9cc39bac7..8d103c213d2e 100644 --- a/webapp/src/minecraftAuthClient.ts +++ b/webapp/src/minecraftAuthClient.ts @@ -1,4 +1,5 @@ -import { AuthClient } from "./auth"; +import { AuthClient, OFFLINE } from "./auth"; +import * as data from "./data"; interface IPCRenderer { on(event: "responseFromApp", handler: (event: any, message: string) => void): void; @@ -114,7 +115,16 @@ export class MinecraftAuthClient extends AuthClient { async apiAsync(url: string, data?: any, method?: string, authToken?: string): Promise> { try { - return this.apiAsyncCore(url, data, method, authToken); + const result = await this.apiAsyncCore(url, data, method, authToken); + + const offline = result?.statusCode === 503; + + if (pxt.auth.cachedAuthOffline !== offline) { + pxt.auth.cachedAuthOffline = offline; + data.invalidate(OFFLINE); + } + + return result; } catch (e) { return { From 0d91488b52925b111e5b9e07d93171c0c9bf0ba9 Mon Sep 17 00:00:00 2001 From: Richard Knoll Date: Thu, 10 Apr 2025 10:57:47 -0700 Subject: [PATCH 08/10] if performing a full sync, make sure to set missing headers if deleted --- webapp/src/cloud.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/webapp/src/cloud.ts b/webapp/src/cloud.ts index 136fe7fe429b..dc540d30e73c 100644 --- a/webapp/src/cloud.ts +++ b/webapp/src/cloud.ts @@ -70,6 +70,10 @@ async function listAsync(hdrs?: Header[]): Promise { header.cloudVersion = proj.version; return header; }); + + if (!hdrs) { + + } pxt.tickEvent(`identity.cloudApi.list.success`, { count: headers.length }); resolve(headers); } else { @@ -421,8 +425,9 @@ async function syncAsyncInternal(opts: SyncAsyncOptions): Promise { const projShorthand = shortName(local); + let cloudCurrent = local.cloudCurrent && (!fullSync || remoteHeaders.some(h => h.id === local.id)) try { - if (!local.cloudCurrent) { + if (!cloudCurrent) { if (local.isDeleted) { // Deleted local project, push to cloud const res = await toCloud(local, null); From cec4d67c96f38775529d729216078a957ff4e2b2 Mon Sep 17 00:00:00 2001 From: Richard Knoll Date: Fri, 11 Apr 2025 15:46:00 -0700 Subject: [PATCH 09/10] add some logging, handle 404 --- webapp/src/cloud.ts | 6 +++++- webapp/src/minecraftAuthClient.ts | 5 +++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/webapp/src/cloud.ts b/webapp/src/cloud.ts index dc540d30e73c..c82b14bc5915 100644 --- a/webapp/src/cloud.ts +++ b/webapp/src/cloud.ts @@ -76,7 +76,11 @@ async function listAsync(hdrs?: Header[]): Promise { } pxt.tickEvent(`identity.cloudApi.list.success`, { count: headers.length }); resolve(headers); - } else { + } + else if (result.statusCode === 404) { + resolve([]); + } + else { pxt.tickEvent(`identity.cloudApi.list.failed`); reject(result.err); } diff --git a/webapp/src/minecraftAuthClient.ts b/webapp/src/minecraftAuthClient.ts index 8d103c213d2e..848b7800f674 100644 --- a/webapp/src/minecraftAuthClient.ts +++ b/webapp/src/minecraftAuthClient.ts @@ -124,9 +124,12 @@ export class MinecraftAuthClient extends AuthClient { data.invalidate(OFFLINE); } + console.log(JSON.stringify(result)); + return result; } catch (e) { + console.log(`ERROR`, e) return { success: false, statusCode: 500, @@ -155,6 +158,8 @@ export class MinecraftAuthClient extends AuthClient { } } + console.log(`API: ${url} ${data} ${method}`) + if (path === "/api/user") { if (method === "DELETE") { return ( From bc2741ee7ac7d14107e6df5f21e673bf040ef975 Mon Sep 17 00:00:00 2001 From: Richard Knoll Date: Mon, 19 May 2025 11:06:38 -0700 Subject: [PATCH 10/10] fix share, fix signed in message, remove logs --- react-common/components/share/ShareInfo.tsx | 3 ++- webapp/src/app.tsx | 2 +- webapp/src/auth.ts | 4 +++- webapp/src/minecraftAuthClient.ts | 6 +++--- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/react-common/components/share/ShareInfo.tsx b/react-common/components/share/ShareInfo.tsx index 17993b4910f6..af93165f473e 100644 --- a/react-common/components/share/ShareInfo.tsx +++ b/react-common/components/share/ShareInfo.tsx @@ -66,6 +66,7 @@ export const ShareInfo = (props: ShareInfoProps) => { const prePublish = shareState === "share" || shareState === "publishing"; const isPublished = shareState === "publish" || shareState === "publish-vscode"; const showDescription = !isPublished; + const showPersistentShareCheckbox = isLoggedIn && hasProjectBeenPersistentShared && !pxt.auth.proxyIdentityThroughIPC(); let qrCodeButtonRef: HTMLButtonElement; let inputRef: HTMLInputElement; let kioskInputRef: HTMLInputElement; @@ -355,7 +356,7 @@ export const ShareInfo = (props: ShareInfoProps) => { onBlur={setName} onEnterKey={setName} preserveValueOnBlur={true} /> - {isLoggedIn && hasProjectBeenPersistentShared && extends data.Component { 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))); + if (!pxt.auth.proxyIdentityThroughIPC()) { + core.infoNotification(lf("Signed in: {0}", pxt.auth.userName(state.profile))); + } if (!!workspace.getWorkspaceType()) await cloud.syncAsync(); pxt.storage.setLocal(HAS_USED_CLOUD, "true"); diff --git a/webapp/src/minecraftAuthClient.ts b/webapp/src/minecraftAuthClient.ts index 848b7800f674..e1e8b665dd43 100644 --- a/webapp/src/minecraftAuthClient.ts +++ b/webapp/src/minecraftAuthClient.ts @@ -124,12 +124,12 @@ export class MinecraftAuthClient extends AuthClient { data.invalidate(OFFLINE); } - console.log(JSON.stringify(result)); + pxt.debug(JSON.stringify(result)); return result; } catch (e) { - console.log(`ERROR`, e) + pxt.warn(`MINECRAFT AUTH ERROR`, e) return { success: false, statusCode: 500, @@ -158,7 +158,7 @@ export class MinecraftAuthClient extends AuthClient { } } - console.log(`API: ${url} ${data} ${method}`) + pxt.debug(`API: ${url} ${data} ${method}`) if (path === "/api/user") { if (method === "DELETE") {