diff --git a/.gitignore b/.gitignore index b947077..eb4cb9b 100755 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ node_modules/ dist/ + +src/assets +src/config.json diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/.prettierrc @@ -0,0 +1 @@ +{} diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..23b7050 --- /dev/null +++ b/TODO.md @@ -0,0 +1,11 @@ +# TODO +- Mutation observer thingy +- Component lifecycle, managed through a FinReg in every place a handle of a component was provided +- Prevent scripts, meta tag +- Protect styling with shadowRoot + +# To consider +- Should cathodique events follow $ also? + +# Long term +LT-TODO diff --git a/package-lock.json b/package-lock.json index e3298bb..19a01da 100755 --- a/package-lock.json +++ b/package-lock.json @@ -10,17 +10,25 @@ "license": "MIT", "dependencies": { "@cathodique/mmap-io": "file:../mmap-io", + "@cathodique/usocket": "file:../usocket", "@cathodique/wl-serv-high": "file:../wl-serv-high", "@paralleldrive/cuid2": "^2.2.2", - "electron": "35.7", + "@typescript/native-preview": "^7.0.0-dev.20251220.1", + "electron": "35.6", + "esbuild": "^0.27.2", + "events": "^3.3.0", "koffi": "^2.14.0", "node-abi": "^4.12.0", - "xml-parser": "^1.2.1" + "xml-parser": "^1.2.1", + "zod": "^4.2.1" }, "devDependencies": { "@electron/rebuild": "^3.7.1", "@types/electron": "^1.4.38", + "@types/events": "^3.0.3", "@types/node": "^22.10.2", + "concurrently": "^9.2.1", + "prettier": "3.7.4", "typescript": "^5.7.2" } }, @@ -43,6 +51,24 @@ "typescript": "^3.5.3" } }, + "../usocket": { + "name": "@cathodique/usocket", + "version": "1.0.7", + "hasInstallScript": true, + "license": "ISC", + "dependencies": { + "bindings": "^1.5.0", + "debug": "^4.3.4", + "nan": "^2.23.0", + "node-gyp": "^11.3.0" + }, + "devDependencies": { + "@types/debug": "^4.1.12", + "@types/node": "^22.10.2", + "mocha": "^9.2.2", + "typescript": "^5.7.2" + } + }, "../wayland-server-js-impl": { "name": "@cathodique/wayland-server-js-impl", "version": "1.0.13", @@ -82,6 +108,10 @@ "resolved": "../mmap-io", "link": true }, + "node_modules/@cathodique/usocket": { + "resolved": "../usocket", + "link": true + }, "node_modules/@cathodique/wl-serv-high": { "resolved": "../wl-serv-high", "link": true @@ -238,6 +268,422 @@ "node": ">= 10.0.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@gar/promisify": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", @@ -387,6 +833,13 @@ "@types/node": "*" } }, + "node_modules/@types/events": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/events/-/events-3.0.3.tgz", + "integrity": "sha512-trOc4AAUThEz9hapPtSd7wf5tiQKvTtu5b371UxXdTuqzIh0ArcRspRP0i0Viu+LXstIQ1z96t1nsPxT9ol01g==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/http-cache-semantics": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz", @@ -430,6 +883,115 @@ "@types/node": "*" } }, + "node_modules/@typescript/native-preview": { + "version": "7.0.0-dev.20251220.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview/-/native-preview-7.0.0-dev.20251220.1.tgz", + "integrity": "sha512-PmKa/JV9oVC+34VDVDj8fCnjtJKbcFXzPOOUtebsQhudnJN2L7cUvSUAvsPA36W3MwHA030rNUHaelcKG9bY3w==", + "license": "Apache-2.0", + "bin": { + "tsgo": "bin/tsgo.js" + }, + "optionalDependencies": { + "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20251220.1", + "@typescript/native-preview-darwin-x64": "7.0.0-dev.20251220.1", + "@typescript/native-preview-linux-arm": "7.0.0-dev.20251220.1", + "@typescript/native-preview-linux-arm64": "7.0.0-dev.20251220.1", + "@typescript/native-preview-linux-x64": "7.0.0-dev.20251220.1", + "@typescript/native-preview-win32-arm64": "7.0.0-dev.20251220.1", + "@typescript/native-preview-win32-x64": "7.0.0-dev.20251220.1" + } + }, + "node_modules/@typescript/native-preview-darwin-arm64": { + "version": "7.0.0-dev.20251220.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-arm64/-/native-preview-darwin-arm64-7.0.0-dev.20251220.1.tgz", + "integrity": "sha512-kFdUHBL0f6tzZfgviBJm7SpX7NBMUIJvS7Gp0SsFbV72Lc/W5k7aFYG5cJScpdlNzG64dC0A5GBl3C/WkPe9Rg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@typescript/native-preview-darwin-x64": { + "version": "7.0.0-dev.20251220.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-x64/-/native-preview-darwin-x64-7.0.0-dev.20251220.1.tgz", + "integrity": "sha512-i2RNLjZaiskvqeNt9XBN/FdssB+i/PURqLkDP6mY6cLSOVClygBtha0qqBAmj+huTvpa64Nwb740a7uFMpVudw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@typescript/native-preview-linux-arm": { + "version": "7.0.0-dev.20251220.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm/-/native-preview-linux-arm-7.0.0-dev.20251220.1.tgz", + "integrity": "sha512-KRLhiLNEjWfWX9cu8/iXtsebQdfH43QVSmkwcnQJCD2lVodw9bAJRL6o7jVXJM4tofDP3i8dCk85SAiwaNiC+A==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@typescript/native-preview-linux-arm64": { + "version": "7.0.0-dev.20251220.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm64/-/native-preview-linux-arm64-7.0.0-dev.20251220.1.tgz", + "integrity": "sha512-iiRl8pG4tfImt0LM+M4sYnsdf39eFMGdK2ThgBhVWRUSKZfrtvkqM5odwwVuw9xPKF5hFbx3k9lx2s4mTSM6Gg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@typescript/native-preview-linux-x64": { + "version": "7.0.0-dev.20251220.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-x64/-/native-preview-linux-x64-7.0.0-dev.20251220.1.tgz", + "integrity": "sha512-Gq+YxQWFV5+gBuGv9J939Vw5vYB/ux+q2DLyTGXrgLcXrSCiNGAhf9j2F4DGs0aJOJZIsZN+emp2GTRCUXqdXg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@typescript/native-preview-win32-arm64": { + "version": "7.0.0-dev.20251220.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-arm64/-/native-preview-win32-arm64-7.0.0-dev.20251220.1.tgz", + "integrity": "sha512-7oBfrT5DalZPhmm4SMS0DzUxw5VEG+cq3Qh6Zgr09+QrAuKBHcuwyZNvbcWhHN7ERMY5xNAIMPILmXOpiarTKQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@typescript/native-preview-win32-x64": { + "version": "7.0.0-dev.20251220.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-x64/-/native-preview-win32-x64-7.0.0-dev.20251220.1.tgz", + "integrity": "sha512-Jvg2hAotYaRTp4z/6gJWDfvTZXAPOHQ4/81PsZC68asms8mUBrZT/xBy3rxTpWTKmebsGGRg4cUKHMZCEKNq1Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", @@ -779,6 +1341,47 @@ "dev": true, "license": "MIT" }, + "node_modules/concurrently": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", + "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "4.1.2", + "rxjs": "7.8.2", + "shell-quote": "1.8.3", + "supports-color": "8.1.1", + "tree-kill": "1.2.2", + "yargs": "17.7.2" + }, + "bin": { + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -914,9 +1517,9 @@ "optional": true }, "node_modules/electron": { - "version": "35.7.5", - "resolved": "https://registry.npmjs.org/electron/-/electron-35.7.5.tgz", - "integrity": "sha512-dnL+JvLraKZl7iusXTVTGYs10TKfzUi30uEDTqsmTm0guN9V2tbOjTzyIZbh9n3ygUjgEYyo+igAwMRXIi3IPw==", + "version": "35.6.0", + "resolved": "https://registry.npmjs.org/electron/-/electron-35.6.0.tgz", + "integrity": "sha512-C+fzUIVkF6HzJjVeCjd6efL5Y01DwuQAnU6yqwh09grYvSQnlMJDC1qsQ8lSWl+sNYwtwUHAdZd7JtUi6Uyo/A==", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -1001,6 +1604,47 @@ "license": "MIT", "optional": true }, + "node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -1024,6 +1668,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/exponential-backoff": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.1.tgz", @@ -1920,6 +2573,22 @@ "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", "license": "MIT" }, + "node_modules/prettier": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz", + "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/proc-log": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-2.0.1.tgz", @@ -2143,6 +2812,16 @@ "node": ">=8.0" } }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -2227,6 +2906,19 @@ "node": ">=8" } }, + "node_modules/shell-quote": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -2386,6 +3078,23 @@ "node": ">=8" } }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, "node_modules/type-fest": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", @@ -2590,6 +3299,15 @@ "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } + }, + "node_modules/zod": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.2.1.tgz", + "integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 9d0c2b6..1498133 100755 --- a/package.json +++ b/package.json @@ -2,11 +2,16 @@ "name": "@cathodique/de", "version": "1.0.1", "description": "Cathodique Desktop Environment", - "main": "dist/main.js", + "main": "dist/main/main.js", "scripts": { - "start": "tsc && LOGGERS=wl_serv_i8a56qb0lm3ox1ng1cqai0e1 electron .", + "start": "npm run build && LOGGERS=wl_serv_i8a56qb0lm3ox1ng1cqai0e1 electron .", "test": "echo \"Error: no test specified\" && exit 1", - "build": "tsc && cp -frfr assets/* dist/" + "build-asset-homomorphic": "rm -frfr dist && mkdir dist && cp -frfr src/* dist/ && find dist/ -name \"*.ts\" -type f -delete", + "build-browser-host": "esbuild dist/renderer/wayland/index.js --bundle --platform=node --target=node22.4 --external:electron --external:@cathodique/wl-serv-high --outfile=dist/renderer/index.js", + "build-browser-common": "esbuild dist/modules/.common/index.js --bundle --outfile=dist/modules/.common/bundle.js --format=esm", + "build-source": "npm run build-asset-homomorphic && npx tsgo && npx tsc -p tsconfig.modules.json && npm run build-browser-host && npm run build-browser-common", + "build-bin": "npx electron-rebuild", + "build": "concurrently \"npm run build-source\" \"npm run build-bin\"" }, "repository": { "type": "git", @@ -21,17 +26,25 @@ "devDependencies": { "@electron/rebuild": "^3.7.1", "@types/electron": "^1.4.38", + "@types/events": "^3.0.3", "@types/node": "^22.10.2", + "concurrently": "^9.2.1", + "prettier": "3.7.4", "typescript": "^5.7.2" }, "dependencies": { "@cathodique/mmap-io": "file:../mmap-io", + "@cathodique/usocket": "file:../usocket", "@cathodique/wl-serv-high": "file:../wl-serv-high", "@paralleldrive/cuid2": "^2.2.2", - "electron": "35.7", + "@typescript/native-preview": "^7.0.0-dev.20251220.1", + "electron": "35.6", + "esbuild": "^0.27.2", + "events": "^3.3.0", "koffi": "^2.14.0", "node-abi": "^4.12.0", - "xml-parser": "^1.2.1" + "xml-parser": "^1.2.1", + "zod": "^4.2.1" }, "overrides": { "nan": "^2.23.0" diff --git a/src/index.ts b/src/index.ts deleted file mode 100644 index b6f5130..0000000 --- a/src/index.ts +++ /dev/null @@ -1,459 +0,0 @@ -import { HLCompositor, HLConnection } from "@cathodique/wl-serv-high"; -import { - InstructionType, - RegRectangle, -} from "@cathodique/wl-serv-high/dist/objects/wl_region"; -import { SeatConfiguration, WlSeat } from "@cathodique/wl-serv-high/dist/objects/wl_seat"; -import { KeyboardRegistry } from "@cathodique/wl-serv-high/dist/objects/wl_keyboard"; -import { BaseObject } from "@cathodique/wl-serv-high/dist/objects/base_object"; -import { WlSurface } from "@cathodique/wl-serv-high/dist/objects/wl_surface"; -import { XdgWmBase } from "@cathodique/wl-serv-high/dist/objects/xdg_wm_base"; -import { ipcRenderer } from "electron"; - -import { codeToScan } from "./codeToScancode"; -import { WlSubsurface } from "@cathodique/wl-serv-high/dist/objects/wl_subsurface"; -import { XdgToplevel } from "@cathodique/wl-serv-high/dist/objects/xdg_toplevel"; -import { WindowGeometry, XdgSurface } from "@cathodique/wl-serv-high/dist/objects/xdg_surface"; -import { ZxdgToplevelDecorationV1 } from "@cathodique/wl-serv-high/dist/objects/zxdg_decoration_manager_v1"; -import { WlBuffer } from "@cathodique/wl-serv-high/dist/objects/wl_buffer"; -import { SeatAuthority, SeatInstances, SeatRegistry } from "@cathodique/wl-serv-high/dist/registries/seat"; -import { OutputRegistry } from "@cathodique/wl-serv-high/dist/registries/output"; -// import { WlPointer } from "@cathodique/wl-serv-high/dist/objects/wl_pointer"; - -// HERE -// TODO::: -// Direct events towards their respective authorities -// for both Seat and Output - -export function isInRegion(reg: RegRectangle[], y: number, x: number, defaultValue: boolean = false) { - if (reg.length === 0) return defaultValue; - - return ( - reg.reduce((a, v) => { - if (v.hasCoordinate(y, x)) return v.type; - return a; - }, null) === InstructionType.Add - ); -} - -const knownMods = ["Shift", "Lock", "Control", "Mod1", "Mod2", "Mod3", "Mod4", "Mod5"] as const; -class Modifiers { - depressed = Object.fromEntries(knownMods.map((v) => [v, false])) as Record; - depressedBitmask = 0; - latched = Object.fromEntries(knownMods.map((v) => [v, false])) as Record; - latchedBitmask = 0; - locked = Object.fromEntries(knownMods.map((v) => [v, false])) as Record; - lockedBitmask = 0; - - group = 0; - - seatConfig: SeatConfiguration; - - constructor(seatConfig: SeatConfiguration) { - this.seatConfig = seatConfig; - } - - updateAccordingly(evt: KeyboardEvent | MouseEvent) { - let changed = { depressed: false, latched: false, locked: false }; - function checkIfChangedAndUpdate(origin: Record, modifier: typeof knownMods[number], value: boolean) { - if (origin[modifier] === value) return false; - origin[modifier] = value; - return true; - } - // Shift: "Shift" - changed.depressed ||= checkIfChangedAndUpdate(this.depressed, "Shift", evt.getModifierState("Shift")); - // Lock: "CapsLock" - changed.locked ||= checkIfChangedAndUpdate(this.locked, "Lock", evt.getModifierState("CapsLock")); - if (evt instanceof KeyboardEvent) changed.depressed ||= checkIfChangedAndUpdate(this.depressed, "Lock", evt.type === "keydown" && evt.key === "CapsLock"); - // Control: "Control" - changed.depressed ||= checkIfChangedAndUpdate(this.depressed, "Control", evt.getModifierState("Control")); - // Mod1: "Alt" - changed.depressed ||= checkIfChangedAndUpdate(this.depressed, "Mod1", evt.getModifierState("Alt")); - // Mod2: "NumLock" - changed.depressed ||= checkIfChangedAndUpdate(this.depressed, "Mod2", evt.getModifierState("NumLock")); - // Mod3: "Hyper" (No Level 5 in browser spec) - changed.depressed ||= checkIfChangedAndUpdate(this.depressed, "Mod3", evt.getModifierState("Hyper")); - // Mod4: "Meta" - changed.depressed ||= checkIfChangedAndUpdate(this.depressed, "Mod4", evt.getModifierState("Meta")); - // Mod5: "AltGraph" - changed.depressed ||= checkIfChangedAndUpdate(this.depressed, "Mod5", evt.getModifierState("AltGraph")); - - return changed; - } - - static createMask(object: Record) { - let result = 0; - for (let modIdx = 0; modIdx < knownMods.length; modIdx += 1) { - const mask = 2 ** modIdx; - if (object[knownMods[modIdx]]) result += mask; - } - - return result; - } - - update(connection: HLConnection, serial?: number) { - const authority = connection.display.seatRegistry.get(this.seatConfig)!.get(connection)!; - - authority.modifiers(this.depressedBitmask, this.latchedBitmask, this.lockedBitmask, this.group, serial); - } - - ifUpdateThenEmit(evt: KeyboardEvent | MouseEvent, connection: HLConnection) { - const xWasUpdated = this.updateAccordingly(evt); - if (xWasUpdated.depressed || xWasUpdated.latched || xWasUpdated.locked) { - if (xWasUpdated.depressed) this.depressedBitmask = Modifiers.createMask(this.depressed); - if (xWasUpdated.latched) this.latchedBitmask = Modifiers.createMask(this.latched); - if (xWasUpdated.locked) this.lockedBitmask = Modifiers.createMask(this.locked); - - this.update(connection); - } - } -} - -const mySeatConfig = { - name: "seat0", - capabilities: 3, - modifiers: null as unknown as Modifiers, -}; -mySeatConfig.modifiers = new Modifiers(mySeatConfig); - -const seatReg = new SeatRegistry(); -seatReg.addAuthority(mySeatConfig); - -const myOutput = { - x: 0, - y: 0, - w: 1920, - h: 1080, - effectiveW: 1920, - effectiveH: 1080, -}; -const outputReg = new OutputRegistry(); -outputReg.addAuthority(myOutput); - -// const outputMap = new Map(); - -const compo = new HLCompositor({ - wl_registry: { - outputs: outputReg, - seats: seatReg, - }, - wl_keyboard: new KeyboardRegistry({ keymap: "us" }), -}); - -const tickAnimationFrame = () => { - compo.ticks.emit("tick"); - requestAnimationFrame(tickAnimationFrame); -}; -tickAnimationFrame(); - -const surfaceToDom = new Map(); - -let currentSeat: SeatInstances | undefined = undefined; -// WTF!! -// let currentKeyboards: Map = new Map(); - -document.body.addEventListener("keydown", (v) => { - if (!currentSeat) { - mySeatConfig.modifiers.updateAccordingly(v); - return; - } - v.preventDefault(); - mySeatConfig.modifiers.ifUpdateThenEmit(v, currentSeat.connection); - - const isInMap = (code: string): code is keyof typeof codeToScan => - code in codeToScan; - if (!isInMap(v.code)) return; - - const scancode = codeToScan[v.code]; - - // if (currentSeat) surf.modifiers(currentSeat, 0, 0, 0, 0); - currentSeat.keyDown(scancode); -}); - -document.body.addEventListener("keyup", (v) => { - if (!currentSeat) { - mySeatConfig.modifiers.updateAccordingly(v); - return; - } - v.preventDefault(); - mySeatConfig.modifiers.ifUpdateThenEmit(v, currentSeat.connection); - - const isInMap = (code: string): code is keyof typeof codeToScan => - code in codeToScan; - if (!isInMap(v.code)) return; - - const scancode = codeToScan[v.code]; - - // if (currentSeat) surf.modifiers(currentSeat, 0, 0, 0, 0); - currentSeat.keyUp(scancode); -}); - -const buffers = new Map(); - -compo.on("connection", (c) => { - // console.log(c); - - // let currentKeyboard: WlKeyboard | undefined; - // let currentPointer: WlPointer | undefined; - // const myOutputTransport = outputReg.transports.get(c)!.get(myOutput)!; - - c.on("new_obj", async (obj: BaseObject) => { - // TODO: Separate buffer logic up here! - if (obj instanceof ZxdgToplevelDecorationV1) { - obj.on('wlSetMode', () => { - obj.sendToplevelDecoration('server_side'); - }); - obj.sendToplevelDecoration('server_side'); - } - if (obj instanceof WlSubsurface) { - const parentDom = surfaceToDom.get(obj.meta.parent)!; - - parentDom.append(surfaceToDom.get(obj.meta.surface)!); - - // Subsurface shenanigans - // TODO: Apply on commit - obj.on("wlPlaceAbove", function (this: WlSubsurface, { sibling: other }: { sibling: WlSurface }) { - switch (this.getRelationWith(other)) { - case "sibling": { - const siblingDom = surfaceToDom.get(other)!; - const parentDom = siblingDom.parentElement!; - - parentDom.insertBefore(surfaceToDom.get(this.meta.surface)!, siblingDom); - break; - } - case "parent": { - const parentDom = surfaceToDom.get(other)!; - const parentCanvas = Array.from(parentDom.children).find((v) => v.tagName === 'canvas')!; - - parentDom.insertBefore(surfaceToDom.get(this.meta.surface)!, parentCanvas); - break; - } - default: - // Already handled by wl-serv-high - } - }); - - obj.on("wlPlaceBelow", function (this: WlSubsurface, { sibling: other }: { sibling: WlSurface }) { - switch (this.getRelationWith(other)) { - case "sibling": { - const siblingDom = surfaceToDom.get(other)!; - const parentDom = siblingDom.parentElement!; - - parentDom.insertBefore(surfaceToDom.get(this.meta.surface)!, siblingDom.nextSibling); - break; - } - case "parent": { - const parentDom = surfaceToDom.get(other)!; - const parentCanvas = Array.from(parentDom.children).find((v) => v.tagName === 'canvas')!; - - parentDom.insertBefore(surfaceToDom.get(this.meta.surface)!, parentCanvas.nextSibling); - break; - } - default: - // Already handled by wl-serv-high - } - }); - obj.on('wlSetPosition', function (this: WlSubsurface, { y, x }: { y: number, x: number }) { - const thisDom = surfaceToDom.get(this.meta.surface)!; - - thisDom.style.top = `${y}px`; - thisDom.style.left = `${x}px`; - }); - } - - if (!(obj instanceof WlSurface)) return; - - // console.log(surf); - // const awaitCommit = () => new Promise((r) => surf.once('wlCommit', () => r())); - const container = document.createElement("div") as HTMLDivElement; - container.classList.add("surface-container"); - - container.style.display = "none"; - - const canvas = document.createElement("canvas") as HTMLCanvasElement; - canvas.classList.add("surface-contents"); - - // FPS element - // const fpsEl = document.createElement('p'); - // let lastFrameTimes = [1]; - // let lastNow = Date.now(); - - container.append(canvas); - - surfaceToDom.set(obj, container); - - const ctx = canvas.getContext("2d"); - if (!ctx) - throw new Error( - "Failed to derive 2d context from canvas element; is anything disabled?", - ); - - // console.log(surf); - - let wasInSurface = false; - - obj.on("updateRole", () => { - switch (obj.role) { - case "cursor": - break; - case "toplevel": - const titleTextNode = document.createTextNode('Window'); - - const toplevel = obj.xdgSurface!.toplevel!; - - const windowTemplate = document.querySelector('template#window')! as HTMLTemplateElement; - const clone = windowTemplate.content.cloneNode(true) as DocumentFragment; - - const cropContainer = document.createElement('div'); - cropContainer.classList.add('crop-container'); - cropContainer.append(container); - - const windowGeometryDoubleBuff = (toplevel.parent as XdgSurface).geometry; - function applyWindowGeometry(windowGeometry: WindowGeometry) { - cropContainer.style.height = `${windowGeometry.height}px`; - cropContainer.style.width = `${windowGeometry.width}px`; - container.style.top = `-${windowGeometry.y}px`; - container.style.left = `-${windowGeometry.x}px`; - } - applyWindowGeometry(windowGeometryDoubleBuff.current); - windowGeometryDoubleBuff.on('current', applyWindowGeometry); - - clone.querySelector('slot[name=window_title]')!.replaceWith(titleTextNode); - clone.querySelector('slot[name=window_contents]')!.replaceWith(cropContainer); - - titleTextNode.textContent = toplevel.title || 'Untitled window'; - toplevel.on('wlSetTitle', (v) => titleTextNode.textContent = toplevel.title || 'Untitled window'); - - document.body.append(clone); - container.classList.add("xdg-toplevel"); - break; - case "popup": - document.body.append(container); - container.classList.add("xdg-popup"); - break; - case "subsurface": - container.classList.add("subsurface"); - break; - } - }); - - const move = function (evt: MouseEvent, forceLeave?: boolean) { - (obj.xdgSurface?.parent as XdgWmBase)?.addCommand("ping", { - serial: obj.connection.time.getTime(), - }); - - const containerPos = container.getBoundingClientRect(); - - const mouseY = evt.clientY - containerPos.top; - const mouseX = evt.clientX - containerPos.left; - - console.log("Something ok?"); - - console.log(obj, obj.inputRegions, obj.inputRegions.current, mouseY, mouseX); - - evt.stopPropagation(); - - if ( - !forceLeave && - isInRegion(obj.inputRegions.current, mouseY, mouseX, true) - ) { - if (!wasInSurface) { - wasInSurface = true; - currentSeat = obj.connection.display.seatRegistry.get(mySeatConfig)!.get(c)!; - console.log('enter'); - const enterSerial = currentSeat.focus(obj, []); - mySeatConfig.modifiers.update(currentSeat.connection, enterSerial); - currentSeat.enter(obj, mouseX, mouseY); - } - currentSeat!.moveTo(mouseX, mouseY); - } else { - if (wasInSurface) { - wasInSurface = false; - if (currentSeat) currentSeat.blur(obj); - if (currentSeat) currentSeat.leave(obj); - currentSeat = undefined; - console.log('leave'); - } - } - }; - - container.addEventListener("mouseenter", move); - container.addEventListener("mousemove", move); - container.addEventListener("mouseleave", (v) => move(v, true)); - const webToButtonMap: Record = { - 0: 0x110, - 1: 0x112, - 2: 0x111, - 3: 0x116, - 4: 0x115, - }; - container.addEventListener("mousedown", (evt) => { - if (wasInSurface && currentSeat) - currentSeat.buttonDown(webToButtonMap[evt.button]); - }); - container.addEventListener("mouseup", (evt) => { - if (wasInSurface && currentSeat) - currentSeat.buttonUp(webToButtonMap[evt.button]); - }); - - let wasShown = false; - - let lastDimensions: [number, number] = [-Infinity, -Infinity]; - const commitHandler = async function () { - - const b = obj.buffer.current; - - if (b === null) container.style.display = "none"; - if (b == null) return; - - if (!wasShown) { - wasShown = true; - obj.shown(myOutput); - } - - container.style.display = "block"; - if (lastDimensions[0] !== b.meta.height || lastDimensions[1] !== b.meta.width) { - container.style.width = `${b.meta.width}px`; - container.style.height = `${b.meta.height}px`; - canvas.width = b.meta.width; - canvas.height = b.meta.height; - lastDimensions = [b.meta.height, b.meta.width]; - } - - container.style.transform = ``; - - const currlyDamagedBuffer = obj.getCurrlyDammagedBuffer(); - - for (const rect of currlyDamagedBuffer) { - b.updateBufferArea(rect.y, rect.x, rect.h, rect.w) - } - const arr = new Uint8ClampedArray( - b.buffer.buffer, - 0, - b.meta.width * b.meta.height * 4, - ); - if (arr.length > 0) { - let imageData = new ImageData(arr, b.meta.width, b.meta.height); - - for (const rect of currlyDamagedBuffer) { - ctx!.putImageData(imageData, 0, 0, rect.x, rect.y, rect.w, rect.h); - } - } - }; - - commitHandler(); - obj.on("update", () => commitHandler()); - - obj.once("beforeWlDestroy", () => { - // Unsure vvv - container.remove(); - }); - }); -}); -compo.start(); - -compo.on("ready", () => { - document.body.append(`Ready at ${compo.params.socketPath}`); - ipcRenderer.send("addToDeleteQueue", compo.params.socketPath); - ipcRenderer.send(`Ready at ${compo.params.socketPath}.lock`); -}); diff --git a/src/main.ts b/src/main/main.ts similarity index 78% rename from src/main.ts rename to src/main/main.ts index d7cd640..862e262 100755 --- a/src/main.ts +++ b/src/main/main.ts @@ -1,24 +1,23 @@ import { app, BrowserWindow, ipcMain } from "electron"; import { rmSync } from "node:fs"; -import { join } from "node:path"; -// app.allowRendererProcessReuse = false; - -// app.commandLine.appendSwitch("disable-hid-blocklist"); +import { registerProtocols } from "./protocols.js"; const createWindow = () => { const win = new BrowserWindow({ - width: 800, - height: 600, // fullscreen: true, + // resizable: false, webPreferences: { nodeIntegration: true, + nodeIntegrationInSubFrames: false, contextIsolation: false, }, }); - // win.webContents.openDevTools(); - win.loadFile(join(__dirname, "../dist/index.html")); + registerProtocols(); + + win.webContents.openDevTools({ mode: "detach" }); + win.loadURL("app://top/index.html"); }; const deleteQueue: string[] = []; diff --git a/src/main/permissions.ts b/src/main/permissions.ts new file mode 100644 index 0000000..a718d4f --- /dev/null +++ b/src/main/permissions.ts @@ -0,0 +1,5 @@ + + +export const processPermissions = () => { + +}; diff --git a/src/main/protocols.ts b/src/main/protocols.ts new file mode 100644 index 0000000..0e4fa62 --- /dev/null +++ b/src/main/protocols.ts @@ -0,0 +1,74 @@ +import { net, OnBeforeRequestListenerDetails, protocol, session } from "electron"; +import { join } from "node:path"; + +import { pathToFileURL } from "node:url"; + +protocol.registerSchemesAsPrivileged([{ + scheme: "app", + privileges: { + standard: true, + secure: true, + bypassCSP: false, + allowServiceWorkers: false, + corsEnabled: true, + stream: true, + codeCache: true, + }, +}]); + +export const registerProtocols = () => { + protocol.handle('app', (request) => { + const reqUrl = new URL(request.url); + + switch (reqUrl.host) { + case 'top': + if (reqUrl.pathname.split('/').some((v) => v === '.' || v === '..')) { // Path accesses + return new Response("Forbidden", { status: 403 }); + } + return net.fetch(pathToFileURL(join(__dirname, '../renderer', reqUrl.pathname)).toString()); + default: + return new Response("Not found", { status: 404 }); + } + }); + + protocol.handle('https', (request) => { + const reqUrl = new URL(request.url); + + if (reqUrl.pathname.split('/').some((v) => v === '.' || v === '..')) { // Path accesses + return new Response("Forbidden", { status: 403 }); + } + + const [tld, sld, ...rest] = reqUrl.host.split('.').toReversed(); + const domain = `${sld}.${tld}`; + + switch (domain) { + case "raytu.be": { + if (reqUrl.pathname.startsWith('/.common/')) { + return net.fetch(pathToFileURL(join(__dirname, '../modules', reqUrl.pathname)).toString()); + } + + return net.fetch(pathToFileURL(join(__dirname, '../modules', rest.join('.'), reqUrl.pathname)).toString()); + } + } + + return new Response("Not found", { status: 404 }); + }); + + session.defaultSession.webRequest.onBeforeRequest((request: OnBeforeRequestListenerDetails, callback) => { + const url = new URL(request.url); + if (['http', 'https', 'file', 'ftp'].some((v) => request.url.startsWith(v))) { + const { frame } = request; + if (frame == null) { + return callback({ cancel: true }); + } + + if (url.host.endsWith(".raytu.be") || url.host === "raytu.be") { + return callback({ cancel: false }); + } + + // TODO: Handle permissions of each module. For now though... + callback({ cancel: true }); + } + callback({}); + }); +}; diff --git a/src/modules/.common/classes/component.ts b/src/modules/.common/classes/component.ts new file mode 100644 index 0000000..5cb6442 --- /dev/null +++ b/src/modules/.common/classes/component.ts @@ -0,0 +1,67 @@ +import { EventEmitter } from "events"; +import { componentTypes } from "../utils/types.js"; +import { nanoid } from "../utils/utils.js"; +import { wrapValue } from "../utils/wrap.js"; +import { componentList } from "./componentList.js"; +import { OrderedPeer } from "./orderedPeer.js"; + +export type ComponentHandle = { + componentId: string | Promise; + + init(): any; +} & { [k in `$${string}`]: any; }; + +const isComponentSymbol = Symbol(); +export abstract class Component implements ComponentHandle { + [k: `$${string}`]: any; + + static isComponentSymbol: typeof isComponentSymbol = isComponentSymbol; + componentId: string; + + static type: typeof componentTypes[number] = "NORMAL"; + + [isComponentSymbol] = true; + constructor() { + this.componentId = nanoid(); + + componentList.componentInstances.set( + this.componentId, + this, + ); + } + + init(): any {} + + #listenersFromRemote = new Map>(); + listenFor(eventName: string, peer: OrderedPeer) { + const innerSet = this.#listenersFromRemote.get(eventName) || new Set(); + if (!this.#listenersFromRemote.has(eventName)) this.#listenersFromRemote.set(eventName, innerSet); + + innerSet.add(peer); + } + unlistenFor(eventName: string, peer: OrderedPeer) { + const innerSet = this.#listenersFromRemote.get(eventName) || new Set(); + + innerSet.delete(peer); + + if (innerSet.size === 0) this.#listenersFromRemote.delete(eventName); + } + emit(eventName: string, ...args: any[]) { + const wrapped = args.map((v) => wrapValue(v)) + + const innerSet = this.#listenersFromRemote.get(eventName); + if (!innerSet) return false; + + Promise.all( + [...innerSet] + .map((peer) => + peer.rpc("emitEvent", { + componentId: this.componentId, + eventName: eventName, + args: wrapped, + }) + ), + ); + return true; + } +} diff --git a/src/modules/.common/classes/componentList.ts b/src/modules/.common/classes/componentList.ts new file mode 100644 index 0000000..0366c06 --- /dev/null +++ b/src/modules/.common/classes/componentList.ts @@ -0,0 +1,84 @@ +import { ComponentHandleClass, ComponentHandleFactory } from "../utils/types.js"; +import { Component } from "./component.js"; +import { Latch } from "./latch.js"; + +export class InvalidComponentError extends Error {} + +export class ComponentList extends EventTarget implements ComponentListHandle { + componentClasses = new Map(); + componentClassToClassName = new Map() + + componentInstances = new Map(); + instanceExists(id: string) { + return this.componentInstances.has(id); + } + + componentTypeOf(component: Component) { + // Traversing prototype chain (from most specific to least specific) + // will take less time than traversing all the possible components. + let currentPrototype: any = Object.getPrototypeOf(component); + while (currentPrototype !== null) { + if (this.componentClassToClassName.has(currentPrototype.constructor)) { + return this.componentClassToClassName.get(currentPrototype.constructor)!; + } + currentPrototype = Object.getPrototypeOf(currentPrototype); + } + + throw new InvalidComponentError(); + // Implications: The object has had [Symbol(Component.isComponentSymbol)] set to true but was not a component + } + + register(componentName: string, componentClass: ComponentHandleClass) { + if (this.componentClasses.has(componentName)) + throw new Error("This component already exists"); + + this.componentClasses.set(componentName, componentClass); + this.componentClassToClassName.set(componentClass, componentName); + } + markAs(componentClass: ComponentHandleClass, componentName: string) { + this.componentClassToClassName.set(componentClass, componentName); + } + + #readyLatch = new Latch(); + get ready() { + return this.#readyLatch.promise; + } + markReady() { + this.#readyLatch.resolve?.(); + } + + get(componentName: string) { + const InnerClass = this.componentClasses.get(componentName); + if (!InnerClass) return; + + return { + create: (...args: any[]) => { + switch (InnerClass.type) { + case "REF_ONLY": { + throw new Error("You are not supposed to instanciate this class"); + } + case "SINGLETON": { + if (!InnerClass.singletonInstance) throw new Error("Singleton instance was not set up."); + return InnerClass.singletonInstance; + } + case "NORMAL": { + return "create" in InnerClass + ? InnerClass.create(...args) + : new InnerClass(...args); + } + } + } + }; + } + + has(componentName: string) { + return this.componentClasses.has(componentName); + } +} + +export type ComponentListHandle = { + get(componentName: string): undefined + | ComponentHandleFactory; +}; + +export const componentList = new ComponentList(); diff --git a/src/modules/.common/classes/latch.ts b/src/modules/.common/classes/latch.ts new file mode 100644 index 0000000..fc055a7 --- /dev/null +++ b/src/modules/.common/classes/latch.ts @@ -0,0 +1,175 @@ +export enum LatchState { + Pending, + Fulfilled, +} + +export class Latch { + promise: Promise; + resolve: ((v: T) => void) | undefined; + + constructor(value?: T) { + if (value != null) { + this.resolve = undefined; + this.promise = Promise.resolve(value); + } else { + let resultingResolve: (r: T) => void; + + // Assignment with side effect onto resultingResolve + this.promise = new Promise((r) => { resultingResolve = r }); + + this.resolve = (r: T) => { + resultingResolve(r); + this.resolve = undefined; + }; + } + } + + getState() { + if (this.resolve) return LatchState.Pending; + return LatchState.Fulfilled; + } +} + +export class KeyedLatch { + map = new Map>(); + + getStateOf(key: T): LatchState { + return this.map.get(key)?.getState() ?? LatchState.Pending; + } + + get(key: T): Promise { + if (this.map.has(key)) return this.map.get(key)!.promise; + + const latch = new Latch(); + this.map.set(key, latch); + + return latch.promise; + } + + resolve(key: T, value: U): typeof value { + if (!this.map.has(key)) { + this.map.set(key, new Latch(value)); + } else { + const { resolve } = this.map.get(key)!; + if (!resolve) throw new Error('Double resolution is unacceptable'); + + resolve(value); + } + return value; + } + + getOptional(key: T) { + if (this.map.has(key)) return this.get(key); + return undefined; + } + + delete(key: T) { + this.map.delete(key); + } +} + +export class WeakKeyedLatch { + map = new WeakMap>(); + + getStateOf(key: T) { + return this.map.get(key)?.getState() ?? LatchState.Pending; + } + + get(key: T) { + if (this.map.has(key)) return this.map.get(key)!.promise; + + const latch = new Latch(); + this.map.set(key, latch); + + return latch.promise; + } + + resolve(key: T, value: U) { + if (!this.map.has(key)) { + this.map.set(key, new Latch(value)); + } else { + const { resolve } = this.map.get(key)!; + if (!resolve) throw new Error('Double resolution is unacceptable'); + + resolve(value); + } + return value; + } + + getOptional(key: T) { + if (this.map.has(key)) return this.get(key); + return undefined; + } + + delete(key: T) { + this.map.delete(key); + } +} + +export class ConsumableKeyedLatch extends KeyedLatch { + consumed = new Set(); + + consume(key: T) { + const result = super.get(key); + switch (this.getStateOf(key)) { + case LatchState.Pending: + this.consumed.add(key); + break; + case LatchState.Fulfilled: + this.delete(key); + break; + } + return result; + } + + /** + * @deprecated For semantic reasons, use consume instead + * Method kept for inheritance + */ + get(key: T) { + return this.consume(key); + } + + resolve(key: T, value: U) { + const result = super.resolve(key, value); + if (this.consumed.has(key)) { + this.consumed.delete(key); + this.delete(key); + } + return result; + } +} + +export class ConsumableWeakKeyedLatch extends WeakKeyedLatch { + consumed = new WeakSet(); + + consume(key: T) { + const result = super.get(key); + switch (this.getStateOf(key)) { + case LatchState.Pending: + this.consumed.add(key); + break; + case LatchState.Fulfilled: + this.delete(key); + break; + } + return result; + } + + /** + * @deprecated For semantic reasons, use consume instead + * Method kept for inheritance + */ + get(key: T) { + return this.consume(key); + } + + resolve(key: T, value: U) { + const result = super.resolve(key, value); + if (this.consumed.has(key)) { + this.consumed.delete(key); + this.delete(key); + } + return result; + } +} diff --git a/src/modules/.common/classes/module.ts b/src/modules/.common/classes/module.ts new file mode 100644 index 0000000..9aec643 --- /dev/null +++ b/src/modules/.common/classes/module.ts @@ -0,0 +1,86 @@ +import { CathodiqueAvailableComponentsHandler, CathodiqueConsumerHandler } from "../ipcHandlers/cathodiqueConsumer.js"; +import { parentIpc } from "../parentIpc.js"; +import { ComponentInstanceProxy, makeComponentProxy } from "../utils/remoteToLocalAdapter.js"; +import { KeyedLatch, Latch } from "./latch.js"; +import { OrderedPeer } from "./orderedPeer.js"; +import { Resolver } from "./resolver.js"; +import { DummyNodeRegistry } from "./sharedDomDummy.js"; + +// The RemoteModule class manages the lifecycle of a RemoteModule +// Mainly, the components made available (through keyed latch componentReady) +export class RemoteModule { + static summonnedModulesByPort = new Map(); + static summonnedModulesByToken = new Map(); + + static getOrCreate(port: MessagePort | undefined, id: string) { + if (this.summonnedModulesByToken.has(id)) return this.summonnedModulesByToken.get(id)!; + + if (!port) throw new Error("Message port supposedly neutered yet is not in my registry"); + + return this.createModule(port, id); + } + + static async createModule(port: MessagePort, opaqueToken: string) { + const peer = new OrderedPeer(port, opaqueToken, "*"); + const latch = new Latch(); + peer.addHandler(new CathodiqueAvailableComponentsHandler(latch)); + + const availableComponents = await latch.promise; + + const mod = new RemoteModule(port, availableComponents, peer, opaqueToken); + this.summonnedModulesByPort.set(port, mod); + this.summonnedModulesByToken.set(opaqueToken, mod) + + return mod; + } + + static async moduleByOpaqueToken(opaqueToken: string) { + return this.summonnedModulesByToken.get(opaqueToken) + || Resolver.getModuleByToken(opaqueToken); + } + static moduleOfPort(port: MessagePort) { + return this.summonnedModulesByPort.get(port); + } + + #componentInstances = new Map(); // TODO Memory management + registerInstanceProxy(id: string, comp: ComponentInstanceProxy) { + this.#componentInstances.set(id, comp); + } + instanceProxyExists(componentId: string) { + return this.#componentInstances.get(componentId); + } + getInstanceProxy(componentId: string) { + return this.#componentInstances.get(componentId); + } + + id: string; + + availableComponents: string[]; + + port: MessagePort; + peer: OrderedPeer; + constructor(port: MessagePort, availableComponents: string[], peer: OrderedPeer, id: string) { + this.port = port; + this.id = id; + + this.availableComponents = availableComponents; + + // "*" is fine because we can trust the origin who passed the messageport onto us + this.peer = peer; + this.peer.addHandler(new CathodiqueConsumerHandler(this)); + + DummyNodeRegistry.setRegistry(this.peer, new DummyNodeRegistry(this.peer)); + } + + localHandle = { + get: (componentName: string) => ({ + create: (...args: any[]) => { + return makeComponentProxy(this, componentName, { args }); + }, + }), + }; + + async instanceExists(componentId: string) { + return await this.peer.rpc("instanceExists", { componentId }); + } +} diff --git a/src/modules/.common/classes/orderedPeer.ts b/src/modules/.common/classes/orderedPeer.ts new file mode 100644 index 0000000..4ae9ce6 --- /dev/null +++ b/src/modules/.common/classes/orderedPeer.ts @@ -0,0 +1,157 @@ +import { ConsumableKeyedLatch } from "./latch.js"; + +import { nanoid } from "../utils/utils.js"; +import { WithTransfer } from "./withTransfer.js"; +import { HandlerContext } from "../utils/types.js"; + +export class OrderedPeer { + handlers: Record, ctx: HandlerContext) => any>[] = []; + + static actualHandlers = new WeakMap void>(); + private static registered = false; + static registerIpcListener() { + if (this.registered) return; + window.addEventListener( + "message", + (evt) => { + const actualHandler = this.actualHandlers.get(evt.source as MessageEventSource); + if (!actualHandler) return; + actualHandler(evt); + }, + ); + this.registered = true; + } + + currentOrderSubmission: bigint = 0n; + pendingMessages: any[] = []; + pendingTransfer: Transferable[] = []; + origin: string; + + promiseMap = new ConsumableKeyedLatch(); + + source: MessageEventSource; + postMessage: typeof window["postMessage"]; + opaqueToken: string | undefined; + constructor(source: MessageEventSource, opaqueToken: string | undefined, origin = "*") { + if (OrderedPeer.actualHandlers.has(source)) throw new Error("A window may only admit a single OrderedPeer"); + + this.source = source; + this.postMessage = source.postMessage.bind(source); + this.origin = origin; + this.opaqueToken = opaqueToken; + + if (source instanceof MessagePort) { + if (origin !== "*") throw new Error("Cannot restrain origin if source is a MessagePort"); + source.addEventListener("message", this.orderedDecoder.bind(this)); + } else { + OrderedPeer.actualHandlers.set(source, this.orderedDecoder.bind(this)); + } + } + + addHandler(handler: (typeof this.handlers)[number]) { + this.handlers.push(handler); + if (this.source instanceof MessagePort) this.source.start(); + return this; // Builder + } + + post(data: any) { + let transfer: WithTransfer[] = []; + if (data instanceof WithTransfer) { + transfer = data.transfer; + data = data.data; + } + + this.pendingMessages.push(data); + this.pendingTransfer.push(...transfer); + + if (this.pendingMessages.length === 1) { + const error = new Error().stack!; + queueMicrotask(() => { + if (error.length < 1) { + throw new Error("Just to get the stack trace."); + } + this.source.postMessage( + { + messages: this.pendingMessages, + currentOrder: this.currentOrderSubmission, + }, + { + targetOrigin: this.origin, + transfer: this.pendingTransfer, + }, + ); + + this.pendingMessages = []; + this.pendingTransfer = []; + this.currentOrderSubmission += 1n; + }); + } + } + + async rpc(type: string, oldData: any | WithTransfer, obj: Record = {}) { + const promiseId = nanoid(); + const { data, transfer } = new WithTransfer(oldData); + // console.log(window.origin, "RPC WITH", promiseId, type, data, transfer); + this.post(new WithTransfer({ type, data, promiseId, ...obj }, transfer)); + const result = await this.promiseMap.consume(promiseId); + // console.log(window.origin, "RPC WAS ALL GOOD!", promiseId, type, result); + + if (!result.error) return result.reply; + throw result.error; + } + + remainingMessages = new Map(); + currentOrderReception: bigint = 0n; + + originMatch(evt: MessageEvent) { + if (evt.origin === "" && this.source instanceof MessagePort) return true; + + if (this.origin === '*') return true; + if (this.origin === '/') return evt.origin === window.origin; + return evt.origin === this.origin; + } + + async orderedDecoder(evt: MessageEvent) { + if (!this.originMatch(evt)) return; + + const { data: { messages, currentOrder } } = evt; + // this.remainingMessages.set(currentOrder, messages); + + // while (this.remainingMessages.has(this.currentOrderReception)) { + // const messages = this.remainingMessages.get(this.currentOrderReception)!; + // this.remainingMessages.delete(this.currentOrderReception); + // this.currentOrderReception += 1n; + + // Used to be await Promise.all. + // Trying to prevent deadlock here... + messages.map(async (message: { data: any, type: string, error?: string, promiseId?: string, componentHandle?: string }) => { + const { type, promiseId } = message; + + if (type === "reply") { + this.promiseMap.resolve(promiseId!, message); + if (message.error) throw message.error; + return; + } + + const handler = this.handlers.find((v) => type in v); + + if (!handler) { + console.error("Client attempted", type, "which was not impl'd"); + if (promiseId) this.post({ type: "reply", error: new Error("No such function"), promiseId }); + return; + } + + try { + const result = new WithTransfer(await handler[type](message, { ipc: this, event: evt })); + + if (promiseId) { + this.post(new WithTransfer({ type: "reply", reply: result.data, promiseId }, result.transfer)); + } + } catch (e) { + console.error((e as Error).toString()); + this.post({ type: "reply", error: e, promiseId }); + } + }); + // } + } +} diff --git a/src/modules/.common/classes/resolver.ts b/src/modules/.common/classes/resolver.ts new file mode 100644 index 0000000..0382494 --- /dev/null +++ b/src/modules/.common/classes/resolver.ts @@ -0,0 +1,57 @@ +import { parentIpc } from "../parentIpc.js"; +import { RemoteModule } from "./module.js"; +import z from "zod"; + +export class Resolver { + static async getDependencyModule(dependency: string) { + const result = await parentIpc.rpc("getDependency", { dependency }); + const value = z.object({ + port: z.instanceof(MessagePort).optional(), + id: z.string(), + }).parse(result); + + return await RemoteModule.getOrCreate(value.port, value.id); + } + static async getDependency(dependency: string) { + return (await this.getDependencyModule(dependency)).localHandle; + } + static async getModuleByToken(opaqueToken: string) { + const result = await parentIpc.rpc("getModuleByToken", { opaqueToken }); + const value = z.object({ + port: z.instanceof(MessagePort).optional(), + id: z.string(), + }).parse(result); + + return await RemoteModule.getOrCreate(value.port, value.id); + } + static async getByToken(token: string) { + return (await this.getModuleByToken(token)).localHandle; + } + static async summon(id: string, args: any[] = []) { + if (id.split('.').length !== 2) throw new Error("Malformed component identifier"); + const [schema, component] = id.split('.'); + + return (await this.getDependency(schema)).get(component).create(...args); + } + + static async getAllDependency(dependency: string) { + const result = await parentIpc.rpc("getAllDependency", { dependency }); + const handles = z.array(z.object({ + port: z.instanceof(MessagePort).optional(), + id: z.string(), + })).parse(result); + return await Promise.all( + handles.map( + async (handle) => + (await RemoteModule.getOrCreate(handle.port, handle.id)) + .localHandle + ) + ); + } + static async summonAll(id: string, args: any[] = []) { + if (id.split('.').length !== 2) throw new Error("Malformed component identifier"); + const [schema, component] = id.split('.'); + + return (await this.getDependency(schema)).get(component).create(args); + } +} diff --git a/src/modules/.common/classes/sharedDomDummy.ts b/src/modules/.common/classes/sharedDomDummy.ts new file mode 100644 index 0000000..d54e2f4 --- /dev/null +++ b/src/modules/.common/classes/sharedDomDummy.ts @@ -0,0 +1,51 @@ +import { parentIpc } from "../parentIpc"; +import { OrderedPeer } from "./orderedPeer"; +import { NodeRegistry, SharedDOM } from "./sharedDomRemote"; + +export class DummyNodeRegistry { + static registryPerSource = new WeakMap(); + + static registryOf(source: OrderedPeer) { + return this.registryPerSource.get(source); + } + static setRegistry(source: OrderedPeer, nr: DummyNodeRegistry) { + if (DummyNodeRegistry.registryPerSource.has(source)) throw new Error("Only one NodeRegistry per window may exist"); + return this.registryPerSource.set(source, nr); + } + + nodeToId = new WeakMap(); + idToNode = new Map>(); + + source: OrderedPeer; + + constructor(source: OrderedPeer) { + this.source = source; + } + + hasNode(node: Node) { + return this.nodeToId.has(node); + } + async getNode(id: string) { + if (this.idToNode.has(id)) { + const result = this.idToNode.get(id)!.deref(); + if (result) return result; + } + + const node = document.createElement("div"); + + const actualId = await SharedDOM.initOrGet(node); + await parentIpc.rpc("containForeign", { + id: actualId, + toId: id, + toOpaqueToken: this.source.opaqueToken, + }); + + this.nodeToId.set(node, id); + this.idToNode.set(id, new WeakRef(node)); + return node; + } + + getId(node: Node) { + return this.nodeToId.get(node); + } +} diff --git a/src/modules/.common/classes/sharedDomRemote.ts b/src/modules/.common/classes/sharedDomRemote.ts new file mode 100644 index 0000000..96f0a60 --- /dev/null +++ b/src/modules/.common/classes/sharedDomRemote.ts @@ -0,0 +1,192 @@ +import { parentIpc } from "../parentIpc.js"; +import { handlersMap } from "../utils/nodeEventListener.js"; +import { nanoid } from "../utils/utils.js"; + +export class NodeRegistry { + static nodeToId = new WeakMap(); + static idToNode = new Map>(); + + static getNode(id: string) { + return this.idToNode.get(id)?.deref(); + } + + static hasNode(node: Node) { + return this.nodeToId.has(node); + } + + static getId(node: Node): string { + let id = this.nodeToId.get(node); + if (!id) { + id = nanoid(); + this.nodeToId.set(node, id); + this.idToNode.set(id, new WeakRef(node)); + } + return id; + } +} + +function serializeEvents(node: Node) { + return [...(handlersMap.get(node)?.keys() ?? [])]; +} + +function serializeNode(node: Node) { + switch (node.nodeType) { + case Node.ELEMENT_NODE: { + const el = node as Element; + + // console.log(el.cloneNode(true), el); + + return { + kind: "element", + tagName: el.tagName, + attributes: Array.from(el.attributes).map(a => [ + a.namespaceURI, + a.name, + a.value, + ]), + children: Array.from(el.childNodes) + .map(function (this: typeof NodeRegistry, v: Node) { + return NodeRegistry.getId(v); + }.bind(NodeRegistry)), + content: (el as HTMLTemplateElement).content && NodeRegistry.getId((el as HTMLTemplateElement).content), + }; + } + + case Node.TEXT_NODE: + return { + kind: "text", + content: node.nodeValue, + }; + + case Node.DOCUMENT_FRAGMENT_NODE: + const el = node as DocumentFragment; + return { + kind: "document_fragment", + children: Array.from(el.childNodes) + .map(function (this: typeof NodeRegistry, v: Node) { + return NodeRegistry.getId(v); + }.bind(NodeRegistry)), + }; + + default: + return { + kind: "arbitrary", + nodeType: node.nodeType, + }; + } +} + +class MutationDispatcher { + private static observers = new WeakMap(); + private static handle(mutations: MutationRecord[]) { + for (const m of mutations) { + SharedDOM.handleMutation(m); + } + } + + static observe(root: Node) { + const observer = new MutationObserver(this.handle); + observer.observe(root, { + subtree: true, + attributes: true, + childList: true, + characterData: true, + }); + this.observers.set(root, observer); + } + + static disconnect(root: Node) { + this.observers.get(root)?.disconnect(); + this.observers.delete(root); + } +} + +export class SharedDOM { + static finReg = new FinalizationRegistry((heldValue) => { + parentIpc.rpc("deleteNode", { id: heldValue }); + }); + + static async initOrGet(root: Node) { + if (NodeRegistry.hasNode(root)) return NodeRegistry.getId(root); + await this.init(root); + return NodeRegistry.getId(root); + } + + static async init(root: Node) { + await this.registerSubtree(root); + MutationDispatcher.observe(root); + } + + static async registerSubtree(node: Node) { + if (NodeRegistry.hasNode(node)) { + // We presuppose it will be registered again by init + return MutationDispatcher.disconnect(node); + } + + const id = NodeRegistry.getId(node); + if (node instanceof HTMLTemplateElement) await this.registerSubtree(node.content); + + await Promise.all(Array.from(node.childNodes).map((n: Node) => this.registerSubtree(n))); + + await parentIpc.rpc("createNode", { + id: id, + payload: serializeNode(node), + events: serializeEvents(node), + }); + this.finReg.register(node, id); + } + + static async handleMutation(m: MutationRecord) { + const targetId = NodeRegistry.getId(m.target); + + switch (m.type) { + case "attributes": + // console.log(m); + parentIpc.post({ + type: "changeAttribute", + data: { + target: targetId, + name: m.attributeName!, + namespace: m.attributeNamespace, + value: (m.target as Element).getAttributeNS(m.attributeNamespace, m.attributeName!), + }, + }); + break; + + case "childList": + if (m.addedNodes.length) { + const ids = await Promise.all(Array.from(m.addedNodes).map(SharedDOM.initOrGet.bind(SharedDOM))); + parentIpc.post({ + type: "addNodes", + data: { + target: targetId, + added: ids, + before: m.nextSibling && NodeRegistry.getId(m.nextSibling), + }, + }); + } + + if (m.removedNodes.length) { + const ids = await Promise.all(Array.from(m.removedNodes).map(SharedDOM.initOrGet.bind(SharedDOM))); + parentIpc.post({ + type: "removeNodes", + data: { + target: targetId, + removed: ids, + }, + }); + } + break; + + case "characterData": + parentIpc.post({ + type: "characterData", + data: { + target: targetId, + value: m.target.nodeValue, + }, + }); + break; + } + } +} diff --git a/src/modules/.common/classes/withTransfer.ts b/src/modules/.common/classes/withTransfer.ts new file mode 100644 index 0000000..e9dbd3c --- /dev/null +++ b/src/modules/.common/classes/withTransfer.ts @@ -0,0 +1,17 @@ + +export class WithTransfer { + data: any; + transfer: any[]; + constructor(data: any, transfer: any[] = []) { + if (data instanceof WithTransfer) { + this.data = data.data; + this.transfer = [...data.transfer, ...transfer]; + } else { + this.data = data; + this.transfer = transfer; + } + } + clone() { + return new WithTransfer(this.data, this.transfer); + } +} diff --git a/src/modules/.common/index.ts b/src/modules/.common/index.ts new file mode 100644 index 0000000..7606fe2 --- /dev/null +++ b/src/modules/.common/index.ts @@ -0,0 +1,44 @@ +// NOTES FOR FUTURE USE +// TODO: TURN INTO README +// - Element lifetimes are handled by the client (this, here!) +// Because, them being dereferenced implied it's not ref'able +// through the DOM tree + +import { OrderedPeer } from "./classes/orderedPeer.js"; +import { patchAllEvents } from "./utils/nodeEventListener.js"; +patchAllEvents(); +OrderedPeer.registerIpcListener(); + +export { Component } from "./classes/component.js"; +export { componentList } from "./classes/componentList.js" +export { Resolver } from "./classes/resolver.js"; + +const constrainLookup = new ResizeObserver((entries) => { + requestAnimationFrame(() => { + for (const entry of entries) { + + + const targetRect = entry.target.getBoundingClientRect(); + + targetRect.width, targetRect.height; + } + }); +}); + +export interface ElementSetupOptions { + constrained: boolean; +} +export function setupElement(elt: Element, options: Partial = {}) { + const sanityWrapper = document.createElement("div"); + + sanityWrapper.append(elt); + sanityWrapper.style.position = "absolute"; + sanityWrapper.style.top = "0"; + sanityWrapper.style.left = "0"; + + document.body.append(sanityWrapper); + + if (options.constrained) { + elt.setAttribute("data-constrained", ""); + } +} diff --git a/src/modules/.common/ipcHandlers/cathodiqueConsumer.ts b/src/modules/.common/ipcHandlers/cathodiqueConsumer.ts new file mode 100644 index 0000000..b5a2d09 --- /dev/null +++ b/src/modules/.common/ipcHandlers/cathodiqueConsumer.ts @@ -0,0 +1,48 @@ +import z from "zod"; +import { HandlerContext } from "../utils/types.js"; +import { RemoteModule } from "../classes/module.js"; +import { unwrapValue, WrappedValue, zodWrappedValue } from "../utils/wrap.js"; +import { Latch } from "../classes/latch.js"; + +export class CathodiqueAvailableComponentsHandler { + [k: string]: (arg: Record, ctx: HandlerContext) => any; + + #availableComponents: Latch; + + constructor(availableComponentsLatch: Latch) { + this.#availableComponents = availableComponentsLatch; + } + + moduleReady(args: Record) { + return this.#moduleReady(z.object({ + data: z.object({ componentList: z.array(z.string()) }), + }).parse(args)); + } + async #moduleReady({ data }: { data: { componentList: string[] } }) { + this.#availableComponents.resolve?.(data.componentList); + } +} + +export class CathodiqueConsumerHandler { + [k: string]: (arg: Record, ctx: HandlerContext) => any; + + #module: RemoteModule; + + constructor(module: RemoteModule) { + this.#module = module; + } + + emitEvent(arg: Record) { + return this.#emitEvent(z.object({ + data: z.object({ + eventName: z.string(), + componentId: z.string(), + args: z.array(zodWrappedValue), + }), + }).parse(arg)); + } + async #emitEvent({ data }: { data: { eventName: string, componentId: string, args: WrappedValue[] } }) { + const unwrappedArgs = await Promise.all(data.args.map((v) => unwrapValue(v, this.#module.peer))); + this.#module.getInstanceProxy(data.componentId)?.emit(data.eventName, ...unwrappedArgs); + } +}; diff --git a/src/modules/.common/ipcHandlers/cathodiqueProvider.ts b/src/modules/.common/ipcHandlers/cathodiqueProvider.ts new file mode 100644 index 0000000..4c656dd --- /dev/null +++ b/src/modules/.common/ipcHandlers/cathodiqueProvider.ts @@ -0,0 +1,176 @@ +import z from "zod"; +import { componentList, ComponentList } from "../classes/componentList.js"; +import { HandlerContext } from "../utils/types.js"; +import { unwrapValue, WrappedValue, wrapValue, zodWrappedValue } from "../utils/wrap.js"; +import { Component } from "../classes/component.js"; +import { ShouldHaveBeenZodError, stringStartsWithDollar } from "../utils/utils.js"; +import { parentIpc } from "../parentIpc.js"; +import { OrderedPeer } from "../classes/orderedPeer.js"; + +// DISTINCTIONS WITH HOST +// A single module has a single componentList +// So componentList need be static + +// A single module determines its own IDs thus makes ID attacks impossible +// So componentInstances need be static + +// TODO: Manage lifecycle of component + +export class CathodiqueProviderHandler { + [k: string]: (arg: Record, ctx: HandlerContext) => any; + + static #componentList: ComponentList = componentList; + + #peer: OrderedPeer; + constructor(peer: OrderedPeer) { + this.#peer = peer; + + this.#init(); + } + async #init() { + await componentList.ready; + await this.#peer.rpc("moduleReady", { + componentList: [...componentList.componentClasses.keys()], + }); + } + + createInstance(arg: Record) { + return this.#createInstance(z.object({ + data: z.object({ + className: z.string().refine(CathodiqueProviderHandler.#componentList.has + .bind(CathodiqueProviderHandler.#componentList)), + args: z.array(zodWrappedValue), + }), + }).parse(arg)); + } + async #createInstance({ data }: { data: { className: string, args: WrappedValue[] } }) { + // alert(1); + const ClassObj = CathodiqueProviderHandler.#componentList.get(data.className); + if (!ClassObj) throw new ShouldHaveBeenZodError(); + + const unwrapped = await Promise.all(data.args.map((v: WrappedValue) => unwrapValue(v, this.#peer))); + const pctx = {}; + + const componentInstance = (await ClassObj.create(pctx, ...unwrapped)) as Component; + + await componentInstance.init(); + + return componentInstance.componentId; + } + + instanceExists(arg: Record) { + return this.#instanceExists(z.object({ + data: z.object({ + componentId: z.string(), + }), + }).parse(arg)); + } + #instanceExists({ data }: { data: { componentId: string } }) { + return componentList.componentInstances.has(data.componentId); + } + + getInstanceData(arg: Record) { + return this.#getInstanceData(z.object({ + data: z.object({ + componentId: z.string(), + }), + }).parse(arg)); + } + #getInstanceData({ data }: { data: { componentId: string } }) { + const instance = componentList.componentInstances.get(data.componentId); + return instance && { + componentName: componentList.componentTypeOf(instance), + }; + } + + getProperty(arg: Record) { + return this.#getProperty(z.object({ + data: z.object({ + propertyName: z.string().startsWith("$"), + componentId: z.string().refine(componentList.instanceExists + .bind(componentList)), + }), + }).parse(arg)); + } + async #getProperty({ data }: { data: { propertyName: string; componentId: string } }) { + const component = componentList.componentInstances.get(data.componentId); + if (!component) throw new ShouldHaveBeenZodError(); + + // Zod... + const propertyName = data.propertyName; + if (!stringStartsWithDollar(propertyName)) throw new ShouldHaveBeenZodError(); + + const value = component[propertyName]; + + return wrapValue(value); + } + + callProperty(arg: Record) { + return this.#callProperty(z.object({ + data: z.object({ + methodName: z.string().startsWith('$'), + arguments: z.array(z.any()), + componentId: z.string().refine(componentList.instanceExists + .bind(componentList)), + }), + }).parse(arg)); + } + async #callProperty({ data }: { + data: { + methodName: string; + arguments: any[]; + componentId: string; + }; + }) { + const component = componentList.componentInstances.get(data.componentId)!; + + // Zod... + const methodName = data.methodName; + if (!stringStartsWithDollar(methodName)) throw new ShouldHaveBeenZodError(); + + const value = await component?.[methodName]?.(...data.arguments); + + return wrapValue(value); + } + + listenToEvent(arg: Record) { + return this.#listenToEvent(z.object({ + data: z.object({ + eventName: z.string(), + componentId: z.string(), + }), + }).parse(arg)); + } + async #listenToEvent({ data }: { + data: { + eventName: string; + componentId: string; + }; + }) { + const component = componentList.componentInstances.get(data.componentId); + if (!component) throw new ShouldHaveBeenZodError(); + + component.listenFor(data.eventName, this.#peer ?? parentIpc); + } + + unlistenToEvent(arg: Record) { + return this.#unlistenToEvent(z.object({ + data: z.object({ + eventName: z.string(), + componentId: z.string().refine(componentList.instanceExists + .bind(componentList)), + }), + }).parse(arg)); + } + async #unlistenToEvent({ data }: { + data: { + eventName: string; + componentId: string; + }; + }) { + const component = componentList.componentInstances.get(data.componentId); + if (!component) throw new ShouldHaveBeenZodError(); + + component.unlistenFor(data.eventName, this.#peer ?? parentIpc); + } +}; diff --git a/src/modules/.common/ipcHandlers/cathodiqueRemote.ts b/src/modules/.common/ipcHandlers/cathodiqueRemote.ts new file mode 100644 index 0000000..59bd81f --- /dev/null +++ b/src/modules/.common/ipcHandlers/cathodiqueRemote.ts @@ -0,0 +1,47 @@ +import z from "zod"; +import { RemoteModule } from "../classes/module.js"; +import { OrderedPeer } from "../classes/orderedPeer.js"; +import { HandlerContext } from "../utils/types.js"; +import { CathodiqueProviderHandler } from "./cathodiqueProvider.js"; +import { Latch } from "../classes/latch.js"; + +const moduleIdLatch = new Latch(); +export const moduleId = moduleIdLatch.promise; + +export class CathodiqueRemoteHandler { + static instance: CathodiqueRemoteHandler; + [k: string]: (arg: Record, ctx: HandlerContext) => any; + + constructor() { + if (CathodiqueRemoteHandler.instance) throw new Error("Remote is a singleton."); + CathodiqueRemoteHandler.instance = this; + } + + moduleId(arg: Record) { + return this.#moduleId(z.object({ + data: z.object({ + moduleId: z.string(), + }), + }).parse(arg)); + } + #moduleId({ data }: { data: { moduleId: string } }) { + moduleIdLatch.resolve!(data.moduleId); + } + + connectAsProvider(arg: Record) { + return this.#connectAsProvider(z.object({ + data: z.object({ + port: z.instanceof(MessagePort), + opaqueToken: z.string().optional(), + }), + }).parse(arg)); + } + async #connectAsProvider({ data }: { data: { port: MessagePort, opaqueToken?: string } }) { + // const module = await RemoteModule.getOrCreate(data.port, data.moduleToken); + // TODO Expose self as provider + + // Note: We can trust this messageport (unless vuln) because the parent is trusted to be raytube + const peer = new OrderedPeer(data.port, data.opaqueToken, "*"); + peer.addHandler(new CathodiqueProviderHandler(peer) as any); + } +} diff --git a/src/modules/.common/ipcHandlers/domRemote.ts b/src/modules/.common/ipcHandlers/domRemote.ts new file mode 100644 index 0000000..bdde89a --- /dev/null +++ b/src/modules/.common/ipcHandlers/domRemote.ts @@ -0,0 +1,78 @@ +import z from "zod"; +import { NodeRegistry } from "../classes/sharedDomRemote.js"; +import { EventFromIpc, HandlerContext, zodEventFromIpc } from "../utils/types.js"; +import { EventAddedEvent, EventRemovedEvent, nodeEventEvents } from "../utils/nodeEventListener.js"; +import { parentIpc } from "../parentIpc.js"; + +export class DOMRemoteHandler { + [k: string]: (arg: Record, ctx: HandlerContext) => any; + + constructor() { + nodeEventEvents.addEventListener("eventAdded", async (evt: Event) => { + const evtTyped = evt as EventAddedEvent; + + if (!NodeRegistry.hasNode(evtTyped.target)) return; + + try { + await parentIpc.rpc("registerEvent", { + target: NodeRegistry.getId(evtTyped.target), + addedEvent: evtTyped.addedEvent, + }); + } catch { } + }); + nodeEventEvents.addEventListener("eventRemoved", async (evt: Event) => { + const evtTyped = evt as EventRemovedEvent; + + if (!NodeRegistry.hasNode(evtTyped.target)) return; + + try { + await parentIpc.rpc("unregisterEvent", { + target: NodeRegistry.getId(evtTyped.target), + addedEvent: evtTyped.removedEvent, + }); + } catch { } + }); + } + + #deserializeEvent(evtData: EventFromIpc): Event { + if (!evtData.className.endsWith("Event")) throw new Error("Constructor name must be an event"); + const EventClassObj = globalThis[evtData.className as keyof typeof globalThis] as typeof Event; + + const newValues: Record = {}; + for (const [key, value] of Object.entries(evtData.values)) { + if (value === undefined || ("nodeId" in value && value.nodeId === undefined)) { + continue; + } + + if ("nodeId" in value) { + newValues[key] = NodeRegistry.getNode(value.nodeId); + continue; + } + + newValues[key] = value.value; + } + + newValues.bubbles = false; + + return new EventClassObj(evtData.type, newValues); + } + + domEmitEvent(arg: Record) { + return this.#domEmitEvent(z.object({ + data: z.object({ + target: z.string(), + event: zodEventFromIpc, + }), + }).parse(arg)); + } + #domEmitEvent({ data }: { data: { target: string, event: EventFromIpc } }) { + const element = NodeRegistry.getNode(data.target); + + if (!element) return console.error(`Tried to emit event ${data.event} to inexistent element ${data.target}`); + + console.log(data); + console.log(Date.now()); + + element.dispatchEvent(this.#deserializeEvent(data.event)); + } +}; diff --git a/src/modules/.common/parentIpc.ts b/src/modules/.common/parentIpc.ts new file mode 100644 index 0000000..949e608 --- /dev/null +++ b/src/modules/.common/parentIpc.ts @@ -0,0 +1,15 @@ +import { OrderedPeer } from "./classes/orderedPeer.js"; +import { CathodiqueProviderHandler } from "./ipcHandlers/cathodiqueProvider.js"; +import { CathodiqueRemoteHandler } from "./ipcHandlers/cathodiqueRemote.js"; +import { DOMRemoteHandler } from "./ipcHandlers/domRemote.js"; +import { parentOrigin } from "./utils/utils.js"; + +// We are removing the * for now because +// we would rather trust the parent. +// TODO expose public handle with API. +const parentIpc = new OrderedPeer(window.parent, undefined, parentOrigin); +parentIpc.addHandler(new CathodiqueRemoteHandler()); +parentIpc.addHandler(new CathodiqueProviderHandler(parentIpc)); +parentIpc.addHandler(new DOMRemoteHandler()); + +export { parentIpc }; diff --git a/src/modules/.common/setup.ts b/src/modules/.common/setup.ts new file mode 100644 index 0000000..0ef4d1e --- /dev/null +++ b/src/modules/.common/setup.ts @@ -0,0 +1,2 @@ +import { patchAllEvents } from "./utils/nodeEventListener.js"; +patchAllEvents(); diff --git a/src/modules/.common/utils/nodeEventListener.ts b/src/modules/.common/utils/nodeEventListener.ts new file mode 100644 index 0000000..290cc85 --- /dev/null +++ b/src/modules/.common/utils/nodeEventListener.ts @@ -0,0 +1,204 @@ +// This file tracks lifetimes of event listeners so the host only forwards the relevant events. +// AI-gen'd. Sorry, too much stuff (capture, once), couldn't be bothered. + +export const handlersMap = new WeakMap< + Node, + Map< + string, + Map<((...args: any[]) => any) | { handleEvent: (...args: any[]) => any }, number> + > +>(); + +export const nodeEventEvents = new EventTarget(); + +export class EventAddedEvent extends Event { + addedEvent: string; + target: Node; + + constructor(addedEvent: string, target: Node) { + super('eventAdded'); + this.addedEvent = addedEvent; + this.target = target; + } +} +export class EventRemovedEvent extends Event { + removedEvent: string; + target: Node; + + constructor(removedEvent: string, target: Node) { + super('eventRemoved'); + this.removedEvent = removedEvent; + this.target = target; + } +} + +export const getEventsRegisteredOnNode = (node: Node) => [...(handlersMap.get(node)?.keys() ?? [])]; + +function patchNodeEventListeners() { + // Map original listener → wrapped once listener per capture + const onceWrappers = new WeakMap< + ((...args: any[]) => any) | { handleEvent: (...args: any[]) => any }, + [(((...a: any[]) => any) | undefined), (((...a: any[]) => any) | undefined)] + >(); + + /** + * Bitfield explanation: + * 1 → registered with capture: false + * 2 → registered with capture: true + * 3 → registered with both capture values + */ + Node.prototype.addEventListener = function ( + type: string, + listener: ((...args: any[]) => any) | { handleEvent: (...args: any[]) => any }, + options?: boolean | AddEventListenerOptions + ) { + const capture = typeof options === "boolean" ? options : !!options?.capture; + const once = typeof options === "object" && !!options?.once; + + let typeMap = handlersMap.get(this); + if (!typeMap) { + typeMap = new Map(); + handlersMap.set(this, typeMap); + } + + let listeners = typeMap.get(type); + if (!listeners) { + listeners = new Map(); + typeMap.set(type, listeners); + nodeEventEvents.dispatchEvent(new EventAddedEvent(type, this)); + } + + const existingBit = listeners.get(listener) ?? 0; + const bit = capture ? 2 : 1; + + // Update bitfield + listeners.set(listener, existingBit | bit); + + // Once priority: first registration for this capture wins + let effectiveOnce = once; + if (existingBit & bit) effectiveOnce = false; + + let actualListener = listener; + + if (effectiveOnce) { + let captureMap = onceWrappers.get(listener); + if (!captureMap) { + captureMap = [undefined, undefined]; + onceWrappers.set(listener, captureMap); + } + + if (!captureMap[+capture]) { + actualListener = (...args: any[]) => { + try { + if (typeof listener === "function") listener.apply(this, args); + else listener.handleEvent.apply(listener, args); + } finally { + Node.prototype.removeEventListener.call(this, type, listener, capture); + captureMap[+capture] = undefined; + } + }; + captureMap[+capture] = actualListener; + } else { + actualListener = captureMap[+capture]!; // ... + } + } + + return EventTarget.prototype.addEventListener.call(this, type, actualListener as any, options); + }; + + Node.prototype.removeEventListener = function ( + type: string, + listener: ((...args: any[]) => any) | { handleEvent: (...args: any[]) => any }, + options?: boolean | EventListenerOptions + ) { + const capture = typeof options === "boolean" ? options : !!options?.capture; + + let typeMap = handlersMap.get(this); + if (typeMap) { + let listeners = typeMap.get(type); + if (listeners) { + const existingBit = listeners.get(listener); + + if (existingBit) { + const bit = capture ? 2 : 1; + const newBit = existingBit & ~bit; // Remove capture from bitfield + + if (newBit === 0) { + listeners.delete(listener); + if (listeners.size === 0) { + typeMap.delete(type); + + nodeEventEvents.dispatchEvent(new EventRemovedEvent(type, this)); + + if (typeMap.size === 0) { + handlersMap.delete(this); + } + } + } else { + listeners.set(listener, newBit); + } + } + } + } + + const captureMap = onceWrappers.get(listener); + const actualListener = captureMap?.[+capture] ?? listener; + if (captureMap) captureMap[+capture] = undefined; + + return EventTarget.prototype.removeEventListener.call(this, type, actualListener as any, options); + }; +} + +function patchOnEvents() { + // Collect all global constructors inheriting from Node + const globalKeys = Reflect.ownKeys(window) + .filter((v) => typeof v === "string" && v[0] === v[0].toUpperCase()) // class + .filter((v) => window[v as keyof Window]) // from window + .filter((v) => Node.isPrototypeOf(window[v as keyof Window])); // extends Node + const nodeConstructors: Function[] = globalKeys.map((key) => (globalThis as any)[key]); + + for (const ctor of nodeConstructors) { + const proto = ctor.prototype; + const propNames = Object.getOwnPropertyNames(proto); + + for (const prop of propNames) { + if (!prop.startsWith("on")) continue; + + const desc = Object.getOwnPropertyDescriptor(proto, prop); + if (!desc || !desc.configurable) continue; // skip unconfigurable + + let currentListener: ((...args: any[]) => any) | null = null; + + Object.defineProperty(proto, prop, { + configurable: true, + enumerable: true, + get() { + return currentListener; + }, + set(fn: ((...args: any[]) => any) | null) { + + const node = this as Node; + + // Remove previous listener + if (currentListener) { + node.removeEventListener(prop.slice(2), currentListener); + const typeMap = handlersMap.get(node)?.get(prop.slice(2)); + if (typeMap) typeMap.delete(currentListener); + } + + currentListener = fn; + + if (fn) { + // Add new listener through patched addEventListener + node.addEventListener(prop.slice(2), fn); + } + } + }); + } + } +} + +export function patchAllEvents() { + patchNodeEventListeners(); + patchOnEvents(); +} diff --git a/src/modules/.common/utils/remoteToLocalAdapter.ts b/src/modules/.common/utils/remoteToLocalAdapter.ts new file mode 100644 index 0000000..206ee7f --- /dev/null +++ b/src/modules/.common/utils/remoteToLocalAdapter.ts @@ -0,0 +1,148 @@ +import { Component } from "../classes/component.js"; +import { ComponentListHandle } from "../classes/componentList.js"; +import { Latch, LatchState } from "../classes/latch.js"; +import { RemoteModule } from "../classes/module.js"; +import { OrderedPeer } from "../classes/orderedPeer.js"; +import { unwrapValue, wrapValue } from "./wrap.js"; +import { EventEmitter } from "events"; + +export class ComponentListProxy implements ComponentListHandle { + #module: RemoteModule; + constructor(mod: RemoteModule) { + this.#module = mod; + } + + get(componentName: string) { + const availableComponents = this.#module.availableComponents; + + if (!availableComponents.includes(componentName)) return undefined; + + return { + create: (...args: any[]) => { + return makeComponentProxy(this.#module, componentName, { args }); + } + }; + } +} + +export class ComponentInstance extends EventEmitter { + peer: OrderedPeer; + + module: RemoteModule; + + #args: any[] = []; + + componentName: string; + constructor(module: RemoteModule, componentName: string, options: { componentId: string } | { args: any[] }) { + super(); + this.module = module; + + this.peer = this.module.peer; + this.componentName = componentName; + + if ("componentId" in options) this.#cidLatch.resolve!(options.componentId); + if ("args" in options) this.#args = options.args; + + // TODO How to clean? + // Will it undo itself? Since there is no way it can emit and it's not ref'd + this.on("newListener", async function (this: ComponentInstance, evtName: string) { + if (evtName === "newListener" || evtName === "removeListener") return; + + if (this.listenerCount(evtName) === 0) { + this.peer.rpc("listenToEvent", { componentId: await this.componentId, eventName: evtName }); + } + }.bind(this)); + this.on("removeListener", async function (this: ComponentInstance, evtName: string) { + if (evtName === "newListener" || evtName === "removeListener") return; + + if (this.listenerCount(evtName) === 0) { + this.peer.rpc("unlistenToEvent", { componentId: await this.componentId, eventName: evtName }); + } + }.bind(this)); + } + + #cidLatch = new Latch(); + get componentId() { return this.#cidLatch.promise; } + + ready = new Latch(); + + async init() { + if (this.#cidLatch.getState() === LatchState.Pending) { + if (!this.module.availableComponents.includes(this.componentName)) { + throw new Error(`"${this.componentName}" not provided by module`); + } + + const componentId = await this.peer.rpc("createInstance", { + className: this.componentName, + args: await Promise.all(this.#args.map((v) => wrapValue(v))), + }); + this.#cidLatch.resolve!(componentId); + this.ready.resolve!(); + } else { + this.ready.resolve?.(); + } + } +} +export type ComponentInstanceProxy = ComponentInstance + & Partial> + & { [Component.isComponentSymbol]: true }; + +function generateCalledOrAwaited({ called, awaited }: { called: (...args: any[]) => any, awaited: () => any }) { + const result = async function (...args: any[]) { + return called(...args); + }; + result.then = async (resolve: (a: any) => void) => { + resolve(await awaited()); + }; + return result; +} + +export function makeComponentProxy(module: RemoteModule, componentName: string, options: { componentId: string } | { args: any[] }): ComponentInstanceProxy { + if ("componentId" in options && module.instanceProxyExists(options.componentId)) { + return module.getInstanceProxy(options.componentId)!; + } + + const compInst = new ComponentInstance(module, componentName, options); + compInst.init(); + + const compInstProxy = new Proxy(compInst, { + get(target, prop) { + if (prop === Component.isComponentSymbol) return true; + + if (target[prop as keyof typeof target]) return target[prop as keyof typeof target]; + + if (prop === "then") return undefined; + + return generateCalledOrAwaited({ + called: async function (...args: any[]) { + const id = await compInst.componentId; + + const val = await module.peer.rpc("callProperty", { + methodName: prop, + arguments: args.map((v) => wrapValue(v)), + componentId: id, + }); + + return await unwrapValue(val, module.peer); + }, + awaited: async () => { + const id = await compInst.componentId; + + const val = await module.peer.rpc("getProperty", { + propertyName: prop, + componentId: id, + }); + + return await unwrapValue(val, module.peer); + }, + }); + }, + }) as ComponentInstanceProxy; + + (async () => { + const cid = await compInst.componentId; + module.registerInstanceProxy(cid, compInstProxy); + })(); + + return compInstProxy; +} diff --git a/src/modules/.common/utils/types.ts b/src/modules/.common/utils/types.ts new file mode 100644 index 0000000..d56ffa8 --- /dev/null +++ b/src/modules/.common/utils/types.ts @@ -0,0 +1,59 @@ +import z from "zod"; +import { OrderedPeer } from "../classes/orderedPeer"; +import { Component, ComponentHandle } from "../classes/component"; +import { ComponentInstanceProxy } from "./remoteToLocalAdapter"; + +export interface ElementFromIpc { + kind: "element"; + tagName: string; + attributes: [string | null, string, string][]; + children: string[]; + content?: string; +} + +export interface TextNodeFromIpc { + kind: "text"; + content: string; +} + +export interface DocumentFragmentFromIpc { + kind: "document_fragment"; + children: string[]; +} + +export interface ArbitraryNodeFromIpc { + kind: "arbitrary"; + nodeType: string; +} + +export type NodeFromIpc = ElementFromIpc | TextNodeFromIpc | DocumentFragmentFromIpc | ArbitraryNodeFromIpc; + +export const zodEventFromIpc = z.object({ + className: z.string(), + type: z.string(), + values: z.record(z.string(), z.any()), +}); +export type EventFromIpc = z.output; + +export interface HandlerContext { + ipc: OrderedPeer; + event: MessageEvent; +} + +export const componentTypes = [ + "NORMAL", + "SINGLETON", + "REF_ONLY", +] as const; + +export type FactoryOf = { create(...args: U): T | Promise }; +export type ComponentFactory = FactoryOf; +export type ComponentHandleFactory = FactoryOf; +export type ComponentInstanceProxyFactory = FactoryOf; + +// Class is what it might be for users +export type ClassOf = (FactoryOf | (new (...args: U) => T)) + & { type: typeof componentTypes[number], singletonInstance?: T }; +export type ComponentClass = ClassOf; +export type ComponentHandleClass = ClassOf; +export type ComponentInstanceProxyClass = ClassOf; diff --git a/src/modules/.common/utils/utils.ts b/src/modules/.common/utils/utils.ts new file mode 100644 index 0000000..40aff94 --- /dev/null +++ b/src/modules/.common/utils/utils.ts @@ -0,0 +1,25 @@ +let alphabet = + "useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict"; + +export function nanoid(e = 21) { + let t = "", + r = crypto.getRandomValues(new Uint8Array(e)); + for (let n = 0; n < e; n++) t += alphabet[63 & r[n]]; + return t; +} + +export class ShouldHaveBeenZodError extends Error { + constructor(message?: string) { + super(message || "This error should have been caught by zod"); + } +} + +const [projectSubdomain, userSubdomain, ...hostList] = + window.location.hostname.split("."); +export { projectSubdomain, userSubdomain }; +export const host = hostList.join('.') + +export const canonicalHost = `${location.protocol}//${hostList.join(".")}:${location.port}`; +export const parentOrigin = new URLSearchParams(location.search).get("parent_origin") || canonicalHost; + +export const stringStartsWithDollar = (v: string): v is `$${string}` => v.startsWith("$"); diff --git a/src/modules/.common/utils/wrap.ts b/src/modules/.common/utils/wrap.ts new file mode 100644 index 0000000..269af88 --- /dev/null +++ b/src/modules/.common/utils/wrap.ts @@ -0,0 +1,71 @@ +import z from "zod"; +import { Component } from "../classes/component"; +import { componentList } from "../classes/componentList"; +import { SharedDOM } from "../classes/sharedDomRemote"; +import { ComponentInstanceProxy, makeComponentProxy } from "./remoteToLocalAdapter"; +import { RemoteModule } from "../classes/module"; +import { DummyNodeRegistry } from "../classes/sharedDomDummy"; +import { moduleId } from "../ipcHandlers/cathodiqueRemote"; +import { OrderedPeer } from "../classes/orderedPeer"; + +export const zodWrappedValue = z.union([ + z.object({ + type: z.literal("component"), + componentName: z.string(), + componentId: z.string(), + moduleId: z.string(), + }), + z.object({ + type: z.literal("node"), + nodeId: z.string(), + }), + z.object({ + value: z.any(), + }), +]); +export type WrappedValue = z.output; + +export async function wrapValue(value: any): Promise> { + const isComponent = (v: any): v is (Component | ComponentInstanceProxy) => v?.[Component.isComponentSymbol]; + if (isComponent(value)) { + const isRemote = "module" in value; + + return { + type: "component", + componentName: isRemote ? value.componentName : componentList.componentTypeOf(value), + componentId: isRemote ? await value.componentId : value.componentId, + moduleId: isRemote ? value.module.id : await moduleId, + }; + } + + if (value instanceof Node) { + const nodeId = await SharedDOM.initOrGet(value); + + return { + type: "node", + nodeId, + }; + } + + return { value }; +} + +export async function unwrapValue(value: any, peer: OrderedPeer) { + const wrapped = zodWrappedValue.parse(value); + + if (!("type" in wrapped)) return wrapped.value; + + switch (wrapped.type) { + case "component": + const moduleId = wrapped.moduleId; + if (!moduleId) return undefined; + const module = await RemoteModule.moduleByOpaqueToken(moduleId); + + if (!module || !(await module?.instanceExists(wrapped.componentId))) { + return undefined; + } + return makeComponentProxy(module, wrapped.componentName, { componentId: wrapped.componentId }); + case "node": + return await DummyNodeRegistry.registryOf(peer)!.getNode(value.nodeId); + } +} diff --git a/src/modules/cathodique.windowmanager/main.ts b/src/modules/cathodique.windowmanager/main.ts new file mode 100644 index 0000000..06c442d --- /dev/null +++ b/src/modules/cathodique.windowmanager/main.ts @@ -0,0 +1,44 @@ +import type { ComponentListHandle } from "../.common/classes/componentList.js"; +import type { ComponentHandle } from "../.common/classes/component.js"; +import type { ComponentInstanceProxy } from "../.common/utils/remoteToLocalAdapter.js"; +import { Component, componentList, Resolver } from "../.common/index.js"; + +class WindowManager extends Component { + $output = (document.querySelector("window_manager")! as HTMLTemplateElement) + .content.cloneNode(true) as DocumentFragment; + + #windowFrameModule; + #windowRegistry; + windowFrames = new Map(); // Window to WindowFrame + + static async create() { + const windowFrameModule = await Resolver.getDependency("WindowFrame"); + const windowRegistry = await Resolver.summon("Cathodique::Window.WindowRegistry"); + + return new WindowManager(windowFrameModule, windowRegistry); + } + + constructor(windowFrameModule: ComponentListHandle, windowRegistry: ComponentInstanceProxy) { + super(); + + // windowRegistry.$hello(); + + this.#windowFrameModule = windowFrameModule; + this.#windowRegistry = windowRegistry; + + this.#windowRegistry.on("newWindow", this.newWindow); + } + async newWindow(window: ComponentInstanceProxy) { + alert("aaa"); + + // window is a window component :3 + const WindowFrame = this.#windowFrameModule.get("WindowFrame")!; + const windowFrame = await WindowFrame.create(window); + this.windowFrames.set(window, windowFrame); + + this.$output.append(await windowFrame.$output); + } +} + +componentList.register("WindowFrame", WindowManager); +// componentList.rea diff --git a/src/modules/cathodique.windowmanager/module.html b/src/modules/cathodique.windowmanager/module.html new file mode 100644 index 0000000..3f3b77c --- /dev/null +++ b/src/modules/cathodique.windowmanager/module.html @@ -0,0 +1,81 @@ + + + + + + Test + + + +
+

