| author | |
| committer | |
| log | d8fe3d72c82cc3cc6a6e9275494f5e5377b058a6 |
| tree | 695738c2f90e2de2782ac32d727cb9f5495829e1 |
| parent | 70ec39ae8094d06eb7a241a007bbc2b0892c2896 |
- Tie preview and debugger listeners to Effect scopes
- Factor shared automation helpers for snapshot and input handling
- Improve cleanup for browser preview sessions and port scanning13 files changed, 853 insertions(+), 747 deletions(-)
apps/desktop/src/preview/Manager.ts+544-491| ... | @@ -48,7 +48,7 @@ import * as Path from "effect/Path"; | ... | @@ -48,7 +48,7 @@ import * as Path from "effect/Path"; |
| 48 | import * as Ref from "effect/Ref"; | 48 | import * as Ref from "effect/Ref"; |
| 49 | import * as Schema from "effect/Schema"; | 49 | import * as Schema from "effect/Schema"; |
| 50 | import * as Semaphore from "effect/Semaphore"; | 50 | import * as Semaphore from "effect/Semaphore"; |
| 51 | import type * as Scope from "effect/Scope"; | 51 | import * as Scope from "effect/Scope"; |
| 52 | import * as SynchronizedRef from "effect/SynchronizedRef"; | 52 | import * as SynchronizedRef from "effect/SynchronizedRef"; |
| 53 | 53 | ||
| 54 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; | 54 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; |
| ... | @@ -247,10 +247,7 @@ type PreviewInputSignal = | ... | @@ -247,10 +247,7 @@ type PreviewInputSignal = |
| 247 | | { readonly kind: "key"; readonly key: string; readonly code: string }; | 247 | | { readonly kind: "key"; readonly key: string; readonly code: string }; |
| 248 | 248 | ||
| 249 | interface ManagedListeners { | 249 | interface ManagedListeners { |
| 250 | navigate: () => void; | 250 | readonly scope: Scope.Closeable; |
| 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; | ||
| 254 | } | 251 | } |
| 255 | 252 | ||
| 256 | interface PickSession { | 253 | interface PickSession { |
| ... | @@ -260,6 +257,7 @@ interface PickSession { | ... | @@ -260,6 +257,7 @@ interface PickSession { |
| 260 | interface BrowserControlSession { | 257 | interface BrowserControlSession { |
| 261 | readonly webContentsId: number; | 258 | readonly webContentsId: number; |
| 262 | readonly semaphore: Semaphore.Semaphore; | 259 | readonly semaphore: Semaphore.Semaphore; |
| 260 | readonly scope: Scope.Closeable; | ||
| 263 | readonly onMessage: ( | 261 | readonly onMessage: ( |
| 264 | event: Electron.Event, | 262 | event: Electron.Event, |
| 265 | method: string, | 263 | method: string, |
| ... | @@ -339,6 +337,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -339,6 +337,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 339 | ) { | 337 | ) { |
| 340 | const fileSystem = yield* FileSystem.FileSystem; | 338 | const fileSystem = yield* FileSystem.FileSystem; |
| 341 | const path = yield* Path.Path; | 339 | const path = yield* Path.Path; |
| 340 | const parentScope = yield* Scope.Scope; | ||
| 342 | const context = yield* Effect.context<never>(); | 341 | const context = yield* Effect.context<never>(); |
| 343 | const runFork = Effect.runForkWith(context); | 342 | const runFork = Effect.runForkWith(context); |
| 344 | const resolvedArtifactDirectory = path.resolve(artifactDirectory); | 343 | const resolvedArtifactDirectory = path.resolve(artifactDirectory); |
| ... | @@ -622,16 +621,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -622,16 +621,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 622 | copy.delete(webContentsId); | 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 | yield* Ref.update(diagnosticsRef, (diagnostics) => | 628 | yield* Ref.update(diagnosticsRef, (diagnostics) => |
| 626 | replaceMap(diagnostics, (copy) => { | 629 | replaceMap(diagnostics, (copy) => { |
| 627 | copy.delete(webContentsId); | 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 | const ensureControlSession = Effect.fn("PreviewManager.ensureControlSession")(function* ( | 635 | const ensureControlSession = Effect.fn("PreviewManager.ensureControlSession")(function* ( |
| ... | @@ -662,87 +660,103 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(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 | const semaphore = yield* Semaphore.make(1); | 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 | const onMessage: BrowserControlSession["onMessage"] = (_event, method, params) => { | 701 | const onMessage: BrowserControlSession["onMessage"] = (_event, method, params) => { |
| 668 | runFork( | 702 | runFork(handleDebuggerMessage(method, params)); |
| 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 | ); | ||
| 704 | }; | 703 | }; |
| 705 | const control: BrowserControlSession = { webContentsId: wc.id, semaphore, onMessage }; | 704 | yield* Scope.addFinalizer( |
| 706 | yield* Ref.update(diagnosticsRef, (diagnostics) => | 705 | scope, |
| 707 | replaceMap(diagnostics, (copy) => { | 706 | Effect.all( |
| 708 | copy.set(wc.id, { | 707 | [ |
| 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([ | ||
| 727 | Ref.update(diagnosticsRef, (diagnostics) => | 708 | Ref.update(diagnosticsRef, (diagnostics) => |
| 728 | replaceMap(diagnostics, (copy) => { | 709 | replaceMap(diagnostics, (copy) => { |
| 729 | copy.delete(wc.id); | 710 | copy.delete(wc.id); |
| 730 | }), | 711 | }), |
| 731 | ), | 712 | ), |
| 732 | attempt("detachFailedDebugger", () => { | 713 | attempt("detachControlSession", () => { |
| 733 | wc.debugger.off("message", onMessage); | 714 | wc.debugger.off("message", onMessage); |
| 734 | if (wc.debugger.isAttached()) wc.debugger.detach(); | 715 | if (wc.debugger.isAttached()) wc.debugger.detach(); |
| 735 | }).pipe(Effect.ignore), | 716 | }).pipe(Effect.ignore), |
| 736 | ]).pipe(Effect.asVoid), | 717 | ], |
| 718 | { discard: true }, | ||
| 737 | ), | 719 | ), |
| 738 | ); | 720 | ); |
| 739 | return [ | 721 | const control: BrowserControlSession = { |
| 740 | control, | 722 | webContentsId: wc.id, |
| 741 | replaceMap(sessions, (copy) => { | 723 | semaphore, |
| 742 | copy.set(wc.id, control); | 724 | scope, |
| 743 | }), | 725 | onMessage, |
| 744 | ] as const; | 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,25 +920,13 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 906 | const detachListeners = Effect.fn("PreviewManager.detachListeners")(function* ( | 920 | const detachListeners = Effect.fn("PreviewManager.detachListeners")(function* ( |
| 907 | webContentsId: number, | 921 | webContentsId: number, |
| 908 | ) { | 922 | ) { |
| 909 | const handlers = yield* Ref.modify(attachedRef, (attached) => [ | 923 | const managed = yield* Ref.modify(attachedRef, (attached) => [ |
| 910 | attached.get(webContentsId), | 924 | attached.get(webContentsId), |
| 911 | replaceMap(attached, (copy) => { | 925 | replaceMap(attached, (copy) => { |
| 912 | copy.delete(webContentsId); | 926 | copy.delete(webContentsId); |
| 913 | }), | 927 | }), |
| 914 | ]); | 928 | ]); |
| 915 | if (!handlers) return; | 929 | if (managed) yield* Scope.close(managed.scope, Exit.void).pipe(Effect.ignore); |
| 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); | ||
| 928 | }); | 930 | }); |
| 929 | 931 | ||
| 930 | const isAppShortcut = (input: Electron.Input): boolean => | 932 | const isAppShortcut = (input: Electron.Input): boolean => |
| ... | @@ -987,17 +989,16 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -987,17 +989,16 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 987 | tabId: string, | 989 | tabId: string, |
| 988 | wc: Electron.WebContents, | 990 | wc: Electron.WebContents, |
| 989 | ) { | 991 | ) { |
| 990 | const sync = () => | 992 | const scope = yield* Scope.fork(parentScope, "sequential"); |
| 991 | runFork( | 993 | const syncState = Effect.fn("PreviewManager.syncWebContentsState")(function* () { |
| 992 | Effect.gen(function* () { | 994 | if (wc.isDestroyed()) return; |
| 993 | if (wc.isDestroyed()) return; | 995 | yield* update(tabId, { |
| 994 | yield* update(tabId, { | 996 | navStatus: computeNavStatus(wc), |
| 995 | navStatus: computeNavStatus(wc), | 997 | canGoBack: wc.navigationHistory.canGoBack(), |
| 996 | canGoBack: wc.navigationHistory.canGoBack(), | 998 | canGoForward: wc.navigationHistory.canGoForward(), |
| 997 | canGoForward: wc.navigationHistory.canGoForward(), | 999 | }); |
| 998 | }); | 1000 | }); |
| 999 | }), | 1001 | const sync = () => runFork(syncState()); |
| 1000 | ); | ||
| 1001 | const failed = (_event: Event, code: number, description: string): void => { | 1002 | const failed = (_event: Event, code: number, description: string): void => { |
| 1002 | if (code === -3) return; | 1003 | if (code === -3) return; |
| 1003 | runFork( | 1004 | runFork( |
| ... | @@ -1012,73 +1013,85 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -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 | const handleHumanInput = Effect.fn("PreviewManager.handleHumanInput")(function* ( |
| 1016 | runFork( | 1017 | rawSignal?: unknown, |
| 1017 | Effect.gen(function* () { | 1018 | ) { |
| 1018 | if ( | 1019 | if (isPreviewInputSignal(rawSignal) && (yield* consumeExpectedAgentInput(tabId, rawSignal))) { |
| 1019 | isPreviewInputSignal(rawSignal) && | 1020 | return; |
| 1020 | (yield* consumeExpectedAgentInput(tabId, rawSignal)) | 1021 | } |
| 1021 | ) { | 1022 | yield* Ref.update(controlEpochRef, (epochs) => |
| 1022 | return; | 1023 | replaceMap(epochs, (copy) => { |
| 1023 | } | 1024 | copy.set(tabId, (epochs.get(tabId) ?? 0) + 1); |
| 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 | } | ||
| 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 | const beforeInput = (event: Electron.Event, input: Electron.Input): void => { | 1057 | const beforeInput = (event: Electron.Event, input: Electron.Input): void => { |
| 1039 | runFork( | 1058 | runFork(forwardShortcut(event, input)); |
| 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 | ); | ||
| 1062 | }; | 1059 | }; |
| 1063 | yield* attempt("attachListeners", () => { | 1060 | yield* Scope.addFinalizer( |
| 1064 | wc.on("did-navigate", sync); | 1061 | scope, |
| 1065 | wc.on("did-navigate-in-page", sync); | 1062 | attempt("detachListeners", () => { |
| 1066 | wc.on("page-title-updated", sync); | 1063 | wc.off("did-navigate", sync); |
| 1067 | wc.on("did-start-loading", sync); | 1064 | wc.off("did-navigate-in-page", sync); |
| 1068 | wc.on("did-stop-loading", sync); | 1065 | wc.off("page-title-updated", sync); |
| 1069 | wc.on("did-fail-load", failed as never); | 1066 | wc.off("did-start-loading", sync); |
| 1070 | wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput); | 1067 | wc.off("did-stop-loading", sync); |
| 1071 | wc.setWindowOpenHandler(({ url }) => { | 1068 | wc.off("did-fail-load", failed as never); |
| 1072 | runFork(attemptPromise("openPreviewWindow", () => wc.loadURL(url)).pipe(Effect.ignore)); | 1069 | wc.off("before-input-event", beforeInput); |
| 1073 | return { action: "deny" }; | 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) => | 1094 | yield* install().pipe(Effect.onError(() => Scope.close(scope, Exit.void).pipe(Effect.ignore))); |
| 1078 | replaceMap(attached, (copy) => { | ||
| 1079 | copy.set(wc.id, { navigate: sync, failed, humanInput, beforeInput }); | ||
| 1080 | }), | ||
| 1081 | ); | ||
| 1082 | }); | 1095 | }); |
| 1083 | 1096 | ||
| 1084 | const setMainWindow = Effect.fn("PreviewManager.setMainWindow")(function* ( | 1097 | const setMainWindow = Effect.fn("PreviewManager.setMainWindow")(function* ( |
| ... | @@ -1271,7 +1284,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -1271,7 +1284,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1271 | const annotationTheme = yield* Ref.get(annotationThemeRef); | 1284 | const annotationTheme = yield* Ref.get(annotationThemeRef); |
| 1272 | return yield* Effect.callback<PreviewAnnotationPayload | null, PreviewManagerError>( | 1285 | return yield* Effect.callback<PreviewAnnotationPayload | null, PreviewManagerError>( |
| 1273 | (resume) => { | 1286 | (resume) => { |
| 1274 | const cleanup = Effect.gen(function* () { | 1287 | const cleanup = Effect.fn("PreviewManager.cleanupPickElement")(function* () { |
| 1275 | yield* attempt("pickElement.cleanup", () => { | 1288 | yield* attempt("pickElement.cleanup", () => { |
| 1276 | wc.ipc.removeListener(ELEMENT_PICKED_CHANNEL, onMessage); | 1289 | wc.ipc.removeListener(ELEMENT_PICKED_CHANNEL, onMessage); |
| 1277 | wc.off("destroyed", onDestroyed); | 1290 | wc.off("destroyed", onDestroyed); |
| ... | @@ -1283,18 +1296,19 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -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 | const settle = (payload: PreviewAnnotationPayload | null) => { | 1307 | const settle = (payload: PreviewAnnotationPayload | null) => { |
| 1287 | runFork( | 1308 | runFork(settlePick(payload)); |
| 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 | ); | ||
| 1295 | }; | 1309 | }; |
| 1296 | const cancel = Effect.gen(function* () { | 1310 | const cancelPickSession = Effect.fn("PreviewManager.cancelPickSession")(function* () { |
| 1297 | yield* cleanup; | 1311 | yield* cleanup(); |
| 1298 | const tabs = yield* SynchronizedRef.get(tabsRef); | 1312 | const tabs = yield* SynchronizedRef.get(tabsRef); |
| 1299 | const activeTab = tabs.get(tabId); | 1313 | const activeTab = tabs.get(tabId); |
| 1300 | if (activeTab?.webContentsId != null) { | 1314 | if (activeTab?.webContentsId != null) { |
| ... | @@ -1307,6 +1321,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -1307,6 +1321,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1307 | } | 1321 | } |
| 1308 | resume(Effect.succeed(null)); | 1322 | resume(Effect.succeed(null)); |
| 1309 | }); | 1323 | }); |
| 1324 | const cancel = cancelPickSession(); | ||
| 1310 | const onMessage = (_event: Electron.IpcMainEvent, ...args: unknown[]): void => { | 1325 | const onMessage = (_event: Electron.IpcMainEvent, ...args: unknown[]): void => { |
| 1311 | const payload = args[0]; | 1326 | const payload = args[0]; |
| 1312 | if (!isPreviewAnnotationPayload(payload)) { | 1327 | if (!isPreviewAnnotationPayload(payload)) { |
| ... | @@ -1330,24 +1345,25 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -1330,24 +1345,25 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1330 | }; | 1345 | }; |
| 1331 | const onDestroyed = () => settle(null); | 1346 | const onDestroyed = () => settle(null); |
| 1332 | const onNavigated = () => settle(null); | 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 | runFork( | 1362 | runFork( |
| 1334 | Effect.gen(function* () { | 1363 | registerPickElement().pipe( |
| 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( | ||
| 1348 | Effect.catch((error: PreviewManagerError) => { | 1364 | Effect.catch((error: PreviewManagerError) => { |
| 1349 | resume(Effect.fail(error)); | 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,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 | const startRecording = Effect.fn("PreviewManager.startRecording")(function* (tabId: string) { | 1433 | const startRecording = Effect.fn("PreviewManager.startRecording")(function* (tabId: string) { |
| 1405 | const recordingTabId = yield* Ref.get(recordingTabIdRef); | 1434 | const recordingTabId = yield* Ref.get(recordingTabIdRef); |
| 1406 | if (Option.isSome(recordingTabId) && recordingTabId.value !== tabId) { | 1435 | if (Option.isSome(recordingTabId) && recordingTabId.value !== tabId) { |
| ... | @@ -1410,18 +1439,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -1410,18 +1439,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1410 | ); | 1439 | ); |
| 1411 | } | 1440 | } |
| 1412 | const wc = yield* requireWebContents(tabId); | 1441 | const wc = yield* requireWebContents(tabId); |
| 1413 | yield* withControlSession(tabId, wc, "recording.start", (send) => | 1442 | yield* withControlSession(tabId, wc, "recording.start", startScreencast); |
| 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 | ); | ||
| 1425 | yield* Ref.set(recordingTabIdRef, Option.some(tabId)); | 1443 | yield* Ref.set(recordingTabIdRef, Option.some(tabId)); |
| 1426 | }); | 1444 | }); |
| 1427 | 1445 | ||
| ... | @@ -1493,104 +1511,108 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -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 | const automationSnapshot = Effect.fn("PreviewManager.automationSnapshot")(function* ( | 1610 | const automationSnapshot = Effect.fn("PreviewManager.automationSnapshot")(function* ( |
| 1497 | tabId: string, | 1611 | tabId: string, |
| 1498 | ) { | 1612 | ) { |
| 1499 | const wc = yield* requireWebContents(tabId); | 1613 | const wc = yield* requireWebContents(tabId); |
| 1500 | return yield* withControlSession(tabId, wc, "snapshot", (send) => | 1614 | return yield* withControlSession(tabId, wc, "snapshot", (send) => |
| 1501 | Effect.gen(function* () { | 1615 | captureAutomationSnapshot(tabId, wc, send), |
| 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 | }), | ||
| 1594 | ); | 1616 | ); |
| 1595 | }); | 1617 | }); |
| 1596 | 1618 | ||
| ... | @@ -1657,66 +1679,72 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -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 | const automationClick = Effect.fn("PreviewManager.automationClick")(function* ( | 1741 | const automationClick = Effect.fn("PreviewManager.automationClick")(function* ( |
| 1661 | tabId: string, | 1742 | tabId: string, |
| 1662 | input: PreviewAutomationClickInput, | 1743 | input: PreviewAutomationClickInput, |
| 1663 | ) { | 1744 | ) { |
| 1664 | const wc = yield* requireWebContents(tabId); | 1745 | const wc = yield* requireWebContents(tabId); |
| 1665 | yield* withControlSession(tabId, wc, "click", (send) => | 1746 | yield* withControlSession(tabId, wc, "click", (send) => |
| 1666 | Effect.gen(function* () { | 1747 | performAutomationClick(tabId, input, send), |
| 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 | }), | ||
| 1720 | ); | 1748 | ); |
| 1721 | }); | 1749 | }); |
| 1722 | 1750 | ||
| ... | @@ -1769,171 +1797,188 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -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 | const automationType = Effect.fn("PreviewManager.automationType")(function* ( | 1820 | const automationType = Effect.fn("PreviewManager.automationType")(function* ( |
| 1773 | tabId: string, | 1821 | tabId: string, |
| 1774 | input: PreviewAutomationTypeInput, | 1822 | input: PreviewAutomationTypeInput, |
| 1775 | ) { | 1823 | ) { |
| 1776 | const wc = yield* requireWebContents(tabId); | 1824 | const wc = yield* requireWebContents(tabId); |
| 1777 | yield* withControlSession(tabId, wc, "type", (send) => | 1825 | yield* withControlSession(tabId, wc, "type", (send) => |
| 1778 | Effect.gen(function* () { | 1826 | performAutomationType(tabId, input, send), |
| 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 | }), | ||
| 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 | const automationPress = Effect.fn("PreviewManager.automationPress")(function* ( | 1860 | const automationPress = Effect.fn("PreviewManager.automationPress")(function* ( |
| 1797 | tabId: string, | 1861 | tabId: string, |
| 1798 | input: PreviewAutomationPressInput, | 1862 | input: PreviewAutomationPressInput, |
| 1799 | ) { | 1863 | ) { |
| 1800 | const wc = yield* requireWebContents(tabId); | 1864 | const wc = yield* requireWebContents(tabId); |
| 1801 | yield* withControlSession(tabId, wc, "press", (send) => | 1865 | yield* withControlSession(tabId, wc, "press", (send) => |
| 1802 | Effect.gen(function* () { | 1866 | performAutomationPress(tabId, input, send), |
| 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 | }), | ||
| 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 | const automationScroll = Effect.fn("PreviewManager.automationScroll")(function* ( | 1916 | const automationScroll = Effect.fn("PreviewManager.automationScroll")(function* ( |
| 1831 | tabId: string, | 1917 | tabId: string, |
| 1832 | input: PreviewAutomationScrollInput, | 1918 | input: PreviewAutomationScrollInput, |
| 1833 | ) { | 1919 | ) { |
| 1834 | const wc = yield* requireWebContents(tabId); | 1920 | const wc = yield* requireWebContents(tabId); |
| 1835 | yield* withControlSession(tabId, wc, "scroll", (send) => | 1921 | yield* withControlSession(tabId, wc, "scroll", (send) => |
| 1836 | Effect.gen(function* () { | 1922 | performAutomationScroll(tabId, input, send), |
| 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 | }), | ||
| 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 | const automationEvaluate = Effect.fn("PreviewManager.automationEvaluate")(function* ( | 1950 | const automationEvaluate = Effect.fn("PreviewManager.automationEvaluate")(function* ( |
| 1881 | tabId: string, | 1951 | tabId: string, |
| 1882 | input: PreviewAutomationEvaluateInput, | 1952 | input: PreviewAutomationEvaluateInput, |
| 1883 | ) { | 1953 | ) { |
| 1884 | const wc = yield* requireWebContents(tabId); | 1954 | const wc = yield* requireWebContents(tabId); |
| 1885 | return yield* withControlSession(tabId, wc, "evaluate", (send) => | 1955 | return yield* withControlSession(tabId, wc, "evaluate", (send) => |
| 1886 | Effect.gen(function* () { | 1956 | performAutomationEvaluate(input, send), |
| 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 | }), | ||
| 1907 | ); | 1957 | ); |
| 1908 | }); | 1958 | }); |
| 1909 | 1959 | ||
| 1910 | const automationWaitFor = Effect.fn("PreviewManager.automationWaitFor")(function* ( | 1960 | const performAutomationWaitFor = Effect.fn("PreviewManager.performAutomationWaitFor")(function* ( |
| 1911 | tabId: string, | ||
| 1912 | input: PreviewAutomationWaitForInput, | 1961 | input: PreviewAutomationWaitForInput, |
| 1962 | send: SendCommand, | ||
| 1913 | ) { | 1963 | ) { |
| 1914 | const wc = yield* requireWebContents(tabId); | ||
| 1915 | const timeoutMs = input.timeoutMs ?? 15_000; | 1964 | const timeoutMs = input.timeoutMs ?? 15_000; |
| 1916 | yield* withControlSession(tabId, wc, "waitFor", (send) => | 1965 | yield* send("Runtime.enable"); |
| 1917 | Effect.gen(function* () { | 1966 | const locator = automationLocator(input); |
| 1918 | yield* send("Runtime.enable"); | 1967 | if (locator) yield* ensurePlaywrightInjected(send); |
| 1919 | const locator = automationLocator(input); | 1968 | const [locatorJson, textJson, urlIncludesJson] = yield* Effect.all([ |
| 1920 | if (locator) yield* ensurePlaywrightInjected(send); | 1969 | locator ? encodeJson("automationWaitFor.encodeLocator", locator) : Effect.succeed(null), |
| 1921 | const [locatorJson, textJson, urlIncludesJson] = yield* Effect.all([ | 1970 | input.text ? encodeJson("automationWaitFor.encodeText", input.text) : Effect.succeed(null), |
| 1922 | locator ? encodeJson("automationWaitFor.encodeLocator", locator) : Effect.succeed(null), | 1971 | input.urlIncludes |
| 1923 | input.text | 1972 | ? encodeJson("automationWaitFor.encodeUrl", input.urlIncludes) |
| 1924 | ? encodeJson("automationWaitFor.encodeText", input.text) | 1973 | : Effect.succeed(null), |
| 1925 | : Effect.succeed(null), | 1974 | ]); |
| 1926 | input.urlIncludes | 1975 | const deadline = (yield* currentMillis) + timeoutMs; |
| 1927 | ? encodeJson("automationWaitFor.encodeUrl", input.urlIncludes) | 1976 | while ((yield* currentMillis) <= deadline) { |
| 1928 | : Effect.succeed(null), | 1977 | const result = yield* evaluateWithDebugger< |
| 1929 | ]); | 1978 | { matched: boolean } | { invalidSelector: true; message: string } |
| 1930 | const deadline = (yield* currentMillis) + timeoutMs; | 1979 | >( |
| 1931 | while ((yield* currentMillis) <= deadline) { | 1980 | send, |
| 1932 | const result = yield* evaluateWithDebugger< | 1981 | `(() => { |
| 1933 | { matched: boolean } | { invalidSelector: true; message: string } | ||
| 1934 | >( | ||
| 1935 | send, | ||
| 1936 | `(() => { | ||
| 1937 | try { | 1982 | try { |
| 1938 | const selectorMatched = ${locatorJson ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${locatorJson}), document, false) !== null; })()` : "true"}; | 1983 | const selectorMatched = ${locatorJson ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${locatorJson}), document, false) !== null; })()` : "true"}; |
| 1939 | const textMatched = ${ | 1984 | const textMatched = ${ |
| ... | @@ -1947,27 +1992,35 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -1947,27 +1992,35 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1947 | return { invalidSelector: true, message: String(error) }; | 1992 | return { invalidSelector: true, message: String(error) }; |
| 1948 | } | 1993 | } |
| 1949 | })()`, | 1994 | })()`, |
| 1950 | true, | 1995 | true, |
| 1951 | ); | 1996 | ); |
| 1952 | if ("invalidSelector" in result) { | 1997 | 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 | } | ||
| 1963 | return yield* fail( | 1998 | return yield* fail( |
| 1964 | "automationWaitFor", | 1999 | "automationWaitFor", |
| 1965 | automationError( | 2000 | automationError("PreviewAutomationInvalidSelectorError", result.message, { |
| 1966 | "PreviewAutomationTimeoutError", | 2001 | selector: input.selector ?? "", |
| 1967 | `Preview condition did not match within ${timeoutMs}ms.`, | 2002 | }), |
| 1968 | ), | ||
| 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 |
apps/server/src/mcp/McpHttpServer.test.ts+1-1| ... | @@ -158,7 +158,7 @@ it.effect("registers annotated tools and preserves authenticated request context | ... | @@ -158,7 +158,7 @@ it.effect("registers annotated tools and preserves authenticated request context |
| 158 | Effect.provideService(McpSchema.McpServerClient, client), | 158 | Effect.provideService(McpSchema.McpServerClient, client), |
| 159 | ); | 159 | ); |
| 160 | expect(press.isError).toBe(false); | 160 | expect(press.isError).toBe(false); |
| 161 | expect(press.structuredContent).toBeUndefined(); | 161 | expect(press.structuredContent).toBeNull(); |
| 162 | expect(press.content).toEqual([{ type: "text", text: "null" }]); | 162 | expect(press.content).toEqual([{ type: "text", text: "null" }]); |
| 163 | }), | 163 | }), |
| 164 | ).pipe(Effect.provide(TestLayer)), | 164 | ).pipe(Effect.provide(TestLayer)), |
apps/server/src/mcp/McpHttpServer.ts+80-75| ... | @@ -13,8 +13,15 @@ import packageJson from "../../package.json" with { type: "json" }; | ... | @@ -13,8 +13,15 @@ import packageJson from "../../package.json" with { type: "json" }; |
| 13 | import * as McpInvocationContext from "./McpInvocationContext.ts"; | 13 | import * as McpInvocationContext from "./McpInvocationContext.ts"; |
| 14 | import * as McpSessionRegistry from "./McpSessionRegistry.ts"; | 14 | import * as McpSessionRegistry from "./McpSessionRegistry.ts"; |
| 15 | import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts"; | 15 | import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts"; |
| 16 | import { PreviewToolkitHandlersLive } from "./toolkits/preview/handlers.ts"; | 16 | import { |
| 17 | import { PreviewToolkit } from "./toolkits/preview/tools.ts"; | 17 | PreviewSnapshotToolkitHandlersLive, |
| 18 | PreviewStandardToolkitHandlersLive, | ||
| 19 | } from "./toolkits/preview/handlers.ts"; | ||
| 20 | import { | ||
| 21 | PreviewSnapshotTool, | ||
| 22 | PreviewSnapshotToolkit, | ||
| 23 | PreviewStandardToolkit, | ||
| 24 | } from "./toolkits/preview/tools.ts"; | ||
| 18 | 25 | ||
| 19 | const unauthorized = HttpServerResponse.jsonUnsafe( | 26 | const unauthorized = HttpServerResponse.jsonUnsafe( |
| 20 | { | 27 | { |
| ... | @@ -81,97 +88,95 @@ const McpAuthMiddlewareLive = HttpRouter.middleware<{ | ... | @@ -81,97 +88,95 @@ const McpAuthMiddlewareLive = HttpRouter.middleware<{ |
| 81 | provides: McpInvocationContext.McpInvocationContext; | 88 | provides: McpInvocationContext.McpInvocationContext; |
| 82 | }>()(makeMcpAuthMiddleware).layer; | 89 | }>()(makeMcpAuthMiddleware).layer; |
| 83 | 90 | ||
| 84 | const registerPreviewToolkit = Effect.fn("McpHttpServer.registerPreviewToolkit")(function* () { | 91 | const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot")(function* () { |
| 85 | const server = yield* McpServer.McpServer; | 92 | const server = yield* McpServer.McpServer; |
| 86 | const built = yield* PreviewToolkit; | 93 | const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; |
| 87 | const handleTool = built.handle as unknown as ( | 94 | const built = yield* PreviewSnapshotToolkit; |
| 88 | name: keyof typeof built.tools, | 95 | const tool = PreviewSnapshotTool; |
| 89 | payload: unknown, | 96 | yield* server.addTool({ |
| 90 | ) => Effect.Effect< | 97 | tool: new McpSchema.Tool({ |
| 91 | Stream.Stream<{ readonly encodedResult: unknown }, Error>, | 98 | name: tool.name, |
| 92 | Error, | 99 | description: Tool.getDescription(tool), |
| 93 | McpInvocationContext.McpInvocationContext | 100 | inputSchema: Tool.getJsonSchema(tool), |
| 94 | >; | 101 | annotations: { |
| 95 | for (const tool of Object.values(built.tools)) { | 102 | ...Context.getOption(tool.annotations, Tool.Title).pipe( |
| 96 | yield* server.addTool({ | 103 | Option.map((title) => ({ title })), |
| 97 | tool: new McpSchema.Tool({ | 104 | Option.getOrUndefined, |
| 98 | name: tool.name, | 105 | ), |
| 99 | description: Tool.getDescription(tool), | 106 | readOnlyHint: Context.get(tool.annotations, Tool.Readonly), |
| 100 | inputSchema: Tool.getJsonSchema(tool), | 107 | destructiveHint: Context.get(tool.annotations, Tool.Destructive), |
| 101 | annotations: { | 108 | idempotentHint: Context.get(tool.annotations, Tool.Idempotent), |
| 102 | ...Context.getOption(tool.annotations, Tool.Title).pipe( | 109 | openWorldHint: Context.get(tool.annotations, Tool.OpenWorld), |
| 103 | Option.map((title) => ({ title })), | 110 | }, |
| 104 | Option.getOrUndefined, | 111 | }), |
| 105 | ), | 112 | annotations: tool.annotations, |
| 106 | readOnlyHint: Context.get(tool.annotations, Tool.Readonly), | 113 | handle: (payload) => |
| 107 | destructiveHint: Context.get(tool.annotations, Tool.Destructive), | 114 | Effect.withFiber((fiber) => { |
| 108 | idempotentHint: Context.get(tool.annotations, Tool.Idempotent), | 115 | const invocation = Context.getUnsafe( |
| 109 | openWorldHint: Context.get(tool.annotations, Tool.OpenWorld), | 116 | fiber.context, |
| 110 | }, | 117 | McpInvocationContext.McpInvocationContext, |
| 111 | }), | 118 | ); |
| 112 | annotations: tool.annotations, | 119 | return built.handle("preview_snapshot", payload).pipe( |
| 113 | handle: (payload) => | ||
| 114 | handleTool(tool.name as keyof typeof built.tools, payload).pipe( | ||
| 115 | Stream.unwrap, | 120 | Stream.unwrap, |
| 116 | Stream.run(Sink.last()), | 121 | Stream.run(Sink.last()), |
| 117 | Effect.flatMap(Effect.fromOption), | 122 | Effect.flatMap(Effect.fromOption), |
| 123 | Effect.provideService(PreviewAutomationBroker.PreviewAutomationBroker, broker), | ||
| 124 | Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), | ||
| 118 | Effect.matchCause({ | 125 | Effect.matchCause({ |
| 119 | onFailure: (cause) => | 126 | onFailure: (cause) => |
| 120 | new McpSchema.CallToolResult({ | 127 | new McpSchema.CallToolResult({ |
| 121 | isError: true, | 128 | isError: true, |
| 122 | content: [{ type: "text", text: Cause.pretty(cause) }], | 129 | content: [{ type: "text", text: Cause.pretty(cause) }], |
| 123 | }), | 130 | }), |
| 124 | onSuccess: (result) => { | 131 | onSuccess: ({ encodedResult }) => { |
| 125 | if (tool.name === "preview_snapshot") { | 132 | const snapshot = encodedResult as { |
| 126 | const snapshot = result.encodedResult as { | 133 | readonly screenshot: { |
| 127 | readonly screenshot: { | 134 | readonly mimeType: "image/png"; |
| 128 | readonly mimeType: "image/png"; | 135 | readonly data: string; |
| 129 | readonly data: string; | 136 | readonly width: number; |
| 130 | readonly width: number; | 137 | readonly height: number; |
| 131 | readonly height: number; | ||
| 132 | }; | ||
| 133 | readonly [key: string]: unknown; | ||
| 134 | }; | 138 | }; |
| 135 | const { screenshot, ...page } = snapshot; | 139 | readonly [key: string]: unknown; |
| 136 | const metadata = { | 140 | }; |
| 137 | ...page, | 141 | const { screenshot, ...page } = snapshot; |
| 138 | screenshot: { | 142 | const metadata = { |
| 139 | mimeType: screenshot.mimeType, | 143 | ...page, |
| 140 | width: screenshot.width, | 144 | screenshot: { |
| 141 | height: screenshot.height, | 145 | mimeType: screenshot.mimeType, |
| 142 | }, | 146 | width: screenshot.width, |
| 143 | }; | 147 | height: screenshot.height, |
| 144 | return new McpSchema.CallToolResult({ | 148 | }, |
| 145 | isError: false, | 149 | }; |
| 146 | structuredContent: metadata, | ||
| 147 | content: [ | ||
| 148 | { type: "text", text: JSON.stringify(metadata) }, | ||
| 149 | { | ||
| 150 | type: "image", | ||
| 151 | data: new Uint8Array(Buffer.from(screenshot.data, "base64")), | ||
| 152 | mimeType: screenshot.mimeType, | ||
| 153 | }, | ||
| 154 | ], | ||
| 155 | }); | ||
| 156 | } | ||
| 157 | const encodedResultText = JSON.stringify(result.encodedResult) ?? "null"; | ||
| 158 | return new McpSchema.CallToolResult({ | 150 | return new McpSchema.CallToolResult({ |
| 159 | isError: false, | 151 | isError: false, |
| 160 | structuredContent: | 152 | structuredContent: metadata, |
| 161 | result.encodedResult !== null && typeof result.encodedResult === "object" | 153 | content: [ |
| 162 | ? result.encodedResult | 154 | { type: "text", text: JSON.stringify(metadata) }, |
| 163 | : undefined, | 155 | { |
| 164 | content: [{ type: "text", text: encodedResultText }], | 156 | type: "image", |
| 157 | data: new Uint8Array(Buffer.from(screenshot.data, "base64")), | ||
| 158 | mimeType: screenshot.mimeType, | ||
| 159 | }, | ||
| 160 | ], | ||
| 165 | }); | 161 | }); |
| 166 | }, | 162 | }, |
| 167 | }), | 163 | }), |
| 168 | ) as unknown as Effect.Effect<McpSchema.CallToolResult, never, McpSchema.McpServerClient>, | 164 | ); |
| 169 | }); | 165 | }), |
| 170 | } | 166 | }); |
| 171 | }); | 167 | }); |
| 172 | 168 | ||
| 173 | export const PreviewToolkitRegistrationLive = Layer.effectDiscard(registerPreviewToolkit()).pipe( | 169 | const PreviewStandardToolkitRegistrationLive = McpServer.toolkit(PreviewStandardToolkit).pipe( |
| 174 | Layer.provide(PreviewToolkitHandlersLive), | 170 | Layer.provide(PreviewStandardToolkitHandlersLive), |
| 171 | ); | ||
| 172 | |||
| 173 | const PreviewSnapshotRegistrationLive = Layer.effectDiscard(registerPreviewSnapshot()).pipe( | ||
| 174 | Layer.provide(PreviewSnapshotToolkitHandlersLive), | ||
| 175 | ); | ||
| 176 | |||
| 177 | export const PreviewToolkitRegistrationLive = Layer.mergeAll( | ||
| 178 | PreviewStandardToolkitRegistrationLive, | ||
| 179 | PreviewSnapshotRegistrationLive, | ||
| 175 | ); | 180 | ); |
| 176 | 181 | ||
| 177 | const McpTransportLive = McpServer.layerHttp({ | 182 | const McpTransportLive = McpServer.layerHttp({ |
apps/server/src/mcp/McpSessionRegistry.ts+35-33| ... | @@ -1,9 +1,10 @@ | ... | @@ -1,9 +1,10 @@ |
| 1 | import { ProviderInstanceId, ThreadId } from "@t3tools/contracts"; | 1 | import { ProviderInstanceId, ThreadId } from "@t3tools/contracts"; |
| 2 | import * as Clock from "effect/Clock"; | ||
| 2 | import * as Context from "effect/Context"; | 3 | import * as Context from "effect/Context"; |
| 3 | import * as Crypto from "effect/Crypto"; | 4 | import * as Crypto from "effect/Crypto"; |
| 4 | import * as Effect from "effect/Effect"; | 5 | import * as Effect from "effect/Effect"; |
| 5 | import * as Layer from "effect/Layer"; | 6 | import * as Layer from "effect/Layer"; |
| 6 | import * as Ref from "effect/Ref"; | 7 | import * as SynchronizedRef from "effect/SynchronizedRef"; |
| 7 | import { HttpServer } from "effect/unstable/http"; | 8 | import { HttpServer } from "effect/unstable/http"; |
| 8 | 9 | ||
| 9 | import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts"; | 10 | import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts"; |
| ... | @@ -59,15 +60,15 @@ const bytesToHex = (bytes: Uint8Array): string => | ... | @@ -59,15 +60,15 @@ const bytesToHex = (bytes: Uint8Array): string => |
| 59 | 60 | ||
| 60 | const tokenFromBytes = (bytes: Uint8Array): string => Buffer.from(bytes).toString("base64url"); | 61 | const tokenFromBytes = (bytes: Uint8Array): string => Buffer.from(bytes).toString("base64url"); |
| 61 | 62 | ||
| 62 | const make = Effect.fn("McpSessionRegistry.make")(function* ( | 63 | const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( |
| 63 | options: McpSessionRegistryOptions = {}, | 64 | options: McpSessionRegistryOptions = {}, |
| 64 | ) { | 65 | ) { |
| 65 | const crypto = yield* Crypto.Crypto; | 66 | const crypto = yield* Crypto.Crypto; |
| 66 | const environment = yield* ServerEnvironment; | 67 | const environment = yield* ServerEnvironment; |
| 67 | const environmentId = yield* environment.getEnvironmentId; | 68 | const environmentId = yield* environment.getEnvironmentId; |
| 68 | const httpServer = yield* HttpServer.HttpServer; | 69 | const httpServer = yield* HttpServer.HttpServer; |
| 69 | const state = yield* Ref.make<RegistryState>({ records: new Map() }); | 70 | const state = yield* SynchronizedRef.make<RegistryState>({ records: new Map() }); |
| 70 | const now = options.now ?? Date.now; | 71 | const currentTimeMillis = options.now ? Effect.sync(options.now) : Clock.currentTimeMillis; |
| 71 | const idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS; | 72 | const idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS; |
| 72 | const maximumLifetimeMs = options.maximumLifetimeMs ?? DEFAULT_MAXIMUM_LIFETIME_MS; | 73 | const maximumLifetimeMs = options.maximumLifetimeMs ?? DEFAULT_MAXIMUM_LIFETIME_MS; |
| 73 | const endpoint = | 74 | const endpoint = |
| ... | @@ -81,21 +82,18 @@ const make = Effect.fn("McpSessionRegistry.make")(function* ( | ... | @@ -81,21 +82,18 @@ const make = Effect.fn("McpSessionRegistry.make")(function* ( |
| 81 | .pipe(Effect.map(bytesToHex), Effect.orDie); | 82 | .pipe(Effect.map(bytesToHex), Effect.orDie); |
| 82 | 83 | ||
| 83 | const pruneExpired = (records: ReadonlyMap<string, CredentialRecord>, timestamp: number) => { | 84 | const pruneExpired = (records: ReadonlyMap<string, CredentialRecord>, timestamp: number) => { |
| 84 | let changed = false; | 85 | const next = new Map( |
| 85 | const next = new Map<string, CredentialRecord>(); | 86 | Array.from(records).filter( |
| 86 | for (const [hash, record] of records) { | 87 | ([, record]) => |
| 87 | if (timestamp <= record.scope.expiresAt && timestamp - record.lastUsedAt <= idleTimeoutMs) { | 88 | timestamp <= record.scope.expiresAt && timestamp - record.lastUsedAt <= idleTimeoutMs, |
| 88 | next.set(hash, record); | 89 | ), |
| 89 | } else { | 90 | ); |
| 90 | changed = true; | 91 | return next.size === records.size ? records : next; |
| 91 | } | ||
| 92 | } | ||
| 93 | return changed ? next : records; | ||
| 94 | }; | 92 | }; |
| 95 | 93 | ||
| 96 | const issue: McpSessionRegistryShape["issue"] = Effect.fn("McpSessionRegistry.issue")( | 94 | const issue: McpSessionRegistryShape["issue"] = Effect.fn("McpSessionRegistry.issue")( |
| 97 | function* (request) { | 95 | function* (request) { |
| 98 | const issuedAt = now(); | 96 | const issuedAt = yield* currentTimeMillis; |
| 99 | const providerSessionId = yield* crypto.randomUUIDv4.pipe(Effect.orDie); | 97 | const providerSessionId = yield* crypto.randomUUIDv4.pipe(Effect.orDie); |
| 100 | const rawToken = yield* crypto.randomBytes(32).pipe(Effect.map(tokenFromBytes), Effect.orDie); | 98 | const rawToken = yield* crypto.randomBytes(32).pipe(Effect.map(tokenFromBytes), Effect.orDie); |
| 101 | const tokenHash = yield* hashToken(rawToken); | 99 | const tokenHash = yield* hashToken(rawToken); |
| ... | @@ -109,7 +107,7 @@ const make = Effect.fn("McpSessionRegistry.make")(function* ( | ... | @@ -109,7 +107,7 @@ const make = Effect.fn("McpSessionRegistry.make")(function* ( |
| 109 | issuedAt, | 107 | issuedAt, |
| 110 | expiresAt, | 108 | expiresAt, |
| 111 | }; | 109 | }; |
| 112 | yield* Ref.update(state, ({ records }) => { | 110 | yield* SynchronizedRef.update(state, ({ records }) => { |
| 113 | const next = new Map(pruneExpired(records, issuedAt)); | 111 | const next = new Map(pruneExpired(records, issuedAt)); |
| 114 | next.set(tokenHash, { tokenHash, scope, lastUsedAt: issuedAt }); | 112 | next.set(tokenHash, { tokenHash, scope, lastUsedAt: issuedAt }); |
| 115 | return { records: next }; | 113 | return { records: next }; |
| ... | @@ -132,23 +130,20 @@ const make = Effect.fn("McpSessionRegistry.make")(function* ( | ... | @@ -132,23 +130,20 @@ const make = Effect.fn("McpSessionRegistry.make")(function* ( |
| 132 | function* (rawToken) { | 130 | function* (rawToken) { |
| 133 | if (rawToken.length === 0) return undefined; | 131 | if (rawToken.length === 0) return undefined; |
| 134 | const tokenHash = yield* hashToken(rawToken); | 132 | const tokenHash = yield* hashToken(rawToken); |
| 135 | const timestamp = now(); | 133 | const timestamp = yield* currentTimeMillis; |
| 136 | let resolved: McpInvocationContext.McpInvocationScope | undefined; | 134 | return yield* SynchronizedRef.modify(state, ({ records }) => { |
| 137 | yield* Ref.update(state, ({ records }) => { | ||
| 138 | const current = pruneExpired(records, timestamp); | 135 | const current = pruneExpired(records, timestamp); |
| 139 | const record = current.get(tokenHash); | 136 | const record = current.get(tokenHash); |
| 140 | if (!record) return { records: current }; | 137 | if (!record) return [undefined, { records: current }] as const; |
| 141 | resolved = record.scope; | ||
| 142 | const next = new Map(current); | 138 | const next = new Map(current); |
| 143 | next.set(tokenHash, { ...record, lastUsedAt: timestamp }); | 139 | next.set(tokenHash, { ...record, lastUsedAt: timestamp }); |
| 144 | return { records: next }; | 140 | return [record.scope, { records: next }] as const; |
| 145 | }); | 141 | }); |
| 146 | return resolved; | ||
| 147 | }, | 142 | }, |
| 148 | ); | 143 | ); |
| 149 | 144 | ||
| 150 | const revokeWhere = (predicate: (record: CredentialRecord) => boolean) => | 145 | const revokeWhere = (predicate: (record: CredentialRecord) => boolean) => |
| 151 | Ref.update(state, ({ records }) => ({ | 146 | SynchronizedRef.update(state, ({ records }) => ({ |
| 152 | records: new Map(Array.from(records).filter(([, record]) => !predicate(record))), | 147 | records: new Map(Array.from(records).filter(([, record]) => !predicate(record))), |
| 153 | })); | 148 | })); |
| 154 | 149 | ||
| ... | @@ -163,27 +158,34 @@ const make = Effect.fn("McpSessionRegistry.make")(function* ( | ... | @@ -163,27 +158,34 @@ const make = Effect.fn("McpSessionRegistry.make")(function* ( |
| 163 | revokeThread: Effect.fn("McpSessionRegistry.revokeThread")(function* (threadId) { | 158 | revokeThread: Effect.fn("McpSessionRegistry.revokeThread")(function* (threadId) { |
| 164 | yield* revokeWhere((record) => record.scope.threadId === threadId); | 159 | yield* revokeWhere((record) => record.scope.threadId === threadId); |
| 165 | }), | 160 | }), |
| 166 | revokeAll: Ref.set(state, { records: new Map() }), | 161 | revokeAll: SynchronizedRef.set(state, { records: new Map() }), |
| 167 | }); | 162 | }); |
| 168 | }); | 163 | }); |
| 169 | 164 | ||
| 170 | let activeMcpSessionRegistry: McpSessionRegistryShape | undefined; | 165 | let activeMcpSessionRegistry: McpSessionRegistryShape | undefined; |
| 171 | 166 | ||
| 172 | export const layer: Layer.Layer< | 167 | const make = Effect.acquireRelease( |
| 173 | McpSessionRegistry, | 168 | makeWithOptions().pipe( |
| 174 | never, | ||
| 175 | Crypto.Crypto | ServerEnvironment | HttpServer.HttpServer | ||
| 176 | > = Layer.effect( | ||
| 177 | McpSessionRegistry, | ||
| 178 | make().pipe( | ||
| 179 | Effect.tap((registry) => | 169 | Effect.tap((registry) => |
| 180 | Effect.sync(() => { | 170 | Effect.sync(() => { |
| 181 | activeMcpSessionRegistry = registry; | 171 | activeMcpSessionRegistry = registry; |
| 182 | }), | 172 | }), |
| 183 | ), | 173 | ), |
| 184 | ), | 174 | ), |
| 175 | (registry) => | ||
| 176 | Effect.sync(() => { | ||
| 177 | if (activeMcpSessionRegistry === registry) { | ||
| 178 | activeMcpSessionRegistry = undefined; | ||
| 179 | } | ||
| 180 | }), | ||
| 185 | ); | 181 | ); |
| 186 | 182 | ||
| 183 | export const layer: Layer.Layer< | ||
| 184 | McpSessionRegistry, | ||
| 185 | never, | ||
| 186 | Crypto.Crypto | ServerEnvironment | HttpServer.HttpServer | ||
| 187 | > = Layer.effect(McpSessionRegistry, make); | ||
| 188 | |||
| 187 | export const issueActiveMcpCredential = ( | 189 | export const issueActiveMcpCredential = ( |
| 188 | request: McpCredentialRequest, | 190 | request: McpCredentialRequest, |
| 189 | ): Effect.Effect<McpIssuedCredential | undefined> => | 191 | ): Effect.Effect<McpIssuedCredential | undefined> => |
| ... | @@ -201,5 +203,5 @@ export const revokeAllActiveMcpCredentials = (): Effect.Effect<void> => | ... | @@ -201,5 +203,5 @@ export const revokeAllActiveMcpCredentials = (): Effect.Effect<void> => |
| 201 | 203 | ||
| 202 | /** Exposed for tests. */ | 204 | /** Exposed for tests. */ |
| 203 | export const __testing = { | 205 | export const __testing = { |
| 204 | make, | 206 | make: makeWithOptions, |
| 205 | }; | 207 | }; |
apps/server/src/mcp/PreviewAutomationBroker.test.ts+3-3| ... | @@ -23,7 +23,7 @@ const scope = { | ... | @@ -23,7 +23,7 @@ const scope = { |
| 23 | it.effect("routes a request to the focused owner and correlates its response", () => | 23 | it.effect("routes a request to the focused owner and correlates its response", () => |
| 24 | Effect.scoped( | 24 | Effect.scoped( |
| 25 | Effect.gen(function* () { | 25 | Effect.gen(function* () { |
| 26 | const broker = yield* PreviewAutomationBroker.__testing.make(); | 26 | const broker = yield* PreviewAutomationBroker.__testing.make; |
| 27 | const requests = yield* broker.connect("client-1"); | 27 | const requests = yield* broker.connect("client-1"); |
| 28 | yield* Stream.runForEach(requests, (request) => | 28 | yield* Stream.runForEach(requests, (request) => |
| 29 | broker.respond({ | 29 | broker.respond({ |
| ... | @@ -56,7 +56,7 @@ it.effect("routes a request to the focused owner and correlates its response", ( | ... | @@ -56,7 +56,7 @@ it.effect("routes a request to the focused owner and correlates its response", ( |
| 56 | 56 | ||
| 57 | it.effect("rejects calls when no focused owner exists", () => | 57 | it.effect("rejects calls when no focused owner exists", () => |
| 58 | Effect.gen(function* () { | 58 | Effect.gen(function* () { |
| 59 | const broker = yield* PreviewAutomationBroker.__testing.make(); | 59 | const broker = yield* PreviewAutomationBroker.__testing.make; |
| 60 | const error = yield* broker | 60 | const error = yield* broker |
| 61 | .invoke<void>({ scope, operation: "status", input: {} }) | 61 | .invoke<void>({ scope, operation: "status", input: {} }) |
| 62 | .pipe(Effect.flip); | 62 | .pipe(Effect.flip); |
| ... | @@ -67,7 +67,7 @@ it.effect("rejects calls when no focused owner exists", () => | ... | @@ -67,7 +67,7 @@ it.effect("rejects calls when no focused owner exists", () => |
| 67 | it.effect("routes interactive commands to a hidden durable browser host", () => | 67 | it.effect("routes interactive commands to a hidden durable browser host", () => |
| 68 | Effect.scoped( | 68 | Effect.scoped( |
| 69 | Effect.gen(function* () { | 69 | Effect.gen(function* () { |
| 70 | const broker = yield* PreviewAutomationBroker.__testing.make(); | 70 | const broker = yield* PreviewAutomationBroker.__testing.make; |
| 71 | const requests = yield* broker.connect("client-hidden"); | 71 | const requests = yield* broker.connect("client-hidden"); |
| 72 | yield* Stream.runForEach(requests, (request) => | 72 | yield* Stream.runForEach(requests, (request) => |
| 73 | broker.respond({ requestId: request.requestId, ok: true }), | 73 | broker.respond({ requestId: request.requestId, ok: true }), |
apps/server/src/mcp/PreviewAutomationBroker.ts+53-49| ... | @@ -21,8 +21,8 @@ import * as Effect from "effect/Effect"; | ... | @@ -21,8 +21,8 @@ import * as Effect from "effect/Effect"; |
| 21 | import * as Layer from "effect/Layer"; | 21 | import * as Layer from "effect/Layer"; |
| 22 | import * as Option from "effect/Option"; | 22 | import * as Option from "effect/Option"; |
| 23 | import * as Queue from "effect/Queue"; | 23 | import * as Queue from "effect/Queue"; |
| 24 | import * as Ref from "effect/Ref"; | ||
| 25 | import * as Stream from "effect/Stream"; | 24 | import * as Stream from "effect/Stream"; |
| 25 | import * as SynchronizedRef from "effect/SynchronizedRef"; | ||
| 26 | 26 | ||
| 27 | import * as McpInvocationContext from "./McpInvocationContext.ts"; | 27 | import * as McpInvocationContext from "./McpInvocationContext.ts"; |
| 28 | 28 | ||
| ... | @@ -71,6 +71,7 @@ interface BrokerState { | ... | @@ -71,6 +71,7 @@ interface BrokerState { |
| 71 | readonly clients: ReadonlyMap<string, ClientConnection>; | 71 | readonly clients: ReadonlyMap<string, ClientConnection>; |
| 72 | readonly owners: ReadonlyMap<string, PreviewAutomationOwner>; | 72 | readonly owners: ReadonlyMap<string, PreviewAutomationOwner>; |
| 73 | readonly pending: ReadonlyMap<string, PendingRequest>; | 73 | readonly pending: ReadonlyMap<string, PendingRequest>; |
| 74 | readonly requestSequence: number; | ||
| 74 | } | 75 | } |
| 75 | 76 | ||
| 76 | const makeResponseError = ( | 77 | const makeResponseError = ( |
| ... | @@ -119,33 +120,35 @@ const makeResponseError = ( | ... | @@ -119,33 +120,35 @@ const makeResponseError = ( |
| 119 | } | 120 | } |
| 120 | }; | 121 | }; |
| 121 | 122 | ||
| 122 | const make = Effect.fn("PreviewAutomationBroker.make")(function* () { | 123 | const make = Effect.gen(function* PreviewAutomationBrokerMake() { |
| 123 | const state = yield* Ref.make<BrokerState>({ | 124 | const state = yield* SynchronizedRef.make<BrokerState>({ |
| 124 | clients: new Map(), | 125 | clients: new Map(), |
| 125 | owners: new Map(), | 126 | owners: new Map(), |
| 126 | pending: new Map(), | 127 | pending: new Map(), |
| 128 | requestSequence: 0, | ||
| 127 | }); | 129 | }); |
| 128 | let requestSequence = 0; | ||
| 129 | 130 | ||
| 130 | const disconnect = Effect.fn("PreviewAutomationBroker.disconnect")(function* ( | 131 | const disconnect = Effect.fn("PreviewAutomationBroker.disconnect")(function* ( |
| 131 | clientId: string, | 132 | clientId: string, |
| 132 | queue: ClientConnection["queue"], | 133 | queue: ClientConnection["queue"], |
| 133 | ) { | 134 | ) { |
| 134 | const toFail: PendingRequest[] = []; | 135 | const toFail = yield* SynchronizedRef.modify(state, (current) => { |
| 135 | yield* Ref.update(state, (current) => { | 136 | if (current.clients.get(clientId)?.queue !== queue) { |
| 136 | if (current.clients.get(clientId)?.queue !== queue) return current; | 137 | return [[] as ReadonlyArray<PendingRequest>, current] as const; |
| 138 | } | ||
| 137 | const clients = new Map(current.clients); | 139 | const clients = new Map(current.clients); |
| 138 | const owners = new Map(current.owners); | 140 | const owners = new Map(current.owners); |
| 139 | const pending = new Map(current.pending); | 141 | const pending = new Map(current.pending); |
| 142 | const disconnected: PendingRequest[] = []; | ||
| 140 | clients.delete(clientId); | 143 | clients.delete(clientId); |
| 141 | owners.delete(clientId); | 144 | owners.delete(clientId); |
| 142 | for (const [requestId, entry] of pending) { | 145 | for (const [requestId, entry] of pending) { |
| 143 | if (entry.clientId === clientId) { | 146 | if (entry.clientId === clientId) { |
| 144 | pending.delete(requestId); | 147 | pending.delete(requestId); |
| 145 | toFail.push(entry); | 148 | disconnected.push(entry); |
| 146 | } | 149 | } |
| 147 | } | 150 | } |
| 148 | return { clients, owners, pending }; | 151 | return [disconnected, { ...current, clients, owners, pending }] as const; |
| 149 | }); | 152 | }); |
| 150 | yield* Effect.forEach( | 153 | yield* Effect.forEach( |
| 151 | toFail, | 154 | toFail, |
| ... | @@ -165,12 +168,10 @@ const make = Effect.fn("PreviewAutomationBroker.make")(function* () { | ... | @@ -165,12 +168,10 @@ const make = Effect.fn("PreviewAutomationBroker.make")(function* () { |
| 165 | "PreviewAutomationBroker.connect", | 168 | "PreviewAutomationBroker.connect", |
| 166 | )(function* (clientId) { | 169 | )(function* (clientId) { |
| 167 | const queue = yield* Queue.unbounded<import("@t3tools/contracts").PreviewAutomationRequest>(); | 170 | const queue = yield* Queue.unbounded<import("@t3tools/contracts").PreviewAutomationRequest>(); |
| 168 | let previous: ClientConnection | undefined; | 171 | const previous = yield* SynchronizedRef.modify(state, (current) => { |
| 169 | yield* Ref.update(state, (current) => { | ||
| 170 | previous = current.clients.get(clientId); | ||
| 171 | const clients = new Map(current.clients); | 172 | const clients = new Map(current.clients); |
| 172 | clients.set(clientId, { clientId, queue }); | 173 | clients.set(clientId, { clientId, queue }); |
| 173 | return { ...current, clients }; | 174 | return [current.clients.get(clientId), { ...current, clients }] as const; |
| 174 | }); | 175 | }); |
| 175 | if (previous) yield* disconnect(clientId, previous.queue); | 176 | if (previous) yield* disconnect(clientId, previous.queue); |
| 176 | return Stream.fromQueue(queue).pipe(Stream.ensuring(disconnect(clientId, queue))); | 177 | return Stream.fromQueue(queue).pipe(Stream.ensuring(disconnect(clientId, queue))); |
| ... | @@ -179,7 +180,7 @@ const make = Effect.fn("PreviewAutomationBroker.make")(function* () { | ... | @@ -179,7 +180,7 @@ const make = Effect.fn("PreviewAutomationBroker.make")(function* () { |
| 179 | const reportOwner: PreviewAutomationBrokerShape["reportOwner"] = Effect.fn( | 180 | const reportOwner: PreviewAutomationBrokerShape["reportOwner"] = Effect.fn( |
| 180 | "PreviewAutomationBroker.reportOwner", | 181 | "PreviewAutomationBroker.reportOwner", |
| 181 | )(function* (owner) { | 182 | )(function* (owner) { |
| 182 | yield* Ref.update(state, (current) => { | 183 | yield* SynchronizedRef.update(state, (current) => { |
| 183 | const owners = new Map(current.owners); | 184 | const owners = new Map(current.owners); |
| 184 | owners.set(owner.clientId, owner); | 185 | owners.set(owner.clientId, owner); |
| 185 | return { ...current, owners }; | 186 | return { ...current, owners }; |
| ... | @@ -189,7 +190,7 @@ const make = Effect.fn("PreviewAutomationBroker.make")(function* () { | ... | @@ -189,7 +190,7 @@ const make = Effect.fn("PreviewAutomationBroker.make")(function* () { |
| 189 | const clearOwner: PreviewAutomationBrokerShape["clearOwner"] = Effect.fn( | 190 | const clearOwner: PreviewAutomationBrokerShape["clearOwner"] = Effect.fn( |
| 190 | "PreviewAutomationBroker.clearOwner", | 191 | "PreviewAutomationBroker.clearOwner", |
| 191 | )(function* (clientId) { | 192 | )(function* (clientId) { |
| 192 | yield* Ref.update(state, (current) => { | 193 | yield* SynchronizedRef.update(state, (current) => { |
| 193 | const owners = new Map(current.owners); | 194 | const owners = new Map(current.owners); |
| 194 | owners.delete(clientId); | 195 | owners.delete(clientId); |
| 195 | return { ...current, owners }; | 196 | return { ...current, owners }; |
| ... | @@ -199,13 +200,12 @@ const make = Effect.fn("PreviewAutomationBroker.make")(function* () { | ... | @@ -199,13 +200,12 @@ const make = Effect.fn("PreviewAutomationBroker.make")(function* () { |
| 199 | const respond: PreviewAutomationBrokerShape["respond"] = Effect.fn( | 200 | const respond: PreviewAutomationBrokerShape["respond"] = Effect.fn( |
| 200 | "PreviewAutomationBroker.respond", | 201 | "PreviewAutomationBroker.respond", |
| 201 | )(function* (response) { | 202 | )(function* (response) { |
| 202 | let pending: PendingRequest | undefined; | 203 | const pending = yield* SynchronizedRef.modify(state, (current) => { |
| 203 | yield* Ref.update(state, (current) => { | 204 | const entry = current.pending.get(response.requestId); |
| 204 | pending = current.pending.get(response.requestId); | 205 | if (!entry) return [undefined, current] as const; |
| 205 | if (!pending) return current; | ||
| 206 | const next = new Map(current.pending); | 206 | const next = new Map(current.pending); |
| 207 | next.delete(response.requestId); | 207 | next.delete(response.requestId); |
| 208 | return { ...current, pending: next }; | 208 | return [entry, { ...current, pending: next }] as const; |
| 209 | }); | 209 | }); |
| 210 | if (!pending) return; | 210 | if (!pending) return; |
| 211 | if (response.ok) { | 211 | if (response.ok) { |
| ... | @@ -225,7 +225,7 @@ const make = Effect.fn("PreviewAutomationBroker.make")(function* () { | ... | @@ -225,7 +225,7 @@ const make = Effect.fn("PreviewAutomationBroker.make")(function* () { |
| 225 | const invoke = Effect.fn("PreviewAutomationBroker.invoke")(function* <A = unknown>( | 225 | const invoke = Effect.fn("PreviewAutomationBroker.invoke")(function* <A = unknown>( |
| 226 | input: Parameters<PreviewAutomationBrokerShape["invoke"]>[0], | 226 | input: Parameters<PreviewAutomationBrokerShape["invoke"]>[0], |
| 227 | ): Effect.fn.Return<A, PreviewAutomationError> { | 227 | ): Effect.fn.Return<A, PreviewAutomationError> { |
| 228 | const current = yield* Ref.get(state); | 228 | const current = yield* SynchronizedRef.get(state); |
| 229 | const candidates = Array.from(current.owners.values()) | 229 | const candidates = Array.from(current.owners.values()) |
| 230 | .filter( | 230 | .filter( |
| 231 | (owner) => | 231 | (owner) => |
| ... | @@ -256,48 +256,52 @@ const make = Effect.fn("PreviewAutomationBroker.make")(function* () { | ... | @@ -256,48 +256,52 @@ const make = Effect.fn("PreviewAutomationBroker.make")(function* () { |
| 256 | message: "The browser host does not have an active tab.", | 256 | message: "The browser host does not have an active tab.", |
| 257 | }); | 257 | }); |
| 258 | } | 258 | } |
| 259 | const requestId = `preview-${requestSequence++}`; | ||
| 260 | const timeoutMs = input.timeoutMs ?? 15_000; | 259 | const timeoutMs = input.timeoutMs ?? 15_000; |
| 261 | const deferred = yield* Deferred.make<unknown, PreviewAutomationError>(); | 260 | const deferred = yield* Deferred.make<unknown, PreviewAutomationError>(); |
| 262 | yield* Ref.update(state, (next) => { | 261 | const requestId = yield* SynchronizedRef.modify(state, (next) => { |
| 262 | const requestId = `preview-${next.requestSequence}`; | ||
| 263 | const pending = new Map(next.pending); | 263 | const pending = new Map(next.pending); |
| 264 | pending.set(requestId, { clientId: owner.clientId, deferred }); | 264 | pending.set(requestId, { clientId: owner.clientId, deferred }); |
| 265 | return { ...next, pending }; | 265 | return [requestId, { ...next, pending, requestSequence: next.requestSequence + 1 }] as const; |
| 266 | }); | ||
| 267 | const offered = yield* Queue.offer(connection.queue, { | ||
| 268 | requestId, | ||
| 269 | threadId: input.scope.threadId, | ||
| 270 | tabId: input.tabId ?? owner.tabId ?? undefined, | ||
| 271 | operation: input.operation, | ||
| 272 | input: input.input, | ||
| 273 | timeoutMs, | ||
| 274 | }); | 266 | }); |
| 275 | if (!offered) { | 267 | const removePending = SynchronizedRef.update(state, (next) => { |
| 276 | return yield* new PreviewAutomationUnavailableError({ | 268 | if (!next.pending.has(requestId)) return next; |
| 277 | message: "The preview automation client is no longer accepting requests.", | ||
| 278 | }); | ||
| 279 | } | ||
| 280 | const result = yield* Deferred.await(deferred).pipe(Effect.timeoutOption(timeoutMs)); | ||
| 281 | yield* Ref.update(state, (next) => { | ||
| 282 | const pending = new Map(next.pending); | 269 | const pending = new Map(next.pending); |
| 283 | pending.delete(requestId); | 270 | pending.delete(requestId); |
| 284 | return { ...next, pending }; | 271 | return { ...next, pending }; |
| 285 | }); | 272 | }); |
| 286 | return yield* Option.match(result, { | 273 | const awaitResponse = Effect.fn("PreviewAutomationBroker.awaitResponse")(function* () { |
| 287 | onNone: () => | 274 | const offered = yield* Queue.offer(connection.queue, { |
| 288 | Effect.fail( | 275 | requestId, |
| 289 | new PreviewAutomationTimeoutError({ | 276 | threadId: input.scope.threadId, |
| 290 | message: `Preview automation timed out after ${timeoutMs}ms.`, | 277 | tabId: input.tabId ?? owner.tabId ?? undefined, |
| 291 | }), | 278 | operation: input.operation, |
| 292 | ), | 279 | input: input.input, |
| 293 | onSome: (value) => Effect.succeed(value as A), | 280 | timeoutMs, |
| 281 | }); | ||
| 282 | if (!offered) { | ||
| 283 | return yield* new PreviewAutomationUnavailableError({ | ||
| 284 | message: "The preview automation client is no longer accepting requests.", | ||
| 285 | }); | ||
| 286 | } | ||
| 287 | const result = yield* Deferred.await(deferred).pipe(Effect.timeoutOption(timeoutMs)); | ||
| 288 | return yield* Option.match(result, { | ||
| 289 | onNone: () => | ||
| 290 | Effect.fail( | ||
| 291 | new PreviewAutomationTimeoutError({ | ||
| 292 | message: `Preview automation timed out after ${timeoutMs}ms.`, | ||
| 293 | }), | ||
| 294 | ), | ||
| 295 | onSome: (value) => Effect.succeed(value as A), | ||
| 296 | }); | ||
| 294 | }); | 297 | }); |
| 298 | return yield* awaitResponse().pipe(Effect.ensuring(removePending)); | ||
| 295 | }); | 299 | }); |
| 296 | 300 | ||
| 297 | return PreviewAutomationBroker.of({ connect, reportOwner, clearOwner, respond, invoke }); | 301 | return PreviewAutomationBroker.of({ connect, reportOwner, clearOwner, respond, invoke }); |
| 298 | }); | 302 | }).pipe(Effect.withSpan("PreviewAutomationBroker.make")); |
| 299 | 303 | ||
| 300 | export const layer = Layer.effect(PreviewAutomationBroker, make()); | 304 | export const layer = Layer.effect(PreviewAutomationBroker, make); |
| 301 | 305 | ||
| 302 | /** Exposed for tests. */ | 306 | /** Exposed for tests. */ |
| 303 | export const __testing = { | 307 | export const __testing = { |
apps/server/src/mcp/toolkits/preview/handlers.ts+20-8| ... | @@ -9,7 +9,7 @@ import type { | ... | @@ -9,7 +9,7 @@ import type { |
| 9 | 9 | ||
| 10 | import * as McpInvocationContext from "../../McpInvocationContext.ts"; | 10 | import * as McpInvocationContext from "../../McpInvocationContext.ts"; |
| 11 | import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts"; | 11 | import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts"; |
| 12 | import { PreviewToolkit } from "./tools.ts"; | 12 | import { PreviewSnapshotToolkit, PreviewStandardToolkit, PreviewToolkit } from "./tools.ts"; |
| 13 | 13 | ||
| 14 | const invoke = Effect.fn("PreviewToolkit.invoke")(function* <A>( | 14 | const invoke = Effect.fn("PreviewToolkit.invoke")(function* <A>( |
| 15 | operation: PreviewAutomationOperation, | 15 | operation: PreviewAutomationOperation, |
| ... | @@ -30,7 +30,7 @@ const invoke = Effect.fn("PreviewToolkit.invoke")(function* <A>( | ... | @@ -30,7 +30,7 @@ const invoke = Effect.fn("PreviewToolkit.invoke")(function* <A>( |
| 30 | }); | 30 | }); |
| 31 | }); | 31 | }); |
| 32 | 32 | ||
| 33 | export const PreviewToolkitHandlersLive = PreviewToolkit.toLayer({ | 33 | const handlers = { |
| 34 | preview_status: () => invoke<PreviewAutomationStatus>("status", {}), | 34 | preview_status: () => invoke<PreviewAutomationStatus>("status", {}), |
| 35 | preview_open: (input) => | 35 | preview_open: (input) => |
| 36 | invoke<PreviewAutomationStatus>("open", { | 36 | invoke<PreviewAutomationStatus>("open", { |
| ... | @@ -40,12 +40,24 @@ export const PreviewToolkitHandlersLive = PreviewToolkit.toLayer({ | ... | @@ -40,12 +40,24 @@ export const PreviewToolkitHandlersLive = PreviewToolkit.toLayer({ |
| 40 | }), | 40 | }), |
| 41 | preview_navigate: (input) => invoke<PreviewAutomationStatus>("navigate", input, input.timeoutMs), | 41 | preview_navigate: (input) => invoke<PreviewAutomationStatus>("navigate", input, input.timeoutMs), |
| 42 | preview_snapshot: () => invoke<PreviewAutomationSnapshot>("snapshot", {}), | 42 | preview_snapshot: () => invoke<PreviewAutomationSnapshot>("snapshot", {}), |
| 43 | preview_click: (input) => invoke<void>("click", input, input.timeoutMs), | 43 | preview_click: (input) => invoke<void>("click", input, input.timeoutMs).pipe(Effect.as(null)), |
| 44 | preview_type: (input) => invoke<void>("type", input, input.timeoutMs), | 44 | preview_type: (input) => invoke<void>("type", input, input.timeoutMs).pipe(Effect.as(null)), |
| 45 | preview_press: (input) => invoke<void>("press", input), | 45 | preview_press: (input) => invoke<void>("press", input).pipe(Effect.as(null)), |
| 46 | preview_scroll: (input) => invoke<void>("scroll", input), | 46 | preview_scroll: (input) => invoke<void>("scroll", input).pipe(Effect.as(null)), |
| 47 | preview_evaluate: (input) => invoke<unknown>("evaluate", input), | 47 | preview_evaluate: (input) => |
| 48 | preview_wait_for: (input) => invoke<void>("waitFor", input, input.timeoutMs), | 48 | invoke<unknown>("evaluate", input).pipe(Effect.map((result) => result ?? null)), |
| 49 | preview_wait_for: (input) => | ||
| 50 | invoke<void>("waitFor", input, input.timeoutMs).pipe(Effect.as(null)), | ||
| 49 | preview_recording_start: () => invoke<PreviewAutomationRecordingStatus>("recordingStart", {}), | 51 | preview_recording_start: () => invoke<PreviewAutomationRecordingStatus>("recordingStart", {}), |
| 50 | preview_recording_stop: () => invoke<PreviewAutomationRecordingArtifact>("recordingStop", {}), | 52 | preview_recording_stop: () => invoke<PreviewAutomationRecordingArtifact>("recordingStop", {}), |
| 53 | } satisfies Parameters<typeof PreviewToolkit.toLayer>[0]; | ||
| 54 | |||
| 55 | const { preview_snapshot, ...standardHandlers } = handlers; | ||
| 56 | |||
| 57 | export const PreviewStandardToolkitHandlersLive = PreviewStandardToolkit.toLayer(standardHandlers); | ||
| 58 | |||
| 59 | export const PreviewSnapshotToolkitHandlersLive = PreviewSnapshotToolkit.toLayer({ | ||
| 60 | preview_snapshot, | ||
| 51 | }); | 61 | }); |
| 62 | |||
| 63 | export const PreviewToolkitHandlersLive = PreviewToolkit.toLayer(handlers); |
apps/server/src/mcp/toolkits/preview/tools.ts+21| ... | @@ -84,6 +84,7 @@ export const PreviewClickTool = browserTool( | ... | @@ -84,6 +84,7 @@ export const PreviewClickTool = browserTool( |
| 84 | description: | 84 | description: |
| 85 | "Click exactly one page target. Prefer locator with a Playwright selector such as role=button[name='Send']; selector accepts legacy CSS; x and y are viewport CSS pixels and must be supplied together. Call preview_snapshot first when the target is unknown.", | 85 | "Click exactly one page target. Prefer locator with a Playwright selector such as role=button[name='Send']; selector accepts legacy CSS; x and y are viewport CSS pixels and must be supplied together. Call preview_snapshot first when the target is unknown.", |
| 86 | parameters: PreviewAutomationClickInput, | 86 | parameters: PreviewAutomationClickInput, |
| 87 | success: Schema.Null, | ||
| 87 | failure: PreviewAutomationError, | 88 | failure: PreviewAutomationError, |
| 88 | dependencies, | 89 | dependencies, |
| 89 | }).annotate(Tool.Title, "Click preview page"), | 90 | }).annotate(Tool.Title, "Click preview page"), |
| ... | @@ -94,6 +95,7 @@ export const PreviewTypeTool = browserTool( | ... | @@ -94,6 +95,7 @@ export const PreviewTypeTool = browserTool( |
| 94 | description: | 95 | description: |
| 95 | "Insert literal text into one input. Prefer locator with a Playwright role/text selector; selector accepts legacy CSS. If neither is supplied, types into the currently focused element. Set clear=true to replace existing text.", | 96 | "Insert literal text into one input. Prefer locator with a Playwright role/text selector; selector accepts legacy CSS. If neither is supplied, types into the currently focused element. Set clear=true to replace existing text.", |
| 96 | parameters: PreviewAutomationTypeInput, | 97 | parameters: PreviewAutomationTypeInput, |
| 98 | success: Schema.Null, | ||
| 97 | failure: PreviewAutomationError, | 99 | failure: PreviewAutomationError, |
| 98 | dependencies, | 100 | dependencies, |
| 99 | }).annotate(Tool.Title, "Type into preview page"), | 101 | }).annotate(Tool.Title, "Type into preview page"), |
| ... | @@ -104,6 +106,7 @@ export const PreviewPressTool = browserTool( | ... | @@ -104,6 +106,7 @@ export const PreviewPressTool = browserTool( |
| 104 | description: | 106 | description: |
| 105 | "Press one keyboard key in the active page, for example {key:'Enter'}, {key:'Escape'}, or {key:'a',modifiers:['Meta']}. This targets the page's current focus.", | 107 | "Press one keyboard key in the active page, for example {key:'Enter'}, {key:'Escape'}, or {key:'a',modifiers:['Meta']}. This targets the page's current focus.", |
| 106 | parameters: PreviewAutomationPressInput, | 108 | parameters: PreviewAutomationPressInput, |
| 109 | success: Schema.Null, | ||
| 107 | failure: PreviewAutomationError, | 110 | failure: PreviewAutomationError, |
| 108 | dependencies, | 111 | dependencies, |
| 109 | }).annotate(Tool.Title, "Press key in preview page"), | 112 | }).annotate(Tool.Title, "Press key in preview page"), |
| ... | @@ -114,6 +117,7 @@ export const PreviewScrollTool = safeBrowserTool( | ... | @@ -114,6 +117,7 @@ export const PreviewScrollTool = safeBrowserTool( |
| 114 | description: | 117 | description: |
| 115 | "Scroll by CSS pixels. Positive deltaY scrolls down and positive deltaX scrolls right. Without locator/selector it scrolls the viewport; otherwise it scrolls that container. At least one delta is required.", | 118 | "Scroll by CSS pixels. Positive deltaY scrolls down and positive deltaX scrolls right. Without locator/selector it scrolls the viewport; otherwise it scrolls that container. At least one delta is required.", |
| 116 | parameters: PreviewAutomationScrollInput, | 119 | parameters: PreviewAutomationScrollInput, |
| 120 | success: Schema.Null, | ||
| 117 | failure: PreviewAutomationError, | 121 | failure: PreviewAutomationError, |
| 118 | dependencies, | 122 | dependencies, |
| 119 | }).annotate(Tool.Title, "Scroll preview page"), | 123 | }).annotate(Tool.Title, "Scroll preview page"), |
| ... | @@ -135,6 +139,7 @@ export const PreviewWaitForTool = readonlyBrowserTool( | ... | @@ -135,6 +139,7 @@ export const PreviewWaitForTool = readonlyBrowserTool( |
| 135 | description: | 139 | description: |
| 136 | "Wait until all supplied conditions match: a Playwright locator, legacy CSS selector, visible-text substring, and/or URL substring. Provide at least one condition. Defaults to 15 seconds, maximum 60 seconds.", | 140 | "Wait until all supplied conditions match: a Playwright locator, legacy CSS selector, visible-text substring, and/or URL substring. Provide at least one condition. Defaults to 15 seconds, maximum 60 seconds.", |
| 137 | parameters: PreviewAutomationWaitForInput, | 141 | parameters: PreviewAutomationWaitForInput, |
| 142 | success: Schema.Null, | ||
| 138 | failure: PreviewAutomationError, | 143 | failure: PreviewAutomationError, |
| 139 | dependencies, | 144 | dependencies, |
| 140 | }).annotate(Tool.Title, "Wait for preview page condition"), | 145 | }).annotate(Tool.Title, "Wait for preview page condition"), |
| ... | @@ -173,3 +178,19 @@ export const PreviewToolkit = Toolkit.make( | ... | @@ -173,3 +178,19 @@ export const PreviewToolkit = Toolkit.make( |
| 173 | PreviewRecordingStartTool, | 178 | PreviewRecordingStartTool, |
| 174 | PreviewRecordingStopTool, | 179 | PreviewRecordingStopTool, |
| 175 | ); | 180 | ); |
| 181 | |||
| 182 | export const PreviewStandardToolkit = Toolkit.make( | ||
| 183 | PreviewStatusTool, | ||
| 184 | PreviewOpenTool, | ||
| 185 | PreviewNavigateTool, | ||
| 186 | PreviewClickTool, | ||
| 187 | PreviewTypeTool, | ||
| 188 | PreviewPressTool, | ||
| 189 | PreviewScrollTool, | ||
| 190 | PreviewEvaluateTool, | ||
| 191 | PreviewWaitForTool, | ||
| 192 | PreviewRecordingStartTool, | ||
| 193 | PreviewRecordingStopTool, | ||
| 194 | ); | ||
| 195 | |||
| 196 | export const PreviewSnapshotToolkit = Toolkit.make(PreviewSnapshotTool); |
apps/server/src/preview/Manager.ts+3-3| ... | @@ -127,7 +127,7 @@ const buildIdleSnapshot = (input: { | ... | @@ -127,7 +127,7 @@ const buildIdleSnapshot = (input: { |
| 127 | updatedAt: input.updatedAt, | 127 | updatedAt: input.updatedAt, |
| 128 | }); | 128 | }); |
| 129 | 129 | ||
| 130 | const make = Effect.fn("PreviewManager.make")(function* () { | 130 | const make = Effect.gen(function* PreviewManagerMake() { |
| 131 | const stateRef = yield* SynchronizedRef.make<ManagerState>(initialState); | 131 | const stateRef = yield* SynchronizedRef.make<ManagerState>(initialState); |
| 132 | // Unbounded PubSub is fine here — events are tiny and we don't want to | 132 | // Unbounded PubSub is fine here — events are tiny and we don't want to |
| 133 | // block publishers if a subscriber is slow. WS clients backpressure on | 133 | // block publishers if a subscriber is slow. WS clients backpressure on |
| ... | @@ -357,6 +357,6 @@ const make = Effect.fn("PreviewManager.make")(function* () { | ... | @@ -357,6 +357,6 @@ const make = Effect.fn("PreviewManager.make")(function* () { |
| 357 | events, | 357 | events, |
| 358 | subscribeEvents: PubSub.subscribe(eventsPubSub), | 358 | subscribeEvents: PubSub.subscribe(eventsPubSub), |
| 359 | } satisfies PreviewManagerShape; | 359 | } satisfies PreviewManagerShape; |
| 360 | }); | 360 | }).pipe(Effect.withSpan("PreviewManager.make")); |
| 361 | 361 | ||
| 362 | export const layer = Layer.effect(PreviewManager, make()); | 362 | export const layer = Layer.effect(PreviewManager, make); |
apps/server/src/preview/PortScanner.test.ts+3-5| ... | @@ -249,20 +249,18 @@ effectIt.layer(TestPortDiscoveryLive)("PortDiscovery integration (TCP probe fall | ... | @@ -249,20 +249,18 @@ effectIt.layer(TestPortDiscoveryLive)("PortDiscovery integration (TCP probe fall |
| 249 | ); | 249 | ); |
| 250 | 250 | ||
| 251 | it.effect( | 251 | it.effect( |
| 252 | "retain() drives an immediate broadcast to subscribers", | 252 | "retain drives an immediate broadcast to subscribers", |
| 253 | Effect.fn("PortScannerTest.retainBroadcastsImmediately")(function* () { | 253 | Effect.fn("PortScannerTest.retainBroadcastsImmediately")(function* () { |
| 254 | yield* windowsPlatform; | 254 | yield* windowsPlatform; |
| 255 | const { port } = yield* commonDevServer; | 255 | const { port } = yield* commonDevServer; |
| 256 | const received: number[] = []; | 256 | const received: number[] = []; |
| 257 | const scanner = yield* PortScanner.PortDiscovery; | 257 | const scanner = yield* PortScanner.PortDiscovery; |
| 258 | const unsubscribe = yield* scanner.subscribe((servers) => | 258 | yield* scanner.subscribe((servers) => |
| 259 | Effect.sync(() => { | 259 | Effect.sync(() => { |
| 260 | for (const server of servers) received.push(server.port); | 260 | for (const server of servers) received.push(server.port); |
| 261 | }), | 261 | }), |
| 262 | ); | 262 | ); |
| 263 | const release = yield* scanner.retain(); | 263 | yield* scanner.retain; |
| 264 | unsubscribe(); | ||
| 265 | release(); | ||
| 266 | expect(received).toContain(port); | 264 | expect(received).toContain(port); |
| 267 | }), | 265 | }), |
| 268 | ); | 266 | ); |
apps/server/src/preview/PortScanner.ts+74-55| ... | @@ -8,13 +8,13 @@ | ... | @@ -8,13 +8,13 @@ |
| 8 | * Windows / lsof missing: checks a curated list of common dev ports through | 8 | * Windows / lsof missing: checks a curated list of common dev ports through |
| 9 | * the shared Net service. | 9 | * the shared Net service. |
| 10 | * | 10 | * |
| 11 | * Polling is reference-counted via `retain()`. A single layer-scoped fiber | 11 | * Polling is reference-counted via scoped `retain`. A single layer-scoped fiber |
| 12 | * polls forever, but each tick is a no-op when the retain count is zero. | 12 | * polls forever, but each tick is a no-op when the retain count is zero. |
| 13 | */ | 13 | */ |
| 14 | import { ThreadId, type DiscoveredLocalServer } from "@t3tools/contracts"; | 14 | import { ThreadId, type DiscoveredLocalServer } from "@t3tools/contracts"; |
| 15 | import * as Net from "@t3tools/shared/Net"; | 15 | import * as Net from "@t3tools/shared/Net"; |
| 16 | import { LSOF_LOCAL_HOST_TOKENS } from "@t3tools/shared/preview"; | 16 | import { LSOF_LOCAL_HOST_TOKENS } from "@t3tools/shared/preview"; |
| 17 | import { Cause, Context, Duration, Effect, Layer, Ref, Schedule } from "effect"; | 17 | import { Cause, Context, Duration, Effect, Layer, Ref, Schedule, Scope } from "effect"; |
| 18 | 18 | ||
| 19 | import { ProcessRunner } from "../processRunner.ts"; | 19 | import { ProcessRunner } from "../processRunner.ts"; |
| 20 | 20 | ||
| ... | @@ -22,8 +22,8 @@ export interface PortDiscoveryShape { | ... | @@ -22,8 +22,8 @@ export interface PortDiscoveryShape { |
| 22 | readonly scan: () => Effect.Effect<ReadonlyArray<DiscoveredLocalServer>>; | 22 | readonly scan: () => Effect.Effect<ReadonlyArray<DiscoveredLocalServer>>; |
| 23 | readonly subscribe: ( | 23 | readonly subscribe: ( |
| 24 | listener: (servers: ReadonlyArray<DiscoveredLocalServer>) => Effect.Effect<void>, | 24 | listener: (servers: ReadonlyArray<DiscoveredLocalServer>) => Effect.Effect<void>, |
| 25 | ) => Effect.Effect<() => void>; | 25 | ) => Effect.Effect<void, never, Scope.Scope>; |
| 26 | readonly retain: () => Effect.Effect<() => void>; | 26 | readonly retain: Effect.Effect<void, never, Scope.Scope>; |
| 27 | readonly registerTerminalProcesses: (input: { | 27 | readonly registerTerminalProcesses: (input: { |
| 28 | readonly threadId: string; | 28 | readonly threadId: string; |
| 29 | readonly terminalId: string; | 29 | readonly terminalId: string; |
| ... | @@ -51,6 +51,15 @@ type Listener = (servers: ReadonlyArray<DiscoveredLocalServer>) => Effect.Effect | ... | @@ -51,6 +51,15 @@ type Listener = (servers: ReadonlyArray<DiscoveredLocalServer>) => Effect.Effect |
| 51 | 51 | ||
| 52 | interface ScannerState { | 52 | interface ScannerState { |
| 53 | readonly lastSnapshot: ReadonlyArray<DiscoveredLocalServer>; | 53 | readonly lastSnapshot: ReadonlyArray<DiscoveredLocalServer>; |
| 54 | readonly listeners: ReadonlySet<Listener>; | ||
| 55 | readonly terminalProcesses: ReadonlyMap< | ||
| 56 | string, | ||
| 57 | { | ||
| 58 | readonly owner: TerminalProcessOwner; | ||
| 59 | readonly processIds: ReadonlySet<number>; | ||
| 60 | } | ||
| 61 | >; | ||
| 62 | readonly retainCount: number; | ||
| 54 | } | 63 | } |
| 55 | 64 | ||
| 56 | interface TerminalProcessOwner { | 65 | interface TerminalProcessOwner { |
| ... | @@ -170,24 +179,15 @@ const serversEqual = ( | ... | @@ -170,24 +179,15 @@ const serversEqual = ( |
| 170 | return true; | 179 | return true; |
| 171 | }; | 180 | }; |
| 172 | 181 | ||
| 173 | const make = Effect.fn("PortDiscovery.make")(function* () { | 182 | const make = Effect.gen(function* PortDiscoveryMake() { |
| 174 | const net = yield* Net.NetService; | 183 | const net = yield* Net.NetService; |
| 175 | const processRunner = yield* ProcessRunner; | 184 | const processRunner = yield* ProcessRunner; |
| 176 | const stateRef = yield* Ref.make<ScannerState>({ | 185 | const stateRef = yield* Ref.make<ScannerState>({ |
| 177 | lastSnapshot: [], | 186 | lastSnapshot: [], |
| 187 | listeners: new Set(), | ||
| 188 | terminalProcesses: new Map(), | ||
| 189 | retainCount: 0, | ||
| 178 | }); | 190 | }); |
| 179 | const listeners = new Set<Listener>(); | ||
| 180 | const terminalProcesses = new Map< | ||
| 181 | string, | ||
| 182 | { | ||
| 183 | readonly owner: TerminalProcessOwner; | ||
| 184 | readonly processIds: ReadonlySet<number>; | ||
| 185 | } | ||
| 186 | >(); | ||
| 187 | // Plain integer because the release callback returned by `retain()` runs | ||
| 188 | // outside any Effect context (the WS subscriber's release path) and must | ||
| 189 | // be a synchronous side-effect-only function. | ||
| 190 | let retainCount = 0; | ||
| 191 | 191 | ||
| 192 | const probeCommonPorts = Effect.fn("PortDiscovery.probeCommonPorts")(function* () { | 192 | const probeCommonPorts = Effect.fn("PortDiscovery.probeCommonPorts")(function* () { |
| 193 | const results = yield* Effect.forEach( | 193 | const results = yield* Effect.forEach( |
| ... | @@ -214,8 +214,9 @@ const make = Effect.fn("PortDiscovery.make")(function* () { | ... | @@ -214,8 +214,9 @@ const make = Effect.fn("PortDiscovery.make")(function* () { |
| 214 | }); | 214 | }); |
| 215 | 215 | ||
| 216 | const scanOnce = Effect.fn("PortDiscovery.scan")(function* () { | 216 | const scanOnce = Effect.fn("PortDiscovery.scan")(function* () { |
| 217 | const state = yield* Ref.get(stateRef); | ||
| 217 | const terminalByProcessId = new Map<number, TerminalProcessOwner>(); | 218 | const terminalByProcessId = new Map<number, TerminalProcessOwner>(); |
| 218 | for (const registration of terminalProcesses.values()) { | 219 | for (const registration of state.terminalProcesses.values()) { |
| 219 | for (const processId of registration.processIds) { | 220 | for (const processId of registration.processIds) { |
| 220 | terminalByProcessId.set(processId, registration.owner); | 221 | terminalByProcessId.set(processId, registration.owner); |
| 221 | } | 222 | } |
| ... | @@ -254,17 +255,23 @@ const make = Effect.fn("PortDiscovery.make")(function* () { | ... | @@ -254,17 +255,23 @@ const make = Effect.fn("PortDiscovery.make")(function* () { |
| 254 | return yield* probeCommonPorts(); | 255 | return yield* probeCommonPorts(); |
| 255 | }); | 256 | }); |
| 256 | 257 | ||
| 257 | const broadcast = (servers: ReadonlyArray<DiscoveredLocalServer>): Effect.Effect<void> => | 258 | const broadcast = Effect.fn("PortDiscovery.broadcast")(function* ( |
| 258 | Effect.forEach(Array.from(listeners), (listener) => listener(servers), { discard: true }); | 259 | servers: ReadonlyArray<DiscoveredLocalServer>, |
| 260 | ) { | ||
| 261 | const listeners = (yield* Ref.get(stateRef)).listeners; | ||
| 262 | yield* Effect.forEach(listeners, (listener) => listener(servers), { discard: true }); | ||
| 263 | }); | ||
| 259 | 264 | ||
| 260 | const pollTick = Effect.fn("PortDiscovery.pollTick")( | 265 | const pollTick = Effect.fn("PortDiscovery.pollTick")( |
| 261 | function* () { | 266 | function* () { |
| 262 | if (retainCount <= 0) return; | 267 | if ((yield* Ref.get(stateRef)).retainCount <= 0) return; |
| 263 | const next = yield* scanOnce(); | 268 | const next = yield* scanOnce(); |
| 264 | const state = yield* Ref.get(stateRef); | 269 | const changed = yield* Ref.modify(stateRef, (state) => |
| 265 | if (serversEqual(state.lastSnapshot, next)) return; | 270 | serversEqual(state.lastSnapshot, next) |
| 266 | yield* Ref.update(stateRef, (s) => ({ ...s, lastSnapshot: next })); | 271 | ? [false, state] |
| 267 | yield* broadcast(next); | 272 | : [true, { ...state, lastSnapshot: next }], |
| 273 | ); | ||
| 274 | if (changed) yield* broadcast(next); | ||
| 268 | }, | 275 | }, |
| 269 | Effect.catchCause((cause: Cause.Cause<never>) => | 276 | Effect.catchCause((cause: Cause.Cause<never>) => |
| 270 | Effect.logWarning("preview port scan failed", Cause.pretty(cause)), | 277 | Effect.logWarning("preview port scan failed", Cause.pretty(cause)), |
| ... | @@ -275,58 +282,70 @@ const make = Effect.fn("PortDiscovery.make")(function* () { | ... | @@ -275,58 +282,70 @@ const make = Effect.fn("PortDiscovery.make")(function* () { |
| 275 | // currently retained, so the cost is one Ref.get every POLL_INTERVAL. | 282 | // currently retained, so the cost is one Ref.get every POLL_INTERVAL. |
| 276 | yield* Effect.forkScoped(pollTick().pipe(Effect.repeat(Schedule.spaced(POLL_INTERVAL)))); | 283 | yield* Effect.forkScoped(pollTick().pipe(Effect.repeat(Schedule.spaced(POLL_INTERVAL)))); |
| 277 | 284 | ||
| 278 | const retain: PortDiscoveryShape["retain"] = Effect.fn("PortDiscovery.retain")(function* () { | 285 | const acquireRetention = Effect.fn("PortDiscovery.retain")(function* () { |
| 279 | const wasIdle = retainCount === 0; | 286 | const wasIdle = yield* Ref.modify(stateRef, (state) => [ |
| 280 | retainCount += 1; | 287 | state.retainCount === 0, |
| 288 | { ...state, retainCount: state.retainCount + 1 }, | ||
| 289 | ]); | ||
| 281 | if (wasIdle) { | 290 | if (wasIdle) { |
| 282 | // Run an immediate scan + broadcast so the new retainer doesn't have | 291 | // Run an immediate scan + broadcast so the new retainer doesn't have |
| 283 | // to wait up to POLL_INTERVAL for the first emission. | 292 | // to wait up to POLL_INTERVAL for the first emission. |
| 284 | yield* pollTick(); | 293 | yield* pollTick(); |
| 285 | } | 294 | } |
| 286 | let released = false; | ||
| 287 | return () => { | ||
| 288 | if (released) return; | ||
| 289 | released = true; | ||
| 290 | retainCount = Math.max(0, retainCount - 1); | ||
| 291 | }; | ||
| 292 | }); | 295 | }); |
| 293 | 296 | ||
| 297 | const retain: PortDiscoveryShape["retain"] = Effect.acquireRelease(acquireRetention(), () => | ||
| 298 | Ref.update(stateRef, (state) => ({ | ||
| 299 | ...state, | ||
| 300 | retainCount: Math.max(0, state.retainCount - 1), | ||
| 301 | })), | ||
| 302 | ); | ||
| 303 | |||
| 294 | const subscribe: PortDiscoveryShape["subscribe"] = Effect.fn("PortDiscovery.subscribe")( | 304 | const subscribe: PortDiscoveryShape["subscribe"] = Effect.fn("PortDiscovery.subscribe")( |
| 295 | function* (listener) { | 305 | (listener) => |
| 296 | return yield* Effect.sync(() => { | 306 | Effect.acquireRelease( |
| 297 | listeners.add(listener); | 307 | Ref.update(stateRef, (state) => ({ |
| 298 | return () => { | 308 | ...state, |
| 299 | listeners.delete(listener); | 309 | listeners: new Set([...state.listeners, listener]), |
| 300 | }; | 310 | })), |
| 301 | }); | 311 | () => |
| 302 | }, | 312 | Ref.update(stateRef, (state) => { |
| 313 | const listeners = new Set(state.listeners); | ||
| 314 | listeners.delete(listener); | ||
| 315 | return { ...state, listeners }; | ||
| 316 | }), | ||
| 317 | ), | ||
| 303 | ); | 318 | ); |
| 304 | 319 | ||
| 305 | const registerTerminalProcesses: PortDiscoveryShape["registerTerminalProcesses"] = Effect.fn( | 320 | const registerTerminalProcesses: PortDiscoveryShape["registerTerminalProcesses"] = Effect.fn( |
| 306 | "PortDiscovery.registerTerminalProcesses", | 321 | "PortDiscovery.registerTerminalProcesses", |
| 307 | )(function* (input) { | 322 | )(function* (input) { |
| 308 | yield* Effect.sync(() => { | 323 | const owner = { |
| 309 | const owner = { | 324 | threadId: ThreadId.make(input.threadId), |
| 310 | threadId: ThreadId.make(input.threadId), | 325 | terminalId: input.terminalId, |
| 311 | terminalId: input.terminalId, | 326 | }; |
| 312 | }; | 327 | const processIds = new Set( |
| 313 | const processIds = new Set( | 328 | input.processIds.filter((processId) => Number.isInteger(processId) && processId > 0), |
| 314 | input.processIds.filter((processId) => Number.isInteger(processId) && processId > 0), | 329 | ); |
| 315 | ); | 330 | yield* Ref.update(stateRef, (state) => { |
| 331 | const terminalProcesses = new Map(state.terminalProcesses); | ||
| 316 | const key = terminalOwnerKey(owner); | 332 | const key = terminalOwnerKey(owner); |
| 317 | if (processIds.size === 0) { | 333 | if (processIds.size === 0) { |
| 318 | terminalProcesses.delete(key); | 334 | terminalProcesses.delete(key); |
| 319 | return; | 335 | } else { |
| 336 | terminalProcesses.set(key, { owner, processIds }); | ||
| 320 | } | 337 | } |
| 321 | terminalProcesses.set(key, { owner, processIds }); | 338 | return { ...state, terminalProcesses }; |
| 322 | }); | 339 | }); |
| 323 | }); | 340 | }); |
| 324 | 341 | ||
| 325 | const unregisterTerminal: PortDiscoveryShape["unregisterTerminal"] = Effect.fn( | 342 | const unregisterTerminal: PortDiscoveryShape["unregisterTerminal"] = Effect.fn( |
| 326 | "PortDiscovery.unregisterTerminal", | 343 | "PortDiscovery.unregisterTerminal", |
| 327 | )(function* (input) { | 344 | )(function* (input) { |
| 328 | yield* Effect.sync(() => { | 345 | yield* Ref.update(stateRef, (state) => { |
| 346 | const terminalProcesses = new Map(state.terminalProcesses); | ||
| 329 | terminalProcesses.delete(terminalOwnerKey(input)); | 347 | terminalProcesses.delete(terminalOwnerKey(input)); |
| 348 | return { ...state, terminalProcesses }; | ||
| 330 | }); | 349 | }); |
| 331 | }); | 350 | }); |
| 332 | 351 | ||
| ... | @@ -337,9 +356,9 @@ const make = Effect.fn("PortDiscovery.make")(function* () { | ... | @@ -337,9 +356,9 @@ const make = Effect.fn("PortDiscovery.make")(function* () { |
| 337 | registerTerminalProcesses, | 356 | registerTerminalProcesses, |
| 338 | unregisterTerminal, | 357 | unregisterTerminal, |
| 339 | } satisfies PortDiscoveryShape; | 358 | } satisfies PortDiscoveryShape; |
| 340 | }); | 359 | }).pipe(Effect.withSpan("PortDiscovery.make")); |
| 341 | 360 | ||
| 342 | export const layer = Layer.effect(PortDiscovery, make()); | 361 | export const layer = Layer.effect(PortDiscovery, make); |
| 343 | 362 | ||
| 344 | /** Exposed for tests. */ | 363 | /** Exposed for tests. */ |
| 345 | export const __testing = { | 364 | export const __testing = { |
apps/server/src/server.test.ts+2-2| ... | @@ -683,8 +683,8 @@ const buildAppUnderTest = (options?: { | ... | @@ -683,8 +683,8 @@ const buildAppUnderTest = (options?: { |
| 683 | }), | 683 | }), |
| 684 | Layer.mock(PortScanner.PortDiscovery)({ | 684 | Layer.mock(PortScanner.PortDiscovery)({ |
| 685 | scan: () => Effect.succeed([]), | 685 | scan: () => Effect.succeed([]), |
| 686 | subscribe: () => Effect.succeed(() => {}), | 686 | subscribe: () => Effect.void, |
| 687 | retain: () => Effect.succeed(() => {}), | 687 | retain: Effect.void, |
| 688 | registerTerminalProcesses: () => Effect.void, | 688 | registerTerminalProcesses: () => Effect.void, |
| 689 | unregisterTerminal: () => Effect.void, | 689 | unregisterTerminal: () => Effect.void, |
| 690 | }), | 690 | }), |
apps/server/src/ws.ts+14-22| ... | @@ -1425,29 +1425,21 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => | ... | @@ -1425,29 +1425,21 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => |
| 1425 | observeRpcStream( | 1425 | observeRpcStream( |
| 1426 | WS_METHODS.subscribeDiscoveredLocalServers, | 1426 | WS_METHODS.subscribeDiscoveredLocalServers, |
| 1427 | Stream.callback<DiscoveredLocalServerList>((queue) => | 1427 | Stream.callback<DiscoveredLocalServerList>((queue) => |
| 1428 | Effect.acquireRelease( | 1428 | Effect.gen(function* () { |
| 1429 | Effect.gen(function* () { | 1429 | yield* portDiscovery.retain; |
| 1430 | const release = yield* portDiscovery.retain(); | 1430 | const initial = yield* portDiscovery.scan(); |
| 1431 | const initial = yield* portDiscovery.scan(); | 1431 | const initialScannedAt = DateTime.formatIso(yield* DateTime.now); |
| 1432 | const initialScannedAt = DateTime.formatIso(yield* DateTime.now); | 1432 | yield* Queue.offer(queue, { |
| 1433 | yield* Queue.offer(queue, { | 1433 | servers: initial, |
| 1434 | servers: initial, | 1434 | scannedAt: initialScannedAt, |
| 1435 | scannedAt: initialScannedAt, | 1435 | }); |
| 1436 | }); | 1436 | yield* portDiscovery.subscribe((servers) => |
| 1437 | const unsubscribe = yield* portDiscovery.subscribe((servers) => | 1437 | Effect.gen(function* () { |
| 1438 | Effect.gen(function* () { | 1438 | const scannedAt = DateTime.formatIso(yield* DateTime.now); |
| 1439 | const scannedAt = DateTime.formatIso(yield* DateTime.now); | 1439 | yield* Queue.offer(queue, { servers, scannedAt }); |
| 1440 | yield* Queue.offer(queue, { servers, scannedAt }); | ||
| 1441 | }), | ||
| 1442 | ); | ||
| 1443 | return { unsubscribe, release }; | ||
| 1444 | }), | ||
| 1445 | ({ unsubscribe, release }) => | ||
| 1446 | Effect.sync(() => { | ||
| 1447 | unsubscribe(); | ||
| 1448 | release(); | ||
| 1449 | }), | 1440 | }), |
| 1450 | ), | 1441 | ); |
| 1442 | }), | ||
| 1451 | ), | 1443 | ), |
| 1452 | { "rpc.aggregate": "preview" }, | 1444 | { "rpc.aggregate": "preview" }, |
| 1453 | ), | 1445 | ), |