acrylblends 1.0.4 → 1.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/framework/agents/index.md +7 -5
- package/lib/accept-CNNHu4Cf.js +193 -0
- package/lib/{accepts-XBuZPVRI.js → accepts-Btq83xPd.js} +1 -1
- package/lib/accepts.js +1 -1
- package/lib/acryl-base.js +1 -331
- package/lib/adapter-CaZ6FZUU.js +37 -0
- package/lib/adapter.js +1 -36
- package/lib/{app-BM0rTCp7.js → app-CQcGWjrk.js} +4 -4
- package/lib/app-base-CpRMZL4v.js +332 -0
- package/lib/{audit-D5z9uwOP.d.ts → audit-GcLtvNLV.d.ts} +4 -2
- package/lib/{base-NSN_SNv-.js → base-D_6kfOr-.js} +1 -1
- package/lib/basic-auth-C0HTRpXh.js +20 -0
- package/lib/basic-auth-DFLflXNe.js +95 -0
- package/lib/basic-auth.js +1 -94
- package/lib/bearer-auth-Cwxt-vgS.js +94 -0
- package/lib/bearer-auth.js +1 -93
- package/lib/body-D7f4e7w_.js +94 -0
- package/lib/body-limit-WmIjWRLt.js +66 -0
- package/lib/body-limit.js +1 -65
- package/lib/buffer-CkEOJCcY.js +51 -0
- package/lib/bun.js +3 -3
- package/lib/cache-B0bHH7s5.js +169 -0
- package/lib/cache.js +1 -168
- package/lib/{client-DDLXwhBo.js → client-BrMFnMEd.js} +1 -1
- package/lib/{client-modules-CLvWOjbd.js → client-modules-drj4mjhj.js} +4 -4
- package/lib/client.js +1 -1
- package/lib/color-COa3DplO.js +37 -0
- package/lib/combine-TWuGdcbC.js +137 -0
- package/lib/combine.js +1 -136
- package/lib/{components-cEW3ovK0.js → components-B2ka9Ax1.js} +4 -4
- package/lib/{components-JXVm45kK.js → components-PVXwaOaC.js} +2 -2
- package/lib/compress-BzWqsHF1.js +79 -0
- package/lib/compress-C-9vK4B2.d.ts +11 -0
- package/lib/compress-DCyrrQ5D.js +11 -0
- package/lib/compress.d.ts +1 -1
- package/lib/compress.js +2 -78
- package/lib/concurrent-DrF3d2c-.js +30 -0
- package/lib/conninfo-BCROmdnf.d.ts +11 -0
- package/lib/conninfo-CWJwieh4.js +19 -0
- package/lib/constants-CBSEudrb.js +7 -0
- package/lib/{context-DDO0cfuP.js → context-CkOh7Ygv.js} +2 -2
- package/lib/context-storage-iuPoI_CR.js +52 -0
- package/lib/context-storage.js +1 -51
- package/lib/cookie-B1LlkzOe.js +125 -0
- package/lib/cookie-BHIduy2T.js +82 -0
- package/lib/cookie.js +1 -81
- package/lib/cordis/db-sqlite.d.ts +1 -1
- package/lib/cordis/door.d.ts +99 -5
- package/lib/cordis/door.js +562 -20
- package/lib/cordis/inspect.js +4 -4
- package/lib/cordis/kv-db.d.ts +1 -1
- package/lib/cordis/kv-db.js +1 -1
- package/lib/cordis/kv-json.d.ts +1 -1
- package/lib/cordis/kv-json.js +1 -1
- package/lib/cordis/node-server.d.ts +1 -1
- package/lib/cordis/node-server.js +3 -116
- package/lib/cordis/router-http.js +2 -2
- package/lib/cordis/router.js +4 -4
- package/lib/cordis/state.js +7 -7
- package/lib/{core-CaY8XDZ6.js → core-BOu1jYua.js} +1 -1
- package/lib/cors-CbAQ-8qU.js +106 -0
- package/lib/cors.js +1 -105
- package/lib/crypto-iVP-N-tS.js +34 -0
- package/lib/csrf-DqqOZtgy.js +101 -0
- package/lib/csrf.js +1 -100
- package/lib/css-C5CPVMvM.js +134 -0
- package/lib/css.js +2 -133
- package/lib/db/sqlite.d.ts +1 -25
- package/lib/dev-CLgu-JRe.js +50 -0
- package/lib/dev.js +1 -49
- package/lib/dom-YjND9Jt3.js +74 -0
- package/lib/door/host.d.ts +2 -2
- package/lib/door/host.js +1 -1
- package/lib/door/page.d.ts +3 -1
- package/lib/door/page.js +2 -2
- package/lib/door/protocol.d.ts +1 -1
- package/lib/door/protocol.js +1 -212
- package/lib/encode-BhbNLrjp.js +33 -0
- package/lib/factory-CLdSf-xS.js +30 -0
- package/lib/factory.js +1 -29
- package/lib/handler-C0wZ0KwO.js +12 -0
- package/lib/helpers-BEjcdbgw.js +5 -0
- package/lib/{hooks-w72iLeFR.js → hooks-CfC07ri0.js} +1 -1
- package/lib/html-CgNa89xw.js +31 -0
- package/lib/html-DGExBFNg.js +103 -0
- package/lib/html.d.ts +2 -5
- package/lib/html.js +2 -30
- package/lib/http-exception-MjWkT89V.js +54 -0
- package/lib/http-exception.js +1 -53
- package/lib/index-dAzQsJG9.d.ts +6 -0
- package/lib/index.js +3 -3
- package/lib/ip-restriction-jKtKCRM_.js +166 -0
- package/lib/ip-restriction.js +1 -165
- package/lib/ipaddr-DCoIo528.js +238 -0
- package/lib/jsx/dom/client.js +1 -1
- package/lib/jsx/dom/jsx-dev-runtime.js +1 -1
- package/lib/jsx/dom/jsx-runtime.js +2 -1
- package/lib/jsx/dom/server.js +3 -3
- package/lib/jsx/dom.js +5 -73
- package/lib/jsx/jsx-dev-runtime.d.ts +2 -5
- package/lib/jsx/jsx-dev-runtime.js +2 -13
- package/lib/jsx/jsx-runtime.d.ts +2 -2
- package/lib/jsx/jsx-runtime.js +4 -4
- package/lib/jsx/streaming.js +1 -1
- package/lib/jsx-CUQgttxU.js +47 -0
- package/lib/jsx-dev-runtime-B4WTyq8r.js +14 -0
- package/lib/{jsx-dev-runtime-CWLRA2F3.js → jsx-dev-runtime-BLfA-EhI.js} +2 -2
- package/lib/jsx-dev-runtime-CGt4kNYr.d.ts +6 -0
- package/lib/jsx-renderer-CfifM4hT.js +99 -0
- package/lib/jsx-renderer.js +1 -98
- package/lib/jsx-runtime-COCRWBJ9.js +2 -0
- package/lib/jsx.js +5 -46
- package/lib/{jwk-LkzeoaU8.js → jwk-CDPpYQpD.js} +3 -3
- package/lib/jwk.js +1 -1
- package/lib/{jwt-BGJBw5AF.js → jwt-BXoIA2fQ.js} +2 -2
- package/lib/{jwt-CskdhEW9.js → jwt-lyS4Q3mT.js} +3 -3
- package/lib/jwt.js +2 -2
- package/lib/kv/db.d.ts +1 -10
- package/lib/kv/db.js +1 -56
- package/lib/kv/json.d.ts +1 -9
- package/lib/kv/json.js +1 -223
- package/lib/{language-D9a4zqvn.js → language-DeGRtLJt.js} +2 -2
- package/lib/language.js +1 -1
- package/lib/{linear-router-C3FPib6h.js → linear-router-CmZn_JLV.js} +2 -2
- package/lib/logger-Ckxu8diz.js +54 -0
- package/lib/logger.js +1 -53
- package/lib/loopback-only-BbasoF7R.js +90 -0
- package/lib/loopback-only.js +1 -89
- package/lib/method-not-allowed-TO2_hnfl.js +82 -0
- package/lib/method-not-allowed.js +1 -81
- package/lib/method-override-DnZKfJbV.js +103 -0
- package/lib/method-override.js +1 -102
- package/lib/middleware-BfG_fp8P.js +12 -0
- package/lib/mime-BnScXkQn.js +77 -0
- package/lib/mount-QkK_3NtJ.js +67 -0
- package/lib/mount.js +1 -66
- package/lib/node-server/conninfo.d.ts +1 -10
- package/lib/node-server/conninfo.js +1 -18
- package/lib/node-server/register.js +25 -2
- package/lib/node-server/serve-static.d.ts +1 -17
- package/lib/node-server/serve-static.js +1 -1
- package/lib/node-server.d.ts +2 -2
- package/lib/node-server.js +4 -4
- package/lib/open-BqK4D2Wn.d.ts +11 -0
- package/lib/open-But9E2rL.js +57 -0
- package/lib/open-CULVJgOc.d.ts +12 -0
- package/lib/open-Dp4sxspc.js +224 -0
- package/lib/open-T7kgbO2O.d.ts +27 -0
- package/lib/{pattern-router-HZxjDGEQ.js → pattern-router-CJBecJBd.js} +1 -1
- package/lib/powered-by-CXQNjNIX.js +24 -0
- package/lib/powered-by.js +1 -23
- package/lib/pretty-json-CeC1yvYt.js +39 -0
- package/lib/pretty-json.js +1 -38
- package/lib/{protocol-C37gPePD.d.ts → protocol-CR31M7K_.d.ts} +2 -0
- package/lib/protocol-Dt0AZBOz.js +213 -0
- package/lib/proxy-C2w_mH7k.js +121 -0
- package/lib/proxy.js +1 -120
- package/lib/quick.js +4 -4
- package/lib/{reg-exp-router-DC1R3qoY.js → reg-exp-router-C_WV84eV.js} +2 -2
- package/lib/request-DvMgGtlW.js +323 -0
- package/lib/request.js +1 -322
- package/lib/route-D8m-a2I1.js +126 -0
- package/lib/route.js +1 -125
- package/lib/router/linear-router.js +1 -1
- package/lib/router/pattern-router.js +1 -1
- package/lib/router/reg-exp-router.js +1 -1
- package/lib/router/smart-router.js +1 -1
- package/lib/router/trie-router.js +1 -1
- package/lib/router-Be_o6nFu.js +35 -0
- package/lib/router.js +1 -34
- package/lib/schema-validator-EK3cUUCt.js +22 -0
- package/lib/schema-validator.js +1 -21
- package/lib/{secure-headers-mP3j6Hm6.js → secure-headers-C5ZOcXqk.js} +1 -1
- package/lib/secure-headers.js +1 -1
- package/lib/serve-static-B7sqnftW.d.ts +18 -0
- package/lib/{serve-static-3uo_9rjF.js → serve-static-C1Nkj_xB.js} +2 -2
- package/lib/{serve-static-Cp5adzGo.js → serve-static-EiJf1zkO.js} +4 -4
- package/lib/serve-static.js +1 -1
- package/lib/{smart-router-C5CZ5XSM.js → smart-router-BjxkDQCE.js} +1 -1
- package/lib/{snapshot-text-BLz8nlSc.js → snapshot-text-CtQm3n8n.js} +23 -14
- package/lib/{src-dy3Fk9tA.js → src-BTBm4OPD.js} +6 -6
- package/lib/src-LX0E4ec4.js +117 -0
- package/lib/{ssg-BwjcA-Ge.js → ssg-BEl7MHqg.js} +5 -5
- package/lib/ssg.js +1 -1
- package/lib/stream-CzM_SHkq.js +88 -0
- package/lib/{streaming-DHST3Scg.js → streaming-BlDaph8U.js} +3 -3
- package/lib/streaming.js +1 -1
- package/lib/{test-websocket-D8WWqE5G.js → test-websocket-BLeEvybj.js} +2 -2
- package/lib/testing-BQx5T_UX.js +17 -0
- package/lib/testing.js +1 -16
- package/lib/timeout-FeqZ9A-0.js +42 -0
- package/lib/timeout.js +1 -41
- package/lib/tiny.js +2 -2
- package/lib/trailing-slash-DwTx-1sk.js +85 -0
- package/lib/trailing-slash.js +1 -84
- package/lib/{trie-router-CfFbapzL.js → trie-router-JydLKUa2.js} +2 -2
- package/lib/url-BPh4d6j-.js +197 -0
- package/lib/utils/accept.js +1 -192
- package/lib/utils/basic-auth.js +1 -19
- package/lib/utils/body.js +1 -93
- package/lib/utils/buffer.js +1 -50
- package/lib/utils/color.js +1 -36
- package/lib/utils/compress.d.ts +1 -10
- package/lib/utils/compress.js +1 -10
- package/lib/utils/concurrent.js +1 -29
- package/lib/utils/constants.js +1 -6
- package/lib/utils/cookie.js +1 -124
- package/lib/utils/crypto.js +1 -33
- package/lib/utils/encode.js +1 -32
- package/lib/utils/handler.js +1 -11
- package/lib/utils/html.js +1 -102
- package/lib/utils/ipaddr.js +1 -237
- package/lib/utils/jwt.js +1 -1
- package/lib/utils/mime.js +1 -76
- package/lib/utils/stream.js +1 -87
- package/lib/utils/url.js +1 -196
- package/lib/{validator-D84mq-dO.js → validator-Btiyt5VJ.js} +3 -3
- package/lib/validator.js +1 -1
- package/lib/websocket-DsyBIKVA.js +56 -0
- package/lib/ws.js +1 -55
- package/package.json +1 -1
- package/plugins/acryl-door/src/cli/acryl.ts +36 -5
- package/plugins/acryl-door/src/host/channel.ts +14 -6
- package/plugins/acryl-door/src/host/chat-tools.ts +2 -0
- package/plugins/acryl-door/src/host/door.ts +97 -12
- package/plugins/acryl-door/src/host/page-routes.ts +3 -1
- package/plugins/acryl-door/src/host/shell.ts +147 -0
- package/plugins/acryl-door/src/host/shim.ts +39 -3
- package/plugins/acryl-door/src/host/verbs.ts +39 -2
- package/plugins/acryl-door/src/index.ts +22 -3
- package/plugins/acryl-door/src/page/channel.ts +3 -1
- package/plugins/acryl-door/src/page/index.ts +2 -1
- package/plugins/acryl-door/src/page/protocol.ts +2 -0
- package/plugins/acryl-door/src/shell/protocol.ts +130 -0
- package/plugins/acryl-node-server/src/register.ts +29 -2
- package/plugins/agent/model.ts +6 -1
- package/plugins/agent/tools.ts +12 -2
- package/plugins/agent-panel/client/describe.ts +44 -0
- package/plugins/agent-panel/client/main.ts +4 -1
- package/plugins/agent-panel/headless.ts +29 -8
- package/plugins/agent-panel/index.ts +22 -10
- package/plugins/agent-panel/launch.ts +67 -0
- package/plugins/desktop/attach.ts +72 -0
- package/plugins/desktop/door-shell.ts +379 -0
- package/plugins/desktop/main.ts +82 -15
- package/plugins/extensions/index.ts +3 -4
- package/plugins/extensions/stage.ts +33 -5
- package/plugins/openai/index.ts +1 -1
- package/templates/agent/AGENTS.md +4 -0
- package/templates/agent/blend.yaml +4 -1
- package/templates/agent/src/app.ts +2 -0
- package/templates/agent/src/routes.ts +1 -1
- package/templates/agent/template.json +1 -0
- package/templates/agent/test/builder.test.ts +26 -21
- package/lib/helpers-CvTauQT5.js +0 -5
- package/lib/middleware-CPQ8himN.js +0 -12
- package/plugins/acryl-router/src/connection.d.ts +0 -25
- package/plugins/acryl-router/src/core/app-base.d.ts +0 -215
- package/plugins/acryl-router/src/core/app.d.ts +0 -19
- package/plugins/acryl-router/src/core/client/client.d.ts +0 -4
- package/plugins/acryl-router/src/core/client/fetch-result-please.d.ts +0 -35
- package/plugins/acryl-router/src/core/client/index.d.ts +0 -7
- package/plugins/acryl-router/src/core/client/types.d.ts +0 -229
- package/plugins/acryl-router/src/core/client/utils.d.ts +0 -18
- package/plugins/acryl-router/src/core/compose.d.ts +0 -14
- package/plugins/acryl-router/src/core/context.d.ts +0 -443
- package/plugins/acryl-router/src/core/helper/accepts/accepts.d.ts +0 -30
- package/plugins/acryl-router/src/core/helper/accepts/index.d.ts +0 -5
- package/plugins/acryl-router/src/core/helper/conninfo/index.d.ts +0 -5
- package/plugins/acryl-router/src/core/helper/conninfo/types.d.ts +0 -36
- package/plugins/acryl-router/src/core/helper/cookie/index.d.ts +0 -25
- package/plugins/acryl-router/src/core/helper/css/common.d.ts +0 -59
- package/plugins/acryl-router/src/core/helper/css/index.d.ts +0 -80
- package/plugins/acryl-router/src/core/helper/dev/index.d.ts +0 -20
- package/plugins/acryl-router/src/core/helper/factory/index.d.ts +0 -82
- package/plugins/acryl-router/src/core/helper/html/index.d.ts +0 -8
- package/plugins/acryl-router/src/core/helper/proxy/index.d.ts +0 -73
- package/plugins/acryl-router/src/core/helper/route/index.d.ts +0 -75
- package/plugins/acryl-router/src/core/helper/ssg/index.d.ts +0 -7
- package/plugins/acryl-router/src/core/helper/ssg/middleware.d.ts +0 -44
- package/plugins/acryl-router/src/core/helper/ssg/plugins.d.ts +0 -27
- package/plugins/acryl-router/src/core/helper/ssg/ssg.d.ts +0 -91
- package/plugins/acryl-router/src/core/helper/ssg/utils.d.ts +0 -16
- package/plugins/acryl-router/src/core/helper/streaming/index.d.ts +0 -8
- package/plugins/acryl-router/src/core/helper/streaming/sse.d.ts +0 -13
- package/plugins/acryl-router/src/core/helper/streaming/stream.d.ts +0 -3
- package/plugins/acryl-router/src/core/helper/streaming/text.d.ts +0 -3
- package/plugins/acryl-router/src/core/helper/streaming/utils.d.ts +0 -1
- package/plugins/acryl-router/src/core/helper/testing/index.d.ts +0 -13
- package/plugins/acryl-router/src/core/helper/websocket/index.d.ts +0 -70
- package/plugins/acryl-router/src/core/http-exception.d.ts +0 -58
- package/plugins/acryl-router/src/core/index.d.ts +0 -37
- package/plugins/acryl-router/src/core/jsx/base.d.ts +0 -61
- package/plugins/acryl-router/src/core/jsx/children.d.ts +0 -9
- package/plugins/acryl-router/src/core/jsx/components.d.ts +0 -15
- package/plugins/acryl-router/src/core/jsx/constants.d.ts +0 -6
- package/plugins/acryl-router/src/core/jsx/context.d.ts +0 -51
- package/plugins/acryl-router/src/core/jsx/dom/components.d.ts +0 -10
- package/plugins/acryl-router/src/core/jsx/dom/context.d.ts +0 -3
- package/plugins/acryl-router/src/core/jsx/dom/css.d.ts +0 -83
- package/plugins/acryl-router/src/core/jsx/dom/hooks/index.d.ts +0 -39
- package/plugins/acryl-router/src/core/jsx/dom/intrinsic-element/components.d.ts +0 -16
- package/plugins/acryl-router/src/core/jsx/dom/jsx-dev-runtime.d.ts +0 -8
- package/plugins/acryl-router/src/core/jsx/dom/jsx-runtime.d.ts +0 -7
- package/plugins/acryl-router/src/core/jsx/dom/render.d.ts +0 -73
- package/plugins/acryl-router/src/core/jsx/dom/utils.d.ts +0 -1
- package/plugins/acryl-router/src/core/jsx/hooks/index.d.ts +0 -47
- package/plugins/acryl-router/src/core/jsx/index.d.ts +0 -76
- package/plugins/acryl-router/src/core/jsx/intrinsic-element/common.d.ts +0 -6
- package/plugins/acryl-router/src/core/jsx/intrinsic-element/components.d.ts +0 -13
- package/plugins/acryl-router/src/core/jsx/intrinsic-elements.d.ts +0 -735
- package/plugins/acryl-router/src/core/jsx/streaming.d.ts +0 -36
- package/plugins/acryl-router/src/core/jsx/types.d.ts +0 -36
- package/plugins/acryl-router/src/core/jsx/utils.d.ts +0 -4
- package/plugins/acryl-router/src/core/request/constants.d.ts +0 -1
- package/plugins/acryl-router/src/core/request.d.ts +0 -306
- package/plugins/acryl-router/src/core/router/reg-exp-router/index.d.ts +0 -6
- package/plugins/acryl-router/src/core/router/reg-exp-router/matcher.d.ts +0 -7
- package/plugins/acryl-router/src/core/router/reg-exp-router/node.d.ts +0 -13
- package/plugins/acryl-router/src/core/router/reg-exp-router/prepared-router.d.ts +0 -17
- package/plugins/acryl-router/src/core/router/reg-exp-router/router.d.ts +0 -11
- package/plugins/acryl-router/src/core/router/reg-exp-router/trie.d.ts +0 -8
- package/plugins/acryl-router/src/core/router/smart-router/index.d.ts +0 -5
- package/plugins/acryl-router/src/core/router/smart-router/router.d.ts +0 -11
- package/plugins/acryl-router/src/core/router/trie-router/index.d.ts +0 -5
- package/plugins/acryl-router/src/core/router/trie-router/node.d.ts +0 -6
- package/plugins/acryl-router/src/core/router/trie-router/router.d.ts +0 -7
- package/plugins/acryl-router/src/core/router/utils.d.ts +0 -1
- package/plugins/acryl-router/src/core/router.d.ts +0 -97
- package/plugins/acryl-router/src/core/types.d.ts +0 -573
- package/plugins/acryl-router/src/core/utils/accept.d.ts +0 -6
- package/plugins/acryl-router/src/core/utils/body.d.ts +0 -79
- package/plugins/acryl-router/src/core/utils/buffer.d.ts +0 -17
- package/plugins/acryl-router/src/core/utils/color.d.ts +0 -21
- package/plugins/acryl-router/src/core/utils/concurrent.d.ts +0 -11
- package/plugins/acryl-router/src/core/utils/constants.d.ts +0 -4
- package/plugins/acryl-router/src/core/utils/cookie.d.ts +0 -41
- package/plugins/acryl-router/src/core/utils/crypto.d.ts +0 -15
- package/plugins/acryl-router/src/core/utils/handler.d.ts +0 -6
- package/plugins/acryl-router/src/core/utils/headers.d.ts +0 -8
- package/plugins/acryl-router/src/core/utils/html.d.ts +0 -44
- package/plugins/acryl-router/src/core/utils/http-status.d.ts +0 -32
- package/plugins/acryl-router/src/core/utils/mime.d.ts +0 -70
- package/plugins/acryl-router/src/core/utils/stream.d.ts +0 -31
- package/plugins/acryl-router/src/core/utils/types.d.ts +0 -128
- package/plugins/acryl-router/src/core/utils/url.d.ts +0 -44
- package/plugins/acryl-router/src/core/validator/index.d.ts +0 -7
- package/plugins/acryl-router/src/core/validator/utils.d.ts +0 -29
- package/plugins/acryl-router/src/core/validator/validator.d.ts +0 -16
- package/plugins/acryl-router/src/helpers/index.d.ts +0 -17
- package/plugins/acryl-router/src/helpers/schema-validator.d.ts +0 -54
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Starting a coding agent (`claude`, `codex`, `pi`) without a shell. Off Windows the program is spawned by name. On Windows an npm-installed CLI is a `.cmd` shim, which CreateProcess
|
|
3
|
+
* cannot start, and a shell would pass the person's message through cmd.exe quoting (`& | < > ^ "` would run or break). So the shim is read: npm writes the script it runs
|
|
4
|
+
* (`"%dp0%\node_modules\<package>\dist\cli.js"`), and that script is started with Node directly, as one process. A native `.exe` (the Windows installer of claude) is started as it is.
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
7
|
+
import { win32 } from 'node:path'
|
|
8
|
+
|
|
9
|
+
export interface Launch {
|
|
10
|
+
readonly file: string
|
|
11
|
+
readonly args: string[]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface LaunchDeps {
|
|
15
|
+
readonly platform?: NodeJS.Platform
|
|
16
|
+
readonly env?: NodeJS.ProcessEnv
|
|
17
|
+
readonly exists?: (path: string) => boolean
|
|
18
|
+
readonly read?: (path: string) => string
|
|
19
|
+
/** The Node that starts a shim's script (this process's). */
|
|
20
|
+
readonly execPath?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The script an npm `.cmd` shim runs: the quoted path after `%dp0%\`, ending in .js, .cjs or .mjs. */
|
|
24
|
+
const SHIM_SCRIPT = /"%dp0%[\\/]([^"\r\n]+?\.(?:mjs|cjs|js))"/iu
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @returns how to start `program` with `args`, or a sentence saying why it cannot be started here. A program that is not found is also a sentence.
|
|
28
|
+
*/
|
|
29
|
+
export function resolveLaunch(
|
|
30
|
+
program: string,
|
|
31
|
+
args: readonly string[],
|
|
32
|
+
deps: LaunchDeps = {},
|
|
33
|
+
): Launch | { readonly error: string } {
|
|
34
|
+
const platform = deps.platform ?? process.platform
|
|
35
|
+
if (platform !== 'win32') return { file: program, args: [...args] }
|
|
36
|
+
const env = deps.env ?? process.env
|
|
37
|
+
const exists = deps.exists ?? existsSync
|
|
38
|
+
const read = deps.read ?? ((path: string) => readFileSync(path, 'utf8'))
|
|
39
|
+
const execPath = deps.execPath ?? process.execPath
|
|
40
|
+
const folders = (env.PATH ?? env.Path ?? '').split(';').filter(Boolean)
|
|
41
|
+
for (const folder of folders) {
|
|
42
|
+
for (const extension of ['.exe', '.com', '.cmd', '.bat']) {
|
|
43
|
+
const found = win32.join(folder, `${program}${extension}`)
|
|
44
|
+
if (!exists(found)) continue
|
|
45
|
+
if (extension === '.exe' || extension === '.com') return { file: found, args: [...args] }
|
|
46
|
+
let text: string
|
|
47
|
+
try {
|
|
48
|
+
text = read(found)
|
|
49
|
+
} catch {
|
|
50
|
+
return { error: `${program} is installed as ${found} but that file cannot be read` }
|
|
51
|
+
}
|
|
52
|
+
const script = SHIM_SCRIPT.exec(text)?.[1]
|
|
53
|
+
if (script === undefined) {
|
|
54
|
+
return {
|
|
55
|
+
error: `${program} is installed as ${found}, a batch file the panel cannot start without a shell (a shell would pass the message through cmd.exe quoting). Install the native program, or an npm package that creates an npm shim`,
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const directory = win32.dirname(found)
|
|
59
|
+
const node = win32.join(directory, 'node.exe')
|
|
60
|
+
return {
|
|
61
|
+
file: exists(node) ? node : execPath,
|
|
62
|
+
args: [win32.join(directory, script), ...args],
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return { error: `${program} was not found on the PATH` }
|
|
67
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `npm run desktop` while `npm run dev` runs: the door allows one process per app folder, so a second server cannot start. Instead of failing, the window attaches to the dev server
|
|
3
|
+
* that is running, and its door (the same pages, the same agent, the same data); closing the window then leaves that server running.
|
|
4
|
+
*
|
|
5
|
+
* The running server is found where the door says it is: `.acryl/door.json` (its loopback address and pid). Only a live process on a loopback address that answers is attached to.
|
|
6
|
+
*/
|
|
7
|
+
import { readFileSync } from 'node:fs'
|
|
8
|
+
import { join } from 'node:path'
|
|
9
|
+
|
|
10
|
+
export interface RunningApp {
|
|
11
|
+
readonly url: string
|
|
12
|
+
readonly pid: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface AttachDeps {
|
|
16
|
+
readonly read?: (file: string) => string
|
|
17
|
+
/** True when a process with this pid is alive. */
|
|
18
|
+
readonly alive?: (pid: number) => boolean
|
|
19
|
+
/** Whether something answers HTTP at the address. */
|
|
20
|
+
readonly answers?: (url: string) => Promise<boolean>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const LOOPBACK = new Set(['127.0.0.1', 'localhost', '[::1]'])
|
|
24
|
+
|
|
25
|
+
const processAlive = (pid: number): boolean => {
|
|
26
|
+
try {
|
|
27
|
+
process.kill(pid, 0)
|
|
28
|
+
return true
|
|
29
|
+
} catch (error) {
|
|
30
|
+
return (error as NodeJS.ErrnoException).code === 'EPERM'
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const httpAnswers = async (url: string): Promise<boolean> => {
|
|
35
|
+
try {
|
|
36
|
+
await fetch(url, { signal: AbortSignal.timeout(1500), redirect: 'manual' })
|
|
37
|
+
return true
|
|
38
|
+
} catch {
|
|
39
|
+
return false
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** @returns the dev server running from `root`, or `undefined` when there is none (no door file, a dead process, a stranger's address, nothing answering). */
|
|
44
|
+
export async function runningApp(
|
|
45
|
+
root: string,
|
|
46
|
+
deps: AttachDeps = {},
|
|
47
|
+
): Promise<RunningApp | undefined> {
|
|
48
|
+
const read = deps.read ?? ((file: string) => readFileSync(file, 'utf8'))
|
|
49
|
+
let found: { url?: unknown; pid?: unknown }
|
|
50
|
+
try {
|
|
51
|
+
found = JSON.parse(read(join(root, '.acryl', 'door.json'))) as { url?: unknown; pid?: unknown }
|
|
52
|
+
} catch {
|
|
53
|
+
return undefined
|
|
54
|
+
}
|
|
55
|
+
if (
|
|
56
|
+
typeof found.url !== 'string' ||
|
|
57
|
+
typeof found.pid !== 'number' ||
|
|
58
|
+
!Number.isInteger(found.pid) ||
|
|
59
|
+
found.pid <= 0
|
|
60
|
+
)
|
|
61
|
+
return undefined
|
|
62
|
+
let parsed: URL
|
|
63
|
+
try {
|
|
64
|
+
parsed = new URL(found.url)
|
|
65
|
+
} catch {
|
|
66
|
+
return undefined
|
|
67
|
+
}
|
|
68
|
+
if (parsed.protocol !== 'http:' || !LOOPBACK.has(parsed.hostname)) return undefined
|
|
69
|
+
if (!(deps.alive ?? processAlive)(found.pid)) return undefined
|
|
70
|
+
if (!(await (deps.answers ?? httpAnswers)(parsed.origin))) return undefined
|
|
71
|
+
return { url: parsed.origin, pid: found.pid }
|
|
72
|
+
}
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The desktop shell's side of the door: the app's windows and menu, for an agent that drives the app (`acryl window list|open|focus|close|resize`, `acryl menu list|click`).
|
|
3
|
+
*
|
|
4
|
+
* The door lives in the app's server, which is this process when `npm run desktop` started it and another one when the window attached to `npm run dev`; so the shell
|
|
5
|
+
* reaches the door the way a page does, over HTTP: it reads `.acryl/door.json` (the address and this run's secret), opens an event stream, answers each call by POST, and
|
|
6
|
+
* opens the stream again when the server restarts. Nothing here imports electron: `main.ts` passes in what Electron offers ({@link DesktopPort}), so the handler is tested
|
|
7
|
+
* without it. The door is development only; with no `.acryl/door.json` this keeps looking and does nothing else.
|
|
8
|
+
*/
|
|
9
|
+
import { readFileSync } from 'node:fs'
|
|
10
|
+
import { join } from 'node:path'
|
|
11
|
+
|
|
12
|
+
/** A window as Electron has it, the part this file uses. */
|
|
13
|
+
export interface WindowPort {
|
|
14
|
+
readonly id: number
|
|
15
|
+
getTitle(): string
|
|
16
|
+
getURL(): string
|
|
17
|
+
isFocused(): boolean
|
|
18
|
+
isMinimized(): boolean
|
|
19
|
+
getSize(): number[]
|
|
20
|
+
restore(): void
|
|
21
|
+
focus(): void
|
|
22
|
+
close(): void
|
|
23
|
+
setSize(width: number, height: number): void
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A menu item as Electron has it. */
|
|
27
|
+
export interface MenuItemPort {
|
|
28
|
+
readonly label: string
|
|
29
|
+
readonly enabled: boolean
|
|
30
|
+
readonly visible: boolean
|
|
31
|
+
readonly type: string
|
|
32
|
+
/** Electron's role of the item (`quit`, `close`, ...). */
|
|
33
|
+
readonly role?: string | undefined
|
|
34
|
+
readonly submenu?: { readonly items: readonly MenuItemPort[] } | null | undefined
|
|
35
|
+
click(): void
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface DesktopPort {
|
|
39
|
+
/** Every window, in Electron's order. */
|
|
40
|
+
windows(): readonly WindowPort[]
|
|
41
|
+
/** A new window on a path of the app (the page is not awaited). @returns the window. */
|
|
42
|
+
open(path: string | undefined): WindowPort
|
|
43
|
+
/** The application menu's items, or none when the app has no menu. */
|
|
44
|
+
menu(): readonly MenuItemPort[]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The door's side of a call, as `plugins/acryl-door/src/shell/protocol.ts` defines it. */
|
|
48
|
+
export type ShellRequest =
|
|
49
|
+
| { readonly op: 'window-list' }
|
|
50
|
+
| { readonly op: 'window-open'; readonly path?: string }
|
|
51
|
+
| { readonly op: 'window-focus'; readonly id: number }
|
|
52
|
+
| { readonly op: 'window-close'; readonly id: number; readonly force?: boolean }
|
|
53
|
+
| {
|
|
54
|
+
readonly op: 'window-resize'
|
|
55
|
+
readonly id: number
|
|
56
|
+
readonly width: number
|
|
57
|
+
readonly height: number
|
|
58
|
+
}
|
|
59
|
+
| { readonly op: 'menu-list' }
|
|
60
|
+
| { readonly op: 'menu-click'; readonly path: string; readonly force?: boolean }
|
|
61
|
+
|
|
62
|
+
export type ShellResult =
|
|
63
|
+
| {
|
|
64
|
+
readonly windows: readonly {
|
|
65
|
+
id: number
|
|
66
|
+
title: string
|
|
67
|
+
path: string
|
|
68
|
+
focused: boolean
|
|
69
|
+
width: number
|
|
70
|
+
height: number
|
|
71
|
+
}[]
|
|
72
|
+
}
|
|
73
|
+
| { readonly items: readonly { path: string; label: string; enabled: boolean; ends?: boolean }[] }
|
|
74
|
+
| { readonly ok: true; readonly detail: string; readonly id?: number }
|
|
75
|
+
|
|
76
|
+
/** A refusal with a code the door passes on to the agent. */
|
|
77
|
+
export class ShellRefusal extends Error {
|
|
78
|
+
readonly code: string
|
|
79
|
+
|
|
80
|
+
constructor(code: string, message: string) {
|
|
81
|
+
super(message)
|
|
82
|
+
this.code = code
|
|
83
|
+
this.name = 'ShellRefusal'
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The path and query a window shows, never its origin. */
|
|
88
|
+
function pathOf(url: string): string {
|
|
89
|
+
try {
|
|
90
|
+
const parsed = new URL(url)
|
|
91
|
+
return `${parsed.pathname}${parsed.search}`
|
|
92
|
+
} catch {
|
|
93
|
+
return ''
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The menu as paths (`File>New Note`), submenus in order, separators and hidden items left out. */
|
|
98
|
+
export function menuPaths(
|
|
99
|
+
items: readonly MenuItemPort[],
|
|
100
|
+
prefix = '',
|
|
101
|
+
): { path: string; label: string; enabled: boolean; item: MenuItemPort }[] {
|
|
102
|
+
const found: { path: string; label: string; enabled: boolean; item: MenuItemPort }[] = []
|
|
103
|
+
for (const item of items) {
|
|
104
|
+
if (item.type === 'separator' || !item.visible || item.label === '') continue
|
|
105
|
+
const path = prefix === '' ? item.label : `${prefix}>${item.label}`
|
|
106
|
+
const children = item.submenu?.items ?? []
|
|
107
|
+
if (children.length > 0) found.push(...menuPaths(children, path))
|
|
108
|
+
else found.push({ path, label: item.label, enabled: item.enabled, item })
|
|
109
|
+
}
|
|
110
|
+
return found
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Whether pressing this item ends the desktop app: Quit always, Close Window when it is the last window. */
|
|
114
|
+
function endsApp(item: MenuItemPort, windows: number): boolean {
|
|
115
|
+
return item.role === 'quit' || (item.role === 'close' && windows <= 1)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Answer one call of the door. @throws ShellRefusal for what the app cannot do (no such window, the last window, a disabled item). */
|
|
119
|
+
export function handleShellCall(request: ShellRequest, desktop: DesktopPort): ShellResult {
|
|
120
|
+
const windowById = (id: number): WindowPort => {
|
|
121
|
+
const found = desktop.windows().find((window) => window.id === id)
|
|
122
|
+
if (found === undefined) {
|
|
123
|
+
const ids = desktop.windows().map((window) => String(window.id))
|
|
124
|
+
throw new ShellRefusal(
|
|
125
|
+
'no-such-window',
|
|
126
|
+
`there is no window ${String(id)}${ids.length === 0 ? ' (no window is open)' : `; the windows are ${ids.join(', ')}`}`,
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
return found
|
|
130
|
+
}
|
|
131
|
+
switch (request.op) {
|
|
132
|
+
case 'window-list':
|
|
133
|
+
return {
|
|
134
|
+
windows: desktop.windows().map((window) => {
|
|
135
|
+
const [width = 0, height = 0] = window.getSize()
|
|
136
|
+
return {
|
|
137
|
+
id: window.id,
|
|
138
|
+
title: window.getTitle(),
|
|
139
|
+
path: pathOf(window.getURL()),
|
|
140
|
+
focused: window.isFocused(),
|
|
141
|
+
width,
|
|
142
|
+
height,
|
|
143
|
+
}
|
|
144
|
+
}),
|
|
145
|
+
}
|
|
146
|
+
case 'window-open': {
|
|
147
|
+
const window = desktop.open(request.path)
|
|
148
|
+
return {
|
|
149
|
+
ok: true,
|
|
150
|
+
detail: `opened window ${String(window.id)} on ${request.path ?? '/'}`,
|
|
151
|
+
id: window.id,
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
case 'window-focus': {
|
|
155
|
+
const window = windowById(request.id)
|
|
156
|
+
if (window.isMinimized()) window.restore()
|
|
157
|
+
window.focus()
|
|
158
|
+
return { ok: true, detail: `focused window ${String(window.id)}`, id: window.id }
|
|
159
|
+
}
|
|
160
|
+
case 'window-close': {
|
|
161
|
+
const window = windowById(request.id)
|
|
162
|
+
// The door refuses this first; the shell does not rely on it, because the last window ends the desktop app.
|
|
163
|
+
if (desktop.windows().length === 1 && request.force !== true) {
|
|
164
|
+
throw new ShellRefusal(
|
|
165
|
+
'last-window',
|
|
166
|
+
`window ${String(window.id)} is the app's last window: closing it ends the desktop app. Open another window first, or pass force to end it on purpose`,
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
window.close()
|
|
170
|
+
return { ok: true, detail: `closed window ${String(window.id)}`, id: window.id }
|
|
171
|
+
}
|
|
172
|
+
case 'window-resize': {
|
|
173
|
+
const window = windowById(request.id)
|
|
174
|
+
window.setSize(request.width, request.height)
|
|
175
|
+
return {
|
|
176
|
+
ok: true,
|
|
177
|
+
detail: `window ${String(window.id)} is now ${String(request.width)}x${String(request.height)}`,
|
|
178
|
+
id: window.id,
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
case 'menu-list':
|
|
182
|
+
return {
|
|
183
|
+
items: menuPaths(desktop.menu()).map(({ path, label, enabled, item }) => ({
|
|
184
|
+
path,
|
|
185
|
+
label,
|
|
186
|
+
enabled,
|
|
187
|
+
...(endsApp(item, desktop.windows().length) ? { ends: true } : {}),
|
|
188
|
+
})),
|
|
189
|
+
}
|
|
190
|
+
case 'menu-click': {
|
|
191
|
+
const wanted = request.path
|
|
192
|
+
.split('>')
|
|
193
|
+
.map((label) => label.trim())
|
|
194
|
+
.join('>')
|
|
195
|
+
const found = menuPaths(desktop.menu()).find((entry) => entry.path === wanted)
|
|
196
|
+
if (found === undefined)
|
|
197
|
+
throw new ShellRefusal(
|
|
198
|
+
'no-such-menu-item',
|
|
199
|
+
`the menu has no item ${JSON.stringify(wanted)}; menu list prints the paths`,
|
|
200
|
+
)
|
|
201
|
+
if (!found.enabled) throw new ShellRefusal('disabled', `${found.path} is disabled`)
|
|
202
|
+
// Quit, or Close Window on the last window, ends the desktop app: an agent must not end the session it works in by accident.
|
|
203
|
+
if (endsApp(found.item, desktop.windows().length) && request.force !== true) {
|
|
204
|
+
throw new ShellRefusal(
|
|
205
|
+
'quits-app',
|
|
206
|
+
`${found.path} ends the desktop app${found.item.role === 'close' ? ' (it is the last window)' : ''}. Pass force to do it on purpose`,
|
|
207
|
+
)
|
|
208
|
+
}
|
|
209
|
+
found.item.click()
|
|
210
|
+
return { ok: true, detail: `pressed ${found.path}` }
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export interface DoorShellDeps {
|
|
216
|
+
readonly desktop: DesktopPort
|
|
217
|
+
readonly fetch?: typeof fetch
|
|
218
|
+
readonly read?: (file: string) => string
|
|
219
|
+
/** Milliseconds to wait before looking for the door again; grows to `maxDelayMs`. */
|
|
220
|
+
readonly retryMs?: number
|
|
221
|
+
readonly maxDelayMs?: number
|
|
222
|
+
readonly log?: (message: string) => void
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
interface DoorLocation {
|
|
226
|
+
readonly url: string
|
|
227
|
+
readonly secret: string
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const LOOPBACK = new Set(['127.0.0.1', 'localhost', '[::1]'])
|
|
231
|
+
|
|
232
|
+
/** Where the door is now: its address and this run's secret, from `.acryl/door.json` (read again on every connection: a restarted server has a new secret). */
|
|
233
|
+
export function findDoor(
|
|
234
|
+
root: string,
|
|
235
|
+
read: (file: string) => string = (file) => readFileSync(file, 'utf8'),
|
|
236
|
+
): DoorLocation | undefined {
|
|
237
|
+
try {
|
|
238
|
+
const file = JSON.parse(read(join(root, '.acryl', 'door.json'))) as {
|
|
239
|
+
url?: unknown
|
|
240
|
+
secret?: unknown
|
|
241
|
+
}
|
|
242
|
+
if (typeof file.url !== 'string' || typeof file.secret !== 'string' || file.secret === '')
|
|
243
|
+
return undefined
|
|
244
|
+
const parsed = new URL(file.url)
|
|
245
|
+
if (parsed.protocol !== 'http:' || !LOOPBACK.has(parsed.hostname)) return undefined
|
|
246
|
+
return { url: parsed.origin, secret: file.secret }
|
|
247
|
+
} catch {
|
|
248
|
+
return undefined
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Server-sent events from a response body: `{ event, data }` per block. */
|
|
253
|
+
async function* events(
|
|
254
|
+
body: ReadableStream<Uint8Array>,
|
|
255
|
+
): AsyncGenerator<{ event: string; data: string }> {
|
|
256
|
+
const reader = body.getReader()
|
|
257
|
+
const decoder = new TextDecoder()
|
|
258
|
+
let buffer = ''
|
|
259
|
+
try {
|
|
260
|
+
for (;;) {
|
|
261
|
+
const { done, value } = await reader.read()
|
|
262
|
+
if (done) return
|
|
263
|
+
buffer += decoder.decode(value, { stream: true })
|
|
264
|
+
for (let end = buffer.indexOf('\n\n'); end !== -1; end = buffer.indexOf('\n\n')) {
|
|
265
|
+
const block = buffer.slice(0, end)
|
|
266
|
+
buffer = buffer.slice(end + 2)
|
|
267
|
+
let event = 'message'
|
|
268
|
+
const data: string[] = []
|
|
269
|
+
for (const line of block.split('\n')) {
|
|
270
|
+
if (line.startsWith('event:')) event = line.slice(6).trim()
|
|
271
|
+
else if (line.startsWith('data:'))
|
|
272
|
+
data.push(line.slice(line.startsWith('data: ') ? 6 : 5))
|
|
273
|
+
}
|
|
274
|
+
yield { event, data: data.join('\n') }
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
} finally {
|
|
278
|
+
await reader.cancel().catch(() => undefined)
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Keep the shell connected to the door of the app in `root` until the returned function is called. The door is found again after each loss, so a dev server that restarts (a
|
|
284
|
+
* change of `src/`) is followed. Each call is answered with its result, or with the refusal's code and message.
|
|
285
|
+
*/
|
|
286
|
+
export function startDoorShell(root: string, deps: DoorShellDeps): () => void {
|
|
287
|
+
const request = deps.fetch ?? fetch
|
|
288
|
+
const retry = deps.retryMs ?? 500
|
|
289
|
+
const maxDelay = deps.maxDelayMs ?? 5000
|
|
290
|
+
const controller = new AbortController()
|
|
291
|
+
let wait: NodeJS.Timeout | undefined
|
|
292
|
+
const sleep = (ms: number): Promise<void> =>
|
|
293
|
+
new Promise((resolve) => {
|
|
294
|
+
wait = setTimeout(resolve, ms)
|
|
295
|
+
controller.signal.addEventListener(
|
|
296
|
+
'abort',
|
|
297
|
+
() => {
|
|
298
|
+
clearTimeout(wait)
|
|
299
|
+
resolve()
|
|
300
|
+
},
|
|
301
|
+
{ once: true },
|
|
302
|
+
)
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
const answer = async (door: DoorLocation, body: unknown): Promise<void> => {
|
|
306
|
+
await request(`${door.url}/api/acryl-door/shell/answer`, {
|
|
307
|
+
method: 'POST',
|
|
308
|
+
headers: { authorization: `Bearer ${door.secret}`, 'content-type': 'application/json' },
|
|
309
|
+
body: JSON.stringify(body),
|
|
310
|
+
signal: controller.signal,
|
|
311
|
+
}).catch(() => undefined)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const serve = async (door: DoorLocation): Promise<void> => {
|
|
315
|
+
const response = await request(`${door.url}/api/acryl-door/shell/events`, {
|
|
316
|
+
headers: { authorization: `Bearer ${door.secret}`, accept: 'text/event-stream' },
|
|
317
|
+
signal: controller.signal,
|
|
318
|
+
})
|
|
319
|
+
if (!response.ok || response.body === null)
|
|
320
|
+
throw new Error(`the door answered ${String(response.status)}`)
|
|
321
|
+
for await (const { event, data } of events(response.body)) {
|
|
322
|
+
if (event !== 'call') continue
|
|
323
|
+
let call: { id?: unknown; request?: ShellRequest }
|
|
324
|
+
try {
|
|
325
|
+
call = JSON.parse(data) as { id?: unknown; request?: ShellRequest }
|
|
326
|
+
} catch {
|
|
327
|
+
continue
|
|
328
|
+
}
|
|
329
|
+
if (typeof call.id !== 'number' || call.request === undefined) continue
|
|
330
|
+
try {
|
|
331
|
+
await answer(door, {
|
|
332
|
+
t: 'result',
|
|
333
|
+
id: call.id,
|
|
334
|
+
value: handleShellCall(call.request, deps.desktop),
|
|
335
|
+
})
|
|
336
|
+
} catch (cause) {
|
|
337
|
+
const refusal =
|
|
338
|
+
cause instanceof ShellRefusal
|
|
339
|
+
? cause
|
|
340
|
+
: new ShellRefusal(
|
|
341
|
+
'failed',
|
|
342
|
+
cause instanceof Error ? cause.message : 'the shell could not do that',
|
|
343
|
+
)
|
|
344
|
+
await answer(door, {
|
|
345
|
+
t: 'error',
|
|
346
|
+
id: call.id,
|
|
347
|
+
code: refusal.code,
|
|
348
|
+
message: refusal.message,
|
|
349
|
+
})
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
void (async () => {
|
|
355
|
+
let delay = retry
|
|
356
|
+
let announced = false
|
|
357
|
+
while (!controller.signal.aborted) {
|
|
358
|
+
const door = findDoor(root, deps.read)
|
|
359
|
+
if (door !== undefined) {
|
|
360
|
+
try {
|
|
361
|
+
if (!announced)
|
|
362
|
+
deps.log?.(
|
|
363
|
+
'desktop: windows and menu are available to the door (acryl window list, acryl menu list)',
|
|
364
|
+
)
|
|
365
|
+
announced = true
|
|
366
|
+
await serve(door)
|
|
367
|
+
delay = retry
|
|
368
|
+
} catch {
|
|
369
|
+
// The server restarted or is not up yet: look again.
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
await sleep(delay)
|
|
373
|
+
delay = Math.min(delay * 2, maxDelay)
|
|
374
|
+
}
|
|
375
|
+
})()
|
|
376
|
+
return () => {
|
|
377
|
+
controller.abort()
|
|
378
|
+
}
|
|
379
|
+
}
|
package/plugins/desktop/main.ts
CHANGED
|
@@ -9,10 +9,17 @@
|
|
|
9
9
|
import { mkdirSync, readFileSync } from 'node:fs'
|
|
10
10
|
import { join } from 'node:path'
|
|
11
11
|
import { fileURLToPath } from 'node:url'
|
|
12
|
-
import { app, BrowserWindow, shell } from 'electron'
|
|
12
|
+
import { app, BrowserWindow, Menu, type MenuItem, shell } from 'electron'
|
|
13
13
|
import type { App } from '../../src/app.ts'
|
|
14
14
|
import '../acryl-node-server/src/register.ts'
|
|
15
15
|
import { selectInstance } from '../instance/index.ts'
|
|
16
|
+
import { runningApp } from './attach.ts'
|
|
17
|
+
import {
|
|
18
|
+
type DesktopPort,
|
|
19
|
+
type MenuItemPort,
|
|
20
|
+
startDoorShell,
|
|
21
|
+
type WindowPort,
|
|
22
|
+
} from './door-shell.ts'
|
|
16
23
|
import { createDesktopShutdown, installShutdownRequests } from './shutdown.ts'
|
|
17
24
|
import { resolveDesktopUserDataOverride } from './user-data.ts'
|
|
18
25
|
import { windowOptions } from './window.ts'
|
|
@@ -40,9 +47,11 @@ if (!app.requestSingleInstanceLock()) {
|
|
|
40
47
|
} else {
|
|
41
48
|
let running: App | undefined
|
|
42
49
|
let window: BrowserWindow | undefined
|
|
50
|
+
let stopDoorShell: (() => void) | undefined
|
|
43
51
|
|
|
44
52
|
const shutdown = createDesktopShutdown(
|
|
45
53
|
async () => {
|
|
54
|
+
stopDoorShell?.()
|
|
46
55
|
await running?.stop()
|
|
47
56
|
},
|
|
48
57
|
(code) => {
|
|
@@ -65,21 +74,79 @@ if (!app.requestSingleInstanceLock()) {
|
|
|
65
74
|
|
|
66
75
|
void app.whenReady().then(async () => {
|
|
67
76
|
// The hook above is registered before the app's modules are loaded, so the app may have .tsx pages: they are loaded here, not as a static import.
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
+
// A dev server is already running from this folder (`npm run dev`): the door allows one process per folder, so the window opens on that server instead of starting a second one,
|
|
78
|
+
// and closing the window leaves it running.
|
|
79
|
+
const attached = await runningApp(projectRoot)
|
|
80
|
+
let url: string
|
|
81
|
+
if (attached !== undefined) {
|
|
82
|
+
console.log(
|
|
83
|
+
`desktop: attached to the app already running from this folder at ${attached.url} (its server keeps running when the window closes)`,
|
|
84
|
+
)
|
|
85
|
+
url = attached.url
|
|
86
|
+
} else {
|
|
87
|
+
const { createApp } = await import('../../src/app.ts')
|
|
88
|
+
running = await createApp(chosen)
|
|
89
|
+
url = running.url
|
|
90
|
+
}
|
|
91
|
+
const origin = new URL(url).origin
|
|
92
|
+
/** One window on a path of the app. Navigation stays on the app's own server; any other address opens in the system browser. The user agent names the window to the door (`AcrylWindow/<id>`), so an agent can drive it with `--window <id>`. */
|
|
93
|
+
const createWindow = (path: string): BrowserWindow => {
|
|
94
|
+
const created = new BrowserWindow(windowOptions({ title: chosen.name }))
|
|
95
|
+
created.webContents.setUserAgent(
|
|
96
|
+
`${created.webContents.getUserAgent()} AcrylWindow/${String(created.id)}`,
|
|
97
|
+
)
|
|
98
|
+
created.once('ready-to-show', () => created.show())
|
|
99
|
+
created.webContents.setWindowOpenHandler(({ url }) => {
|
|
100
|
+
void shell.openExternal(url)
|
|
101
|
+
return { action: 'deny' }
|
|
102
|
+
})
|
|
103
|
+
created.webContents.on('will-navigate', (event, url) => {
|
|
104
|
+
if (new URL(url).origin === origin) return
|
|
105
|
+
event.preventDefault()
|
|
106
|
+
void shell.openExternal(url)
|
|
107
|
+
})
|
|
108
|
+
created.loadURL(new URL(path, url).href).catch((cause: unknown) => {
|
|
109
|
+
console.warn(
|
|
110
|
+
`desktop: the window could not load ${path}: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
111
|
+
)
|
|
112
|
+
})
|
|
113
|
+
return created
|
|
114
|
+
}
|
|
115
|
+
window = createWindow(startPath())
|
|
116
|
+
// The door (development) reaches the windows and the menu through this port: `acryl window list`, `acryl menu click`.
|
|
117
|
+
const live = (): BrowserWindow[] =>
|
|
118
|
+
BrowserWindow.getAllWindows().filter((candidate) => !candidate.isDestroyed())
|
|
119
|
+
const port = (candidate: BrowserWindow): WindowPort => ({
|
|
120
|
+
id: candidate.id,
|
|
121
|
+
getTitle: () => candidate.getTitle(),
|
|
122
|
+
getURL: () => candidate.webContents.getURL(),
|
|
123
|
+
isFocused: () => candidate.isFocused(),
|
|
124
|
+
isMinimized: () => candidate.isMinimized(),
|
|
125
|
+
getSize: () => candidate.getSize(),
|
|
126
|
+
restore: () => candidate.restore(),
|
|
127
|
+
focus: () => candidate.focus(),
|
|
128
|
+
close: () => candidate.close(),
|
|
129
|
+
setSize: (width, height) => candidate.setSize(width, height),
|
|
77
130
|
})
|
|
78
|
-
window
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
131
|
+
// Pressing an item as Electron's own menu does: with the window that has focus (the roles reload, zoom and close act on it).
|
|
132
|
+
const menuItem = (item: MenuItem): MenuItemPort => ({
|
|
133
|
+
label: item.label,
|
|
134
|
+
enabled: item.enabled,
|
|
135
|
+
visible: item.visible,
|
|
136
|
+
type: item.type,
|
|
137
|
+
role: item.role,
|
|
138
|
+
// Electron gives a plain item `submenu: null` on Linux and `undefined` elsewhere.
|
|
139
|
+
submenu: item.submenu ? { items: item.submenu.items.map(menuItem) } : null,
|
|
140
|
+
click: () => {
|
|
141
|
+
const target = BrowserWindow.getFocusedWindow() ?? live()[0]
|
|
142
|
+
item.click(undefined, target, target?.webContents)
|
|
143
|
+
},
|
|
82
144
|
})
|
|
83
|
-
|
|
145
|
+
const desktop: DesktopPort = {
|
|
146
|
+
windows: () => live().map(port),
|
|
147
|
+
open: (path) => port(createWindow(path ?? startPath())),
|
|
148
|
+
menu: () => (Menu.getApplicationMenu()?.items ?? []).map(menuItem),
|
|
149
|
+
}
|
|
150
|
+
stopDoorShell = startDoorShell(projectRoot, { desktop, log: console.log })
|
|
84
151
|
})
|
|
85
152
|
}
|
|
@@ -21,7 +21,6 @@ import {
|
|
|
21
21
|
readdirSync,
|
|
22
22
|
readFileSync,
|
|
23
23
|
renameSync,
|
|
24
|
-
rmSync,
|
|
25
24
|
statSync,
|
|
26
25
|
writeFileSync,
|
|
27
26
|
} from 'node:fs'
|
|
@@ -33,7 +32,7 @@ import type {} from '../instance/index.ts'
|
|
|
33
32
|
import type { Sandbox } from '../sandbox/index.ts'
|
|
34
33
|
import { appendLedger, type LedgerEntry, readLedger } from './ledger.ts'
|
|
35
34
|
import { resolveFromApp, routerAliases } from './resolve.ts'
|
|
36
|
-
import { hashFolder, prune, stage } from './stage.ts'
|
|
35
|
+
import { discard, hashFolder, prune, stage } from './stage.ts'
|
|
37
36
|
|
|
38
37
|
/** A fiber's lifecycle state, by name (Cordis's own enum is a `const enum`, which does not exist at run time). */
|
|
39
38
|
export type ExtensionState = 'pending' | 'loading' | 'active' | 'failed' | 'disposed' | 'unloading'
|
|
@@ -611,7 +610,7 @@ export class Extensions extends Service {
|
|
|
611
610
|
try {
|
|
612
611
|
module = await this.load(staged.dir, name)
|
|
613
612
|
} catch (error) {
|
|
614
|
-
|
|
613
|
+
discard(staged.dir)
|
|
615
614
|
this.record({ kind: 'refused', name, version: staged.version, error: message(error) })
|
|
616
615
|
return {
|
|
617
616
|
ok: false,
|
|
@@ -638,7 +637,7 @@ export class Extensions extends Service {
|
|
|
638
637
|
: undefined
|
|
639
638
|
if (failure) {
|
|
640
639
|
await mounted.fiber.dispose().catch(() => undefined)
|
|
641
|
-
|
|
640
|
+
discard(staged.dir)
|
|
642
641
|
const error = failure.error
|
|
643
642
|
this.record({ kind: 'refused', name, version: staged.version, error })
|
|
644
643
|
let restored = false
|