Hello World

+
+ + + diff --git a/src/modules/immjs.macos-aqua-windowframe/module.html b/src/modules/immjs.macos-aqua-windowframe/module.html new file mode 100644 index 0000000..4a2f55d --- /dev/null +++ b/src/modules/immjs.macos-aqua-windowframe/module.html @@ -0,0 +1,219 @@ + + + + + + Test + + + +
+ +
+
+
+
+
+
+
Hello World +
+
+
+
+
+ + + diff --git a/src/renderer/classes/handlers/dom/base.ts b/src/renderer/classes/handlers/dom/base.ts new file mode 100644 index 0000000..6fa516b --- /dev/null +++ b/src/renderer/classes/handlers/dom/base.ts @@ -0,0 +1,29 @@ +import { BaseObject } from "@cathodique/wl-serv-high/objects"; + +export class BaseDom { + wl: From; + dom: To; + constructor(wl: From, dom: To) { + this.wl = wl; + this.dom = dom; + + this.wl.once("beforeWlDestroy", () => { + this.destroy(); + }); + } + + unmount: (() => any)[] = [ + () => { + this.dom.remove(); + }, + ]; + onUnmount(f: (this: this) => any) { + this.unmount.push(f.bind(this)); + } + + destroy() { + type yo = this; + + this.unmount.forEach(function (this: yo, v: typeof this.unmount[number]) { v.bind(this)(); }.bind(this)); + } +} diff --git a/src/renderer/classes/handlers/dom/popup.ts b/src/renderer/classes/handlers/dom/popup.ts new file mode 100644 index 0000000..4dd8bc9 --- /dev/null +++ b/src/renderer/classes/handlers/dom/popup.ts @@ -0,0 +1,17 @@ +import { BaseDom } from "./base.js"; +import { XdgPopup } from "@cathodique/wl-serv-high/objects"; +import { wlToObj } from "../handlers.js"; + +// Toplevels: context for other subsurfaces to appear in +// Popups are the same +// TODO: (to reconsider) Should Toplevels and Popups have a mother class? +export class PopupDom extends BaseDom { + constructor(wl: XdgPopup) { + super(wl, document.createElement('div')); + wlToObj.set(wl, this); + } + + // async init() { + // const posOfPopup = this.wl.meta.positioner.positionWithinOutputAndStruts(); + // } +} diff --git a/src/renderer/classes/handlers/dom/subsurface.ts b/src/renderer/classes/handlers/dom/subsurface.ts new file mode 100644 index 0000000..a36b5e9 --- /dev/null +++ b/src/renderer/classes/handlers/dom/subsurface.ts @@ -0,0 +1,81 @@ +import { WlSubsurface } from "@cathodique/wl-serv-high/objects"; +import { WlSurface } from "@cathodique/wl-serv-high/objects"; +import { wlToObj } from "../handlers.js"; +import { BaseDom } from "./base.js"; + +export class SubsurfaceDom extends BaseDom { + get surfaceDom() { return wlToObj.get(this.wl.meta.surface)! }; + get parentSurfaceDom() { return wlToObj.get(this.wl.meta.parent)! }; + + constructor(wl: WlSubsurface) { + super(wl, document.createElement("div")); + this.dom.append(this.surfaceDom.dom); + this.dom.style.position = "absolute"; + + wlToObj.set(wl, this); + } + + init () { + // Subsurface shenanigans + // TODO: Apply on commit + this.wl.on("wlPlaceAbove", this.placeAbove.bind(this)); + this.placeAbove({ sibling: this.wl.meta.parent }); + + this.wl.on("wlPlaceBelow", this.placeBelow.bind(this)); + + this.wl.on('wlSetPosition', ({ y, x }: { y: number, x: number }) => { + this.dom.style.top = `${y}px`; + this.dom.style.left = `${x}px`; + }); + } + + get kid() { + const kidWl = this.wl.meta.surface; + const kidDom = wlToObj.get(kidWl); + + if (!kidDom) throw new Error("DOM of surface does not exist"); + return kidDom; + } + + placeAbove({ sibling: other }: { sibling: WlSurface }) { + // console.log(other, this.wl.getRelationWith(other)); + switch (this.wl.getRelationWith(other)) { + case "sibling": { + const sibling = wlToObj.get(other)!; + const commonParentWl = other.subsurface!.meta.parent; + const commonParent = wlToObj.get(commonParentWl)!; + + commonParent.dom.insertBefore(this.dom, sibling.dom); + break; + } + case "parent": { + const parent = wlToObj.get(other)!; + + parent.dom.insertBefore(this.dom, parent.canvas); + break; + } + default: + // Already handled by wl-serv-high + } + } + placeBelow({ sibling: other }: { sibling: WlSurface }) { + switch (this.wl.getRelationWith(other)) { + case "sibling": { + const sibling = wlToObj.get(other)!; + const commonParentWl = other.subsurface!.meta.parent; + const commonParent = wlToObj.get(commonParentWl)!; + + commonParent.dom.insertBefore(this.dom, sibling.dom.nextSibling); + break; + } + case "parent": { + const parent = wlToObj.get(other)!; + + parent.dom.insertBefore(this.dom, parent.canvas.nextSibling); + break; + } + default: + // Already handled by wl-serv-high + } + } +} diff --git a/src/renderer/classes/handlers/dom/surface.ts b/src/renderer/classes/handlers/dom/surface.ts new file mode 100644 index 0000000..e014e0f --- /dev/null +++ b/src/renderer/classes/handlers/dom/surface.ts @@ -0,0 +1,150 @@ +import { WlSurface } from "@cathodique/wl-serv-high/objects"; +import { Seat } from "../../wayland/seat/seat.js"; +import { BaseDom } from "./base.js"; +import { Output } from "../../wayland/output/output.js"; +import { wlToObj } from "../handlers.js"; +import { seat } from "../../../wayland/index.js"; +import { outputRegistry } from "../../../wayland/overlays/outputRegistryOverlay.js"; +import { isIntersecting } from "../../../utils/domIntersect.js"; + +export class SurfaceDom extends BaseDom { + ctx: CanvasRenderingContext2D; + canvas = document.createElement("canvas"); + constructor(wl: WlSurface) { + super(wl, document.createElement("div")); + this.dom.append(this.canvas); + + wlToObj.set(wl, this); + + this.canvas.style.position = "absolute"; + this.canvas.style.top = "0"; + this.canvas.style.left = "0"; + this.dom.style.position = "relative"; + + const ctx = this.canvas.getContext("2d"); + if (!ctx) + throw new Error( + "Failed to derive 2d context from canvas element; is anything disabled?", + ); + this.ctx = ctx; + } + + shownOnOutputs = new Set(); + + init() { + this.initDraw(); + this.initSeatMouse(seat); + this.initOutputs(); + } + initDraw() { + let lastDimensions: [number, number] = [-Infinity, -Infinity]; + const commitHandler = async function (this: SurfaceDom) { + const b = this.wl.buffer.current; + + this.canvas.style.display = b === null ? "none" : "block"; + if (b == null) return; + + if (lastDimensions[0] !== b.meta.height || lastDimensions[1] !== b.meta.width) { + this.dom.style.width = `${b.meta.width}px`; + this.dom.style.height = `${b.meta.height}px`; + this.canvas.width = b.meta.width; + this.canvas.height = b.meta.height; + lastDimensions = [b.meta.height, b.meta.width]; + } + + const currlyDamagedBuffer = this.wl.getCurrlyDammagedBuffer(); + + for (const rect of currlyDamagedBuffer) { + b.updateBufferArea(rect.y, rect.x, rect.h, rect.w) + } + const arr = new Uint8ClampedArray( + b.buffer.buffer as ArrayBuffer, + 0, + b.meta.width * b.meta.height * 4, + ); + if (arr.length > 0) { + console.log("Got Update?", currlyDamagedBuffer); + + let imageData = new ImageData(arr, b.meta.width, b.meta.height); + + for (const rect of currlyDamagedBuffer) { + const w = Math.min(rect.w, b.meta.width - rect.x); + const h = Math.min(rect.h, b.meta.height - rect.y); + this.ctx.putImageData(imageData, 0, 0, rect.x, rect.y, w, h); + } + } + }.bind(this); + + commitHandler(); + this.wl.on("update", () => commitHandler()); + } + + initSeatMouse(seat: Seat) { + // LT-TODO(multiparty): Single E.L. for each seat; + + const enter = (e: MouseEvent) => { + seat.setKeyboardFocus(this); + seat.move(e, this); + }; + this.dom.addEventListener("mouseenter", enter); + this.onUnmount(() => { this.dom.removeEventListener("mouseenter", enter) }); + + const move = (e: MouseEvent) => seat.move(e, this); + this.dom.addEventListener("mousemove", move); + this.onUnmount(() => { this.dom.removeEventListener("mousemove", move) }); + + const leave = (e: MouseEvent) => { + seat.move(e, this, true); + seat.unsetKeyboardFocus(); + }; + this.onUnmount(() => { this.dom.removeEventListener("mouseleave", leave) }); + + const mouseDown = (evt: MouseEvent) => { + if (seat.mouseFocus) + seat.mouseFocus.instances.buttonDown(Seat.mouseWebToButtonMap[evt.button]); + }; + this.dom.addEventListener("mousedown", mouseDown); + this.onUnmount(() => { this.dom.removeEventListener("mousedown", mouseDown) }); + + const mouseUp = (evt: MouseEvent) => { + if (seat.mouseFocus) + seat.mouseFocus.instances.buttonUp(Seat.mouseWebToButtonMap[evt.button]); + } + this.dom.addEventListener("mouseup", mouseUp); + this.onUnmount(() => { this.dom.removeEventListener("mouseup", mouseUp) }); + } + + unmounted: boolean = false; + updateAllOutputs() { + if (this.unmounted) return; + + if (this.wl.buffer.current == null) { + for (const shownOn of this.wl.outputs) { + this.wl.leaveOutput(shownOn.config); + } + } else { + for (const output of outputRegistry.allOutputs()) { + const intersect = isIntersecting(this.dom, output.dom); + + if (this.wl.outputs.has(output.wlOutputAuth.get(this.wl.connection)!) !== intersect) { + console.log(intersect); + + if (intersect) this.wl.enterOutput(output.config); + else this.wl.leaveOutput(output.config); + } + } + } + + requestAnimationFrame(this.updateAllOutputs.bind(this)); + } + initOutputs() { + requestAnimationFrame(this.updateAllOutputs.bind(this)); + this.onUnmount(() => { this.unmounted = false }); + } + enterOutput(output: Output) { + this.wl.enterOutput(output.config); + } + leaveOutput(output: Output) { + this.wl.leaveOutput(output.config); + } +} diff --git a/src/renderer/classes/handlers/handlers.ts b/src/renderer/classes/handlers/handlers.ts new file mode 100644 index 0000000..15b48f2 --- /dev/null +++ b/src/renderer/classes/handlers/handlers.ts @@ -0,0 +1,20 @@ +import { WlSubsurface, WlSurface, XdgPopup, XdgToplevel } from "@cathodique/wl-serv-high/objects"; +import { PopupDom } from "./dom/popup.js"; +import { ToplevelDom } from "../../host/localModules/window_toplevel.js"; +import { PolyMap } from "../../host/classes/polymap.js"; +import { SurfaceDom } from "./dom/surface.js"; +import { SubsurfaceDom } from "./dom/subsurface.js"; + +export const objectHandlers = { + 'xdg_popup': PopupDom, + 'xdg_toplevel': ToplevelDom, + 'wl_surface': SurfaceDom, + 'wl_subsurface': SubsurfaceDom, +}; + +export const wlToObj = new PolyMap< + | [XdgPopup, PopupDom] + | [XdgToplevel, ToplevelDom] + | [WlSurface, SurfaceDom] + | [WlSubsurface, SubsurfaceDom] +>(); diff --git a/src/renderer/classes/handlers/lib/toplevel_decoration_manager.ts b/src/renderer/classes/handlers/lib/toplevel_decoration_manager.ts new file mode 100644 index 0000000..b8bc7c0 --- /dev/null +++ b/src/renderer/classes/handlers/lib/toplevel_decoration_manager.ts @@ -0,0 +1,13 @@ +import { ZxdgToplevelDecorationV1 } from "@cathodique/wl-serv-high/objects"; + +export class ZxdgToplevelDecorationManager { + wl: ZxdgToplevelDecorationV1; + constructor(wl: ZxdgToplevelDecorationV1) { + this.wl = wl; + + wl.on('wlSetMode', () => { + wl.sendToplevelDecoration('server_side'); + }); + wl.sendToplevelDecoration('server_side'); + } +} diff --git a/src/renderer/classes/wayland/output/output.ts b/src/renderer/classes/wayland/output/output.ts new file mode 100644 index 0000000..a255948 --- /dev/null +++ b/src/renderer/classes/wayland/output/output.ts @@ -0,0 +1,32 @@ +import { OutputConfiguration, OutputRegistry } from "@cathodique/wl-serv-high/registries"; + +export class Output { + configToOutput = new Map(); + + wlOutputReg: OutputRegistry; + config: OutputConfiguration; + dom = document.createElement("div"); + get wlOutputAuth() { + const result = this.wlOutputReg.get(this.config); + if (!result) throw new Error(); + return result; + } + + constructor(config: OutputConfiguration, seatReg: OutputRegistry) { + this.wlOutputReg = seatReg; + this.config = config; + + this.initOutput(); + } + + initOutput() { + this.dom.style.position = "absolute"; + this.dom.style.pointerEvents = "none"; + this.dom.style.top = `${this.config.x}px`; + this.dom.style.left = `${this.config.y}px`; + this.dom.style.width = `${this.config.w}px`; + this.dom.style.height = `${this.config.h}px`; + + document.body.append(this.dom); + } +} diff --git a/src/codeToScancode.ts b/src/renderer/classes/wayland/seat/codeToScancode.ts similarity index 98% rename from src/codeToScancode.ts rename to src/renderer/classes/wayland/seat/codeToScancode.ts index 0fbc0d3..22ee4e5 100644 --- a/src/codeToScancode.ts +++ b/src/renderer/classes/wayland/seat/codeToScancode.ts @@ -1,3 +1,5 @@ +// TODO: Find a much better way.... + export const codeToScan: Record = { "Escape": 0x0009, "Digit1": 0x000A, @@ -161,4 +163,4 @@ export const codeToScan: Record = { "F23": 0x00C9, "F24": 0x00CA, "BrowserSearch": 0x00E1, -} +}; diff --git a/src/renderer/classes/wayland/seat/modifiers.ts b/src/renderer/classes/wayland/seat/modifiers.ts new file mode 100644 index 0000000..6fd17aa --- /dev/null +++ b/src/renderer/classes/wayland/seat/modifiers.ts @@ -0,0 +1,75 @@ +import { HLConnection } from "@cathodique/wl-serv-high"; +import { Seat } from "./seat.js"; + +const knownMods = ["Shift", "Lock", "Control", "Mod1", "Mod2", "Mod3", "Mod4", "Mod5"] as const; +export class Modifiers { + depressed = Object.fromEntries(knownMods.map((v) => [v, false])) as Record; + depressedBitmask = 0; + latched = Object.fromEntries(knownMods.map((v) => [v, false])) as Record; + latchedBitmask = 0; + locked = Object.fromEntries(knownMods.map((v) => [v, false])) as Record; + lockedBitmask = 0; + + group = 0; + + seat: Seat; + + constructor(seat: Seat) { + this.seat = seat; + } + + updateAccordingly(evt: KeyboardEvent | MouseEvent) { + let changed = { depressed: false, latched: false, locked: false }; + function checkIfChangedAndUpdate(origin: Record, modifier: typeof knownMods[number], value: boolean) { + if (origin[modifier] === value) return false; + origin[modifier] = value; + return true; + } + // Shift: "Shift" + changed.depressed ||= checkIfChangedAndUpdate(this.depressed, "Shift", evt.getModifierState("Shift")); + // Lock: "CapsLock" + changed.locked ||= checkIfChangedAndUpdate(this.locked, "Lock", evt.getModifierState("CapsLock")); + if (evt instanceof KeyboardEvent) changed.depressed ||= checkIfChangedAndUpdate(this.depressed, "Lock", evt.type === "keydown" && evt.key === "CapsLock"); + // Control: "Control" + changed.depressed ||= checkIfChangedAndUpdate(this.depressed, "Control", evt.getModifierState("Control")); + // Mod1: "Alt" + changed.depressed ||= checkIfChangedAndUpdate(this.depressed, "Mod1", evt.getModifierState("Alt")); + // Mod2: "NumLock" + changed.depressed ||= checkIfChangedAndUpdate(this.depressed, "Mod2", evt.getModifierState("NumLock")); + // Mod3: "Hyper" (No Level 5 in browser spec) + changed.depressed ||= checkIfChangedAndUpdate(this.depressed, "Mod3", evt.getModifierState("Hyper")); + // Mod4: "Meta" + changed.depressed ||= checkIfChangedAndUpdate(this.depressed, "Mod4", evt.getModifierState("Meta")); + // Mod5: "AltGraph" + changed.depressed ||= checkIfChangedAndUpdate(this.depressed, "Mod5", evt.getModifierState("AltGraph")); + + return changed; + } + + static createMask(object: Record) { + let result = 0; + for (let modIdx = 0; modIdx < knownMods.length; modIdx += 1) { + const mask = 2 ** modIdx; + if (object[knownMods[modIdx]]) result += mask; + } + + return result; + } + + update(connection: HLConnection, serial?: number) { + const authority = this.seat.wlSeatAuth.get(connection)!; + + authority.modifiers(this.depressedBitmask, this.latchedBitmask, this.lockedBitmask, this.group, serial); + } + + ifUpdateThenEmit(evt: KeyboardEvent | MouseEvent, connection: HLConnection) { + const xWasUpdated = this.updateAccordingly(evt); + if (xWasUpdated.depressed || xWasUpdated.latched || xWasUpdated.locked) { + if (xWasUpdated.depressed) this.depressedBitmask = Modifiers.createMask(this.depressed); + if (xWasUpdated.latched) this.latchedBitmask = Modifiers.createMask(this.latched); + if (xWasUpdated.locked) this.lockedBitmask = Modifiers.createMask(this.locked); + + this.update(connection); + } + } +} diff --git a/src/renderer/classes/wayland/seat/seat.ts b/src/renderer/classes/wayland/seat/seat.ts new file mode 100644 index 0000000..e4f5c46 --- /dev/null +++ b/src/renderer/classes/wayland/seat/seat.ts @@ -0,0 +1,126 @@ +import { SeatConfiguration, SeatInstances, SeatRegistry } from "@cathodique/wl-serv-high/registries"; +import { Modifiers } from "./modifiers.js"; +import { codeToScan } from "./codeToScancode.js"; +import { SurfaceDom } from "../../handlers/dom/surface.js"; +import { isInRegion } from "../../../wayland/index.js"; + +export class Seat { + static mouseWebToButtonMap: Record = { + 0: 0x110, + 1: 0x112, + 2: 0x111, + 3: 0x116, + 4: 0x115, + }; + + wlSeatReg: SeatRegistry; + config: SeatConfiguration; + get wlSeatAuth() { + const result = this.wlSeatReg.get(this.config); + if (!result) throw new Error(); + return result; + } + + keyboardFocus?: { instances: SeatInstances, surface: SurfaceDom }; + + mouseFocus?: { instances: SeatInstances, surface: SurfaceDom }; + + modifiers: Modifiers; + constructor(config: SeatConfiguration, seatReg: SeatRegistry) { + this.wlSeatReg = seatReg; + this.config = config; + this.modifiers = new Modifiers(this); + + this.initKeydown(); + this.initKeyup(); + } + + initKeyup() { + document.body.addEventListener("keyup", function (this: Seat, v: KeyboardEvent) { + if (!this.keyboardFocus) { + this.modifiers.updateAccordingly(v); + return; + } + v.preventDefault(); + this.modifiers.ifUpdateThenEmit(v, this.keyboardFocus.instances.connection); + + const isInMap = (code: string): code is keyof typeof codeToScan => + code in codeToScan; + if (!isInMap(v.code)) return; + + const scancode = codeToScan[v.code]; + + this.keyboardFocus.instances.keyUp(scancode); + }.bind(this)); + } + + initKeydown() { + document.body.addEventListener("keydown", (v) => { + if (!this.keyboardFocus) { + this.modifiers.updateAccordingly(v); + return; + } + v.preventDefault(); + this.modifiers.ifUpdateThenEmit(v, this.keyboardFocus.instances.connection); + + const isInMap = (code: string): code is keyof typeof codeToScan => + code in codeToScan; + if (!isInMap(v.code)) return; + + const scancode = codeToScan[v.code]; + + this.keyboardFocus.instances.keyDown(scancode); + }); + } + + setMouseFocus(surface: SurfaceDom) { + this.mouseFocus = { + surface, + instances: this.wlSeatAuth.get(surface.wl.connection)!, + }; + } + unsetMouseFocus() { + this.mouseFocus?.instances.blur(this.mouseFocus.surface.wl); + this.mouseFocus?.instances.leave(this.mouseFocus.surface.wl); + this.mouseFocus = undefined; + } + + setKeyboardFocus(surface: SurfaceDom) { + this.keyboardFocus = { + surface, + instances: this.wlSeatAuth.get(surface.wl.connection)!, + }; + } + unsetKeyboardFocus() { this.keyboardFocus = undefined; } + + move(evt: MouseEvent, surface: SurfaceDom, forceLeave?: boolean) { + // (obj.xdgSurface?.parent as XdgWmBase)?.addCommand("ping", { + // serial: obj.connection.time.getTime(), + // }); + // We'll see abt that later + + const containerPos = surface.dom.getBoundingClientRect(); + + const mouseY = evt.clientY - containerPos.top; + const mouseX = evt.clientX - containerPos.left; + + evt.stopPropagation(); + + if ( + !forceLeave && + isInRegion(surface.wl.inputRegions.current, mouseY, mouseX, true) + ) { + // We are in the region and not looking to leave + if (this.mouseFocus?.surface !== surface) { + this.setMouseFocus(surface); + // We are currently focusing a surface that is not ours + this.mouseFocus!.instances = this.wlSeatAuth.get(surface.wl.connection)!; + const enterSerial = this.mouseFocus!.instances.focus(surface.wl, []); + this.modifiers.update(this.mouseFocus!.instances.connection, enterSerial); + this.mouseFocus!.instances.enter(surface.wl, mouseX, mouseY); + // ????? + } + this.mouseFocus?.instances.moveTo(mouseX, mouseY); + } + } +} diff --git a/src/renderer/host/classes/component.ts b/src/renderer/host/classes/component.ts new file mode 100644 index 0000000..c66e5c7 --- /dev/null +++ b/src/renderer/host/classes/component.ts @@ -0,0 +1,78 @@ +import { componentTypes } from "../utils/types.js"; +import { nanoid } from "../utils/utils.js"; +import { wrapValue } from "../utils/wrap.js"; +import { BaseModule, LocalModule } from "./module.js"; +import { orchestrator } from "./orchestrator.js"; +import { OrderedPeer } from "./orderedPeer.js"; + +export interface PartialComponentContext { } +export interface ComponentContext extends PartialComponentContext { + module: LocalModule; +} +export type ComponentHandle = { + module: BaseModule; + componentId: string | Promise; + + init(): any; +} & { [k in `$${string}`]: any }; + +const isComponentSymbol = Symbol(); +export abstract class Component implements ComponentHandle { + static isComponentSymbol: typeof isComponentSymbol = isComponentSymbol; + + [x: `$${string}`]: any; + + componentId: string; + module: LocalModule; + + static type: typeof componentTypes[number] = "NORMAL"; + + [isComponentSymbol] = true; + constructor(module: LocalModule) { + this.module = module; + this.componentId = nanoid(); + + this.module.localHandle.componentInstances.set( + this.componentId, + this, + ); + } + + init(): any {} + + async getDependency(dependency: string) { + const newMod = await orchestrator.load(dependency); + return newMod?.localHandle; + } + async getAllDependency(dependency: string) { + const newMod = await orchestrator.loadAll(dependency); + return newMod?.map((v) => v.localHandle); + } + #listenersFromRemote = new Map>(); + listenFor(eventName: string, peer: OrderedPeer) { + const innerSet = this.#listenersFromRemote.get(eventName) || new Set(); + if (!this.#listenersFromRemote.has(eventName)) this.#listenersFromRemote.set(eventName, innerSet); + + innerSet.add(peer); + } + unlistenFor(eventName: string, peer: OrderedPeer) { + const innerSet = this.#listenersFromRemote.get(eventName) || new Set(); + + innerSet.delete(peer); + + if (innerSet.size === 0) this.#listenersFromRemote.delete(eventName); + } + async emit(eventName: string, ...args: any[]) { + const wrapped = await Promise.all(args.map((v) => wrapValue(v))) + + const innerSet = this.#listenersFromRemote.get(eventName); + if (!innerSet) return; + for (const peer of innerSet) { + peer.rpc("emitEvent", { + componentId: this.componentId, + eventName: eventName, + args: wrapped, + }); + } + } +}; diff --git a/src/renderer/host/classes/componentList.ts b/src/renderer/host/classes/componentList.ts new file mode 100644 index 0000000..5584ee1 --- /dev/null +++ b/src/renderer/host/classes/componentList.ts @@ -0,0 +1,90 @@ +import { ComponentClass, ComponentHandleFactory } from "../utils/types.js"; +import { Component } from "./component.js"; +import { Latch } from "./latch.js"; +import { LocalModule } from "./module.js"; + +export class InvalidComponentError extends Error {} + +// ASSUMPTION: All components will return themselves. +export class ComponentList extends EventTarget implements ComponentListHandle { + componentClasses = new Map(); + componentClassToClassName = new Map() + + componentInstances = new Map(); + instanceExists(id: string) { + return this.componentInstances.has(id); + } + + componentTypeOf(component: Component) { + // Traversing prototype chain (from most specific to least specific) + // will take less time than traversing all the possible components. + let currentPrototype: any = Object.getPrototypeOf(component); + while (currentPrototype !== null) { + if (this.componentClassToClassName.has(currentPrototype.constructor)) { + return this.componentClassToClassName.get(currentPrototype.constructor)!; + } + currentPrototype = Object.getPrototypeOf(currentPrototype); + } + + throw new InvalidComponentError(); + // Implications: The object has had [Symbol(Component.isComponentSymbol)] set to true but was not a component + } + + module: LocalModule; + constructor(mod: LocalModule) { + super(); + this.module = mod; + } + + #readyLatch = new Latch(); + get ready() { + return this.#readyLatch.promise; + } + markReady() { + this.#readyLatch.resolve?.(); + } + + register(componentName: string, componentClass: ComponentClass) { + if (this.componentClasses.has(componentName)) + throw new Error("This component already exists"); + + this.componentClasses.set(componentName, componentClass); + this.markAs(componentClass, componentName); + } + markAs(componentClass: ComponentClass, componentName: string) { + this.componentClassToClassName.set(componentClass, componentName); + } + + get(componentName: string) { + const InnerClass = this.componentClasses.get(componentName); + if (!InnerClass) return; + + return { + create: (...args: any[]) => { + switch (InnerClass.type) { + case "REF_ONLY": { + throw new Error("You are not supposed to instanciate this class"); + } + case "SINGLETON": { + if (!InnerClass.singletonInstance) throw new Error("Singleton instance was not set up."); + return InnerClass.singletonInstance; + } + case "NORMAL": { + return "create" in InnerClass + ? InnerClass.create(this.module, ...args) + : new InnerClass(this.module, ...args); + } + } + } + }; + } + + has(componentName: string) { + return this.componentClasses.has(componentName); + } +} + +export type ComponentListHandle = { + get(componentName: string): undefined + | ComponentHandleFactory; +}; diff --git a/src/renderer/host/classes/latch.ts b/src/renderer/host/classes/latch.ts new file mode 100644 index 0000000..a60bfd4 --- /dev/null +++ b/src/renderer/host/classes/latch.ts @@ -0,0 +1,175 @@ +export enum LatchState { + Pending, + Fulfilled, +} + +export class Latch { + promise: Promise; + resolve: ((v: T) => void) | undefined; + + constructor(value?: T) { + if (value != null) { + this.resolve = undefined; + this.promise = Promise.resolve(value); + } else { + let resultingResolve: (r: T) => void; + + // Assignment with side effect onto resultingResolve + this.promise = new Promise((r) => { resultingResolve = r }); + + this.resolve = function (this: Latch, r: T) { + resultingResolve(r); + this.resolve = undefined; + }.bind(this); + } + } + + getState() { + if (this.resolve) return LatchState.Pending; + return LatchState.Fulfilled; + } +} + +export class KeyedLatch { + map = new Map>(); + + getStateOf(key: T): LatchState { + return this.map.get(key)?.getState() ?? LatchState.Pending; + } + + get(key: T): Promise { + if (this.map.has(key)) return this.map.get(key)!.promise; + + const latch = new Latch(); + this.map.set(key, latch); + + return latch.promise; + } + + resolve(key: T, value: U): typeof value { + if (!this.map.has(key)) { + this.map.set(key, new Latch(value)); + } else { + const { resolve } = this.map.get(key)!; + if (!resolve) throw new Error('Double resolution is unacceptable'); + + resolve(value); + } + return value; + } + + getOptional(key: T) { + if (this.map.has(key)) return this.get(key); + return undefined; + } + + delete(key: T) { + this.map.delete(key); + } +} + +export class WeakKeyedLatch { + map = new WeakMap>(); + + getStateOf(key: T) { + return this.map.get(key)?.getState() ?? LatchState.Pending; + } + + get(key: T) { + if (this.map.has(key)) return this.map.get(key)!.promise; + + const latch = new Latch(); + this.map.set(key, latch); + + return latch.promise; + } + + resolve(key: T, value: U) { + if (!this.map.has(key)) { + this.map.set(key, new Latch(value)); + } else { + const { resolve } = this.map.get(key)!; + if (!resolve) throw new Error('Double resolution is unacceptable'); + + resolve(value); + } + return value; + } + + getOptional(key: T) { + if (this.map.has(key)) return this.get(key); + return undefined; + } + + delete(key: T) { + this.map.delete(key); + } +} + +export class ConsumableKeyedLatch extends KeyedLatch { + consumed = new Set(); + + consume(key: T) { + const result = super.get(key); + switch (this.getStateOf(key)) { + case LatchState.Pending: + this.consumed.add(key); + break; + case LatchState.Fulfilled: + this.delete(key); + break; + } + return result; + } + + /** + * @deprecated For semantic reasons, use consume instead + * Method kept for inheritance + */ + get(key: T) { + return this.consume(key); + } + + resolve(key: T, value: U) { + const result = super.resolve(key, value); + if (this.consumed.has(key)) { + this.consumed.delete(key); + this.delete(key); + } + return result; + } +} + +export class ConsumableWeakKeyedLatch extends WeakKeyedLatch { + consumed = new WeakSet(); + + consume(key: T) { + const result = super.get(key); + switch (this.getStateOf(key)) { + case LatchState.Pending: + this.consumed.add(key); + break; + case LatchState.Fulfilled: + this.delete(key); + break; + } + return result; + } + + /** + * @deprecated For semantic reasons, use consume instead + * Method kept for inheritance + */ + get(key: T) { + return this.consume(key); + } + + resolve(key: T, value: U) { + const result = super.resolve(key, value); + if (this.consumed.has(key)) { + this.consumed.delete(key); + this.delete(key); + } + return result; + } +} diff --git a/src/renderer/host/classes/module.ts b/src/renderer/host/classes/module.ts new file mode 100644 index 0000000..7a81c34 --- /dev/null +++ b/src/renderer/host/classes/module.ts @@ -0,0 +1,240 @@ +import { KeyedLatch, Latch } from "./latch.js"; +import { OrderedPeer } from "./orderedPeer.js"; +import { CathodiqueAvailableComponentsHandler, CathodiqueConsumerHandler } from "../ipcHandlers/cathodiqueConsumer.js"; +import { CathodiqueHostHandler } from "../ipcHandlers/cathodiqueHost.js"; +import { DOMHostHandler } from "../ipcHandlers/domHost.js"; +import { OtherNodeRegistry } from "./sharedDomHost.js"; +import { ComponentList, ComponentListHandle } from "./componentList.js"; +import { SemanticMessageChannel } from "./semanticMessageChannel.js"; +import { CathodiqueProviderHandler } from "../ipcHandlers/cathodiqueProvider.js"; +import { ComponentInstanceProxy, ComponentListProxy, makeComponentProxy } from "../utils/remoteToLocalAdapter.js"; +import { WithTransfer } from "./withTransfer.js"; +import { orchestrator } from "./orchestrator.js"; +import { Component, ComponentHandle } from "./component.js"; +import z from "zod"; + +/* +Four cases. + +consumer|provider +module |module +--------+-------- +local |local +local |remote +remote |local +remote |remote + +For local consumer, we need to provide a proxy to directly interact with the Module +For remote consumer, we need to create a messagechannel +*/ + +export abstract class BaseModule { + static summonnedModules = new Map(); + static tokenToModule = new Map(); + + static addModule(moduleName: string, module: BaseModule) { + if (BaseModule.summonnedModules.has(moduleName)) throw new Error("Module already initialized"); + BaseModule.summonnedModulesByToken.set(module.opaqueToken, module); + BaseModule.summonnedModules.set(moduleName, module); + } + + static summonnedModulesByToken = new Map(); + static moduleByToken(id: string) { + return this.summonnedModulesByToken.get(id); + } + + static originOfModule(module?: BaseModule) { + if (module instanceof RemoteModule) { + return module.origin; + } + return window.origin; + } + + static async getModule(moduleName: string) { + if (this.summonnedModules.has(moduleName)) { + return this.summonnedModules.get(moduleName)!; + } + + return RemoteModule.create(moduleName); + } + + opaqueToken: string; + abstract win: WindowProxy; + + moduleName: string; + constructor(moduleName: string) { + this.opaqueToken = orchestrator.data.moduleData[moduleName]!.opaqueToken; + + this.moduleName = moduleName; + } + + abstract localHandle: ComponentListHandle; + abstract getRemoteHandle(to: RemoteModule): MessagePort | undefined | Promise; + abstract instanceExists(id: string): boolean | Promise; + abstract getComponent(id: string): ComponentHandle | undefined | Promise; +} + +export class LocalModule extends BaseModule { + static availableModules = new Set(); + + static setupModule(moduleName: string) { + this.availableModules.add(moduleName); + const mod = new LocalModule(moduleName); + return mod; + } + + win = window; + + components = new Map(); + + localHandle: ComponentList; + constructor(moduleName: string) { + super(moduleName); + + this.localHandle = new ComponentList(this); + } + + remoteHandles = new Set(); + getRemoteHandle(from: RemoteModule) { + if (this.remoteHandles.has(from)) return undefined; + + const messageChannel = new SemanticMessageChannel(); + + const peer = new OrderedPeer(messageChannel.providerPort, "*"); + peer.addHandler(new CathodiqueProviderHandler(from, this, peer)); + + this.remoteHandles.add(from); + return messageChannel.consumerPort; + } + + #componentInstances = new Map(); + instanceExists(id: string) { + return this.#componentInstances.has(id); + } + register(id: string, comp: Component) { + this.#componentInstances.set(id, comp); + } + getComponent(id: string) { + return this.#componentInstances.get(id); + } +} + +export class RemoteModule extends BaseModule { + static iframeLoad(iframe: HTMLIFrameElement) { + return new Promise((r) => { + iframe.addEventListener("load", () => r(), { once: true }); + }); + } + static moduleSubdomainOf(moduleName: string) { + return moduleName.split('.').toReversed().join('.'); + } + // Init resolves latches: It's why it's hidden + static async create(moduleName: string) { + const moduleSubdomain = this.moduleSubdomainOf(moduleName); + + const iframe = document.createElement("iframe"); + iframe.src = `https://${moduleSubdomain}.raytu.be/module.html?parent_origin=${encodeURIComponent(window.origin)}`; + iframe.hidden = true; + + document.body.append(iframe); + + const win = iframe.contentWindow!; + OtherNodeRegistry.setRegistry(win, new OtherNodeRegistry(win)); + + const peer = new OrderedPeer( + iframe.contentWindow!, + `https://${moduleSubdomain}.raytu.be`, + ); + const availableComponents = new Latch(); + peer.addHandler(new CathodiqueAvailableComponentsHandler(availableComponents)); + + return new RemoteModule(moduleName, await availableComponents.promise, peer, iframe); + } + + peer: OrderedPeer; + iframe: HTMLIFrameElement; + get win() { return this.iframe.contentWindow! }; + + componentList: ComponentListHandle; + private constructor(moduleName: string, availableComponents: string[], peer: OrderedPeer, iframe: HTMLIFrameElement) { + super(moduleName); + + this.peer = peer; + this.iframe = iframe; + + this.availableComponents = availableComponents; + + this.componentList = new ComponentListProxy(this); + + this.peer.addHandler(new CathodiqueHostHandler(this)); + this.peer.addHandler(new DOMHostHandler(this.win, this)); + this.peer.addHandler(new CathodiqueConsumerHandler(this)); + } + + get #moduleSubdomain() { + return RemoteModule.moduleSubdomainOf(this.moduleName); + } + get origin() { + return `https://${this.#moduleSubdomain}.raytu.be`; + } + + availableComponents: string[]; + + localHandle = { + get: (componentName: string) => ({ + create: (...args: any[]) => { + return makeComponentProxy(this, componentName, { args }); + }, + }), + } + + remoteHandles = new Set(); + async getRemoteHandle(to: RemoteModule) { + if (this.remoteHandles.has(to)) return undefined; + + const messageChannel = new SemanticMessageChannel(); + + await this.peer.rpc("connectAsProvider", + new WithTransfer( + { port: messageChannel.providerPort, id: to.opaqueToken }, + [messageChannel.providerPort], + )); + + this.remoteHandles.add(to); + return messageChannel.consumerPort; + } + + async instanceExists(id: string) { + return await this.peer.rpc("instanceExists", { componentId: id }); + } + async getInstanceData(id: string) { + return await this.peer.rpc("getInstanceData", { componentId: id }); + } + + #componentInstances = new Map(); + instanceProxyExists(id: string) { + return this.#componentInstances.has(id); + } + getInstanceProxy(id: string) { + return this.#componentInstances.get(id); + } + + registerInstanceProxy(id: string, comp: ComponentInstanceProxy) { + this.#componentInstances.set(id, comp); + } + async getComponent(id: string): Promise { + if (this.#componentInstances.has(id)) return this.#componentInstances.get(id)!; + + const componentData = z.union( + [ + z.undefined(), + z.object({ + componentName: z.string(), + }), + ] + ).parse(await this.getInstanceData(id)); + if (componentData) { + return makeComponentProxy(this, componentData.componentName, { componentId: id }); + } + } +} diff --git a/src/renderer/host/classes/orchestrator.ts b/src/renderer/host/classes/orchestrator.ts new file mode 100644 index 0000000..4c17635 --- /dev/null +++ b/src/renderer/host/classes/orchestrator.ts @@ -0,0 +1,67 @@ +import { BaseModule } from "./module.js"; + +interface OrchestratorData { + defaults: Record; + defaultsAll: Record; + moduleData: { + [k in string]: { + opaqueToken: string; + }; + }; +} + +export class Orchestrator { + data: OrchestratorData; + defaults = new Map(); + defaultsAll = new Map(); + + overrides = new Map(); + overridesAll = new Map(); + + constructor() { + const script = document.querySelector("script[type=\"application/vnd.raytube.orchestrator-data\"]"); + + if (!script || !script.textContent) throw new Error("Cannot orchestrate: No script"); + + this.data = JSON.parse(script.textContent); + + for (const [k, v] of Object.entries(this.data.defaults)) this.defaults.set(k, v); + for (const [k, v] of Object.entries(this.data.defaultsAll)) this.defaultsAll.set(k, v); + } + addOverride(str: string, module: string) { + this.overrides.set(str, module); + } + addOverrideAll(str: string, modules: string[]) { + this.overridesAll.set(str, modules); + } + + async load(schemaName: string) { + const overriddenBy = this.overrides.get(schemaName); + + const moduleName = overriddenBy ?? this.defaults.get(schemaName); + if (!moduleName) return undefined; + + if (BaseModule.summonnedModules.has(moduleName)) { + return BaseModule.summonnedModules.get(moduleName); + } + + const mod = await BaseModule.getModule(moduleName); + BaseModule.addModule(moduleName, mod); + return mod; + } + + loadAll(schemaName: string) { + const overriddenBy = this.overridesAll.get(schemaName); + + const moduleNames = overriddenBy ?? this.data.defaultsAll[schemaName]; + if (!moduleNames) return undefined; + + return Promise.all(moduleNames.map(async (moduleName: string) => { + const mod = await BaseModule.getModule(moduleName)!; + BaseModule.addModule(moduleName, mod); + return mod; + })); + } +} + +export const orchestrator = new Orchestrator(); diff --git a/src/renderer/host/classes/orderedPeer.ts b/src/renderer/host/classes/orderedPeer.ts new file mode 100644 index 0000000..57fb456 --- /dev/null +++ b/src/renderer/host/classes/orderedPeer.ts @@ -0,0 +1,158 @@ +import { ConsumableKeyedLatch } from "./latch.js"; + +import { nanoid } from "../utils/utils.js"; +import { WithTransfer } from "./withTransfer.js"; +import { HandlerContext } from "../utils/types.js"; + +// TODO ASAP: Allow for promise MessageEventSource + +export class OrderedPeer { + handlers: Record, ctx: HandlerContext) => any>[] = []; + + static actualHandlers = new WeakMap void>(); + private static registered = false; + static registerIpcListener() { + if (this.registered) return; + window.addEventListener( + "message", + (evt) => { + const actualHandler = this.actualHandlers.get(evt.source as MessageEventSource); + if (!actualHandler) return; + actualHandler(evt); + }, + ); + this.registered = true; + } + + currentOrderSubmission: bigint = 0n; + pendingMessages: any[] = []; + pendingTransfer: Transferable[] = []; + origin: string; + + promiseMap = new ConsumableKeyedLatch(); + + source: MessageEventSource; + postMessage: typeof window["postMessage"]; + constructor(source: MessageEventSource, origin = "*") { + if (OrderedPeer.actualHandlers.has(source)) throw new Error("A window may only admit a single OrderedPeer"); + + this.source = source; + this.postMessage = source.postMessage.bind(source); + this.origin = origin; + + if (source instanceof MessagePort) { + if (origin !== "*") throw new Error("Cannot restrain origin if source is a MessagePort"); + source.addEventListener("message", this.orderedDecoder.bind(this)); + } else { + OrderedPeer.actualHandlers.set(source, this.orderedDecoder.bind(this)); + } + } + + addHandler(handler: (typeof this.handlers)[number]) { + this.handlers.push(handler); + if (this.source instanceof MessagePort) this.source.start(); + return this; // Builder + } + + post(data: any) { + let transfer: WithTransfer[] = []; + if (data instanceof WithTransfer) { + transfer = data.transfer; + data = data.data; + } + + this.pendingMessages.push(data); + this.pendingTransfer.push(...transfer); + + if (this.pendingMessages.length === 1) { + const error = new Error().stack!; + queueMicrotask(() => { + if (error.length < 1) { + throw new Error("Just to get the stack trace."); + } + this.source.postMessage( + { + messages: this.pendingMessages, + currentOrder: this.currentOrderSubmission, + }, + { + targetOrigin: this.origin, + transfer: this.pendingTransfer, + } + ); + + this.pendingMessages = []; + this.pendingTransfer = []; + this.currentOrderSubmission += 1n; + }); + } + } + + async rpc(type: string, oldData: any | WithTransfer, obj: Record = {}) { + const promiseId = nanoid(); + const { data, transfer } = new WithTransfer(oldData); + // console.log(window.origin, "RPC WITH", promiseId, type, data, transfer); + this.post(new WithTransfer({ type, data, promiseId, ...obj }, transfer)); + const result = await this.promiseMap.consume(promiseId); + // console.log(window.origin, "RPC WAS ALL GOOD!", promiseId, type, result); + + if (!result.error) return result.reply; + throw result.error; + } + + remainingMessages = new Map(); + currentOrderReception: bigint = 0n; + + originMatch(evt: MessageEvent) { + if (evt.origin === "" && this.source instanceof MessagePort) return true; + + if (this.origin === '*') return true; + if (this.origin === '/') return evt.origin === window.origin; + return evt.origin === this.origin; + } + + async orderedDecoder(evt: MessageEvent) { + if (!this.originMatch(evt)) return; + + const { data: { messages, currentOrder } } = evt; + // this.remainingMessages.set(currentOrder, messages); + + // while (this.remainingMessages.has(this.currentOrderReception)) { + // const messages = this.remainingMessages.get(this.currentOrderReception)!; + // this.remainingMessages.delete(this.currentOrderReception); + // this.currentOrderReception += 1n; + + // Used to be await Promise.all. + // Trying to prevent deadlock here... + messages.map(async (message: { data: any, type: string, error?: string, promiseId?: string, componentHandle?: string }) => { + const { type, promiseId } = message; + + if (type === "reply") { + this.promiseMap.resolve(promiseId!, message); + if (message.error) throw message.error; + + return; + } + + const handler = this.handlers.find((v) => type in v); + + if (!handler) { + console.error("Client attempted", type, "which was not impl'd"); + if (promiseId) this.post({ type: "reply", error: new Error("No such function"), promiseId }); + return; + } + + try { + const result = new WithTransfer(await handler[type](message, { ipc: this, event: evt })); + + if (promiseId) { + this.post(new WithTransfer({ type: "reply", reply: result.data, promiseId }, result.transfer)); + } + } catch (e) { + console.error(e); + this.post({ type: "reply", error: (e as Error).toString(), promiseId }); + } + }); + // } + } +} diff --git a/src/renderer/host/classes/polymap.ts b/src/renderer/host/classes/polymap.ts new file mode 100644 index 0000000..14d26e3 --- /dev/null +++ b/src/renderer/host/classes/polymap.ts @@ -0,0 +1,10 @@ +type ValueFor = Extract[1]; + +export class PolyMap extends Map { + set(a: T, b: ValueFor) { + return super.set(a, b); + } + get(a: T): ValueFor { + return super.get(a); + } +} diff --git a/src/renderer/host/classes/semanticMessageChannel.ts b/src/renderer/host/classes/semanticMessageChannel.ts new file mode 100644 index 0000000..eac37dd --- /dev/null +++ b/src/renderer/host/classes/semanticMessageChannel.ts @@ -0,0 +1,8 @@ +export class SemanticMessageChannel extends MessageChannel { + get consumerPort() { + return this.port1; + } + get providerPort() { + return this.port2; + } +} diff --git a/src/renderer/host/classes/sharedDomHost.ts b/src/renderer/host/classes/sharedDomHost.ts new file mode 100644 index 0000000..ffe4509 --- /dev/null +++ b/src/renderer/host/classes/sharedDomHost.ts @@ -0,0 +1,190 @@ +import { NodeFromIpc } from "../utils/types.js"; +import { nanoid } from "../utils/utils.js"; + +class NodeData { + node: Node; + eventMap = new Map void>(); + registry: OtherNodeRegistry; + + constructor(node: Node, reg: OtherNodeRegistry) { + this.node = node; + this.registry = reg; + } + + registerEventListener(event: string, fn: (v: Event) => void) { + this.eventMap.set(event, fn); + return fn; + } + deregisterEventListener(event: string) { + const temp = this.eventMap.get(event); + if (!temp) throw new Error("Event not been registered through NodeData"); + this.eventMap.delete(event); + return temp; + } +} + +export class OtherNodeRegistry { + static registryPerSource = new WeakMap(); + + static registryOf(source: WindowProxy) { + return this.registryPerSource.get(source); + } + static setRegistry(source: WindowProxy, nr: OtherNodeRegistry) { + if (OtherNodeRegistry.registryPerSource.has(source)) throw new Error("Only one NodeRegistry per window may exist"); + return this.registryPerSource.set(source, nr); + } + + nodeToId = new Map(); + idToNode = new Map(); + nodeData = new Map(); + nodeDataOf(node: Node) { + return this.nodeData.get(node); + } + + win: WindowProxy; + + constructor(source: WindowProxy) { + this.win = source; + } + + hasNode(node: Node) { + return this.nodeToId.has(node); + } + getNode(id: string) { + return this.idToNode.get(id); + } + + setNodeId(node: Node, id: string) { + this.nodeToId.set(node, id); + this.idToNode.set(id, node); + + this.nodeData.set(node, new NodeData(node, this)); + + return node; + } + + deleteNode(id: string) { + const node = this.idToNode.get(id); + if (node) { + this.nodeToId.delete(node); + this.idToNode.delete(id); + + this.nodeData.delete(node); + } + } + + changeOwnership(id: string, node: Node) { + const originalNode = this.getNode(id); + if (!originalNode) throw new Error(); + + switch (node.nodeType) { + case Node.TEXT_NODE: { + if (!(originalNode instanceof Text)) throw new Error("Node type mismatch"); + + node.nodeValue = originalNode.nodeValue; + + break; + } + case Node.ELEMENT_NODE: { + if (!(originalNode instanceof Element)) throw new Error("Node type mismatch"); + + const el = node as Element; + if (originalNode.tagName !== el.tagName) throw new Error("Tag name mismatch"); + + for (const attr of Array.from(originalNode.attributes)) { + el.setAttributeNode(attr); + } + + for (const child of Array.from(originalNode.childNodes)) { + el.appendChild(child); + } + + break; + } + case Node.DOCUMENT_FRAGMENT_NODE: { + if (!(originalNode instanceof DocumentFragment)) throw new Error("Node type mismatch"); + + const docFrag = node as DocumentFragment; + for (const child of Array.from(originalNode.childNodes)) { + docFrag.appendChild(child); + } + + break; + } + default: + break; + } + this.setNodeId(node, id); + this.nodeToId.delete(originalNode); + } + + getId(node: Node) { + return this.nodeToId.get(node); + } + + deserializeNode(serializedNode: NodeFromIpc): Node { + switch (serializedNode.kind) { + case "element": { + const newEl = document.createElement(serializedNode.tagName); + + for (const [namespaceURI, name, value] of serializedNode.attributes) { + if (name === "id") continue; + newEl.setAttributeNS(namespaceURI, name, value); + } + + for (const kidId of serializedNode.children) { + newEl.append(this.getNode(kidId)!); + } + + if (newEl instanceof HTMLTemplateElement && serializedNode.content) { + this.changeOwnership(serializedNode.content, newEl.content); + } + + return newEl; + } + + case "text": { + return new Text(serializedNode.content); + } + + case "document_fragment": { + const newEl = document.createDocumentFragment(); + + for (const kidId of serializedNode.children) { + newEl.appendChild(this.getNode(kidId)!); + } + + return newEl; + } + + default: { + return new Comment(`Node of type ${serializedNode.nodeType}`); + } + } + } + + registerEvent (node: Node, event: string, evtHandler: (v: Event) => any) { + this.nodeDataOf(node)!.registerEventListener(event, evtHandler); + console.log(event, evtHandler); + node.addEventListener(event, evtHandler); + } + unregisterEvent (node: Node, event: string) { + const eventListener = this.nodeDataOf(node)!.deregisterEventListener(event); + node.removeEventListener(event, eventListener); + } +} + +export const nodeRegistry = new OtherNodeRegistry(window); +OtherNodeRegistry.setRegistry(window, nodeRegistry); + +export class SharedDOM { + static initOrGet(root: Node) { + if (nodeRegistry.hasNode(root)) return nodeRegistry.getId(root)!; + this.init(root); + return nodeRegistry.getId(root)!; + } + + static init(node: Node) { + nodeRegistry.setNodeId(node, nanoid()); + } +} diff --git a/src/renderer/host/classes/withTransfer.ts b/src/renderer/host/classes/withTransfer.ts new file mode 100644 index 0000000..e9dbd3c --- /dev/null +++ b/src/renderer/host/classes/withTransfer.ts @@ -0,0 +1,17 @@ + +export class WithTransfer { + data: any; + transfer: any[]; + constructor(data: any, transfer: any[] = []) { + if (data instanceof WithTransfer) { + this.data = data.data; + this.transfer = [...data.transfer, ...transfer]; + } else { + this.data = data; + this.transfer = transfer; + } + } + clone() { + return new WithTransfer(this.data, this.transfer); + } +} diff --git a/src/renderer/host/index.ts b/src/renderer/host/index.ts new file mode 100644 index 0000000..b187b2f --- /dev/null +++ b/src/renderer/host/index.ts @@ -0,0 +1,12 @@ +// esbuild... tf u doing,,,, +import "../classes/handlers/handlers.js"; + +import { OrderedPeer } from "./classes/orderedPeer.js"; + +OrderedPeer.registerIpcListener(); + +import "./localModules/loadAllLocalModules.js"; + +import { orchestrator } from "./classes/orchestrator.js"; + +export { orchestrator }; diff --git a/src/renderer/host/ipcHandlers/cathodiqueConsumer.ts b/src/renderer/host/ipcHandlers/cathodiqueConsumer.ts new file mode 100644 index 0000000..b18c792 --- /dev/null +++ b/src/renderer/host/ipcHandlers/cathodiqueConsumer.ts @@ -0,0 +1,48 @@ +import z from "zod"; +import { HandlerContext } from "../utils/types.js"; +import { unwrapValue, WrappedValue, zodWrappedValue } from "../utils/wrap.js"; +import { Latch } from "../classes/latch.js"; +import { RemoteModule } from "../classes/module.js"; + +export class CathodiqueAvailableComponentsHandler { + [k: string]: (arg: Record, ctx: HandlerContext) => any; + + #availableComponents: Latch; + + constructor(availableComponentsLatch: Latch) { + this.#availableComponents = availableComponentsLatch; + } + + moduleReady(args: Record) { + return this.#moduleReady(z.object({ + data: z.object({ componentList: z.array(z.string()) }), + }).parse(args)); + } + async #moduleReady({ data }: { data: { componentList: string[] } }) { + this.#availableComponents.resolve?.(data.componentList); + } +} + +export class CathodiqueConsumerHandler { + [k: string]: (arg: Record, ctx: HandlerContext) => any; + + #module: RemoteModule; + + constructor(module: RemoteModule) { + this.#module = module; + } + + emitEvent(arg: Record) { + return this.#emitEvent(z.object({ + data: z.object({ + eventName: z.string(), + componentId: z.string(), + args: z.array(zodWrappedValue), + }), + }).parse(arg)); + } + async #emitEvent({ data }: { data: { eventName: string, componentId: string, args: WrappedValue[] } }) { + const unwrappedArgs = data.args.map((v) => unwrapValue(v, this.#module)); + (await this.#module.getComponent(data.componentId))?.emit(data.eventName, ...unwrappedArgs); + } +}; diff --git a/src/renderer/host/ipcHandlers/cathodiqueHost.ts b/src/renderer/host/ipcHandlers/cathodiqueHost.ts new file mode 100644 index 0000000..72ac35c --- /dev/null +++ b/src/renderer/host/ipcHandlers/cathodiqueHost.ts @@ -0,0 +1,96 @@ +import z from "zod"; +import { BaseModule, RemoteModule } from "../classes/module.js"; +import { orchestrator } from "../classes/orchestrator.js"; +import { WithTransfer } from "../classes/withTransfer.js"; +import { HandlerContext } from "../utils/types.js"; + +export class CathodiqueHostHandler { + [k: string]: (arg: Record, ctx: HandlerContext) => any; + + #mod: RemoteModule; + + constructor(mod: RemoteModule) { + this.#mod = mod; + + this.#mod.peer.rpc("moduleId", { + moduleId: this.#mod.opaqueToken, + }); + } + + getDependency(arg: Record) { + return this.#getDependency(z.object({ + data: z.object({ + dependency: z.string(), + }), + }).parse(arg)); + } + async #getDependency({ data }: { data: { dependency: string } }) { + const module = await orchestrator.load(data.dependency); + + if (!module) throw new Error("No such module"); + + const consumerPort = await module.getRemoteHandle(this.#mod); + const moduleId = module.opaqueToken; + + return new WithTransfer({ port: consumerPort, id: moduleId }, consumerPort ? [consumerPort] : []); + } + + getModuleByToken(arg: Record) { + return this.#getModuleByToken(z.object({ + data: z.object({ + opaqueToken: z.string(), + }), + }).parse(arg)); + } + async #getModuleByToken({ data }: { data: { opaqueToken: string } }) { + const module = RemoteModule.moduleByToken(data.opaqueToken); + + if (!module) throw new Error("No such module"); + + const consumerPort = await module.getRemoteHandle(this.#mod); + const moduleId = module.opaqueToken; + + return new WithTransfer({ port: consumerPort, id: moduleId }, consumerPort ? [consumerPort] : []); + } + + getAllDependency(arg: Record) { + return this.#getAllDependency(z.object({ + data: z.object({ + dependency: z.string(), + }), + }).parse(arg)); + } + async #getAllDependency({ data }: { data: { dependency: string } }) { + const modules = await orchestrator.loadAll(data.dependency); + + if (!modules) return []; + + const messagePorts: MessagePort[] = []; + + // Map with side effect + const handles = await Promise.all(modules.map(async function (this: CathodiqueHostHandler, mod: BaseModule) { + const messagePort = await mod.getRemoteHandle(this.#mod); + if (messagePort) messagePorts.push(messagePort); + + return { port: messagePort, id: mod.opaqueToken }; + }.bind(this))); + + return new WithTransfer(handles, [handles]); + } + + // establishConnection(arg: Record) { + // return this.#establishConnection(z.object({ + // data: z.object({ + // from: z.string().refine((v) => RemoteModule.moduleById(v)), + // to: z.string().refine((v) => RemoteModule.moduleById(v)), + // }), + // }).parse(arg)); + // } + // async #establishConnection({ data }: { data: { from: string, to: string } }) { + // const fromModule = BaseModule.moduleById(data.from)! as RemoteModule; + // const toModule = BaseModule.moduleById(data.to)! as RemoteModule; + + // const port = await fromModule.getRemoteHandle(toModule); + // await toModule.submitRemoteHandle(port, fromModule); + // } +}; diff --git a/src/renderer/host/ipcHandlers/cathodiqueProvider.ts b/src/renderer/host/ipcHandlers/cathodiqueProvider.ts new file mode 100644 index 0000000..fdec14b --- /dev/null +++ b/src/renderer/host/ipcHandlers/cathodiqueProvider.ts @@ -0,0 +1,164 @@ +import z from "zod"; +import { LocalModule, RemoteModule } from "../classes/module.js"; +import { HandlerContext } from "../utils/types.js"; +import { unwrapValue, WrappedValue, wrapValue, zodWrappedValue } from "../utils/wrap.js"; +import { ShouldHaveBeenZodError, stringStartsWithDollar } from "../utils/utils.js"; +import { OrderedPeer } from "../classes/orderedPeer.js"; + +export class CathodiqueProviderHandler { + [k: string]: (arg: Record, ctx: HandlerContext) => any; + + #toModule: LocalModule; + #fromModule: RemoteModule; + #peer: OrderedPeer; + constructor(fromModule: RemoteModule, toModule: LocalModule, peer: OrderedPeer) { + this.#fromModule = fromModule; + this.#toModule = toModule; + this.#peer = peer; + + this.#init(); + } + async #init() { + await this.#toModule.localHandle.ready; + await this.#peer.rpc("moduleReady", { + componentList: [...this.#toModule.localHandle.componentClasses.keys()], + }); + } + + get #componentList() { + return this.#toModule.localHandle; + } + + createInstance(arg: Record) { + return this.#createInstance(z.object({ + data: z.object({ + className: z.string().refine((className) => this.#toModule.localHandle.has(className)), + args: z.array(zodWrappedValue), + }), + }).parse(arg)); + } + async #createInstance({ data }: { data: { className: string; args: WrappedValue[] } }) { + // TODO Obj verification + const ClassObj = this.#toModule.localHandle.get(data.className); + if (!ClassObj) throw new Error("No such component"); + + const unwrapped = await Promise.all(data.args.map(async (v: WrappedValue) => unwrapValue(v, this.#fromModule))); + + const componentInstance = await ClassObj.create(...unwrapped); + + await componentInstance.init(); + return componentInstance.componentId; + } + + instanceExists(arg: Record) { + return this.#instanceExists(z.object({ + data: z.object({ + componentId: z.string(), + }), + }).parse(arg)); + } + #instanceExists({ data }: { data: { componentId: string } }) { + return this.#componentList.instanceExists(data.componentId); + } + + getInstanceData(arg: Record) { + return this.#getInstanceData(z.object({ + data: z.object({ + componentId: z.string(), + }), + }).parse(arg)); + } + #getInstanceData({ data }: { data: { componentId: string } }) { + const instance = this.#componentList.componentInstances.get(data.componentId); + return instance && { + componentName: this.#toModule.localHandle.componentTypeOf(instance), + }; + } + + getProperty(arg: Record) { + return this.#getProperty(z.object({ + data: z.object({ + propertyName: z.string().startsWith("$"), + componentId: z.string().refine((cId) => this.#componentList.instanceExists(cId)), + }), + }).parse(arg)); + } + async #getProperty({ data }: { data: { propertyName: string; componentId: string } }) { + const component = this.#componentList.componentInstances.get(data.componentId); + if (!component) throw new ShouldHaveBeenZodError(); + + const propertyName = data.propertyName; + if (!stringStartsWithDollar(propertyName)) throw new ShouldHaveBeenZodError(); + const value = component[propertyName]; + + console.log({ value }); + + return wrapValue(value); + } + + callProperty(arg: Record) { + return this.#callProperty(z.object({ + data: z.object({ + methodName: z.string(), + arguments: z.array(z.any()), + componentId: z.string().refine((cId) => this.#componentList.instanceExists(cId)), + }), + }).parse(arg)); + } + async #callProperty({ data }: { + data: { + methodName: string; + arguments: any[]; + componentId: string; + }; + }) { + const component = this.#componentList.componentInstances.get(data.componentId); + if (!component) throw new ShouldHaveBeenZodError(); + + const methodName = data.methodName; + if (!stringStartsWithDollar(methodName)) throw new ShouldHaveBeenZodError(); + const value = await component[methodName](...data.arguments); + + return wrapValue(value); + } + + listenToEvent(arg: Record) { + return this.#listenToEvent(z.object({ + data: z.object({ + eventName: z.string(), + componentId: z.string().refine((cId) => this.#componentList.instanceExists(cId)), + }), + }).parse(arg)); + } + async #listenToEvent({ data }: { + data: { + eventName: string; + componentId: string; + }; + }) { + const component = this.#componentList.componentInstances.get(data.componentId); + if (!component) throw new ShouldHaveBeenZodError(); + + component.listenFor(data.eventName, this.#peer); + } + + unlistenToEvent(arg: Record) { + return this.#unlistenToEvent(z.object({ + data: z.object({ + eventName: z.string(), + componentId: z.string().refine((cId) => this.#componentList.instanceExists(cId)), + }), + }).parse(arg)); + } + async #unlistenToEvent({ data }: { + data: { + eventName: string; + componentId: string; + }; + }) { + const component = this.#componentList.componentInstances.get(data.componentId); + if (!component) throw new ShouldHaveBeenZodError(); + + component.unlistenFor(data.eventName, this.#peer); + } +}; diff --git a/src/renderer/host/ipcHandlers/domHost.ts b/src/renderer/host/ipcHandlers/domHost.ts new file mode 100644 index 0000000..ba3e4aa --- /dev/null +++ b/src/renderer/host/ipcHandlers/domHost.ts @@ -0,0 +1,229 @@ +import { EventFromIpc, HandlerContext, NodeFromIpc, zodNodeFromIpc } from "../utils/types.js"; +import { BaseModule, RemoteModule } from "../classes/module.js"; +import { OtherNodeRegistry } from "../classes/sharedDomHost.js"; +import z from "zod"; + +function allProperties(obj: any) { + const result = []; + for (const prop in obj) { + result.push(prop); + } + return result; +} + +export class DOMHostHandler { + [k: string]: (arg: Record, ctx: HandlerContext) => any; + + #win: WindowProxy; + #module: RemoteModule; + constructor(source: WindowProxy, module: RemoteModule) { + this.#win = source; + this.#module = module; + } + + get #nodeReg() { + return OtherNodeRegistry.registryOf(this.#win)!; + } + + #serializeEvent(evt: Event): EventFromIpc { + return { + type: evt.type, + className: evt.constructor.name, + values: Object.fromEntries( + allProperties(evt.constructor.prototype) + .map((v) => [v, evt[v as keyof typeof evt]]) + .filter(([k, v]) => !["function"].includes(typeof v)) // Array if we want to add more types lol + .map(([k, v]) => { + if (v instanceof Node) { + if (this.#nodeReg.hasNode(v)) { + return [k, { nodeId: this.#nodeReg.getId(v) }]; + } + return [k, undefined]; + } + + try { + structuredClone(v); + return [k, { value: v }]; + } catch { + return [k, undefined]; + } + }) + ), + }; + } + + async createNode(arg: Record) { + return this.#createNode(z.object({ + data: z.object({ + id: z.string(), + payload: zodNodeFromIpc, + events: z.array(z.string()), + }), + }).parse(arg)); + } + #createNode({ data }: { data: { id: string, payload: NodeFromIpc, events: string[] } }) { + const node = this.#nodeReg.deserializeNode(data.payload); + + // if (node instanceof Element) node.setAttribute("data-rpcid", data.id); + + this.#nodeReg.setNodeId(node, data.id); + + for (const event of data.events) { + this.#nodeReg.registerEvent(node, event, async (v: Event) => { + const ipc = this.#module.peer; + + await ipc.rpc("domEmitEvent", { target: data.id, event: this.#serializeEvent(v) }); + }); + } + } + + async deleteNode(arg: Record) { + return this.#deleteNode(z.object({ + data: z.object({ + id: z.string(), + }), + }).parse(arg)); + } + #deleteNode({ data }: { data: { id: string } }) { + this.#nodeReg.deleteNode(data.id); + } + + async containForeign(arg: Record) { + return this.#containForeign(z.object({ + data: z.object({ + id: z.string(), + toId: z.string(), + toOpaqueToken: z.string().optional(), + }), + }).parse(arg)); + } + #containForeign({ data }: { data: { id: string, toId: string, toOpaqueToken?: string } }) { + let win = window as Window; + if (data.toOpaqueToken) { + const module = BaseModule.moduleByToken(data.toOpaqueToken); + if (!module) throw new Error("Module not found"); + win = module.win; + } + + const toNode = OtherNodeRegistry.registryOf(win)!.getNode(data.toId); + if (!toNode) throw new Error("Node not found"); + + const node = this.#nodeReg.getNode(data.id); + if (!(node instanceof Element)) throw new Error("Can't contain foreign if node not Element"); + + console.log(this.#module.opaqueToken, data.toOpaqueToken, win, node, toNode); + + const shadowRoot = node.attachShadow({ mode: "open" }); + shadowRoot.append(toNode); + } + + changeAttribute(args: Record) { + this.#changeAttribute(z.object({ + data: z.object({ + target: z.string(), + name: z.string(), + namespace: z.string().nullable(), + value: z.string(), + }), + }).parse(args)); + } + #changeAttribute({ data }: { data: { target: string; name: string; namespace: string | null; value: string } }) { + const targetNode = this.#nodeReg.getNode(data.target); + if (!targetNode) throw new Error("Target node does not exist"); + + (targetNode as Element).setAttributeNS(data.namespace, data.name, data.value); + } + + addNodes(args: Record) { + this.#addNodes(z.object({ + data: z.object({ + target: z.string(), + added: z.array(z.string()), + before: z.string().nullable(), + }), + }).parse(args)); + } + #addNodes({ data }: { data: { target: string, added: string[], before: string | null } }) { + const targetNode = this.#nodeReg.getNode(data.target); + if (!targetNode) throw new Error("Target node does not exist"); + + const beforeNode = data.before ? this.#nodeReg.getNode(data.before) : null; + if (beforeNode === undefined) throw new Error("Before node does not exist"); + + for (const addedNodeId of data.added) { + const addedNode = this.#nodeReg.getNode(addedNodeId); + if (!addedNode) throw new Error("One of addedNodes does not exist"); + + targetNode.insertBefore(addedNode, beforeNode); + } + } + + removeNodes(args: Record) { + this.#removeNodes(z.object({ + data: z.object({ + target: z.string(), + removed: z.array(z.string()), + }), + }).parse(args)); + } + #removeNodes({ data }: { data: { target: string, removed: string[] } }) { + const targetNode = this.#nodeReg.getNode(data.target); + if (!targetNode) throw new Error("Target node does not exist"); + + for (const removedNodeId of data.removed) { + const removedNode = this.#nodeReg.getNode(removedNodeId); + if (!removedNode) throw new Error("One of removedNodes does not exist"); + + targetNode.removeChild(removedNode); + } + } + + characterData(args: Record) { + this.#characterData(z.object({ + data: z.object({ + target: z.string(), + value: z.string(), + }), + }).parse(args)); + } + #characterData({ data }: { data: { target: string; value: string } }) { + const targetNode = this.#nodeReg.getNode(data.target); + if (!targetNode) throw new Error("Target node does not exist"); + + (targetNode as Element).nodeValue = data.value; + } + + registerEvent(args: Record) { + return this.#registerEvent(z.object({ + data: z.object({ + target: z.string(), + addedEvent: z.string(), + }), + }).parse(args)); + } + #registerEvent({ data }: { data: { target: string, addedEvent: string } }) { + const node = this.#nodeReg.getNode(data.target); + if (!node) throw new Error("Inexistant node"); + + this.#nodeReg.registerEvent(node, data.addedEvent, async (v: Event) => { + const ipc = this.#module.peer; + + await ipc.rpc("domEmitEvent", { target: data.target, event: this.#serializeEvent(v) }); + }); + } + + unregisterEvent(args: Record) { + return this.#unregisterEvent(z.object({ + data: z.object({ + target: z.string(), + addedEvent: z.string(), + }), + }).parse(args)); + } + #unregisterEvent({ data }: { data: { target: string, addedEvent: string } }) { + const node = this.#nodeReg.getNode(data.target); + if (!node) throw new Error("Inexistant node"); + + this.#nodeReg.unregisterEvent(node, data.addedEvent); + } +}; diff --git a/src/renderer/host/localModules/basedomcomponent.ts b/src/renderer/host/localModules/basedomcomponent.ts new file mode 100644 index 0000000..734d379 --- /dev/null +++ b/src/renderer/host/localModules/basedomcomponent.ts @@ -0,0 +1,29 @@ +import { BaseObject } from "@cathodique/wl-serv-high/objects"; +import { Component } from "../classes/component"; +import { LocalModule } from "../classes/module"; + +export class BaseDomComponent extends Component { + wl: From; + $output: To; + constructor(mod: LocalModule, wl: From, dom: To) { + super(mod); + this.wl = wl; + this.$output = dom; + + this.wl.once("beforeWlDestroy", () => { + this.destroy(); + this.emit("destroy"); + }); + } + + unmount: (() => any)[] = []; + onUnmount(f: (this: this) => any) { + this.unmount.push(f.bind(this)); + } + + destroy() { + type yo = this; + + this.unmount.forEach(function (this: yo, v: typeof this.unmount[number]) { v.bind(this)(); }.bind(this)); + } +} diff --git a/src/renderer/host/localModules/loadAllLocalModules.ts b/src/renderer/host/localModules/loadAllLocalModules.ts new file mode 100644 index 0000000..007e941 --- /dev/null +++ b/src/renderer/host/localModules/loadAllLocalModules.ts @@ -0,0 +1,5 @@ +import { windowModule } from "./window_toplevel.js"; +import { BaseModule } from "../classes/module.js"; +BaseModule.addModule("Cathodique::Window", windowModule); + +import "./window_fakeToplevel.js" diff --git a/src/renderer/host/localModules/window_fakeToplevel.ts b/src/renderer/host/localModules/window_fakeToplevel.ts new file mode 100644 index 0000000..14c5ade --- /dev/null +++ b/src/renderer/host/localModules/window_fakeToplevel.ts @@ -0,0 +1,9 @@ +import { Component } from "../classes/component.js"; +import { windowModule } from "./window_toplevel.js"; + +export class FakeToplevel extends Component { + constructor(contents: string) { + super(windowModule); + } +} +windowModule.localHandle.markAs(FakeToplevel, "Window"); diff --git a/src/renderer/host/localModules/window_toplevel.ts b/src/renderer/host/localModules/window_toplevel.ts new file mode 100644 index 0000000..d1b5a28 --- /dev/null +++ b/src/renderer/host/localModules/window_toplevel.ts @@ -0,0 +1,60 @@ +import { XdgToplevel } from "@cathodique/wl-serv-high/objects"; +import { BaseDomComponent } from "./basedomcomponent.js"; +import { LocalModule } from "../classes/module.js"; +import { Component } from "../classes/component"; +import { componentTypes } from "../utils/types.js"; +import { wlToObj } from "../../classes/handlers/handlers.js"; + +const windowModule = LocalModule.setupModule("Cathodique::Window"); + +class WindowRegistry extends Component { + static type: typeof componentTypes[number] = "SINGLETON"; + static singletonInstance?: WindowRegistry; + + constructor(mod: LocalModule) { + super(mod); + } + + addWindow(window: ToplevelDom) { + this.emit("newWindow", window); + } +} +WindowRegistry.singletonInstance = new WindowRegistry(windowModule); + +export const wlToToplevelDom = new Map(); + +export class ToplevelDom extends BaseDomComponent { + static type: typeof componentTypes[number] = "REF_ONLY"; + + get surfaceDom() { + return wlToObj.get(this.wl.parent.surface)!; + } + + constructor(wl: XdgToplevel) { + super(windowModule, wl, document.createElement("div")); + this.$output.append(this.surfaceDom.dom); + + WindowRegistry.singletonInstance!.addWindow(this); + + wlToToplevelDom.set(wl, this); + } + + get $geometry() { return this.wl.parent.geometry.current } + async init () { + this.emit("setGeometry", this.$geometry); + this.wl.parent.geometry.on("current", () => { + this.emit("setGeometry", this.$geometry); + }); + + this.emit("setTitle", this.wl.title); + this.wl.on("wlSetTitle", () => { + this.emit("setTitle", this.wl.title); + }); + } +} + +windowModule.localHandle.register("Window", ToplevelDom); +windowModule.localHandle.register("WindowRegistry", WindowRegistry); +windowModule.localHandle.markReady(); + +export { windowModule }; diff --git a/src/renderer/host/utils/remoteToLocalAdapter.ts b/src/renderer/host/utils/remoteToLocalAdapter.ts new file mode 100644 index 0000000..8193205 --- /dev/null +++ b/src/renderer/host/utils/remoteToLocalAdapter.ts @@ -0,0 +1,141 @@ +import { EventEmitter } from "events"; +import { Component } from "../classes/component.js"; +import { ComponentListHandle } from "../classes/componentList.js"; +import { Latch, LatchState } from "../classes/latch.js"; +import { RemoteModule } from "../classes/module.js"; +import { OrderedPeer } from "../classes/orderedPeer.js"; +import { unwrapValue, wrapValue } from "./wrap.js"; + +export class ComponentListProxy implements ComponentListHandle { + #module: RemoteModule; + constructor(mod: RemoteModule) { + this.#module = mod; + } + + get(componentName: string) { + const availableComponents = this.#module.availableComponents; + + if (!availableComponents.includes(componentName)) return undefined; + + return { + create: (...args: any[]) => { + return makeComponentProxy(this.#module, componentName, { args }); + } + }; + } +} + +export class ComponentInstance extends EventEmitter { + peer: OrderedPeer; + + module: RemoteModule; + + #args: any[] = []; + + componentName: string; + constructor(module: RemoteModule, componentName: string, options: { componentId: string } | { args: any[] }) { + super(); + this.module = module; + + this.peer = this.module.peer; + this.componentName = componentName; + + if ("componentId" in options) this.#cidLatch.resolve!(options.componentId); + if ("args" in options) this.#args = options.args; + + this.on("newListener", async function (this: ComponentInstance, evtName: string) { + if (evtName === "newListener" || evtName === "removeListener") return; + + if (this.listenerCount(evtName) === 0) { + this.peer.rpc("listenToEvent", { componentId: await this.componentId, eventName: evtName }); + } + }.bind(this)); + this.on("removeListener", async function (this: ComponentInstance, evtName: string) { + if (evtName === "newListener" || evtName === "removeListener") return; + + if (this.listenerCount(evtName) === 0) { + this.peer.rpc("unlistenToEvent", { componentId: await this.componentId, eventName: evtName }); + } + }.bind(this)); + } + + #cidLatch = new Latch(); + get componentId() { return this.#cidLatch.promise; } + + ready = new Latch(); + + async init() { + if (this.#cidLatch.getState() === LatchState.Pending) { + const componentId = await this.peer.rpc("createInstance", { + className: this.componentName, + args: this.#args.map((v) => wrapValue(v)), + }); + this.#cidLatch.resolve!(componentId); + this.ready.resolve!(); + } else { + this.ready.resolve?.(); + } + } +} +export type ComponentInstanceProxy = ComponentInstance + & Record<`$${string}`, any> + & { [Component.isComponentSymbol]: true }; + +function generateCalledOrAwaited({ called, awaited }: { called: (...args: any[]) => any, awaited: () => any }) { + const result = async function (...args: any[]) { + return called(...args); + }; + result.then = async (resolve: (a: any) => void) => { + resolve(await awaited()); + }; + return result; +} + +export function makeComponentProxy(module: RemoteModule, componentName: string, options: { componentId: string } | { args: any[] }): ComponentInstanceProxy { + if ("componentId" in options && module.instanceProxyExists(options.componentId)) { + return module.getInstanceProxy(options.componentId)!; + } + + const compInst = new ComponentInstance(module, componentName, options); + compInst.init(); + + const compInstProxy = new Proxy(compInst, { + get(target, prop) { + if (prop === Component.isComponentSymbol) return true; + + if (target[prop as keyof typeof target]) return target[prop as keyof typeof target]; + + if (prop === "then") return undefined; + + return generateCalledOrAwaited({ + called: async function (...args: any[]) { + const id = await compInst.componentId; + + const val = await module.peer.rpc("callProperty", { + methodName: prop, + arguments: args.map((v) => wrapValue(v)), + componentId: id, + }); + + return unwrapValue(val, module); + }, + awaited: async () => { + const id = await compInst.componentId; + + const val = await module.peer.rpc("getProperty", { + propertyName: prop, + componentId: id, + }); + + return unwrapValue(val, module); + }, + }); + }, + }) as ComponentInstanceProxy; + + (async () => { + const cid = await compInst.componentId; + module.registerInstanceProxy(cid, compInstProxy); + })(); + return compInstProxy; +} diff --git a/src/renderer/host/utils/types.ts b/src/renderer/host/utils/types.ts new file mode 100644 index 0000000..ab01f2f --- /dev/null +++ b/src/renderer/host/utils/types.ts @@ -0,0 +1,67 @@ +import z from "zod"; +import { OrderedPeer } from "../classes/orderedPeer"; +import { Component, ComponentHandle } from "../classes/component"; +import { ComponentInstanceProxy } from "./remoteToLocalAdapter"; + +export const zodElementFromIpc = z.object({ + kind: z.literal("element"), + tagName: z.string(), + attributes: z.array(z.tuple([ z.string().nullable(), z.string(), z.string() ])), + children: z.array(z.string()), + content: z.string().optional(), +}); +export type ElementFromIpc = z.output; + +export const zodTextNodeFromIpc = z.object({ + kind: z.literal("text"), + content: z.string(), +}); +export type TextNodeFromIpc = z.output; + +export const zodDocumentFragmentFromIpc = z.object({ + kind: z.literal("document_fragment"), + children: z.array(z.string()), +}); +export type DocumentFragmentFromIpc = z.output; + +export const zodArbitraryNodeFromIpc = z.object({ + kind: z.literal("arbitrary"), + nodeType: z.string(), +}); +export type ArbitraryNodeFromIpc = z.output; + +export const zodNodeFromIpc = z.union([ + zodElementFromIpc, + zodTextNodeFromIpc, + zodDocumentFragmentFromIpc, + zodArbitraryNodeFromIpc, +]); +export type NodeFromIpc = z.output; + +export interface EventFromIpc { + className: string; + type: string; + values: Record; +} + +export interface HandlerContext { + ipc: OrderedPeer; + event: MessageEvent; +} + +export const componentTypes = [ + "NORMAL", + "SINGLETON", + "REF_ONLY", +] as const; + +export type FactoryOf = { create(...args: U): T | Promise }; +export type ComponentFactory = FactoryOf; +export type ComponentHandleFactory = FactoryOf; +export type ComponentInstanceProxyFactory = FactoryOf; + +export type ClassOf = (FactoryOf | (new (...args: U) => T)) + & { type: typeof componentTypes[number], singletonInstance?: T }; +export type ComponentClass = ClassOf; +export type ComponentHandleClass = ClassOf; +export type ComponentInstanceProxyClass = ClassOf; diff --git a/src/renderer/host/utils/utils.ts b/src/renderer/host/utils/utils.ts new file mode 100644 index 0000000..01ad932 --- /dev/null +++ b/src/renderer/host/utils/utils.ts @@ -0,0 +1,24 @@ +let alphabet = + "useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict"; + +export class ShouldHaveBeenZodError extends Error { + constructor(message?: string) { + super(message || "This error should have been caught by zod"); + } +} + +export function nanoid(e = 21) { + let t = "", + r = crypto.getRandomValues(new Uint8Array(e)); + for (let n = 0; n < e; n++) t += alphabet[63 & r[n]]; + return t; +} + +const [projectSubdomain, userSubdomain, ...hostList] = + window.location.hostname.split("."); +export { projectSubdomain, userSubdomain }; +export const host = hostList.join('.') + +export const hostWithoutSubdomain = `${location.protocol}//${hostList.join(".")}:${location.port}`; + +export const stringStartsWithDollar = (v: string): v is `$${string}` => v.startsWith("$"); diff --git a/src/renderer/host/utils/wrap.ts b/src/renderer/host/utils/wrap.ts new file mode 100644 index 0000000..b39f572 --- /dev/null +++ b/src/renderer/host/utils/wrap.ts @@ -0,0 +1,70 @@ +import z from "zod"; +import { Component } from "../classes/component"; +import { SharedDOM } from "../classes/sharedDomHost"; +import { ComponentInstanceProxy } from "./remoteToLocalAdapter"; +import { BaseModule, RemoteModule } from "../classes/module"; +import { OtherNodeRegistry } from "../classes/sharedDomHost"; + +export const zodWrappedValue = z.union([ + z.object({ + type: z.literal("component"), + componentName: z.string(), + componentId: z.string(), + moduleId: z.string(), + }), + z.object({ + type: z.literal("node"), + nodeId: z.string(), + }), + z.object({ + value: z.any(), + }), +]); +export type WrappedValue = z.output; + +export async function wrapValue(value: any): Promise> { + const isComponent = (v: any): v is (Component | ComponentInstanceProxy) => v?.[Component.isComponentSymbol]; + if (isComponent(value)) { + const isRemote = "componentName" in value; + + return { + type: "component", + componentName: isRemote ? value.componentName : value.module.localHandle.componentTypeOf(value), + componentId: isRemote ? await value.componentId : value.componentId, + moduleId: value.module.opaqueToken, + }; + } + + if (value instanceof Node) { + const nodeId = SharedDOM.initOrGet(value); + + return { + type: "node", + nodeId, + }; + } + + return { value }; +} + +export async function unwrapValue(value: any, fromModule: RemoteModule) { + const wrapped = zodWrappedValue.parse(value); + + if (!("type" in wrapped)) return wrapped.value; + + switch (wrapped.type) { + case "component": + const moduleId = wrapped.moduleId || fromModule.opaqueToken; + const module = BaseModule.moduleByToken(moduleId); + if (!module?.instanceExists(wrapped.componentId)) { + return undefined; + } + // if (module instanceof remote) + // return makeComponentProxy(module, wrapped.componentName, wrapped.componentId); + return module.localHandle; + case "node": + const { source } = fromModule.peer; + if (source instanceof MessagePort || source instanceof ServiceWorker) throw new Error("Source supposed to be a window"); + return OtherNodeRegistry.registryOf(source)!.getNode(value.nodeId); + } +} diff --git a/src/renderer/index.html b/src/renderer/index.html new file mode 100644 index 0000000..915db03 --- /dev/null +++ b/src/renderer/index.html @@ -0,0 +1,35 @@ + + + + + + Document + + + + + + +
+ + diff --git a/src/renderer/utils/domIntersect.ts b/src/renderer/utils/domIntersect.ts new file mode 100644 index 0000000..603f2a9 --- /dev/null +++ b/src/renderer/utils/domIntersect.ts @@ -0,0 +1,13 @@ +export function isIntersecting(dom1: Element, dom2: Element) { + const firstBBox = dom1.getBoundingClientRect(); + const secondBBox = dom2.getBoundingClientRect(); + + // I used to be able to do these things mentally.... I had to google that + + // console.log(firstBBox, secondBBox); + + return firstBBox.left < secondBBox.right + && firstBBox.right > secondBBox.left + && firstBBox.top < secondBBox.bottom + && firstBBox.bottom > secondBBox.top; +} diff --git a/src/renderer/wayland/index.ts b/src/renderer/wayland/index.ts new file mode 100644 index 0000000..28d9b56 --- /dev/null +++ b/src/renderer/wayland/index.ts @@ -0,0 +1,84 @@ +import "../host/index.js"; + +import { HLCompositor } from "@cathodique/wl-serv-high"; +import { InstructionType, RegRectangle } from "@cathodique/wl-serv-high/objects"; +import { KeyboardRegistry } from "@cathodique/wl-serv-high/objects"; +import { ipcRenderer } from "electron/renderer"; +import { BaseObject } from "@cathodique/wl-serv-high/objects"; +import { seatRegistry } from "./overlays/seatRegistryOverlay.js"; +import { outputRegistry } from "./overlays/outputRegistryOverlay.js"; +import { strutRegistry } from "./overlays/strutRegistryOverlay.js"; +import { objectHandlers } from "../classes/handlers/handlers.js"; +import { orchestrator } from "../host/index.js"; + +// HERE +// TODO::: +// Direct events towards their respective authorities +// for both Seat and Output + +export function isInRegion(reg: RegRectangle[], y: number, x: number, defaultValue: boolean = false) { + if (reg.length === 0) return defaultValue; + + return ( + reg.reduce((a, v) => { + if (v.hasCoordinate(y, x)) return v.type; + return a; + }, null) === InstructionType.Add + ); +} + +const mySeatConfig = { + name: "seat0", + capabilities: 3, +}; +seatRegistry.addAuthority(mySeatConfig); +export const seat = seatRegistry.seatOfCfg(mySeatConfig)!; + +const myOutputConfig = { + x: 0, + y: 0, + w: 1920, + h: 1080, + effectiveW: 1920, + effectiveH: 1080, +}; +outputRegistry.addAuthority(myOutputConfig); + +const compo = new HLCompositor({ + wl_registry: { + outputs: outputRegistry, + seats: seatRegistry, + struts: strutRegistry, + }, + wl_keyboard: new KeyboardRegistry({ keymap: "us" }), +}); + +const tickAnimationFrame = () => { + compo.ticks.emit("tick"); + requestAnimationFrame(tickAnimationFrame); +}; +tickAnimationFrame(); + +compo.on("connection", (c) => { + c.on("new_obj", async (obj: BaseObject) => { + const matching = objectHandlers[obj.iface as keyof typeof objectHandlers]; + + if (!matching) return; + + // @ts-ignore + new matching(obj).init(); + }); +}); +compo.start(); + +compo.on("ready", () => { + document.body.append(`Ready at ${compo.params.socketPath}`); + + ipcRenderer.send("addToDeleteQueue", compo.params.socketPath); + ipcRenderer.send(`Ready at ${compo.params.socketPath}.lock`); +}); + +orchestrator.load("WindowManager").then(async (mod) => { + const stuff = await mod!.localHandle.get("WindowManager")!.create(); + document.body.append(await stuff.$output); +}); diff --git a/src/renderer/wayland/overlays/outputRegistryOverlay.ts b/src/renderer/wayland/overlays/outputRegistryOverlay.ts new file mode 100644 index 0000000..38706b0 --- /dev/null +++ b/src/renderer/wayland/overlays/outputRegistryOverlay.ts @@ -0,0 +1,31 @@ +import { OutputConfiguration, OutputRegistry } from "@cathodique/wl-serv-high/registries"; +import { Output } from "../../classes/wayland/output/output"; + +class OutputRegistryOverlay extends OutputRegistry { + // Singleton + static #instance: OutputRegistry; + static create() { + return new OutputRegistryOverlay(); + } + private constructor() { + super(); + if (OutputRegistryOverlay.#instance) throw new Error("Tried to create multiple seat registries"); + OutputRegistryOverlay.#instance = this; + } + + // TODO Memory management + outputs = new Map(); + allOutputs() { + return this.outputs.values(); + } + outputOfCfg(config: OutputConfiguration) { + return this.outputs.get(config); + } + + addAuthority(cfg: OutputConfiguration) { + super.addAuthority(cfg); + this.outputs.set(cfg, new Output(cfg, this)); + } +} + +export const outputRegistry = OutputRegistryOverlay.create(); diff --git a/src/renderer/wayland/overlays/seatRegistryOverlay.ts b/src/renderer/wayland/overlays/seatRegistryOverlay.ts new file mode 100644 index 0000000..dff457a --- /dev/null +++ b/src/renderer/wayland/overlays/seatRegistryOverlay.ts @@ -0,0 +1,31 @@ +import { SeatConfiguration, SeatRegistry } from "@cathodique/wl-serv-high/registries"; +import { Seat } from "../../classes/wayland/seat/seat"; + +class SeatRegistryOverlay extends SeatRegistry { + // Singleton + static #instance: SeatRegistry; + static create() { + return new SeatRegistryOverlay(); + } + private constructor() { + super(); + if (SeatRegistryOverlay.#instance) throw new Error("Tried to create multiple seat registries"); + SeatRegistryOverlay.#instance = this; + } + + // TODO Memory management + seats = new Map(); + allSeats() { + return this.seats.values(); + } + seatOfCfg(config: SeatConfiguration) { + return this.seats.get(config); + } + + addAuthority(cfg: SeatConfiguration) { + super.addAuthority(cfg); + this.seats.set(cfg, new Seat(cfg, this)); + } +} + +export const seatRegistry = SeatRegistryOverlay.create(); diff --git a/src/renderer/wayland/overlays/strutRegistryOverlay.ts b/src/renderer/wayland/overlays/strutRegistryOverlay.ts new file mode 100644 index 0000000..f59f9f1 --- /dev/null +++ b/src/renderer/wayland/overlays/strutRegistryOverlay.ts @@ -0,0 +1,16 @@ +import { StrutRegistry } from "@cathodique/wl-serv-high/registries"; + +class StrutRegistryOverlay extends StrutRegistry { + // Singleton + static #instance: StrutRegistry; + static create() { + return new StrutRegistryOverlay(); + } + private constructor() { + super(); + if (StrutRegistryOverlay.#instance) throw new Error("Tried to create multiple strut registries"); + StrutRegistryOverlay.#instance = this; + } +} + +export const strutRegistry = StrutRegistryOverlay.create(); diff --git a/src/utils.ts b/src/utils.ts deleted file mode 100644 index 908820b..0000000 --- a/src/utils.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { InstructionType, RegRectangle } from "@cathodique/wl-serv-high/dist/objects/wl_region"; - -export function isInRegion(reg: RegRectangle[], y: number, x: number) { - return reg.reduce((a, v) => { - if (!v.hasCoordinate(y, x)) return a; - return v.type; - }, null) === InstructionType.Add; -} diff --git a/tsconfig.json b/tsconfig.json index 38d57f7..6c37621 100755 --- a/tsconfig.json +++ b/tsconfig.json @@ -27,7 +27,7 @@ /* Modules */ "module": "Node16", /* Specify what module code is generated. */ "rootDir": "./src", /* Specify the root folder within your source files. */ - "moduleResolution": "Node16", /* Specify how TypeScript looks up a file from a given module specifier. */ + "moduleResolution": "nodenext", /* Specify how TypeScript looks up a file from a given module specifier. */ // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ @@ -105,5 +105,12 @@ /* Completeness */ // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ "skipLibCheck": true /* Skip type checking all .d.ts files. */ - } + }, + "include": [ + "src/**/*", + "src/renderer/.common/**/*.ts" + ], + "exclude": [ + "de/src/modules" + ] } diff --git a/tsconfig.modules.json b/tsconfig.modules.json new file mode 100644 index 0000000..419cf5e --- /dev/null +++ b/tsconfig.modules.json @@ -0,0 +1,113 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig to read more about this file */ + + /* Projects */ + // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ + // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ + // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ + // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ + // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ + // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ + + /* Language and Environment */ + "target": "ESNext", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ + "lib": ["ESNext", "DOM"], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ + "jsx": "react-jsx", /* Specify what JSX code is generated. */ + // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ + // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ + // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ + // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ + // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ + // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ + // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ + // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ + // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ + + /* Modules */ + "module": "preserve", /* Specify what module code is generated. */ + "rootDir": "./src", /* Specify the root folder within your source files. */ + // "moduleResolution": "Node16", /* Specify how TypeScript looks up a file from a given module specifier. */ + // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ + // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ + // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ + "typeRoots": ["./src/types"], /* Specify multiple folders that act like './node_modules/@types'. */ + // "types": [], /* Specify type package names to be included without being referenced in a source file. */ + // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ + // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ + // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ + // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ + // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ + // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ + // "resolveJsonModule": true, /* Enable importing .json files. */ + // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */ + // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ + + /* JavaScript Support */ + // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ + // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ + // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ + + /* Emit */ + "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ + // "declarationMap": true, /* Create sourcemaps for d.ts files. */ + // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ + // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ + // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ + // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ + "outDir": "./dist", /* Specify an output folder for all emitted files. */ + // "removeComments": true, /* Disable emitting comments. */ + // "noEmit": true, /* Disable emitting files from a compilation. */ + // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ + // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ + // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ + // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ + // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ + // "newLine": "crlf", /* Set the newline character for emitting files. */ + // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ + // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ + // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ + // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ + // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ + // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ + + /* Interop Constraints */ + // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ + // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */ + "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ + "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ + // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ + "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ + + /* Type Checking */ + "strict": true, /* Enable all strict type-checking options. */ + "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ + // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ + // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ + // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ + // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ + // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ + // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ + // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ + // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ + // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ + "exactOptionalPropertyTypes": false, /* Interpret optional property types as written, rather than adding 'undefined'. */ + // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ + // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ + // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ + // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ + // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ + // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ + // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ + + /* Completeness */ + // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ + "skipLibCheck": true /* Skip type checking all .d.ts files. */ + }, + "include": [ + "src/modules/**/*", + "src/modules/.common/**/*" + ] +}