| ... | ... | @@ -48,7 +48,7 @@ import * as Path from "effect/Path"; |
| 48 | 48 | import * as Ref from "effect/Ref"; |
| 49 | 49 | import * as Schema from "effect/Schema"; |
| 50 | 50 | import * as Semaphore from "effect/Semaphore"; |
| 51 | | import type * as Scope from "effect/Scope"; |
| 51 | import * as Scope from "effect/Scope"; |
| 52 | 52 | import * as SynchronizedRef from "effect/SynchronizedRef"; |
| 53 | 53 | |
| 54 | 54 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; |
| ... | ... | @@ -247,10 +247,7 @@ type PreviewInputSignal = |
| 247 | 247 | | { readonly kind: "key"; readonly key: string; readonly code: string }; |
| 248 | 248 | |
| 249 | 249 | interface ManagedListeners { |
| 250 | | navigate: () => void; |
| 251 | | failed: (event: Event, code: number, description: string) => void; |
| 252 | | humanInput: (_event: unknown, signal?: unknown) => void; |
| 253 | | beforeInput: (event: Electron.Event, input: Electron.Input) => void; |
| 250 | readonly scope: Scope.Closeable; |
| 254 | 251 | } |
| 255 | 252 | |
| 256 | 253 | interface PickSession { |
| ... | ... | @@ -260,6 +257,7 @@ interface PickSession { |
| 260 | 257 | interface BrowserControlSession { |
| 261 | 258 | readonly webContentsId: number; |
| 262 | 259 | readonly semaphore: Semaphore.Semaphore; |
| 260 | readonly scope: Scope.Closeable; |
| 263 | 261 | readonly onMessage: ( |
| 264 | 262 | event: Electron.Event, |
| 265 | 263 | method: string, |
| ... | ... | @@ -339,6 +337,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 339 | 337 | ) { |
| 340 | 338 | const fileSystem = yield* FileSystem.FileSystem; |
| 341 | 339 | const path = yield* Path.Path; |
| 340 | const parentScope = yield* Scope.Scope; |
| 342 | 341 | const context = yield* Effect.context<never>(); |
| 343 | 342 | const runFork = Effect.runForkWith(context); |
| 344 | 343 | const resolvedArtifactDirectory = path.resolve(artifactDirectory); |
| ... | ... | @@ -622,16 +621,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 622 | 621 | copy.delete(webContentsId); |
| 623 | 622 | }), |
| 624 | 623 | ]); |
| 624 | if (control) { |
| 625 | yield* Scope.close(control.scope, Exit.void).pipe(Effect.ignore); |
| 626 | return; |
| 627 | } |
| 625 | 628 | yield* Ref.update(diagnosticsRef, (diagnostics) => |
| 626 | 629 | replaceMap(diagnostics, (copy) => { |
| 627 | 630 | copy.delete(webContentsId); |
| 628 | 631 | }), |
| 629 | 632 | ); |
| 630 | | const wc = webContents.fromId(webContentsId); |
| 631 | | if (!wc || wc.isDestroyed()) return; |
| 632 | | if (control) wc.debugger.off("message", control.onMessage); |
| 633 | | if (!wc.debugger.isAttached()) return; |
| 634 | | yield* attempt("detachControlSession", () => wc.debugger.detach()).pipe(Effect.ignore); |
| 635 | 633 | }); |
| 636 | 634 | |
| 637 | 635 | const ensureControlSession = Effect.fn("PreviewManager.ensureControlSession")(function* ( |
| ... | ... | @@ -662,87 +660,103 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 662 | 660 | ), |
| 663 | 661 | ); |
| 664 | 662 | } |
| 665 | | return Effect.gen(function* () { |
| 663 | const createControlSession = Effect.fn("PreviewManager.createControlSession")(function* () { |
| 666 | 664 | const semaphore = yield* Semaphore.make(1); |
| 665 | const scope = yield* Scope.fork(parentScope, "sequential"); |
| 666 | const handleDebuggerMessage = Effect.fn("PreviewManager.handleDebuggerMessage")(function* ( |
| 667 | method: string, |
| 668 | params: Record<string, unknown>, |
| 669 | ) { |
| 670 | if (method === "Page.screencastFrame") { |
| 671 | const sessionId = params["sessionId"]; |
| 672 | if (typeof sessionId === "number") { |
| 673 | yield* attemptPromise("ackScreencastFrame", () => |
| 674 | wc.debugger.sendCommand("Page.screencastFrameAck", { sessionId }), |
| 675 | ).pipe(Effect.ignore); |
| 676 | } |
| 677 | const tabId = yield* tabIdForWebContents(wc.id); |
| 678 | const metadata = |
| 679 | typeof params["metadata"] === "object" && params["metadata"] !== null |
| 680 | ? (params["metadata"] as Record<string, unknown>) |
| 681 | : {}; |
| 682 | if (tabId && typeof params["data"] === "string") { |
| 683 | const receivedAt = yield* currentIso; |
| 684 | const listeners = yield* Ref.get(recordingFrameListenersRef); |
| 685 | const frame: DesktopPreviewRecordingFrame = { |
| 686 | tabId, |
| 687 | data: params["data"], |
| 688 | width: typeof metadata["deviceWidth"] === "number" ? metadata["deviceWidth"] : 0, |
| 689 | height: typeof metadata["deviceHeight"] === "number" ? metadata["deviceHeight"] : 0, |
| 690 | receivedAt, |
| 691 | }; |
| 692 | yield* Effect.forEach( |
| 693 | listeners, |
| 694 | (listener) => Effect.sync(() => listener(frame)).pipe(Effect.ignore), |
| 695 | { discard: true }, |
| 696 | ); |
| 697 | } |
| 698 | } |
| 699 | yield* captureDiagnosticMessage(wc.id, method, params); |
| 700 | }); |
| 667 | 701 | const onMessage: BrowserControlSession["onMessage"] = (_event, method, params) => { |
| 668 | | runFork( |
| 669 | | Effect.gen(function* () { |
| 670 | | if (method === "Page.screencastFrame") { |
| 671 | | const sessionId = params["sessionId"]; |
| 672 | | if (typeof sessionId === "number") { |
| 673 | | yield* attemptPromise("ackScreencastFrame", () => |
| 674 | | wc.debugger.sendCommand("Page.screencastFrameAck", { sessionId }), |
| 675 | | ).pipe(Effect.ignore); |
| 676 | | } |
| 677 | | const tabId = yield* tabIdForWebContents(wc.id); |
| 678 | | const metadata = |
| 679 | | typeof params["metadata"] === "object" && params["metadata"] !== null |
| 680 | | ? (params["metadata"] as Record<string, unknown>) |
| 681 | | : {}; |
| 682 | | if (tabId && typeof params["data"] === "string") { |
| 683 | | const receivedAt = yield* currentIso; |
| 684 | | const listeners = yield* Ref.get(recordingFrameListenersRef); |
| 685 | | const frame: DesktopPreviewRecordingFrame = { |
| 686 | | tabId, |
| 687 | | data: params["data"], |
| 688 | | width: |
| 689 | | typeof metadata["deviceWidth"] === "number" ? metadata["deviceWidth"] : 0, |
| 690 | | height: |
| 691 | | typeof metadata["deviceHeight"] === "number" ? metadata["deviceHeight"] : 0, |
| 692 | | receivedAt, |
| 693 | | }; |
| 694 | | yield* Effect.forEach( |
| 695 | | listeners, |
| 696 | | (listener) => Effect.sync(() => listener(frame)).pipe(Effect.ignore), |
| 697 | | { discard: true }, |
| 698 | | ); |
| 699 | | } |
| 700 | | } |
| 701 | | yield* captureDiagnosticMessage(wc.id, method, params); |
| 702 | | }), |
| 703 | | ); |
| 702 | runFork(handleDebuggerMessage(method, params)); |
| 704 | 703 | }; |
| 705 | | const control: BrowserControlSession = { webContentsId: wc.id, semaphore, onMessage }; |
| 706 | | yield* Ref.update(diagnosticsRef, (diagnostics) => |
| 707 | | replaceMap(diagnostics, (copy) => { |
| 708 | | copy.set(wc.id, { |
| 709 | | consoleEntries: [], |
| 710 | | networkEntries: [], |
| 711 | | requests: new Map(), |
| 712 | | }); |
| 713 | | }), |
| 714 | | ); |
| 715 | | yield* attempt("attachDebuggerListeners", () => { |
| 716 | | wc.debugger.on("message", onMessage); |
| 717 | | wc.debugger.attach("1.3"); |
| 718 | | }); |
| 719 | | yield* Effect.all( |
| 720 | | ["Runtime.enable", "Accessibility.enable", "Network.enable", "Log.enable"].map((method) => |
| 721 | | attemptPromise("initializeDebugger", () => wc.debugger.sendCommand(method)), |
| 722 | | ), |
| 723 | | { concurrency: "unbounded", discard: true }, |
| 724 | | ).pipe( |
| 725 | | Effect.tapError(() => |
| 726 | | Effect.all([ |
| 704 | yield* Scope.addFinalizer( |
| 705 | scope, |
| 706 | Effect.all( |
| 707 | [ |
| 727 | 708 | Ref.update(diagnosticsRef, (diagnostics) => |
| 728 | 709 | replaceMap(diagnostics, (copy) => { |
| 729 | 710 | copy.delete(wc.id); |
| 730 | 711 | }), |
| 731 | 712 | ), |
| 732 | | attempt("detachFailedDebugger", () => { |
| 713 | attempt("detachControlSession", () => { |
| 733 | 714 | wc.debugger.off("message", onMessage); |
| 734 | 715 | if (wc.debugger.isAttached()) wc.debugger.detach(); |
| 735 | 716 | }).pipe(Effect.ignore), |
| 736 | | ]).pipe(Effect.asVoid), |
| 717 | ], |
| 718 | { discard: true }, |
| 737 | 719 | ), |
| 738 | 720 | ); |
| 739 | | return [ |
| 740 | | control, |
| 741 | | replaceMap(sessions, (copy) => { |
| 742 | | copy.set(wc.id, control); |
| 743 | | }), |
| 744 | | ] as const; |
| 721 | const control: BrowserControlSession = { |
| 722 | webContentsId: wc.id, |
| 723 | semaphore, |
| 724 | scope, |
| 725 | onMessage, |
| 726 | }; |
| 727 | const initialize = Effect.fn("PreviewManager.initializeControlSession")(function* () { |
| 728 | yield* Ref.update(diagnosticsRef, (diagnostics) => |
| 729 | replaceMap(diagnostics, (copy) => { |
| 730 | copy.set(wc.id, { |
| 731 | consoleEntries: [], |
| 732 | networkEntries: [], |
| 733 | requests: new Map(), |
| 734 | }); |
| 735 | }), |
| 736 | ); |
| 737 | yield* attempt("attachDebuggerListeners", () => { |
| 738 | wc.debugger.on("message", onMessage); |
| 739 | wc.debugger.attach("1.3"); |
| 740 | }); |
| 741 | yield* Effect.all( |
| 742 | ["Runtime.enable", "Accessibility.enable", "Network.enable", "Log.enable"].map( |
| 743 | (method) => |
| 744 | attemptPromise("initializeDebugger", () => wc.debugger.sendCommand(method)), |
| 745 | ), |
| 746 | { concurrency: "unbounded", discard: true }, |
| 747 | ); |
| 748 | return [ |
| 749 | control, |
| 750 | replaceMap(sessions, (copy) => { |
| 751 | copy.set(wc.id, control); |
| 752 | }), |
| 753 | ] as const; |
| 754 | }); |
| 755 | return yield* initialize().pipe( |
| 756 | Effect.onError(() => Scope.close(scope, Exit.void).pipe(Effect.ignore)), |
| 757 | ); |
| 745 | 758 | }); |
| 759 | return createControlSession(); |
| 746 | 760 | }); |
| 747 | 761 | }); |
| 748 | 762 | |
| ... | ... | @@ -906,25 +920,13 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 906 | 920 | const detachListeners = Effect.fn("PreviewManager.detachListeners")(function* ( |
| 907 | 921 | webContentsId: number, |
| 908 | 922 | ) { |
| 909 | | const handlers = yield* Ref.modify(attachedRef, (attached) => [ |
| 923 | const managed = yield* Ref.modify(attachedRef, (attached) => [ |
| 910 | 924 | attached.get(webContentsId), |
| 911 | 925 | replaceMap(attached, (copy) => { |
| 912 | 926 | copy.delete(webContentsId); |
| 913 | 927 | }), |
| 914 | 928 | ]); |
| 915 | | if (!handlers) return; |
| 916 | | const wc = webContents.fromId(webContentsId); |
| 917 | | if (!wc || wc.isDestroyed()) return; |
| 918 | | yield* attempt("detachListeners", () => { |
| 919 | | wc.off("did-navigate", handlers.navigate); |
| 920 | | wc.off("did-navigate-in-page", handlers.navigate); |
| 921 | | wc.off("page-title-updated", handlers.navigate); |
| 922 | | wc.off("did-start-loading", handlers.navigate); |
| 923 | | wc.off("did-stop-loading", handlers.navigate); |
| 924 | | wc.off("did-fail-load", handlers.failed as never); |
| 925 | | wc.off("before-input-event", handlers.beforeInput); |
| 926 | | wc.ipc.off(HUMAN_INPUT_CHANNEL, handlers.humanInput); |
| 927 | | }).pipe(Effect.ignore); |
| 929 | if (managed) yield* Scope.close(managed.scope, Exit.void).pipe(Effect.ignore); |
| 928 | 930 | }); |
| 929 | 931 | |
| 930 | 932 | const isAppShortcut = (input: Electron.Input): boolean => |
| ... | ... | @@ -987,17 +989,16 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 987 | 989 | tabId: string, |
| 988 | 990 | wc: Electron.WebContents, |
| 989 | 991 | ) { |
| 990 | | const sync = () => |
| 991 | | runFork( |
| 992 | | Effect.gen(function* () { |
| 993 | | if (wc.isDestroyed()) return; |
| 994 | | yield* update(tabId, { |
| 995 | | navStatus: computeNavStatus(wc), |
| 996 | | canGoBack: wc.navigationHistory.canGoBack(), |
| 997 | | canGoForward: wc.navigationHistory.canGoForward(), |
| 998 | | }); |
| 999 | | }), |
| 1000 | | ); |
| 992 | const scope = yield* Scope.fork(parentScope, "sequential"); |
| 993 | const syncState = Effect.fn("PreviewManager.syncWebContentsState")(function* () { |
| 994 | if (wc.isDestroyed()) return; |
| 995 | yield* update(tabId, { |
| 996 | navStatus: computeNavStatus(wc), |
| 997 | canGoBack: wc.navigationHistory.canGoBack(), |
| 998 | canGoForward: wc.navigationHistory.canGoForward(), |
| 999 | }); |
| 1000 | }); |
| 1001 | const sync = () => runFork(syncState()); |
| 1001 | 1002 | const failed = (_event: Event, code: number, description: string): void => { |
| 1002 | 1003 | if (code === -3) return; |
| 1003 | 1004 | runFork( |
| ... | ... | @@ -1012,73 +1013,85 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1012 | 1013 | }), |
| 1013 | 1014 | ); |
| 1014 | 1015 | }; |
| 1015 | | const humanInput = (_event: unknown, rawSignal?: unknown): void => { |
| 1016 | | runFork( |
| 1017 | | Effect.gen(function* () { |
| 1018 | | if ( |
| 1019 | | isPreviewInputSignal(rawSignal) && |
| 1020 | | (yield* consumeExpectedAgentInput(tabId, rawSignal)) |
| 1021 | | ) { |
| 1022 | | return; |
| 1023 | | } |
| 1024 | | yield* Ref.update(controlEpochRef, (epochs) => |
| 1025 | | replaceMap(epochs, (copy) => { |
| 1026 | | copy.set(tabId, (epochs.get(tabId) ?? 0) + 1); |
| 1027 | | }), |
| 1028 | | ); |
| 1029 | | yield* update(tabId, { controller: "human" }); |
| 1030 | | yield* Effect.sleep(750); |
| 1031 | | const tabs = yield* SynchronizedRef.get(tabsRef); |
| 1032 | | if (tabs.get(tabId)?.controller === "human") { |
| 1033 | | yield* update(tabId, { controller: "none" }); |
| 1034 | | } |
| 1016 | const handleHumanInput = Effect.fn("PreviewManager.handleHumanInput")(function* ( |
| 1017 | rawSignal?: unknown, |
| 1018 | ) { |
| 1019 | if (isPreviewInputSignal(rawSignal) && (yield* consumeExpectedAgentInput(tabId, rawSignal))) { |
| 1020 | return; |
| 1021 | } |
| 1022 | yield* Ref.update(controlEpochRef, (epochs) => |
| 1023 | replaceMap(epochs, (copy) => { |
| 1024 | copy.set(tabId, (epochs.get(tabId) ?? 0) + 1); |
| 1035 | 1025 | }), |
| 1036 | 1026 | ); |
| 1027 | yield* update(tabId, { controller: "human" }); |
| 1028 | yield* Effect.sleep(750); |
| 1029 | const tabs = yield* SynchronizedRef.get(tabsRef); |
| 1030 | if (tabs.get(tabId)?.controller === "human") { |
| 1031 | yield* update(tabId, { controller: "none" }); |
| 1032 | } |
| 1033 | }); |
| 1034 | const humanInput = (_event: unknown, rawSignal?: unknown): void => { |
| 1035 | runFork(handleHumanInput(rawSignal)); |
| 1037 | 1036 | }; |
| 1037 | const forwardShortcut = Effect.fn("PreviewManager.forwardShortcut")(function* ( |
| 1038 | event: Electron.Event, |
| 1039 | input: Electron.Input, |
| 1040 | ) { |
| 1041 | const mainWindow = yield* Ref.get(mainWindowRef); |
| 1042 | if (!isAppShortcut(input) || Option.isNone(mainWindow) || mainWindow.value.isDestroyed()) { |
| 1043 | return; |
| 1044 | } |
| 1045 | event.preventDefault(); |
| 1046 | mainWindow.value.webContents.sendInputEvent({ |
| 1047 | type: "keyDown", |
| 1048 | keyCode: input.key, |
| 1049 | modifiers: [ |
| 1050 | ...(input.meta ? (["meta"] as const) : []), |
| 1051 | ...(input.shift ? (["shift"] as const) : []), |
| 1052 | ...(input.control ? (["control"] as const) : []), |
| 1053 | ...(input.alt ? (["alt"] as const) : []), |
| 1054 | ], |
| 1055 | }); |
| 1056 | }); |
| 1038 | 1057 | const beforeInput = (event: Electron.Event, input: Electron.Input): void => { |
| 1039 | | runFork( |
| 1040 | | Effect.gen(function* () { |
| 1041 | | const mainWindow = yield* Ref.get(mainWindowRef); |
| 1042 | | if ( |
| 1043 | | !isAppShortcut(input) || |
| 1044 | | Option.isNone(mainWindow) || |
| 1045 | | mainWindow.value.isDestroyed() |
| 1046 | | ) { |
| 1047 | | return; |
| 1048 | | } |
| 1049 | | event.preventDefault(); |
| 1050 | | mainWindow.value.webContents.sendInputEvent({ |
| 1051 | | type: "keyDown", |
| 1052 | | keyCode: input.key, |
| 1053 | | modifiers: [ |
| 1054 | | ...(input.meta ? (["meta"] as const) : []), |
| 1055 | | ...(input.shift ? (["shift"] as const) : []), |
| 1056 | | ...(input.control ? (["control"] as const) : []), |
| 1057 | | ...(input.alt ? (["alt"] as const) : []), |
| 1058 | | ], |
| 1059 | | }); |
| 1060 | | }), |
| 1061 | | ); |
| 1058 | runFork(forwardShortcut(event, input)); |
| 1062 | 1059 | }; |
| 1063 | | yield* attempt("attachListeners", () => { |
| 1064 | | wc.on("did-navigate", sync); |
| 1065 | | wc.on("did-navigate-in-page", sync); |
| 1066 | | wc.on("page-title-updated", sync); |
| 1067 | | wc.on("did-start-loading", sync); |
| 1068 | | wc.on("did-stop-loading", sync); |
| 1069 | | wc.on("did-fail-load", failed as never); |
| 1070 | | wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput); |
| 1071 | | wc.setWindowOpenHandler(({ url }) => { |
| 1072 | | runFork(attemptPromise("openPreviewWindow", () => wc.loadURL(url)).pipe(Effect.ignore)); |
| 1073 | | return { action: "deny" }; |
| 1060 | yield* Scope.addFinalizer( |
| 1061 | scope, |
| 1062 | attempt("detachListeners", () => { |
| 1063 | wc.off("did-navigate", sync); |
| 1064 | wc.off("did-navigate-in-page", sync); |
| 1065 | wc.off("page-title-updated", sync); |
| 1066 | wc.off("did-start-loading", sync); |
| 1067 | wc.off("did-stop-loading", sync); |
| 1068 | wc.off("did-fail-load", failed as never); |
| 1069 | wc.off("before-input-event", beforeInput); |
| 1070 | wc.ipc.off(HUMAN_INPUT_CHANNEL, humanInput); |
| 1071 | }).pipe(Effect.ignore), |
| 1072 | ); |
| 1073 | const install = Effect.fn("PreviewManager.installWebContentsListeners")(function* () { |
| 1074 | yield* attempt("attachListeners", () => { |
| 1075 | wc.on("did-navigate", sync); |
| 1076 | wc.on("did-navigate-in-page", sync); |
| 1077 | wc.on("page-title-updated", sync); |
| 1078 | wc.on("did-start-loading", sync); |
| 1079 | wc.on("did-stop-loading", sync); |
| 1080 | wc.on("did-fail-load", failed as never); |
| 1081 | wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput); |
| 1082 | wc.setWindowOpenHandler(({ url }) => { |
| 1083 | runFork(attemptPromise("openPreviewWindow", () => wc.loadURL(url)).pipe(Effect.ignore)); |
| 1084 | return { action: "deny" }; |
| 1085 | }); |
| 1086 | wc.on("before-input-event", beforeInput); |
| 1074 | 1087 | }); |
| 1075 | | wc.on("before-input-event", beforeInput); |
| 1088 | yield* Ref.update(attachedRef, (attached) => |
| 1089 | replaceMap(attached, (copy) => { |
| 1090 | copy.set(wc.id, { scope }); |
| 1091 | }), |
| 1092 | ); |
| 1076 | 1093 | }); |
| 1077 | | yield* Ref.update(attachedRef, (attached) => |
| 1078 | | replaceMap(attached, (copy) => { |
| 1079 | | copy.set(wc.id, { navigate: sync, failed, humanInput, beforeInput }); |
| 1080 | | }), |
| 1081 | | ); |
| 1094 | yield* install().pipe(Effect.onError(() => Scope.close(scope, Exit.void).pipe(Effect.ignore))); |
| 1082 | 1095 | }); |
| 1083 | 1096 | |
| 1084 | 1097 | const setMainWindow = Effect.fn("PreviewManager.setMainWindow")(function* ( |
| ... | ... | @@ -1271,7 +1284,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1271 | 1284 | const annotationTheme = yield* Ref.get(annotationThemeRef); |
| 1272 | 1285 | return yield* Effect.callback<PreviewAnnotationPayload | null, PreviewManagerError>( |
| 1273 | 1286 | (resume) => { |
| 1274 | | const cleanup = Effect.gen(function* () { |
| 1287 | const cleanup = Effect.fn("PreviewManager.cleanupPickElement")(function* () { |
| 1275 | 1288 | yield* attempt("pickElement.cleanup", () => { |
| 1276 | 1289 | wc.ipc.removeListener(ELEMENT_PICKED_CHANNEL, onMessage); |
| 1277 | 1290 | wc.off("destroyed", onDestroyed); |
| ... | ... | @@ -1283,18 +1296,19 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1283 | 1296 | }), |
| 1284 | 1297 | ); |
| 1285 | 1298 | }); |
| 1299 | const settlePick = Effect.fn("PreviewManager.settlePickElement")(function* ( |
| 1300 | payload: PreviewAnnotationPayload | null, |
| 1301 | ) { |
| 1302 | const active = (yield* Ref.get(pickSessionsRef)).get(tabId); |
| 1303 | if (!active || active.cancel !== cancel) return; |
| 1304 | yield* cleanup(); |
| 1305 | resume(Effect.succeed(payload)); |
| 1306 | }); |
| 1286 | 1307 | const settle = (payload: PreviewAnnotationPayload | null) => { |
| 1287 | | runFork( |
| 1288 | | Effect.gen(function* () { |
| 1289 | | const active = (yield* Ref.get(pickSessionsRef)).get(tabId); |
| 1290 | | if (!active || active.cancel !== cancel) return; |
| 1291 | | yield* cleanup; |
| 1292 | | resume(Effect.succeed(payload)); |
| 1293 | | }), |
| 1294 | | ); |
| 1308 | runFork(settlePick(payload)); |
| 1295 | 1309 | }; |
| 1296 | | const cancel = Effect.gen(function* () { |
| 1297 | | yield* cleanup; |
| 1310 | const cancelPickSession = Effect.fn("PreviewManager.cancelPickSession")(function* () { |
| 1311 | yield* cleanup(); |
| 1298 | 1312 | const tabs = yield* SynchronizedRef.get(tabsRef); |
| 1299 | 1313 | const activeTab = tabs.get(tabId); |
| 1300 | 1314 | if (activeTab?.webContentsId != null) { |
| ... | ... | @@ -1307,6 +1321,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1307 | 1321 | } |
| 1308 | 1322 | resume(Effect.succeed(null)); |
| 1309 | 1323 | }); |
| 1324 | const cancel = cancelPickSession(); |
| 1310 | 1325 | const onMessage = (_event: Electron.IpcMainEvent, ...args: unknown[]): void => { |
| 1311 | 1326 | const payload = args[0]; |
| 1312 | 1327 | if (!isPreviewAnnotationPayload(payload)) { |
| ... | ... | @@ -1330,24 +1345,25 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1330 | 1345 | }; |
| 1331 | 1346 | const onDestroyed = () => settle(null); |
| 1332 | 1347 | const onNavigated = () => settle(null); |
| 1348 | const registerPickElement = Effect.fn("PreviewManager.registerPickElement")(function* () { |
| 1349 | yield* attempt("pickElement.register", () => { |
| 1350 | wc.ipc.on(ELEMENT_PICKED_CHANNEL, onMessage); |
| 1351 | wc.once("destroyed", onDestroyed); |
| 1352 | wc.once("did-start-navigation", onNavigated); |
| 1353 | if (!wc.isFocused()) wc.focus(); |
| 1354 | wc.send(START_PICK_CHANNEL, annotationTheme); |
| 1355 | }); |
| 1356 | yield* Ref.update(pickSessionsRef, (sessions) => |
| 1357 | replaceMap(sessions, (copy) => { |
| 1358 | copy.set(tabId, { cancel }); |
| 1359 | }), |
| 1360 | ); |
| 1361 | }); |
| 1333 | 1362 | runFork( |
| 1334 | | Effect.gen(function* () { |
| 1335 | | yield* attempt("pickElement.register", () => { |
| 1336 | | wc.ipc.on(ELEMENT_PICKED_CHANNEL, onMessage); |
| 1337 | | wc.once("destroyed", onDestroyed); |
| 1338 | | wc.once("did-start-navigation", onNavigated); |
| 1339 | | if (!wc.isFocused()) wc.focus(); |
| 1340 | | wc.send(START_PICK_CHANNEL, annotationTheme); |
| 1341 | | }); |
| 1342 | | yield* Ref.update(pickSessionsRef, (sessions) => |
| 1343 | | replaceMap(sessions, (copy) => { |
| 1344 | | copy.set(tabId, { cancel }); |
| 1345 | | }), |
| 1346 | | ); |
| 1347 | | }).pipe( |
| 1363 | registerPickElement().pipe( |
| 1348 | 1364 | Effect.catch((error: PreviewManagerError) => { |
| 1349 | 1365 | resume(Effect.fail(error)); |
| 1350 | | return cleanup; |
| 1366 | return cleanup(); |
| 1351 | 1367 | }), |
| 1352 | 1368 | ), |
| 1353 | 1369 | ); |
| ... | ... | @@ -1401,6 +1417,19 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1401 | 1417 | }; |
| 1402 | 1418 | }); |
| 1403 | 1419 | |
| 1420 | const startScreencast = Effect.fn("PreviewManager.startScreencast")(function* ( |
| 1421 | send: SendCommand, |
| 1422 | ) { |
| 1423 | yield* send("Page.enable"); |
| 1424 | yield* send("Page.startScreencast", { |
| 1425 | format: "jpeg", |
| 1426 | quality: 80, |
| 1427 | maxWidth: 1600, |
| 1428 | maxHeight: 1200, |
| 1429 | everyNthFrame: 1, |
| 1430 | }); |
| 1431 | }); |
| 1432 | |
| 1404 | 1433 | const startRecording = Effect.fn("PreviewManager.startRecording")(function* (tabId: string) { |
| 1405 | 1434 | const recordingTabId = yield* Ref.get(recordingTabIdRef); |
| 1406 | 1435 | if (Option.isSome(recordingTabId) && recordingTabId.value !== tabId) { |
| ... | ... | @@ -1410,18 +1439,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1410 | 1439 | ); |
| 1411 | 1440 | } |
| 1412 | 1441 | const wc = yield* requireWebContents(tabId); |
| 1413 | | yield* withControlSession(tabId, wc, "recording.start", (send) => |
| 1414 | | Effect.gen(function* () { |
| 1415 | | yield* send("Page.enable"); |
| 1416 | | yield* send("Page.startScreencast", { |
| 1417 | | format: "jpeg", |
| 1418 | | quality: 80, |
| 1419 | | maxWidth: 1600, |
| 1420 | | maxHeight: 1200, |
| 1421 | | everyNthFrame: 1, |
| 1422 | | }); |
| 1423 | | }), |
| 1424 | | ); |
| 1442 | yield* withControlSession(tabId, wc, "recording.start", startScreencast); |
| 1425 | 1443 | yield* Ref.set(recordingTabIdRef, Option.some(tabId)); |
| 1426 | 1444 | }); |
| 1427 | 1445 | |
| ... | ... | @@ -1493,104 +1511,108 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1493 | 1511 | }; |
| 1494 | 1512 | }); |
| 1495 | 1513 | |
| 1514 | const captureAutomationSnapshot = Effect.fn("PreviewManager.captureAutomationSnapshot")( |
| 1515 | function* (tabId: string, wc: Electron.WebContents, send: SendCommand) { |
| 1516 | yield* Effect.all([send("Runtime.enable"), send("Accessibility.enable")], { |
| 1517 | concurrency: 2, |
| 1518 | discard: true, |
| 1519 | }); |
| 1520 | const page = yield* evaluateWithDebugger<{ |
| 1521 | url: string; |
| 1522 | title: string; |
| 1523 | loading: boolean; |
| 1524 | visibleText: string; |
| 1525 | interactiveElements: PreviewAutomationSnapshot["interactiveElements"]; |
| 1526 | }>( |
| 1527 | send, |
| 1528 | `(() => { |
| 1529 | const selectorFor = (element) => { |
| 1530 | if (element.id) return "#" + CSS.escape(element.id); |
| 1531 | for (const attribute of ["data-testid", "name"]) { |
| 1532 | const value = element.getAttribute(attribute); |
| 1533 | if (value) return element.tagName.toLowerCase() + "[" + attribute + "=" + JSON.stringify(value) + "]"; |
| 1534 | } |
| 1535 | const buildParts = (current, parts = []) => { |
| 1536 | if (!current || current.nodeType !== Node.ELEMENT_NODE || parts.length >= 8) { |
| 1537 | return parts; |
| 1538 | } |
| 1539 | const parent = current.parentElement; |
| 1540 | const siblings = parent |
| 1541 | ? Array.from(parent.children).filter((child) => child.tagName === current.tagName) |
| 1542 | : []; |
| 1543 | const base = current.tagName.toLowerCase(); |
| 1544 | const part = siblings.length > 1 |
| 1545 | ? base + ":nth-of-type(" + (siblings.indexOf(current) + 1) + ")" |
| 1546 | : base; |
| 1547 | return buildParts(parent, [part, ...parts]); |
| 1548 | }; |
| 1549 | return buildParts(element).join(" > "); |
| 1550 | }; |
| 1551 | const visible = (element) => { |
| 1552 | const style = getComputedStyle(element); |
| 1553 | const rect = element.getBoundingClientRect(); |
| 1554 | return style.visibility !== "hidden" && style.display !== "none" && rect.width > 0 && rect.height > 0; |
| 1555 | }; |
| 1556 | const elements = Array.from(document.querySelectorAll( |
| 1557 | "a[href],button,input,textarea,select,[role],[tabindex]" |
| 1558 | )).filter(visible).slice(0, ${MAX_INTERACTIVE_ELEMENTS}).map((element) => { |
| 1559 | const rect = element.getBoundingClientRect(); |
| 1560 | return { |
| 1561 | tag: element.tagName.toLowerCase(), |
| 1562 | role: element.getAttribute("role"), |
| 1563 | name: element.getAttribute("aria-label") || element.innerText || element.getAttribute("name") || "", |
| 1564 | selector: selectorFor(element), |
| 1565 | x: rect.x, |
| 1566 | y: rect.y, |
| 1567 | width: rect.width, |
| 1568 | height: rect.height |
| 1569 | }; |
| 1570 | }); |
| 1571 | return { |
| 1572 | url: location.href, |
| 1573 | title: document.title, |
| 1574 | loading: document.readyState !== "complete", |
| 1575 | visibleText: (document.body?.innerText || "").slice(0, ${MAX_VISIBLE_TEXT_LENGTH}), |
| 1576 | interactiveElements: elements |
| 1577 | }; |
| 1578 | })()`, |
| 1579 | true, |
| 1580 | ); |
| 1581 | const [accessibility, sourceImage, diagnostics, timelines] = yield* Effect.all([ |
| 1582 | send("Accessibility.getFullAXTree"), |
| 1583 | attemptPromise("automationSnapshot.capturePage", () => wc.capturePage()), |
| 1584 | Ref.get(diagnosticsRef), |
| 1585 | Ref.get(actionTimelineRef), |
| 1586 | ]); |
| 1587 | const sourceSize = sourceImage.getSize(); |
| 1588 | const image = |
| 1589 | sourceSize.width > MAX_SCREENSHOT_WIDTH |
| 1590 | ? sourceImage.resize({ width: MAX_SCREENSHOT_WIDTH }) |
| 1591 | : sourceImage; |
| 1592 | const size = image.getSize(); |
| 1593 | const browserDiagnostics = diagnostics.get(wc.id); |
| 1594 | return { |
| 1595 | ...page, |
| 1596 | accessibilityTree: accessibility, |
| 1597 | consoleEntries: [...(browserDiagnostics?.consoleEntries ?? [])], |
| 1598 | networkEntries: [...(browserDiagnostics?.networkEntries ?? [])], |
| 1599 | actionTimeline: [...(timelines.get(tabId) ?? [])], |
| 1600 | screenshot: { |
| 1601 | mimeType: "image/png" as const, |
| 1602 | data: image.toPNG().toString("base64"), |
| 1603 | width: size.width, |
| 1604 | height: size.height, |
| 1605 | }, |
| 1606 | }; |
| 1607 | }, |
| 1608 | ); |
| 1609 | |
| 1496 | 1610 | const automationSnapshot = Effect.fn("PreviewManager.automationSnapshot")(function* ( |
| 1497 | 1611 | tabId: string, |
| 1498 | 1612 | ) { |
| 1499 | 1613 | const wc = yield* requireWebContents(tabId); |
| 1500 | 1614 | return yield* withControlSession(tabId, wc, "snapshot", (send) => |
| 1501 | | Effect.gen(function* () { |
| 1502 | | yield* Effect.all([send("Runtime.enable"), send("Accessibility.enable")], { |
| 1503 | | concurrency: 2, |
| 1504 | | discard: true, |
| 1505 | | }); |
| 1506 | | const page = yield* evaluateWithDebugger<{ |
| 1507 | | url: string; |
| 1508 | | title: string; |
| 1509 | | loading: boolean; |
| 1510 | | visibleText: string; |
| 1511 | | interactiveElements: PreviewAutomationSnapshot["interactiveElements"]; |
| 1512 | | }>( |
| 1513 | | send, |
| 1514 | | `(() => { |
| 1515 | | const selectorFor = (element) => { |
| 1516 | | if (element.id) return "#" + CSS.escape(element.id); |
| 1517 | | for (const attribute of ["data-testid", "name"]) { |
| 1518 | | const value = element.getAttribute(attribute); |
| 1519 | | if (value) return element.tagName.toLowerCase() + "[" + attribute + "=" + JSON.stringify(value) + "]"; |
| 1520 | | } |
| 1521 | | const buildParts = (current, parts = []) => { |
| 1522 | | if (!current || current.nodeType !== Node.ELEMENT_NODE || parts.length >= 8) { |
| 1523 | | return parts; |
| 1524 | | } |
| 1525 | | const parent = current.parentElement; |
| 1526 | | const siblings = parent |
| 1527 | | ? Array.from(parent.children).filter((child) => child.tagName === current.tagName) |
| 1528 | | : []; |
| 1529 | | const base = current.tagName.toLowerCase(); |
| 1530 | | const part = siblings.length > 1 |
| 1531 | | ? base + ":nth-of-type(" + (siblings.indexOf(current) + 1) + ")" |
| 1532 | | : base; |
| 1533 | | return buildParts(parent, [part, ...parts]); |
| 1534 | | }; |
| 1535 | | return buildParts(element).join(" > "); |
| 1536 | | }; |
| 1537 | | const visible = (element) => { |
| 1538 | | const style = getComputedStyle(element); |
| 1539 | | const rect = element.getBoundingClientRect(); |
| 1540 | | return style.visibility !== "hidden" && style.display !== "none" && rect.width > 0 && rect.height > 0; |
| 1541 | | }; |
| 1542 | | const elements = Array.from(document.querySelectorAll( |
| 1543 | | "a[href],button,input,textarea,select,[role],[tabindex]" |
| 1544 | | )).filter(visible).slice(0, ${MAX_INTERACTIVE_ELEMENTS}).map((element) => { |
| 1545 | | const rect = element.getBoundingClientRect(); |
| 1546 | | return { |
| 1547 | | tag: element.tagName.toLowerCase(), |
| 1548 | | role: element.getAttribute("role"), |
| 1549 | | name: element.getAttribute("aria-label") || element.innerText || element.getAttribute("name") || "", |
| 1550 | | selector: selectorFor(element), |
| 1551 | | x: rect.x, |
| 1552 | | y: rect.y, |
| 1553 | | width: rect.width, |
| 1554 | | height: rect.height |
| 1555 | | }; |
| 1556 | | }); |
| 1557 | | return { |
| 1558 | | url: location.href, |
| 1559 | | title: document.title, |
| 1560 | | loading: document.readyState !== "complete", |
| 1561 | | visibleText: (document.body?.innerText || "").slice(0, ${MAX_VISIBLE_TEXT_LENGTH}), |
| 1562 | | interactiveElements: elements |
| 1563 | | }; |
| 1564 | | })()`, |
| 1565 | | true, |
| 1566 | | ); |
| 1567 | | const [accessibility, sourceImage, diagnostics, timelines] = yield* Effect.all([ |
| 1568 | | send("Accessibility.getFullAXTree"), |
| 1569 | | attemptPromise("automationSnapshot.capturePage", () => wc.capturePage()), |
| 1570 | | Ref.get(diagnosticsRef), |
| 1571 | | Ref.get(actionTimelineRef), |
| 1572 | | ]); |
| 1573 | | const sourceSize = sourceImage.getSize(); |
| 1574 | | const image = |
| 1575 | | sourceSize.width > MAX_SCREENSHOT_WIDTH |
| 1576 | | ? sourceImage.resize({ width: MAX_SCREENSHOT_WIDTH }) |
| 1577 | | : sourceImage; |
| 1578 | | const size = image.getSize(); |
| 1579 | | const browserDiagnostics = diagnostics.get(wc.id); |
| 1580 | | return { |
| 1581 | | ...page, |
| 1582 | | accessibilityTree: accessibility, |
| 1583 | | consoleEntries: [...(browserDiagnostics?.consoleEntries ?? [])], |
| 1584 | | networkEntries: [...(browserDiagnostics?.networkEntries ?? [])], |
| 1585 | | actionTimeline: [...(timelines.get(tabId) ?? [])], |
| 1586 | | screenshot: { |
| 1587 | | mimeType: "image/png" as const, |
| 1588 | | data: image.toPNG().toString("base64"), |
| 1589 | | width: size.width, |
| 1590 | | height: size.height, |
| 1591 | | }, |
| 1592 | | }; |
| 1593 | | }), |
| 1615 | captureAutomationSnapshot(tabId, wc, send), |
| 1594 | 1616 | ); |
| 1595 | 1617 | }); |
| 1596 | 1618 | |
| ... | ... | @@ -1657,66 +1679,72 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1657 | 1679 | ); |
| 1658 | 1680 | }); |
| 1659 | 1681 | |
| 1682 | const performAutomationClick = Effect.fn("PreviewManager.performAutomationClick")(function* ( |
| 1683 | tabId: string, |
| 1684 | input: PreviewAutomationClickInput, |
| 1685 | send: SendCommand, |
| 1686 | ) { |
| 1687 | yield* Effect.all( |
| 1688 | [send("Runtime.enable"), send("Input.setIgnoreInputEvents", { ignore: false })], |
| 1689 | { concurrency: 2, discard: true }, |
| 1690 | ); |
| 1691 | const point = yield* resolveClickPoint(send, input); |
| 1692 | const viewport = yield* evaluateWithDebugger<{ width: number; height: number }>( |
| 1693 | send, |
| 1694 | "({ width: window.innerWidth, height: window.innerHeight })", |
| 1695 | true, |
| 1696 | ); |
| 1697 | if (point.x < 0 || point.y < 0 || point.x > viewport.width || point.y > viewport.height) { |
| 1698 | return yield* fail( |
| 1699 | "automationClick", |
| 1700 | automationError( |
| 1701 | "PreviewAutomationExecutionError", |
| 1702 | `Click coordinates (${point.x}, ${point.y}) are outside the preview viewport.`, |
| 1703 | ), |
| 1704 | ); |
| 1705 | } |
| 1706 | const moveSequence = yield* nextCounter(pointerSequenceRef); |
| 1707 | const moveCreatedAt = yield* currentIso; |
| 1708 | yield* emitPointerEvent({ |
| 1709 | tabId, |
| 1710 | phase: "move", |
| 1711 | ...point, |
| 1712 | sequence: moveSequence, |
| 1713 | createdAt: moveCreatedAt, |
| 1714 | }); |
| 1715 | yield* Effect.sleep(AGENT_CURSOR_MOVE_MS); |
| 1716 | const clickSequence = yield* nextCounter(pointerSequenceRef); |
| 1717 | const clickCreatedAt = yield* currentIso; |
| 1718 | yield* emitPointerEvent({ |
| 1719 | tabId, |
| 1720 | phase: "click", |
| 1721 | ...point, |
| 1722 | sequence: clickSequence, |
| 1723 | createdAt: clickCreatedAt, |
| 1724 | }); |
| 1725 | yield* Effect.sleep(AGENT_CURSOR_CLICK_LEAD_MS); |
| 1726 | yield* expectAgentInput(tabId, { kind: "pointer", ...point, button: 0 }); |
| 1727 | yield* send("Input.dispatchMouseEvent", { |
| 1728 | type: "mousePressed", |
| 1729 | ...point, |
| 1730 | button: "left", |
| 1731 | clickCount: 1, |
| 1732 | }); |
| 1733 | yield* send("Input.dispatchMouseEvent", { |
| 1734 | type: "mouseReleased", |
| 1735 | ...point, |
| 1736 | button: "left", |
| 1737 | clickCount: 1, |
| 1738 | }); |
| 1739 | }); |
| 1740 | |
| 1660 | 1741 | const automationClick = Effect.fn("PreviewManager.automationClick")(function* ( |
| 1661 | 1742 | tabId: string, |
| 1662 | 1743 | input: PreviewAutomationClickInput, |
| 1663 | 1744 | ) { |
| 1664 | 1745 | const wc = yield* requireWebContents(tabId); |
| 1665 | 1746 | yield* withControlSession(tabId, wc, "click", (send) => |
| 1666 | | Effect.gen(function* () { |
| 1667 | | yield* Effect.all( |
| 1668 | | [send("Runtime.enable"), send("Input.setIgnoreInputEvents", { ignore: false })], |
| 1669 | | { concurrency: 2, discard: true }, |
| 1670 | | ); |
| 1671 | | const point = yield* resolveClickPoint(send, input); |
| 1672 | | const viewport = yield* evaluateWithDebugger<{ width: number; height: number }>( |
| 1673 | | send, |
| 1674 | | "({ width: window.innerWidth, height: window.innerHeight })", |
| 1675 | | true, |
| 1676 | | ); |
| 1677 | | if (point.x < 0 || point.y < 0 || point.x > viewport.width || point.y > viewport.height) { |
| 1678 | | return yield* fail( |
| 1679 | | "automationClick", |
| 1680 | | automationError( |
| 1681 | | "PreviewAutomationExecutionError", |
| 1682 | | `Click coordinates (${point.x}, ${point.y}) are outside the preview viewport.`, |
| 1683 | | ), |
| 1684 | | ); |
| 1685 | | } |
| 1686 | | const moveSequence = yield* nextCounter(pointerSequenceRef); |
| 1687 | | const moveCreatedAt = yield* currentIso; |
| 1688 | | yield* emitPointerEvent({ |
| 1689 | | tabId, |
| 1690 | | phase: "move", |
| 1691 | | ...point, |
| 1692 | | sequence: moveSequence, |
| 1693 | | createdAt: moveCreatedAt, |
| 1694 | | }); |
| 1695 | | yield* Effect.sleep(AGENT_CURSOR_MOVE_MS); |
| 1696 | | const clickSequence = yield* nextCounter(pointerSequenceRef); |
| 1697 | | const clickCreatedAt = yield* currentIso; |
| 1698 | | yield* emitPointerEvent({ |
| 1699 | | tabId, |
| 1700 | | phase: "click", |
| 1701 | | ...point, |
| 1702 | | sequence: clickSequence, |
| 1703 | | createdAt: clickCreatedAt, |
| 1704 | | }); |
| 1705 | | yield* Effect.sleep(AGENT_CURSOR_CLICK_LEAD_MS); |
| 1706 | | yield* expectAgentInput(tabId, { kind: "pointer", ...point, button: 0 }); |
| 1707 | | yield* send("Input.dispatchMouseEvent", { |
| 1708 | | type: "mousePressed", |
| 1709 | | ...point, |
| 1710 | | button: "left", |
| 1711 | | clickCount: 1, |
| 1712 | | }); |
| 1713 | | yield* send("Input.dispatchMouseEvent", { |
| 1714 | | type: "mouseReleased", |
| 1715 | | ...point, |
| 1716 | | button: "left", |
| 1717 | | clickCount: 1, |
| 1718 | | }); |
| 1719 | | }), |
| 1747 | performAutomationClick(tabId, input, send), |
| 1720 | 1748 | ); |
| 1721 | 1749 | }); |
| 1722 | 1750 | |
| ... | ... | @@ -1769,171 +1797,188 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1769 | 1797 | } |
| 1770 | 1798 | }); |
| 1771 | 1799 | |
| 1800 | const performAutomationType = Effect.fn("PreviewManager.performAutomationType")(function* ( |
| 1801 | tabId: string, |
| 1802 | input: PreviewAutomationTypeInput, |
| 1803 | send: SendCommand, |
| 1804 | ) { |
| 1805 | yield* send("Runtime.enable"); |
| 1806 | yield* focusAutomationTarget(send, input); |
| 1807 | yield* send("Input.insertText", { text: input.text }); |
| 1808 | const textJson = yield* encodeJson("automationType.encodeText", input.text); |
| 1809 | yield* evaluateWithDebugger( |
| 1810 | send, |
| 1811 | `(() => { |
| 1812 | const element = document.activeElement; |
| 1813 | element?.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: ${textJson} })); |
| 1814 | element?.dispatchEvent(new Event("change", { bubbles: true })); |
| 1815 | })()`, |
| 1816 | false, |
| 1817 | ); |
| 1818 | }); |
| 1819 | |
| 1772 | 1820 | const automationType = Effect.fn("PreviewManager.automationType")(function* ( |
| 1773 | 1821 | tabId: string, |
| 1774 | 1822 | input: PreviewAutomationTypeInput, |
| 1775 | 1823 | ) { |
| 1776 | 1824 | const wc = yield* requireWebContents(tabId); |
| 1777 | 1825 | yield* withControlSession(tabId, wc, "type", (send) => |
| 1778 | | Effect.gen(function* () { |
| 1779 | | yield* send("Runtime.enable"); |
| 1780 | | yield* focusAutomationTarget(send, input); |
| 1781 | | yield* send("Input.insertText", { text: input.text }); |
| 1782 | | const textJson = yield* encodeJson("automationType.encodeText", input.text); |
| 1783 | | yield* evaluateWithDebugger( |
| 1784 | | send, |
| 1785 | | `(() => { |
| 1786 | | const element = document.activeElement; |
| 1787 | | element?.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: ${textJson} })); |
| 1788 | | element?.dispatchEvent(new Event("change", { bubbles: true })); |
| 1789 | | })()`, |
| 1790 | | false, |
| 1791 | | ); |
| 1792 | | }), |
| 1826 | performAutomationType(tabId, input, send), |
| 1793 | 1827 | ); |
| 1794 | 1828 | }); |
| 1795 | 1829 | |
| 1830 | const performAutomationPress = Effect.fn("PreviewManager.performAutomationPress")(function* ( |
| 1831 | tabId: string, |
| 1832 | input: PreviewAutomationPressInput, |
| 1833 | send: SendCommand, |
| 1834 | ) { |
| 1835 | const modifiers = (input.modifiers ?? []).reduce((value, modifier) => { |
| 1836 | switch (modifier) { |
| 1837 | case "Alt": |
| 1838 | return value | 1; |
| 1839 | case "Control": |
| 1840 | return value | 2; |
| 1841 | case "Meta": |
| 1842 | return value | 4; |
| 1843 | case "Shift": |
| 1844 | return value | 8; |
| 1845 | } |
| 1846 | }, 0); |
| 1847 | const key = input.key; |
| 1848 | const text = key.length === 1 ? key : undefined; |
| 1849 | const params = { |
| 1850 | key, |
| 1851 | code: key.length === 1 ? `Key${key.toUpperCase()}` : key, |
| 1852 | modifiers, |
| 1853 | ...(text ? { text, unmodifiedText: text } : {}), |
| 1854 | }; |
| 1855 | yield* expectAgentInput(tabId, { kind: "key", key, code: params.code }); |
| 1856 | yield* send("Input.dispatchKeyEvent", { type: "keyDown", ...params }); |
| 1857 | yield* send("Input.dispatchKeyEvent", { type: "keyUp", ...params }); |
| 1858 | }); |
| 1859 | |
| 1796 | 1860 | const automationPress = Effect.fn("PreviewManager.automationPress")(function* ( |
| 1797 | 1861 | tabId: string, |
| 1798 | 1862 | input: PreviewAutomationPressInput, |
| 1799 | 1863 | ) { |
| 1800 | 1864 | const wc = yield* requireWebContents(tabId); |
| 1801 | 1865 | yield* withControlSession(tabId, wc, "press", (send) => |
| 1802 | | Effect.gen(function* () { |
| 1803 | | const modifiers = (input.modifiers ?? []).reduce((value, modifier) => { |
| 1804 | | switch (modifier) { |
| 1805 | | case "Alt": |
| 1806 | | return value | 1; |
| 1807 | | case "Control": |
| 1808 | | return value | 2; |
| 1809 | | case "Meta": |
| 1810 | | return value | 4; |
| 1811 | | case "Shift": |
| 1812 | | return value | 8; |
| 1813 | | } |
| 1814 | | }, 0); |
| 1815 | | const key = input.key; |
| 1816 | | const text = key.length === 1 ? key : undefined; |
| 1817 | | const params = { |
| 1818 | | key, |
| 1819 | | code: key.length === 1 ? `Key${key.toUpperCase()}` : key, |
| 1820 | | modifiers, |
| 1821 | | ...(text ? { text, unmodifiedText: text } : {}), |
| 1822 | | }; |
| 1823 | | yield* expectAgentInput(tabId, { kind: "key", key, code: params.code }); |
| 1824 | | yield* send("Input.dispatchKeyEvent", { type: "keyDown", ...params }); |
| 1825 | | yield* send("Input.dispatchKeyEvent", { type: "keyUp", ...params }); |
| 1826 | | }), |
| 1866 | performAutomationPress(tabId, input, send), |
| 1827 | 1867 | ); |
| 1828 | 1868 | }); |
| 1829 | 1869 | |
| 1870 | const performAutomationScroll = Effect.fn("PreviewManager.performAutomationScroll")(function* ( |
| 1871 | tabId: string, |
| 1872 | input: PreviewAutomationScrollInput, |
| 1873 | send: SendCommand, |
| 1874 | ) { |
| 1875 | yield* send("Runtime.enable"); |
| 1876 | const locator = automationLocator(input); |
| 1877 | if (locator) yield* ensurePlaywrightInjected(send); |
| 1878 | const locatorJson = locator |
| 1879 | ? yield* encodeJson("automationScroll.encodeLocator", locator) |
| 1880 | : null; |
| 1881 | const result = yield* evaluateWithDebugger< |
| 1882 | { ok: true } | { invalidSelector: true; message: string } | { notFound: true } |
| 1883 | >( |
| 1884 | send, |
| 1885 | `(() => { |
| 1886 | try { |
| 1887 | const target = ${locatorJson ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${locatorJson}), document, true); })()` : "window"}; |
| 1888 | if (!target) return { notFound: true }; |
| 1889 | target.scrollBy({ left: ${input.deltaX ?? 0}, top: ${input.deltaY ?? 0}, behavior: "instant" }); |
| 1890 | return { ok: true }; |
| 1891 | } catch (error) { |
| 1892 | return { invalidSelector: true, message: String(error) }; |
| 1893 | } |
| 1894 | })()`, |
| 1895 | true, |
| 1896 | ); |
| 1897 | if ("invalidSelector" in result) { |
| 1898 | return yield* fail( |
| 1899 | "automationScroll", |
| 1900 | automationError("PreviewAutomationInvalidSelectorError", result.message, { |
| 1901 | selector: input.selector ?? "", |
| 1902 | }), |
| 1903 | ); |
| 1904 | } |
| 1905 | if ("notFound" in result) { |
| 1906 | return yield* fail( |
| 1907 | "automationScroll", |
| 1908 | automationError( |
| 1909 | "PreviewAutomationExecutionError", |
| 1910 | `No element matches locator ${locator}.`, |
| 1911 | ), |
| 1912 | ); |
| 1913 | } |
| 1914 | }); |
| 1915 | |
| 1830 | 1916 | const automationScroll = Effect.fn("PreviewManager.automationScroll")(function* ( |
| 1831 | 1917 | tabId: string, |
| 1832 | 1918 | input: PreviewAutomationScrollInput, |
| 1833 | 1919 | ) { |
| 1834 | 1920 | const wc = yield* requireWebContents(tabId); |
| 1835 | 1921 | yield* withControlSession(tabId, wc, "scroll", (send) => |
| 1836 | | Effect.gen(function* () { |
| 1837 | | yield* send("Runtime.enable"); |
| 1838 | | const locator = automationLocator(input); |
| 1839 | | if (locator) yield* ensurePlaywrightInjected(send); |
| 1840 | | const locatorJson = locator |
| 1841 | | ? yield* encodeJson("automationScroll.encodeLocator", locator) |
| 1842 | | : null; |
| 1843 | | const result = yield* evaluateWithDebugger< |
| 1844 | | { ok: true } | { invalidSelector: true; message: string } | { notFound: true } |
| 1845 | | >( |
| 1846 | | send, |
| 1847 | | `(() => { |
| 1848 | | try { |
| 1849 | | const target = ${locatorJson ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${locatorJson}), document, true); })()` : "window"}; |
| 1850 | | if (!target) return { notFound: true }; |
| 1851 | | target.scrollBy({ left: ${input.deltaX ?? 0}, top: ${input.deltaY ?? 0}, behavior: "instant" }); |
| 1852 | | return { ok: true }; |
| 1853 | | } catch (error) { |
| 1854 | | return { invalidSelector: true, message: String(error) }; |
| 1855 | | } |
| 1856 | | })()`, |
| 1857 | | true, |
| 1858 | | ); |
| 1859 | | if ("invalidSelector" in result) { |
| 1860 | | return yield* fail( |
| 1861 | | "automationScroll", |
| 1862 | | automationError("PreviewAutomationInvalidSelectorError", result.message, { |
| 1863 | | selector: input.selector ?? "", |
| 1864 | | }), |
| 1865 | | ); |
| 1866 | | } |
| 1867 | | if ("notFound" in result) { |
| 1868 | | return yield* fail( |
| 1869 | | "automationScroll", |
| 1870 | | automationError( |
| 1871 | | "PreviewAutomationExecutionError", |
| 1872 | | `No element matches locator ${locator}.`, |
| 1873 | | ), |
| 1874 | | ); |
| 1875 | | } |
| 1876 | | }), |
| 1922 | performAutomationScroll(tabId, input, send), |
| 1877 | 1923 | ); |
| 1878 | 1924 | }); |
| 1879 | 1925 | |
| 1926 | const performAutomationEvaluate = Effect.fn("PreviewManager.performAutomationEvaluate")( |
| 1927 | function* (input: PreviewAutomationEvaluateInput, send: SendCommand) { |
| 1928 | yield* send("Runtime.enable"); |
| 1929 | const value = yield* evaluateWithDebugger( |
| 1930 | send, |
| 1931 | input.expression, |
| 1932 | input.returnByValue ?? true, |
| 1933 | input.awaitPromise ?? true, |
| 1934 | ); |
| 1935 | const serialized = yield* encodeJson("automationEvaluate.encodeResult", value); |
| 1936 | if (Buffer.byteLength(serialized, "utf8") > MAX_EVALUATION_BYTES) { |
| 1937 | return yield* fail( |
| 1938 | "automationEvaluate", |
| 1939 | automationError( |
| 1940 | "PreviewAutomationResultTooLargeError", |
| 1941 | `Evaluation result exceeds ${MAX_EVALUATION_BYTES} bytes.`, |
| 1942 | { maximumBytes: MAX_EVALUATION_BYTES }, |
| 1943 | ), |
| 1944 | ); |
| 1945 | } |
| 1946 | return value; |
| 1947 | }, |
| 1948 | ); |
| 1949 | |
| 1880 | 1950 | const automationEvaluate = Effect.fn("PreviewManager.automationEvaluate")(function* ( |
| 1881 | 1951 | tabId: string, |
| 1882 | 1952 | input: PreviewAutomationEvaluateInput, |
| 1883 | 1953 | ) { |
| 1884 | 1954 | const wc = yield* requireWebContents(tabId); |
| 1885 | 1955 | return yield* withControlSession(tabId, wc, "evaluate", (send) => |
| 1886 | | Effect.gen(function* () { |
| 1887 | | yield* send("Runtime.enable"); |
| 1888 | | const value = yield* evaluateWithDebugger( |
| 1889 | | send, |
| 1890 | | input.expression, |
| 1891 | | input.returnByValue ?? true, |
| 1892 | | input.awaitPromise ?? true, |
| 1893 | | ); |
| 1894 | | const serialized = yield* encodeJson("automationEvaluate.encodeResult", value); |
| 1895 | | if (Buffer.byteLength(serialized, "utf8") > MAX_EVALUATION_BYTES) { |
| 1896 | | return yield* fail( |
| 1897 | | "automationEvaluate", |
| 1898 | | automationError( |
| 1899 | | "PreviewAutomationResultTooLargeError", |
| 1900 | | `Evaluation result exceeds ${MAX_EVALUATION_BYTES} bytes.`, |
| 1901 | | { maximumBytes: MAX_EVALUATION_BYTES }, |
| 1902 | | ), |
| 1903 | | ); |
| 1904 | | } |
| 1905 | | return value; |
| 1906 | | }), |
| 1956 | performAutomationEvaluate(input, send), |
| 1907 | 1957 | ); |
| 1908 | 1958 | }); |
| 1909 | 1959 | |
| 1910 | | const automationWaitFor = Effect.fn("PreviewManager.automationWaitFor")(function* ( |
| 1911 | | tabId: string, |
| 1960 | const performAutomationWaitFor = Effect.fn("PreviewManager.performAutomationWaitFor")(function* ( |
| 1912 | 1961 | input: PreviewAutomationWaitForInput, |
| 1962 | send: SendCommand, |
| 1913 | 1963 | ) { |
| 1914 | | const wc = yield* requireWebContents(tabId); |
| 1915 | 1964 | const timeoutMs = input.timeoutMs ?? 15_000; |
| 1916 | | yield* withControlSession(tabId, wc, "waitFor", (send) => |
| 1917 | | Effect.gen(function* () { |
| 1918 | | yield* send("Runtime.enable"); |
| 1919 | | const locator = automationLocator(input); |
| 1920 | | if (locator) yield* ensurePlaywrightInjected(send); |
| 1921 | | const [locatorJson, textJson, urlIncludesJson] = yield* Effect.all([ |
| 1922 | | locator ? encodeJson("automationWaitFor.encodeLocator", locator) : Effect.succeed(null), |
| 1923 | | input.text |
| 1924 | | ? encodeJson("automationWaitFor.encodeText", input.text) |
| 1925 | | : Effect.succeed(null), |
| 1926 | | input.urlIncludes |
| 1927 | | ? encodeJson("automationWaitFor.encodeUrl", input.urlIncludes) |
| 1928 | | : Effect.succeed(null), |
| 1929 | | ]); |
| 1930 | | const deadline = (yield* currentMillis) + timeoutMs; |
| 1931 | | while ((yield* currentMillis) <= deadline) { |
| 1932 | | const result = yield* evaluateWithDebugger< |
| 1933 | | { matched: boolean } | { invalidSelector: true; message: string } |
| 1934 | | >( |
| 1935 | | send, |
| 1936 | | `(() => { |
| 1965 | yield* send("Runtime.enable"); |
| 1966 | const locator = automationLocator(input); |
| 1967 | if (locator) yield* ensurePlaywrightInjected(send); |
| 1968 | const [locatorJson, textJson, urlIncludesJson] = yield* Effect.all([ |
| 1969 | locator ? encodeJson("automationWaitFor.encodeLocator", locator) : Effect.succeed(null), |
| 1970 | input.text ? encodeJson("automationWaitFor.encodeText", input.text) : Effect.succeed(null), |
| 1971 | input.urlIncludes |
| 1972 | ? encodeJson("automationWaitFor.encodeUrl", input.urlIncludes) |
| 1973 | : Effect.succeed(null), |
| 1974 | ]); |
| 1975 | const deadline = (yield* currentMillis) + timeoutMs; |
| 1976 | while ((yield* currentMillis) <= deadline) { |
| 1977 | const result = yield* evaluateWithDebugger< |
| 1978 | { matched: boolean } | { invalidSelector: true; message: string } |
| 1979 | >( |
| 1980 | send, |
| 1981 | `(() => { |
| 1937 | 1982 | try { |
| 1938 | 1983 | const selectorMatched = ${locatorJson ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${locatorJson}), document, false) !== null; })()` : "true"}; |
| 1939 | 1984 | const textMatched = ${ |
| ... | ... | @@ -1947,27 +1992,35 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1947 | 1992 | return { invalidSelector: true, message: String(error) }; |
| 1948 | 1993 | } |
| 1949 | 1994 | })()`, |
| 1950 | | true, |
| 1951 | | ); |
| 1952 | | if ("invalidSelector" in result) { |
| 1953 | | return yield* fail( |
| 1954 | | "automationWaitFor", |
| 1955 | | automationError("PreviewAutomationInvalidSelectorError", result.message, { |
| 1956 | | selector: input.selector ?? "", |
| 1957 | | }), |
| 1958 | | ); |
| 1959 | | } |
| 1960 | | if (result.matched) return; |
| 1961 | | yield* Effect.sleep(100); |
| 1962 | | } |
| 1995 | true, |
| 1996 | ); |
| 1997 | if ("invalidSelector" in result) { |
| 1963 | 1998 | return yield* fail( |
| 1964 | 1999 | "automationWaitFor", |
| 1965 | | automationError( |
| 1966 | | "PreviewAutomationTimeoutError", |
| 1967 | | `Preview condition did not match within ${timeoutMs}ms.`, |
| 1968 | | ), |
| 2000 | automationError("PreviewAutomationInvalidSelectorError", result.message, { |
| 2001 | selector: input.selector ?? "", |
| 2002 | }), |
| 1969 | 2003 | ); |
| 1970 | | }), |
| 2004 | } |
| 2005 | if (result.matched) return; |
| 2006 | yield* Effect.sleep(100); |
| 2007 | } |
| 2008 | return yield* fail( |
| 2009 | "automationWaitFor", |
| 2010 | automationError( |
| 2011 | "PreviewAutomationTimeoutError", |
| 2012 | `Preview condition did not match within ${timeoutMs}ms.`, |
| 2013 | ), |
| 2014 | ); |
| 2015 | }); |
| 2016 | |
| 2017 | const automationWaitFor = Effect.fn("PreviewManager.automationWaitFor")(function* ( |
| 2018 | tabId: string, |
| 2019 | input: PreviewAutomationWaitForInput, |
| 2020 | ) { |
| 2021 | const wc = yield* requireWebContents(tabId); |
| 2022 | yield* withControlSession(tabId, wc, "waitFor", (send) => |
| 2023 | performAutomationWaitFor(input, send), |
| 1971 | 2024 | ); |
| 1972 | 2025 | }); |
| 1973 | 2026 | |