diff --git a/call/.gitignore b/call/.gitignore new file mode 100644 index 00000000..f928cfcf --- /dev/null +++ b/call/.gitignore @@ -0,0 +1,3 @@ +dist +node_modules +.pushwork diff --git a/call/package.json b/call/package.json new file mode 100644 index 00000000..1fd2a067 --- /dev/null +++ b/call/package.json @@ -0,0 +1,31 @@ +{ + "name": "@tiny-patchwork/call", + "version": "0.1.0", + "type": "module", + "private": true, + "main": "./dist/index.js", + "scripts": { + "dev": "vite build --watch", + "build": "vite build", + "sync": "vite build && pushwork sync", + "register": "pw-modules add \"$MODULE_SETTINGS_DOC_URL\" \"$(pushwork url)\"", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@automerge/automerge": "3.3.0-fragments.1", + "@automerge/automerge-repo": "^2.6.0-subduction.26", + "@inkandswitch/patchwork-elements": "^6.0.2", + "@inkandswitch/patchwork-filesystem": "^0.2.9", + "@inkandswitch/patchwork-plugins": "^1.2.5", + "@inkandswitch/patchwork-providers": "^0.5.2", + "solid-js": "^1.9.9" + }, + "devDependencies": { + "@inkandswitch/patchwork-bootloader": "^0.7.2", + "happy-dom": "^15.11.7", + "vite": "^7.1.9", + "vite-plugin-css-injected-by-js": "^3.5.2", + "vitest": "^3.2.4" + } +} diff --git a/call/pnpm-lock.yaml b/call/pnpm-lock.yaml new file mode 100644 index 00000000..6ebe19d7 --- /dev/null +++ b/call/pnpm-lock.yaml @@ -0,0 +1,1641 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@automerge/automerge': + specifier: 3.3.0-fragments.1 + version: 3.3.0-fragments.1 + '@automerge/automerge-repo': + specifier: ^2.6.0-subduction.26 + version: 2.6.0-subduction.49 + '@inkandswitch/patchwork-elements': + specifier: ^6.0.2 + version: 6.2.1(@automerge/automerge-repo@2.6.0-subduction.49)(@inkandswitch/patchwork-filesystem@0.2.19(@automerge/automerge-repo@2.6.0-subduction.49)(@automerge/automerge@3.3.0-fragments.1))(@inkandswitch/patchwork-plugins@1.2.7(@automerge/automerge-repo@2.6.0-subduction.49)(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.2.19(@automerge/automerge-repo@2.6.0-subduction.49)(@automerge/automerge@3.3.0-fragments.1)))(@inkandswitch/patchwork-providers@0.5.5(@automerge/automerge-repo@2.6.0-subduction.49))(solid-js@1.9.19) + '@inkandswitch/patchwork-filesystem': + specifier: ^0.2.9 + version: 0.2.19(@automerge/automerge-repo@2.6.0-subduction.49)(@automerge/automerge@3.3.0-fragments.1) + '@inkandswitch/patchwork-plugins': + specifier: ^1.2.5 + version: 1.2.7(@automerge/automerge-repo@2.6.0-subduction.49)(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.2.19(@automerge/automerge-repo@2.6.0-subduction.49)(@automerge/automerge@3.3.0-fragments.1)) + '@inkandswitch/patchwork-providers': + specifier: ^0.5.2 + version: 0.5.5(@automerge/automerge-repo@2.6.0-subduction.49) + solid-js: + specifier: ^1.9.9 + version: 1.9.19 + devDependencies: + '@inkandswitch/patchwork-bootloader': + specifier: ^0.7.2 + version: 0.7.2(@automerge/automerge-repo@2.6.0-subduction.49)(@automerge/automerge@3.3.0-fragments.1) + happy-dom: + specifier: ^15.11.7 + version: 15.11.7 + vite: + specifier: ^7.1.9 + version: 7.3.8(@types/node@20.19.43) + vite-plugin-css-injected-by-js: + specifier: ^3.5.2 + version: 3.5.2(vite@7.3.8(@types/node@20.19.43)) + vitest: + specifier: ^3.2.4 + version: 3.2.7(@types/debug@4.1.13)(@types/node@20.19.43)(happy-dom@15.11.7) + +packages: + + '@automerge/automerge-repo-network-broadcastchannel@2.6.0-subduction.48': + resolution: {integrity: sha512-fyq/ZqWkrYOuNrQ917KeCaRSuY6fzqH6Q4KCb5czjwKPKO2zFIGKVfK5hZjYsHGWRKuh5BzViaONHn6bJ0Jk4w==} + engines: {node: '>=22.13'} + + '@automerge/automerge-repo-network-messagechannel@2.6.0-subduction.48': + resolution: {integrity: sha512-pf+cmCi/TWQZpqrm+iinVpIM2Ecfzp8JpGEo28SPbE2Q8+eLyO2cz1Dssnuep1+KQ8k7Dfx7aUF9AD7/6jAvTQ==} + engines: {node: '>=22.13'} + + '@automerge/automerge-repo-network-websocket@2.6.0-subduction.48': + resolution: {integrity: sha512-TRKAq4iTdrSpOQYxyKZMJDJ5Civa+vONrAHBZkpK+DGShzqBB6vCdTCsrBGBF2Q63H33qQM0XYBQEQKIa/zM+Q==} + engines: {node: '>=22.13'} + + '@automerge/automerge-repo-storage-indexeddb@2.6.0-subduction.48': + resolution: {integrity: sha512-7XIn/iGrAVS4ugva8m0bU1shhRtuy/fdCAgF6qaIEvbi7XQllLVn3miTTiusLaksNhnRtbMfDLUknazU+vVHew==} + engines: {node: '>=22.13'} + + '@automerge/automerge-repo@2.6.0-subduction.48': + resolution: {integrity: sha512-HNS1YsD0XmQ0vtwIincF7NvEBrQuOMnk+mjCVluRiIpUFvcaVeNAFXCCyBVeXubmg88AHe+LkIQ96v/lyHZ7Wg==} + engines: {node: '>=22.13'} + + '@automerge/automerge-repo@2.6.0-subduction.49': + resolution: {integrity: sha512-2RRd2rrgrAMIj4hXtDCvNtqqpurjkw9I8Hdv9W+CRhMIZPl8QaAVmG1Bs4+TKBELw6WLQA6ISlrpnIChGzhpDA==} + engines: {node: '>=22.13'} + + '@automerge/automerge-subduction@0.16.1': + resolution: {integrity: sha512-alH7U4eYn0O7sT6hNLv7CJhNODJi+QD6qfRQTNfZCxL3Q5JZdJ8lFpdvXUkBf+I3bu0GLvVlTaYPWGrBihjpIw==} + + '@automerge/automerge-subduction@0.20.1': + resolution: {integrity: sha512-lkRDaVr1I+C1N/IKSvn+TtT7WJnmxM0ntfUshG40Xi3INTL2//e8NcMJ8tEXh4GZYdCof37aG/diNuMLndHApQ==} + + '@automerge/automerge@3.3.0-fragments.1': + resolution: {integrity: sha512-Im2n3qcYSuIsLDSjofTRo7CZyyvFn5pKLSCCO5sjWzESw+9q9G25ZIrL2mczDX43cXtyzi+w/4b9VUaiNdY56Q==} + + '@automerge/automerge@3.3.2': + resolution: {integrity: sha512-9vCdCL7pdQwUra66SBxPVHr+/t9epXKni9KDeak2rNBFMzABVh2u6gSpcwxi3jkR5qr047jVqZv9DlrOfVxLFw==} + + '@cbor-extract/cbor-extract-darwin-arm64@2.2.3': + resolution: {integrity: sha512-qpUy9Yoisi6a7QHOVa+AlnqfN7eVstSRhpLwiLnwi6i3SNLkM5D2Z7MXl4LC5nUGA77mZEt479cPH3u8GD163Q==} + cpu: [arm64] + os: [darwin] + + '@cbor-extract/cbor-extract-darwin-x64@2.2.3': + resolution: {integrity: sha512-6bZbK7dyKn8BUNmG3UfzSnM4L/OzJ8xGAtBHYycprl+bgvtZmZoQC7CucWXuF/oHJP5skzqkqFr6BLSSGIkoWw==} + cpu: [x64] + os: [darwin] + + '@cbor-extract/cbor-extract-linux-arm64@2.2.3': + resolution: {integrity: sha512-QAuFuQkOw0cm4MNc+QwxH9aiMxmrczESnVEu33B8m2Du9fBuH/1AvJfmO8YajpeWLoHqt2iUG3d1/gIWAKY8IQ==} + cpu: [arm64] + os: [linux] + + '@cbor-extract/cbor-extract-linux-arm@2.2.3': + resolution: {integrity: sha512-joCovTc7uEYCL9XxdAJJpGIH7Vvc31Fi7vh3HnlY5zgkLSU/64WxYl5iUi/puE21qT3bmxMfOZEZUbnqVKeJTQ==} + cpu: [arm] + os: [linux] + + '@cbor-extract/cbor-extract-linux-x64@2.2.3': + resolution: {integrity: sha512-IQMPVLulOvFv9Had7J1idJyfQzsFamDVdjH37qW7suHI7mgO4w1Oo3MisL7AjnWARxo0/ZmEEQ3i81WA7y34Ug==} + cpu: [x64] + os: [linux] + + '@cbor-extract/cbor-extract-win32-x64@2.2.3': + resolution: {integrity: sha512-fGFfq6F3l0GCoKOZeyjjm3q+NHO7jo8ILtQ93U3rYJOgG2BGLS/QmBdoFLNEsoWPjyxAS8svWhTyo73MTDawog==} + cpu: [x64] + os: [win32] + + '@codemirror/commands@6.11.1': + resolution: {integrity: sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==} + + '@codemirror/language@6.13.1': + resolution: {integrity: sha512-zfvU2r3TyylQLMj/te+5CnQ0rSqZi8B1oTCBbQkagcLW4Xc6imab6qN+pEJhsuNOLaJqiRyBXp9UhWA1DhVvDw==} + + '@codemirror/state@6.7.6': + resolution: {integrity: sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==} + + '@codemirror/streamparser@6.0.0': + resolution: {integrity: sha512-JTFPF6xrfjt0mnfGPgzcLiMwYmvIyNaoizp8BYzv8FE8KrT6GBGDZ190rtt+O7Wke3gG/MFHyiY9YGNeimof4w==} + + '@codemirror/view@6.43.15': + resolution: {integrity: sha512-HHsfpqFeHyXLs983pSRe38Xajs0vFWZQ2BuX0m7Ki8w3jZ5iD8cuR3lBz2/5/X53Dhf32SBHKh6rtSTB6tKeGw==} + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@inkandswitch/patchwork-bootloader@0.7.2': + resolution: {integrity: sha512-b5ki9fodXB7uEzvBvpZ0uAkaM7CwwtAdD596XKZJY+TjaSnYsWnx0IP0ewz9rL6ASU282ItyCB8917PKxr8ckA==} + peerDependencies: + '@automerge/automerge': 3.5.0 + '@automerge/automerge-repo': 2.6.0-subduction.48 + '@automerge/automerge-repo-keyhive': 0.5.0-alpha.7 + '@automerge/vanillajs': 2.6.0-subduction.48 + peerDependenciesMeta: + '@automerge/automerge': + optional: true + '@automerge/automerge-repo': + optional: true + '@automerge/automerge-repo-keyhive': + optional: true + '@automerge/vanillajs': + optional: true + + '@inkandswitch/patchwork-elements@6.2.1': + resolution: {integrity: sha512-10zdSD9A/mjT6fLqlHzomxxGhL62w5EC6pxClrOFTLxaci10G+uRNVU5vOuQRRg3A84kYSxLXXTTjKyo7VAvlA==} + peerDependencies: + '@automerge/automerge-repo': '*' + '@automerge/automerge-repo-keyhive': '*' + '@inkandswitch/patchwork-filesystem': ^0.2.19 + '@inkandswitch/patchwork-plugins': ^1.2.7 + '@inkandswitch/patchwork-providers': ^0.5.5 + '@types/react': '*' + react: '*' + solid-js: '*' + peerDependenciesMeta: + '@automerge/automerge-repo-keyhive': + optional: true + '@types/react': + optional: true + react: + optional: true + solid-js: + optional: true + + '@inkandswitch/patchwork-filesystem@0.2.19': + resolution: {integrity: sha512-2+ImuE8G0kp3ILQxyDXjoibidMsXxPSHuOfZjgNB4FoohKXIr377ooUaJt4LEXsNdsyF5e5ccr7wGPBIBnHlgA==} + peerDependencies: + '@automerge/automerge': '*' + '@automerge/automerge-repo': '*' + + '@inkandswitch/patchwork-plugins@1.2.7': + resolution: {integrity: sha512-i5mOHa/P296lOw381aEc1lzqr/0aXfdSokH7B1UyoFvBtrJIr5txP0Ttd90/4sIFzeIcV1rABeIW7BoN4InckQ==} + peerDependencies: + '@automerge/automerge': '*' + '@automerge/automerge-repo': '*' + '@inkandswitch/patchwork-filesystem': ^0.2.19 + + '@inkandswitch/patchwork-providers@0.5.5': + resolution: {integrity: sha512-yD7vCwDeHbUKDcD2szthqx77RzUWEp3S3aMoN7IPPwYyCWJSeq+9/hlJ4WGD4d6NUP2P6mIkdjmdcVHOQVmuAA==} + peerDependencies: + '@automerge/automerge-repo': '*' + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@keyhive/keyhive@0.1.0-alpha.8': + resolution: {integrity: sha512-juyDs15N3xyKl9mtGHoghdxiCGiXVReS4GufjUMATE3TsgASA/fznISCic+sKc9mMg+aAOXWFAbNELfBe3GyvA==} + + '@lezer/common@1.5.5': + resolution: {integrity: sha512-Pw7PVCndruF1pb3ebCxpSVpkMHSDjwRCLcTRw+RizUKco5QbWylwq2g4lnG/RYN7yTTQDXE9F77MwtkAyphrjg==} + + '@lezer/highlight@1.2.5': + resolution: {integrity: sha512-O1GMVKgtf5YspFaRzpmqkVgMtIk2HG9uMAbH6wdAIdNh3TVzCsch/+cRi+6l2//UGDKe6NB9BW3cvuR5ezMQ0w==} + + '@lezer/lr@1.4.12': + resolution: {integrity: sha512-On9HU4oMMiHPMkQDgoTnGahjgG5n/MkN38CgG77sMraKLL+3JBbeQNi1FcCXV9QRqwF9/BUXP3caHyJC1fBOTQ==} + + '@marijn/find-cluster-break@1.0.4': + resolution: {integrity: sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==} + + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + + '@rollup/rollup-android-arm-eabi@4.68.2': + resolution: {integrity: sha512-ItKrNMkEL3hy85kLBXwB0+M6CWWVI8vr7RV+Eb7oDlQIMXLaiEhGfdaj5jAUadYn2v3nwzAhIRl/nLyjAqV9vQ==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.68.2': + resolution: {integrity: sha512-dCkT8BMPLo6X6GCFVtuzrnr981PpJSeK4ZO/d/mZdJ/ao61Qa1Usd/OHIQ69MQgw4qQclR/X6uRRUONiWwD8cw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-android-x64@4.68.2': + resolution: {integrity: sha512-p2jkKiscqQOGmyL6qu+KvJctvBc3SQTd+Xi2lQYn5QvFLEWz+QI8Z9iI+xcIjez6vKRsSnWP56Y3Ep7g3Stiug==} + cpu: [x64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.68.2': + resolution: {integrity: sha512-aVIfOteo5zoX8aL742H/S3QsyN6aokNYjdzrLnQBKxgRVoh4cibTErpi61RzSmrv2hBzDB1i751hwwdc8QK0vQ==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.68.2': + resolution: {integrity: sha512-+Wuf4TFTHiE/WnjKgH1SkNf/9D5ddoed1UR0p3jxjNAGemdeeQRL+r1bJ1LlZyha0VKDBYLB2U/a9a9KlSw4Kw==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.68.2': + resolution: {integrity: sha512-feKK2dc13j/6D9mAp7XKhBM/VUqn0N/xRgP6rT7/mdRVVJUaeZ4lsJ9hN90aTi0k3OD+mkjlcN01vQ96Ac9VzQ==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.68.2': + resolution: {integrity: sha512-s1oGXPGX1PNqdi9TLWD+RwtPEYf/BOTbCcq4anJBqNbzyjucti86ldYH37eKvsyTP7Bl5/Hf8XmbIxCz/iafTA==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.68.2': + resolution: {integrity: sha512-ksR3ZmzHrm6HOUP8UlDBy5hNTcG9cEIa3EyGnxn5BjorLRjBh4ucxhwjwwVEm0dqcIN4Egc/XjDUiPQwN1ic9A==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.68.2': + resolution: {integrity: sha512-STZVVBM35wULVsQah0xKfCBQMPpHliOfjnRaUNT35jrbpfapUrD8ix6Yhl7tLHW0xQSWotXSSghjUi/DclwRSw==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.68.2': + resolution: {integrity: sha512-Fm/GPC2R0zlJYQIp9aY9L1lO2z+P+UrcvXG6fhubfo/BbMOLZlLXshnbECOTqr3pxfZFb6nNn0QgDETDtrX7TQ==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.68.2': + resolution: {integrity: sha512-VoqlckWcwNF3WqsUkLZg91zYFgBvrC37Lc5e8AU+BKdCUFDMPoN0Rsin/LvF+rBVF527gWo5KT0oHkaAZaL1GA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.68.2': + resolution: {integrity: sha512-Zr8X/SUf7Jjwxt99BqPDF/Wq+sz4oUBIP6HBaFQWwb8wO1nV6LBIpD6jB9cMybLho93Z+B8rrN0uXEL6oNOUJQ==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.68.2': + resolution: {integrity: sha512-2EhrYEJNXIyhSZESwm/kCke1tiH7QAPOU3v3MsLM9J+Nfn2QCDe/pGROUAvYFK0BL2MEbzNgfrqrbnE5CFXkLw==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.68.2': + resolution: {integrity: sha512-IC0dYuEQknw0m2FYBNBKlbrgBJBH5OMRiaRfCc4CFSpYnmDnqTpNSv+TgX+loezGTdRjBNZuYZi3t+Oc5NxNYg==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.68.2': + resolution: {integrity: sha512-Zlzm6fGuDvtAqMEl071/g17vxq6u2Y2v/rON/gDY4NvzWLJ41BGAlwhF3QuAzwq05zIPo4LYzdqwhx3oisYCUw==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.68.2': + resolution: {integrity: sha512-KhEpYRtHZ3Kj4shavjXeH+K1bQlA6UbP6MJ45rMPF/11JlZb1SRNLeuWCdigUEoMMaFO4+LwwcS+ew1SoLJu/g==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.68.2': + resolution: {integrity: sha512-LPn9qvPsM/Ofd6Ekq3im6nB8Tk1ruJhTd3x/2bJkTR8m/XTsfxA+EU0RJ23WJqgJg123kitHAqbEK7B4cL1uPg==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.68.2': + resolution: {integrity: sha512-Q1+E+pzSP+FiXuIZtxKR+pK7l2KQ9gsM+VaFPv1LR277kp7l61crG46WGLKLLay3Js5cvyU9Keqdzxa1EcFO+g==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.68.2': + resolution: {integrity: sha512-3Btp4Lca/WzsmbaKu9Lh9d/XwyRDIegd/5khRBusHGw/Ll6SoF5Shn63OrjPdkLcpNi2PGDApXzNmxriRxGYjA==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.68.2': + resolution: {integrity: sha512-5SKVaUiVs18+DGBnpg0mORGxgq/0udczngGxV/vf2nvK1djqpOKIxY/+8rBWxAbWGRE4RnnwxWabDwJN3pCwIQ==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.68.2': + resolution: {integrity: sha512-KBpm48/lESsHgWd+dRlcy18APBYhXU4Jb6VlJoqbpWNwfpztFXwP22qVv9aHd3k1quSrmLeJwYLc7rpQ+BFZrw==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.68.2': + resolution: {integrity: sha512-o0D0bfRW8QGA1Ph6OzqVHMq5LRu1xkr1iM7qQIXUSFuR60ddXeDhN9UR4bUBPLYsrT46sZwwgQa51TWtBV3r5g==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.68.2': + resolution: {integrity: sha512-r17kcu5kmd6WQwokYmfBnfwM/icureCWCPjpJVALcrSv5Lfy6Ggk9azxXor6hILhCHbc/4IfEiv+L5RXaOHinA==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.68.2': + resolution: {integrity: sha512-yUtOqq7ZrQoZ3WiHSR6QGTdOl4YgMHqXIYSBYU6siIcC0YF0pEwx7aGsPTm2mRwrmW6SMJkxzEdFm6wqXpmbNw==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.68.2': + resolution: {integrity: sha512-5IYoWxQGo5WECbJbImIf2w33uCHJNLv4o/FQtSyeYDed7AC6zx84NsEcGYccoI2geDEYkrxUXcsYK/6EhwVSIg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.68.2': + resolution: {integrity: sha512-ee+c14ggUiM7lg4PtCjSBYU9O9dHp4FFespRnd5yj7SilUBJCL3x3aHlB+sIqbGjcPo3pEyODASnfKJuZ26YBw==} + cpu: [x64] + os: [win32] + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/debug@4.1.13': + resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.10': + resolution: {integrity: sha512-lTyOMnOcmRBzdCv6/4zUZCrEhV0xrqQiUZBZtVcbmLHBXKLnN8q89v/wKRF/Q0HbwVsqbIG3o5HDoFl9sduM+g==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + + '@types/node@20.19.43': + resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==} + + '@types/serviceworker@0.0.153': + resolution: {integrity: sha512-/cg6dFEkNchJLyRCGo4Gb8mF200qr3xskM5dCPgbtK0OzXxcFcXa6BEBdyG7JksRsTrvCR+V6aFPncoOYAwYhQ==} + + '@vitest/expect@3.2.7': + resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==} + + '@vitest/mocker@3.2.7': + resolution: {integrity: sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.7': + resolution: {integrity: sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==} + + '@vitest/runner@3.2.7': + resolution: {integrity: sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==} + + '@vitest/snapshot@3.2.7': + resolution: {integrity: sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==} + + '@vitest/spy@3.2.7': + resolution: {integrity: sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==} + + '@vitest/utils@3.2.7': + resolution: {integrity: sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + base-x@5.0.1: + resolution: {integrity: sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==} + + bs58@6.0.0: + resolution: {integrity: sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==} + + bs58check@4.0.0: + resolution: {integrity: sha512-FsGDOnFg9aVI9erdriULkd/JjEWONV/lQE5aYziB5PoBsXRind56lh8doIZIc9X4HoxT5x4bLjMWN1/NB8Zp5g==} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + cbor-extract@2.2.3: + resolution: {integrity: sha512-zTOCEYwiQDChj5DV/sUqccv3jhdZqbhymztwcrW2LncIG2fPPkb1ceK5rmr0bdtxzJCXoWzZ95shA+uaSH8cKw==} + hasBin: true + + cbor-x@1.6.9: + resolution: {integrity: sha512-TCT9XHLb4iPGQ99fpQjcTRncbus6cXHvSD/RY+bPrDACBCJKiQLmAM6r7/BgQcI5i+8h4gwp4vjfb6S6T8bPGw==} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + crelt@1.0.7: + resolution: {integrity: sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fast-sha256@1.3.0: + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + happy-dom@15.11.7: + resolution: {integrity: sha512-KyrFvnl+J9US63TEzwoiJOQzZBJY7KgBushJA8X61DMbNsH+2ONkDuLDnCnwUiPTF42tLoEmrPyoqbenVA5zrg==} + engines: {node: '>=18.0.0'} + + isomorphic-ws@5.0.0: + resolution: {integrity: sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==} + peerDependencies: + ws: '*' + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.20: + resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-gyp-build-optional-packages@5.1.1: + resolution: {integrity: sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw==} + hasBin: true + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + postcss@8.5.32: + resolution: {integrity: sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==} + engines: {node: ^10 || ^12 || >=14} + + resolve.exports@2.0.4: + resolution: {integrity: sha512-zepuGL0ulf+nt+XaFrlKeaiTJeyPFPLLY62+ncq1/VTHBdt3HsfXOH60priZ6tnXQozdGcDeIF5JVTanvIcYWQ==} + engines: {node: '>=10'} + + rollup@4.68.2: + resolution: {integrity: sha512-xzt45rKOxzEuqjTdkGTIEaePcq9xRELlFI1uoazlKap8T6bENJkONSCfhK51P6XFTixa4rosPcDHihSdduXndQ==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + seroval-plugins@1.6.12: + resolution: {integrity: sha512-+reiwp/lYxiyctSrdvX7OQ1YLrcbWYgBiZrhSFL+V43b0/DGRLNVSDyZINbQe+tWplPOI2fw2dPrdv6hcc09kQ==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + + seroval@1.6.12: + resolution: {integrity: sha512-CqbIVfk/7GL3ITwZI55lcPbpQbjHZ2DD5cUj4iH7E9YsCfHNJV1mx7yNp86Z84w5IpmkZZS/2s5vPOWmVWSdQg==} + engines: {node: '>=10'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + solid-js@1.9.19: + resolution: {integrity: sha512-NIrV/6o1FtmrA9iuAAYvnQJZaHrGcaH3LKSVj4kCiZfwqjCtJNDgIgR9XcqYl6KR/Lb3oRx1HrlyswPWR8SRvw==} + + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + + style-mod@4.1.4: + resolution: {integrity: sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==} + + tinyargs@0.1.4: + resolution: {integrity: sha512-5OpdhMRRE70j0zT7mrBpx12FJI+y0EGx8zMe8Vl2/aiwGgpW3X70OVHlsHmHeeG0ncnnWdq1o+k/S5d9ONgzGA==} + engines: {node: '>=14'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.6: + resolution: {integrity: sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==} + engines: {node: '>=14.0.0'} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + uuid@14.0.3: + resolution: {integrity: sha512-Sc5u1VVln93y1Xnz4R+CunOZc7R9LUcTmhj7Rc7Eg3HVcf7rVn2FOoa812fe0W8p3jD7xeoGSYbfqCGGH2B6xw==} + hasBin: true + + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite-plugin-css-injected-by-js@3.5.2: + resolution: {integrity: sha512-2MpU/Y+SCZyWUB6ua3HbJCrgnF0KACAsmzOQt1UvRVJCGF6S8xdA3ZUhWcWdM9ivG4I5az8PnQmwwrkC2CAQrQ==} + peerDependencies: + vite: '>2.0.0-0' + + vite@7.3.8: + resolution: {integrity: sha512-3tTi78MAKhVk9h+YZVXMQ8POmneJuE97QUPAWtJghHzhH7BAyiqQOC2KZh1DWOsu/USB2cRXhznPByaabvYXfw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@3.2.7: + resolution: {integrity: sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.7 + '@vitest/ui': 3.2.7 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + w3c-keyname@2.2.8: + resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + ws@8.23.0: + resolution: {integrity: sha512-wCkmX8QMOrKjgO8HW2WyDbTSGeqkocxgDzS9iR79rEAexty6/2qzilx3Ag8Tqf9ykop3irLKle0MtVn5qjVnoQ==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xstate@5.33.4: + resolution: {integrity: sha512-eGNMu3BNqU0Z85P+ouYG5OCZqIABU+QsW6vUieFt9p9gOGs3C+BL10Exg7XdRPSXPQKlP6XoYpYf93r9VmW8mQ==} + +snapshots: + + '@automerge/automerge-repo-network-broadcastchannel@2.6.0-subduction.48': + dependencies: + '@automerge/automerge-repo': 2.6.0-subduction.48 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@automerge/automerge-repo-network-messagechannel@2.6.0-subduction.48': + dependencies: + '@automerge/automerge-repo': 2.6.0-subduction.48 + eventemitter3: 5.0.4 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@automerge/automerge-repo-network-websocket@2.6.0-subduction.48': + dependencies: + '@automerge/automerge-repo': 2.6.0-subduction.48 + cbor-x: 1.6.9 + debug: 4.4.3 + eventemitter3: 5.0.4 + ws: 8.23.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@automerge/automerge-repo-storage-indexeddb@2.6.0-subduction.48': + dependencies: + '@automerge/automerge-repo': 2.6.0-subduction.48 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@automerge/automerge-repo@2.6.0-subduction.48': + dependencies: + '@automerge/automerge': 3.3.2 + '@automerge/automerge-subduction': 0.16.1 + bs58check: 4.0.0 + cbor-x: 1.6.9 + debug: 4.4.3 + eventemitter3: 5.0.4 + fast-sha256: 1.3.0 + isomorphic-ws: 5.0.0(ws@8.23.0) + uuid: 14.0.3 + ws: 8.23.0 + xstate: 5.33.4 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@automerge/automerge-repo@2.6.0-subduction.49': + dependencies: + '@automerge/automerge': 3.3.2 + '@automerge/automerge-subduction': 0.20.1 + bs58check: 4.0.0 + cbor-x: 1.6.9 + debug: 4.4.3 + eventemitter3: 5.0.4 + fast-sha256: 1.3.0 + isomorphic-ws: 5.0.0(ws@8.23.0) + uuid: 14.0.3 + ws: 8.23.0 + xstate: 5.33.4 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@automerge/automerge-subduction@0.16.1': {} + + '@automerge/automerge-subduction@0.20.1': {} + + '@automerge/automerge@3.3.0-fragments.1': {} + + '@automerge/automerge@3.3.2': {} + + '@cbor-extract/cbor-extract-darwin-arm64@2.2.3': + optional: true + + '@cbor-extract/cbor-extract-darwin-x64@2.2.3': + optional: true + + '@cbor-extract/cbor-extract-linux-arm64@2.2.3': + optional: true + + '@cbor-extract/cbor-extract-linux-arm@2.2.3': + optional: true + + '@cbor-extract/cbor-extract-linux-x64@2.2.3': + optional: true + + '@cbor-extract/cbor-extract-win32-x64@2.2.3': + optional: true + + '@codemirror/commands@6.11.1': + dependencies: + '@codemirror/language': 6.13.1 + '@codemirror/state': 6.7.6 + '@codemirror/view': 6.43.15 + '@lezer/common': 1.5.5 + + '@codemirror/language@6.13.1': + dependencies: + '@codemirror/state': 6.7.6 + '@codemirror/streamparser': 6.0.0 + '@codemirror/view': 6.43.15 + '@lezer/common': 1.5.5 + '@lezer/highlight': 1.2.5 + '@lezer/lr': 1.4.12 + style-mod: 4.1.4 + + '@codemirror/state@6.7.6': + dependencies: + '@marijn/find-cluster-break': 1.0.4 + + '@codemirror/streamparser@6.0.0': {} + + '@codemirror/view@6.43.15': + dependencies: + '@codemirror/state': 6.7.6 + crelt: 1.0.7 + style-mod: 4.1.4 + w3c-keyname: 2.2.8 + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@inkandswitch/patchwork-bootloader@0.7.2(@automerge/automerge-repo@2.6.0-subduction.49)(@automerge/automerge@3.3.0-fragments.1)': + dependencies: + '@automerge/automerge-repo-network-broadcastchannel': 2.6.0-subduction.48 + '@automerge/automerge-repo-network-messagechannel': 2.6.0-subduction.48 + '@automerge/automerge-repo-network-websocket': 2.6.0-subduction.48 + '@automerge/automerge-repo-storage-indexeddb': 2.6.0-subduction.48 + '@automerge/automerge-subduction': 0.16.1 + '@codemirror/commands': 6.11.1 + '@codemirror/language': 6.13.1 + '@codemirror/state': 6.7.6 + '@codemirror/view': 6.43.15 + '@inkandswitch/patchwork-elements': 6.2.1(@automerge/automerge-repo@2.6.0-subduction.49)(@inkandswitch/patchwork-filesystem@0.2.19(@automerge/automerge-repo@2.6.0-subduction.49)(@automerge/automerge@3.3.0-fragments.1))(@inkandswitch/patchwork-plugins@1.2.7(@automerge/automerge-repo@2.6.0-subduction.49)(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.2.19(@automerge/automerge-repo@2.6.0-subduction.49)(@automerge/automerge@3.3.0-fragments.1)))(@inkandswitch/patchwork-providers@0.5.5(@automerge/automerge-repo@2.6.0-subduction.49))(solid-js@1.9.19) + '@inkandswitch/patchwork-filesystem': 0.2.19(@automerge/automerge-repo@2.6.0-subduction.49)(@automerge/automerge@3.3.0-fragments.1) + '@inkandswitch/patchwork-plugins': 1.2.7(@automerge/automerge-repo@2.6.0-subduction.49)(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.2.19(@automerge/automerge-repo@2.6.0-subduction.49)(@automerge/automerge@3.3.0-fragments.1)) + '@inkandswitch/patchwork-providers': 0.5.5(@automerge/automerge-repo@2.6.0-subduction.49) + '@keyhive/keyhive': 0.1.0-alpha.8 + '@types/debug': 4.1.13 + debug: 4.4.3 + resolve.exports: 2.0.4 + service-worker-types: '@types/serviceworker@0.0.153' + solid-js: 1.9.19 + tinyargs: 0.1.4 + optionalDependencies: + '@automerge/automerge': 3.3.0-fragments.1 + '@automerge/automerge-repo': 2.6.0-subduction.49 + transitivePeerDependencies: + - '@types/react' + - bufferutil + - react + - supports-color + - utf-8-validate + + '@inkandswitch/patchwork-elements@6.2.1(@automerge/automerge-repo@2.6.0-subduction.49)(@inkandswitch/patchwork-filesystem@0.2.19(@automerge/automerge-repo@2.6.0-subduction.49)(@automerge/automerge@3.3.0-fragments.1))(@inkandswitch/patchwork-plugins@1.2.7(@automerge/automerge-repo@2.6.0-subduction.49)(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.2.19(@automerge/automerge-repo@2.6.0-subduction.49)(@automerge/automerge@3.3.0-fragments.1)))(@inkandswitch/patchwork-providers@0.5.5(@automerge/automerge-repo@2.6.0-subduction.49))(solid-js@1.9.19)': + dependencies: + '@automerge/automerge-repo': 2.6.0-subduction.49 + '@inkandswitch/patchwork-filesystem': 0.2.19(@automerge/automerge-repo@2.6.0-subduction.49)(@automerge/automerge@3.3.0-fragments.1) + '@inkandswitch/patchwork-plugins': 1.2.7(@automerge/automerge-repo@2.6.0-subduction.49)(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.2.19(@automerge/automerge-repo@2.6.0-subduction.49)(@automerge/automerge@3.3.0-fragments.1)) + '@inkandswitch/patchwork-providers': 0.5.5(@automerge/automerge-repo@2.6.0-subduction.49) + debug: 4.4.3 + optionalDependencies: + solid-js: 1.9.19 + transitivePeerDependencies: + - supports-color + + '@inkandswitch/patchwork-filesystem@0.2.19(@automerge/automerge-repo@2.6.0-subduction.49)(@automerge/automerge@3.3.0-fragments.1)': + dependencies: + '@automerge/automerge': 3.3.0-fragments.1 + '@automerge/automerge-repo': 2.6.0-subduction.49 + debug: 4.4.3 + resolve.exports: 2.0.4 + transitivePeerDependencies: + - supports-color + + '@inkandswitch/patchwork-plugins@1.2.7(@automerge/automerge-repo@2.6.0-subduction.49)(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.2.19(@automerge/automerge-repo@2.6.0-subduction.49)(@automerge/automerge@3.3.0-fragments.1))': + dependencies: + '@automerge/automerge': 3.3.0-fragments.1 + '@automerge/automerge-repo': 2.6.0-subduction.49 + '@inkandswitch/patchwork-filesystem': 0.2.19(@automerge/automerge-repo@2.6.0-subduction.49)(@automerge/automerge@3.3.0-fragments.1) + '@types/debug': 4.1.13 + '@types/node': 20.19.43 + debug: 4.4.3 + eventemitter3: 5.0.4 + resolve.exports: 2.0.4 + transitivePeerDependencies: + - supports-color + + '@inkandswitch/patchwork-providers@0.5.5(@automerge/automerge-repo@2.6.0-subduction.49)': + dependencies: + '@automerge/automerge-repo': 2.6.0-subduction.49 + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@keyhive/keyhive@0.1.0-alpha.8': {} + + '@lezer/common@1.5.5': {} + + '@lezer/highlight@1.2.5': + dependencies: + '@lezer/common': 1.5.5 + + '@lezer/lr@1.4.12': + dependencies: + '@lezer/common': 1.5.5 + + '@marijn/find-cluster-break@1.0.4': {} + + '@noble/hashes@1.8.0': {} + + '@rollup/rollup-android-arm-eabi@4.68.2': + optional: true + + '@rollup/rollup-android-arm64@4.68.2': + optional: true + + '@rollup/rollup-android-x64@4.68.2': + optional: true + + '@rollup/rollup-darwin-arm64@4.68.2': + optional: true + + '@rollup/rollup-darwin-x64@4.68.2': + optional: true + + '@rollup/rollup-freebsd-arm64@4.68.2': + optional: true + + '@rollup/rollup-freebsd-x64@4.68.2': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.68.2': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.68.2': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.68.2': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.68.2': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.68.2': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.68.2': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.68.2': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.68.2': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.68.2': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.68.2': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.68.2': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.68.2': + optional: true + + '@rollup/rollup-linux-x64-musl@4.68.2': + optional: true + + '@rollup/rollup-openbsd-x64@4.68.2': + optional: true + + '@rollup/rollup-openharmony-arm64@4.68.2': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.68.2': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.68.2': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.68.2': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.68.2': + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/debug@4.1.13': + dependencies: + '@types/ms': 2.1.0 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.10': {} + + '@types/ms@2.1.0': {} + + '@types/node@20.19.43': + dependencies: + undici-types: 6.21.0 + + '@types/serviceworker@0.0.153': {} + + '@vitest/expect@3.2.7': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.7(vite@7.3.8(@types/node@20.19.43))': + dependencies: + '@vitest/spy': 3.2.7 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.8(@types/node@20.19.43) + + '@vitest/pretty-format@3.2.7': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.7': + dependencies: + '@vitest/utils': 3.2.7 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.7': + dependencies: + tinyspy: 4.0.6 + + '@vitest/utils@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + + assertion-error@2.0.1: {} + + base-x@5.0.1: {} + + bs58@6.0.0: + dependencies: + base-x: 5.0.1 + + bs58check@4.0.0: + dependencies: + '@noble/hashes': 1.8.0 + bs58: 6.0.0 + + cac@6.7.14: {} + + cbor-extract@2.2.3: + dependencies: + node-gyp-build-optional-packages: 5.1.1 + optionalDependencies: + '@cbor-extract/cbor-extract-darwin-arm64': 2.2.3 + '@cbor-extract/cbor-extract-darwin-x64': 2.2.3 + '@cbor-extract/cbor-extract-linux-arm': 2.2.3 + '@cbor-extract/cbor-extract-linux-arm64': 2.2.3 + '@cbor-extract/cbor-extract-linux-x64': 2.2.3 + '@cbor-extract/cbor-extract-win32-x64': 2.2.3 + optional: true + + cbor-x@1.6.9: + optionalDependencies: + cbor-extract: 2.2.3 + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + check-error@2.1.3: {} + + crelt@1.0.7: {} + + csstype@3.2.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-eql@5.0.2: {} + + detect-libc@2.1.2: + optional: true + + entities@4.5.0: {} + + es-module-lexer@1.7.0: {} + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.10 + + eventemitter3@5.0.4: {} + + expect-type@1.4.0: {} + + fast-sha256@1.3.0: {} + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + fsevents@2.3.3: + optional: true + + happy-dom@15.11.7: + dependencies: + entities: 4.5.0 + webidl-conversions: 7.0.0 + whatwg-mimetype: 3.0.0 + + isomorphic-ws@5.0.0(ws@8.23.0): + dependencies: + ws: 8.23.0 + + js-tokens@9.0.1: {} + + loupe@3.2.1: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + ms@2.1.3: {} + + nanoid@3.3.20: {} + + node-gyp-build-optional-packages@5.1.1: + dependencies: + detect-libc: 2.1.2 + optional: true + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + postcss@8.5.32: + dependencies: + nanoid: 3.3.20 + picocolors: 1.1.1 + source-map-js: 1.2.2 + + resolve.exports@2.0.4: {} + + rollup@4.68.2: + dependencies: + '@types/estree': 1.0.10 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.68.2 + '@rollup/rollup-android-arm64': 4.68.2 + '@rollup/rollup-android-x64': 4.68.2 + '@rollup/rollup-darwin-arm64': 4.68.2 + '@rollup/rollup-darwin-x64': 4.68.2 + '@rollup/rollup-freebsd-arm64': 4.68.2 + '@rollup/rollup-freebsd-x64': 4.68.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.68.2 + '@rollup/rollup-linux-arm-musleabihf': 4.68.2 + '@rollup/rollup-linux-arm64-gnu': 4.68.2 + '@rollup/rollup-linux-arm64-musl': 4.68.2 + '@rollup/rollup-linux-loong64-gnu': 4.68.2 + '@rollup/rollup-linux-loong64-musl': 4.68.2 + '@rollup/rollup-linux-ppc64-gnu': 4.68.2 + '@rollup/rollup-linux-ppc64-musl': 4.68.2 + '@rollup/rollup-linux-riscv64-gnu': 4.68.2 + '@rollup/rollup-linux-riscv64-musl': 4.68.2 + '@rollup/rollup-linux-s390x-gnu': 4.68.2 + '@rollup/rollup-linux-x64-gnu': 4.68.2 + '@rollup/rollup-linux-x64-musl': 4.68.2 + '@rollup/rollup-openbsd-x64': 4.68.2 + '@rollup/rollup-openharmony-arm64': 4.68.2 + '@rollup/rollup-win32-arm64-msvc': 4.68.2 + '@rollup/rollup-win32-ia32-msvc': 4.68.2 + '@rollup/rollup-win32-x64-gnu': 4.68.2 + '@rollup/rollup-win32-x64-msvc': 4.68.2 + fsevents: 2.3.3 + + seroval-plugins@1.6.12(seroval@1.6.12): + dependencies: + seroval: 1.6.12 + + seroval@1.6.12: {} + + siginfo@2.0.0: {} + + solid-js@1.9.19: + dependencies: + csstype: 3.2.3 + seroval: 1.6.12 + seroval-plugins: 1.6.12(seroval@1.6.12) + + source-map-js@1.2.2: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + + style-mod@4.1.4: {} + + tinyargs@0.1.4: {} + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.6: {} + + undici-types@6.21.0: {} + + uuid@14.0.3: {} + + vite-node@3.2.4(@types/node@20.19.43): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.8(@types/node@20.19.43) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite-plugin-css-injected-by-js@3.5.2(vite@7.3.8(@types/node@20.19.43)): + dependencies: + vite: 7.3.8(@types/node@20.19.43) + + vite@7.3.8(@types/node@20.19.43): + dependencies: + esbuild: 0.28.2 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + postcss: 8.5.32 + rollup: 4.68.2 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 20.19.43 + fsevents: 2.3.3 + + vitest@3.2.7(@types/debug@4.1.13)(@types/node@20.19.43)(happy-dom@15.11.7): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.7 + '@vitest/mocker': 3.2.7(vite@7.3.8(@types/node@20.19.43)) + '@vitest/pretty-format': 3.2.7 + '@vitest/runner': 3.2.7 + '@vitest/snapshot': 3.2.7 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.4.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.7 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.17 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.8(@types/node@20.19.43) + vite-node: 3.2.4(@types/node@20.19.43) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/debug': 4.1.13 + '@types/node': 20.19.43 + happy-dom: 15.11.7 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + w3c-keyname@2.2.8: {} + + webidl-conversions@7.0.0: {} + + whatwg-mimetype@3.0.0: {} + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + ws@8.23.0: {} + + xstate@5.33.4: {} diff --git a/call/pnpm-workspace.yaml b/call/pnpm-workspace.yaml new file mode 100644 index 00000000..07535357 --- /dev/null +++ b/call/pnpm-workspace.yaml @@ -0,0 +1,9 @@ +# pnpm 11 reads its settings from this file. There is no root workspace, so +# every package carries the settings it needs rather than inheriting them. +minimumReleaseAge: 0 +verifyDepsBeforeRun: false +allowBuilds: + "@swc/core": true + cbor-extract: true + core-js: true + esbuild: true diff --git a/call/src/button.js b/call/src/button.js new file mode 100644 index 00000000..e848f5b0 --- /dev/null +++ b/call/src/button.js @@ -0,0 +1,65 @@ +// the call button, for any doc's title bar: call everyone who has it open, +// join the call that's on it, or hang up. it knows the call only by asking +// the provider (protocol.js), so it works wherever its title bar is. with +// no provider to ask, it's nothing +import { createSignal, Show } from "solid-js"; +import { render } from "solid-js/web"; +import html from "solid-js/html"; +import { subscribe } from "@inkandswitch/patchwork-providers"; +import { CALL, CALL_EVENT } from "./protocol-names.js"; +import { icon } from "./ui/icons.js"; +import "./ui/styles.css"; + +const PROVIDER = "call-provider"; +const LABELS = { call: "call everyone here", join: "join the call", here: "hang up" }; + +export default function CallDoctitleButton(handle, element) { + // the doc the frame has open: the view's own url (the handle may be a + // draft's, presenting it) + const url = element.closest?.("patchwork-view")?.getAttribute("doc-url") ?? handle.url; + const [room, setRoom] = createSignal(null); + let unsubscribe = subscribe(element, { type: CALL, url }, setRoom); + // a provider that turns up after this asked (loaded late, or swapped in + // anew) is asked again + function again(event) { + if (event.detail?.componentId !== PROVIDER) return; + unsubscribe(); + unsubscribe = subscribe(element, { type: CALL, url }, setRoom); + } + addEventListener("patchwork:mounted", again); + + const state = () => (room()?.here ? "here" : room()?.others > 0 ? "join" : "call"); + const send = (detail) => element.dispatchEvent(new CustomEvent(CALL_EVENT, { bubbles: true, composed: true, detail })); + + function press(event) { + if (state() === "here") return send({ action: "hang-up" }); + const box = event.currentTarget.getBoundingClientRect(); + send({ action: "ask", url, at: { x: box.left, y: box.bottom + 6 } }); + } + + const dispose = render( + () => html`<${Show} when=${room}> +
+ +
+ `, + element, + ); + return () => { + removeEventListener("patchwork:mounted", again); + unsubscribe(); + dispose(); + }; +} diff --git a/call/src/call-face.js b/call/src/call-face.js new file mode 100644 index 00000000..b6a12d0e --- /dev/null +++ b/call/src/call-face.js @@ -0,0 +1,41 @@ +// one person's face in your call, for anywhere: lattice puts one on each +// cursor. who it's of is said on its element, as attributes: `peer` (their +// tab, as presence knows it) and/or `contact` (who they are), `size` (px), +// and `no-title` to leave their name off it (a cursor says it already). +// nobody in your call by those, and it's nothing +import { createSignal, Show } from "solid-js"; +import { render } from "solid-js/web"; +import html from "solid-js/html"; +import { call } from "./engine/call.js"; +import { Face } from "./ui/ui.js"; +import "./ui/styles.css"; + +const ASKS = ["peer", "contact", "size", "no-title"]; + +export function CallFace(element) { + const read = () => ({ + peer: element.getAttribute("peer") ?? "", + contact: element.getAttribute("contact") ?? "", + size: Number(element.getAttribute("size")) || undefined, + title: !element.hasAttribute("no-title"), + }); + const [asked, setAsked] = createSignal(read()); + const watching = new MutationObserver(() => setAsked(read())); + watching.observe(element, { attributes: true, attributeFilter: ASKS }); + + const id = () => { + if (!call.get()) return null; + const { peer, contact } = asked(); + return call.find({ peer, contact })?.id ?? null; + }; + const dispose = render( + () => html`<${Show} when=${id} keyed> + ${(them) => html`<${Face} id=${them} size=${() => asked().size} title=${() => asked().title} />`} + `, + element, + ); + return () => { + watching.disconnect(); + dispose(); + }; +} diff --git a/call/src/control-panel.js b/call/src/control-panel.js new file mode 100644 index 00000000..b5e6b48f --- /dev/null +++ b/call/src/control-panel.js @@ -0,0 +1,251 @@ +// the call's control panel, for a sidebar (threepane's left one, at the +// bottom; lattice's call bar, where this came from). the calls on screen +// you're not in, to join, each by where it is (which takes you there); and +// the one you're in: where it is, everyone in it, and your mic, camera, +// screen (where there's one to share) and hanging up. its menu says where +// faces go (floating, or in the sidebar where a frame can show them there), +// which mic and camera, and what's done about noise. with nothing to show, +// it takes no room (data-sidebar-size: none) +import { createEffect, createSignal, For, Show } from "solid-js"; +import { render } from "solid-js/web"; +import html from "solid-js/html"; +import { openDocument } from "@inkandswitch/patchwork-elements"; +import { + FACES, + call, + canShare, + devices, + hangUp, + join, + setCamera, + setDevice, + setFaces, + setMic, + setNoise, + startScreen, + stopScreen, +} from "./engine/call.js"; +import { download, DOWNLOAD_BYTES, NOISE } from "./engine/noise.js"; +import { DragOut, Face, StageButton, names } from "./ui/ui.js"; +import { scrollsAlong } from "./ui/along.js"; +import { arrivals } from "./ui/arriving.js"; +import { icon } from "./ui/icons.js"; +import "./ui/styles.css"; + +export function CallControlPanel(element) { + const showing = () => !!call.get() || call.rooms().length > 0 || !!call.problem(); + const dispose = render(() => { + createEffect(() => (element.dataset.sidebarSize = showing() ? "fit" : "none")); + return html`<${Panel} element=${element} />`; + }, element); + return () => { + dispose(); + element.dataset.sidebarSize = "none"; + }; +} + +function Panel(props) { + let menu; + const [found, setFound] = createSignal({ audio: [], video: [] }); + const go = (url) => openDocument(props.element, url); + + async function openMenu(event) { + if (menu.matches(":popover-open")) return menu.hidePopover(); + const box = event.currentTarget.getBoundingClientRect(); + menu.style.left = `${Math.max(8, box.left)}px`; + menu.style.bottom = `${innerHeight - box.top + 4}px`; + menu.style.top = "auto"; + // it grows up out of its button + menu.style.transformOrigin = "0 100%"; + menu.showPopover(); + setFound(await devices()); + } + + // (choose takes the click, so it's a function, not a getter) + function Choice(props) { + return html``; + } + + function Devices(props) { + const chosen = () => (props.kind === "audio" ? call.settings.mic : call.settings.camera); + return html`<${Show} when=${() => found()[props.kind].length > 1}> +
${props.kind === "audio" ? "mic" : "camera"}
+ <${For} each=${() => found()[props.kind]}> + ${(device, at) => + html`<${Choice} + label=${device.label} + checked=${() => chosen() === device.id || (!chosen() && at() === 0)} + choose=${(event) => setDevice(props.kind, device.id)} + />`} + + `; + } + + const In = () => { + // someone joining while you're in it comes in (ui/arriving.js): not + // whoever was there when you joined + const arriving = arrivals(); + return html`
+
+ ${icon("phone", 12)} + + +
+
+ + <${DragOut} id="me"><${Face} id="me" size=${26} /> + <${For} each=${call.peers}> + ${(peer) => html`<${StageButton} id=${peer.id} class="patchwork-call-panel-face-button" arriving=${arriving(peer.id)}> + <${Face} id=${peer.id} size=${26} /> + `} + + + + + + <${Show} when=${canShare}> + + + + +
+ <${Show} when=${() => call.get()?.problem}> +

${() => call.get()?.problem}

+ + <${Show} when=${() => call.settings.noise === "deepfilternet" && download().state === "loading"}> +

+ getting DeepFilterNet ${() => Math.floor((download().got / download().total) * 100)}% · the browser's till then +

+ + <${Show} when=${() => call.settings.noise === "deepfilternet" && download().state === "failed"}> +

couldn't get DeepFilterNet (${() => download().problem}): the browser's for now

+ + +
`; + }; + + // DeepFilterNet's download, in its line of the menu and under the call + // while it's going: what it is till it's here, how far it's got, or why not + const megabytes = (bytes) => `${Math.round(bytes / 1024 / 1024)}MB`; + function deepFilterNote() { + const { state, got, total } = download(); + if (state === "loading") return `· downloading ${Math.floor((got / total) * 100)}%`; + if (state === "failed") return "· couldn't download it"; + if (state === "ready") return ""; + return `· AI, ${megabytes(DOWNLOAD_BYTES)} once`; + } + + // a call on screen that you're not in: where (which takes you there), who, + // and joining it, leaving the one you're in if you are + // (by url, so a row stays as it is while who's on it is heard again) + const On = (url) => { + const room = () => call.rooms().find((room) => room.url === url) ?? { url, name: "", others: [] }; + return html`
+
+ ${icon("phone", 12)} + +
+

+ ${() => `${names([...new Set(room().others.map((person) => person.who.name))])} on a call`} +

+
+ + +
+ <${Show} when=${() => call.get()}> +

leaves the call in ${() => call.nameOf(call.get()?.room) || "another doc"}

+ +
`; + }; + + return html` + <${For} each=${() => call.rooms().map((room) => room.url)}>${On} + <${Show} when=${() => !call.get() && call.problem()}> +

${() => call.problem()}

+ + <${Show} when=${() => !!call.get()}>${In} + `; +} diff --git a/call/src/engine/call.js b/call/src/engine/call.js new file mode 100644 index 00000000..d43e52b7 --- /dev/null +++ b/call/src/engine/call.js @@ -0,0 +1,1161 @@ +// a call, in a room: everyone in it sees and hears everyone else, over +// webrtc. a room is a doc (or, one day, a key: room.js), and what's needed to +// connect (offers, answers, candidates) goes over its channel (channel.js), +// the doc's ephemeral messages. made from lattice's call (its wm/call), made +// from patchwork's call experiment (its call-session.js) +// +// there's one call at a time in a tab, and it carries on as you go from doc +// to doc, until you hang up. whoever's in one says so every few seconds, and +// straight away when anyone arrives (their hello), so everyone in the room +// knows there's a call on, in it or not +// +// each pair connects once: the one whose id sorts later offers, with a +// transceiver each for sound, for pictures and for a screen being shared +// (SLOTS), and the other answers. turning a camera on or off, changing mic, +// or sharing your screen swaps the track in those, so nothing's asked again. +// when a connection fails it's dropped, and made again +import { createRoot, createSignal } from "solid-js"; +import { createStore } from "solid-js/store"; +import { isValidAutomergeUrl } from "@automerge/automerge-repo/slim"; +import { faceFrom, watchFace } from "./face.js"; +import { lasting } from "./lasting.js"; +import { isKeyRoom, pageRepo, roomUrl } from "./room.js"; +import { openChannel } from "./channel.js"; +import { callToRejoin, forgetCall, keepCall, leavingCall } from "./rejoin.js"; +import { listen } from "./speaking.js"; +import { haveModel, isNoise, loadModel, suppressed } from "./noise.js"; +import { videoFor } from "./sizes.js"; + +// (lattice's name for it: its calls and these hear each other) +export const KEY = "lattice-wm:call"; +// lattice's presence says this arriving, and so is answered too +const LATTICE_HELLO = "lattice-wm:hello?"; +const ICE_SERVERS = [ + { urls: "stun:stun.l.google.com:19302" }, + { urls: "stun:stun1.l.google.com:19302" }, + // over tcp too: some networks (schools) let no udp out at all + { + urls: ["turn:sync3.automerge.org:3478", "turn:sync3.automerge.org:3478?transport=tcp"], + username: "inkandswitch", + credential: "insecure", + }, +]; +// saying you're in it +const HERE_EVERY = 3000; +// and forgotten after this long without +const GONE_AFTER = 12000; +// a connection that's only interrupted gets this long to come back +const INTERRUPTED_FOR = 8000; + +// what each of a connection's transceivers carries, in the order they're made +const SLOTS = ["audio", "video", "screen"]; +const slotOf = (pc, transceiver) => SLOTS[pc.getTransceivers().indexOf(transceiver)]; +// a connection's screen's transceiver, or null (one made before there was one) +const screenOf = (pc) => { + const transceiver = pc.getTransceivers()[SLOTS.indexOf("screen")]; + return transceiver && !transceiver.stopped ? transceiver : null; +}; + +const string = (value) => (value == null ? "" : String(value)); +const isObject = (value) => !!value && typeof value === "object" && !Array.isArray(value); + +// ── what's remembered, in this browser ────────────────────────────────── + +// where faces are: floating over everything, or in the sidebar (call-video, +// where a frame can show it) +export const FACES = [ + ["floating", "floating"], + ["sidebar", "in the sidebar"], +]; +// its own, not lattice's (lattice-wm:call): the same choice can mean a +// different place in another frame, and lattice may share the origin +const SETTINGS_KEY = "patchwork-call:settings"; +function readSettings() { + try { + const saved = JSON.parse(localStorage.getItem(SETTINGS_KEY) ?? "{}"); + return isObject(saved) ? saved : {}; + } catch { + return {}; + } +} +function writeSettings(settings) { + try { + localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); + } catch {} +} + +// ── the state, for anything to show ───────────────────────────────────── +// +// it's the page's (../lasting.js), as the session is: a dev server's changes +// are swapped in by importing this again (lattice/dev-swap.js), and the new +// version shows, and hangs up, the call the old one's in. what an old +// version's set going (its listeners, its timers) carries on running its own +// code till the call ends: changes here are for the next one + +const { + state, + setState, + settings, + setSettings, + heard, + setHeard, + version, + bump, + asking, + setAsking, + rooms, + setRooms, + titles, + setTitles, + problem, + setProblem, + videoSpots, + setVideoSpots, + videoShown, + setVideoShown, +} = lasting("patchwork-call:state", () => createRoot(() => { + const saved = readSettings(); + const [state, setState] = createStore({ + // the call you're in: {room, name, id, mic, camera, joining, problem} + call: null, + // everyone else in it, by their id: {id, peer, contact, name, colour, + // avatar, mic, camera, connection} + peers: {}, + }); + const [settings, setSettings] = createStore({ + faces: saved.faces === "sidebar" ? "sidebar" : "floating", + camera: string(saved.camera), + mic: string(saved.mic), + // what's done about the noise round your voice (noise.js) + noise: isNoise(saved.noise) ? saved.noise : "browser", + floating: isObject(saved.floating) ? saved.floating : {}, + }); + // who's heard to be on a call, in each room heard: {[room]: {[id]: {who, at}}} + const [heard, setHeard] = createStore({}); + // streams change in place: this says they have + const [version, setVersion] = createSignal(0); + // a call to ask about, because something was pressed: {url, at: {x, y}} + const [asking, setAsking] = createSignal(null); + // the rooms heard, by id: {onScreen: how many say it's on screen, names: + // what they call it, latest last} + const [rooms, setRooms] = createStore({}); + // what each doc room's doc is called, by id, once it's known + const [titles, setTitles] = createStore({}); + // what went wrong outside a call (joining one that can't be reached) + const [problem, setProblem] = createSignal(null); + // how many places can show the video (call-video), and how many are + // showing it now + const [videoSpots, setVideoSpots] = createSignal(0); + const [videoShown, setVideoShown] = createSignal(0); + return { + state, + setState, + settings, + setSettings, + heard, + setHeard, + version, + bump: () => setVersion((n) => n + 1), + asking, + setAsking, + rooms, + setRooms, + titles, + setTitles, + problem, + setProblem, + videoSpots, + setVideoSpots, + videoShown, + setVideoShown, + }; + })); + +function saveSettings() { + writeSettings({ + faces: settings.faces, + camera: settings.camera, + mic: settings.mic, + noise: settings.noise, + floating: settings.floating, + }); +} + +// the session: what isn't for showing. {session} so every version of this +// has the same one +const live = lasting("patchwork-call:session", () => ({ session: null })); + +// whose face fills the panes (their id, or null), and whether it fills the +// screen. a lasting of its own, not more of the state's above: a page that +// swaps in a new version keeps the state it made, with nothing added since +const view = lasting("patchwork-call:view", () => + createRoot(() => { + const [stage, setStage] = createSignal(null); + const [full, setFull] = createSignal(false); + return { stage, setStage, full, setFull }; + }), +); + +// ── who you are ───────────────────────────────────────────────────────── + +const myContact = () => { + const account = globalThis.accountDocHandle ?? globalThis.patchwork?.account; + const url = string(account?.doc()?.contactUrl); + return isValidAutomergeUrl(url) ? url : ""; +}; + +async function whoAmI(repo) { + const contactUrl = myContact(); + const who = { contact: contactUrl, name: "anonymous", colour: "", avatar: "" }; + if (!contactUrl || !repo) return who; + try { + const doc = (await repo.find(contactUrl)).doc(); + const registered = doc?.type === "registered"; + who.name = (registered && string(doc.name)) || "anonymous"; + who.colour = string(doc?.color); + who.avatar = (registered && string(doc.avatarUrl)) || ""; + } catch {} + return who; +} + +// what someone said of themselves +function whoFrom(value) { + if (!isObject(value)) return null; + return { + contact: isValidAutomergeUrl(string(value.contact)) ? string(value.contact) : "", + name: string(value.name).slice(0, 200) || "anonymous", + colour: string(value.colour).slice(0, 100), + avatar: isValidAutomergeUrl(string(value.avatar)) ? string(value.avatar) : "", + }; +} + +// ── hearing who's on a call in a room, in it or not ───────────────────── + +const listening = new Map(); // room id → how many are hearing it +let pruning = null; + +function prune() { + const now = Date.now(); + for (const [room, people] of Object.entries(heard)) { + for (const [id, person] of Object.entries(people ?? {})) { + if (now - person.at > GONE_AFTER) setHeard(room, id, undefined); + } + } +} + +// hear the calls on `channel` (channel.js). returns a way to stop +function hearCalls(channel) { + const url = channel.id; + function onMessage(event) { + const message = event.message; + if (!isObject(message) || !(KEY in message)) return; + const from = string(message.from); + if (!from || from === live.session?.id) return; + if (message[KEY] === "bye") { + // (someone never heard here: nothing to forget) + if (heard[url]?.[from]) setHeard(url, from, undefined); + return; + } + if (message[KEY] !== "here") return; + const who = whoFrom(message.who); + if (!who) return; + if (!heard[url]) setHeard(url, {}); + setHeard(url, from, { who, peer: string(event.senderId), mic: !!message.mic, camera: !!message.camera, at: Date.now() }); + } + channel.on(onMessage); + pruning ??= setInterval(prune, 2000); + listening.set(url, (listening.get(url) ?? 0) + 1); + return () => { + channel.off(onMessage); + const count = listening.get(url) - 1; + if (count > 0) listening.set(url, count); + else listening.delete(url); + if (!listening.size) { + clearInterval(pruning); + pruning = null; + } + }; +} + +/** who's on a call in the room at `url`, apart from you: [{id, who, peer, mic, camera}] */ +export function onCallIn(url) { + return Object.entries(heard[url] ?? {}) + .filter(([id, person]) => person && id !== state.call?.id) + .map(([id, { who, peer, mic, camera }]) => ({ id, who, peer, mic, camera })); +} + +// ── the rooms heard ───────────────────────────────────────────────────── +// +// each place that wants a room heard holds it (hearRoom), and lets go: the +// room's heard while anything holds it. some say it's on screen (a doc +// that's mounted, a room a frame says is where you are): those are the +// rooms you're asked about (call.rooms), and the ones rejoined after a +// reload. some give it a name + +const hearing = new Map(); // room id → {holds, stop} + +/** + * hear the calls in the room `url` names (a doc's url, any version of it; + * or a key room, which hears nothing yet) until the function this returns + * is called. `onScreen`: it's where you are. `name`: what it's called, while + * this holds it + */ +export function hearRoom(url, { onScreen = false, name = "" } = {}) { + const id = roomUrl(url); + if (!id) return () => {}; + let entry = hearing.get(id); + if (!entry) { + entry = { holds: 0, stop: null }; + hearing.set(id, entry); + openChannel(id).then((channel) => { + if (!channel || hearing.get(id) !== entry) return; + entry.stop = hearCalls(channel); + // anyone in a call here: say so now, not in a few seconds + channel.send({ [KEY]: "hello" }); + if (!(id in titles)) { + channel.title().then((title) => setTitles(id, title), () => {}); + } + }); + } + entry.holds++; + name = string(name); + setRooms(id, (was) => ({ + onScreen: (was?.onScreen ?? 0) + (onScreen ? 1 : 0), + names: name ? [...(was?.names ?? []), name] : (was?.names ?? []), + })); + if (onScreen && rooms[id].onScreen === 1) rejoin(id); + let held = true; + return () => { + if (!held) return; + held = false; + setRooms(id, (was) => { + const names = [...was.names]; + if (name) names.splice(names.lastIndexOf(name), 1); + return { onScreen: was.onScreen - (onScreen ? 1 : 0), names }; + }); + entry.holds--; + if (entry.holds > 0) return; + hearing.delete(id); + entry.stop?.(); + setRooms(id, undefined); + }; +} + +// the page was reloaded in a call in `id`: back in it, as you were +function rejoin(id) { + if (live.session) return; + const back = callToRejoin(id); + if (!back) return; + forgetCall(); + join(id, { video: back.camera }).then(() => { + if (!back.mic) setMic(false); + }); +} + +/** + * ask whether to call (or join the one on) in the room `url` names: `at` is + * where the question goes ({x, y}, its top left). only for what's pressed + */ +export function ask(url, { at = null } = {}) { + const id = roomUrl(url); + if (id) setAsking({ url: id, at }); +} + +/** + * a place that can show the video (call-video) is there, till the function + * this returns is called: faces can be "in the sidebar" while one is. + * `showing`: it's on screen now (a tab that's open). faces in the sidebar + * float meanwhile, wherever nothing's showing them + */ +export function holdVideoSpot({ showing = false } = {}) { + setVideoSpots((n) => n + 1); + if (showing) setVideoShown((n) => n + 1); + let held = true; + return () => { + if (!held) return; + held = false; + setVideoSpots((n) => n - 1); + if (showing) setVideoShown((n) => n - 1); + }; +} + +// ── media ─────────────────────────────────────────────────────────────── + +// the mic's processing, said outright rather than left to the browser: +// echo cancelling and levelling always, its noise suppression only when +// it's what's chosen (DeepFilterNet wants the mic as it is) +const constraint = (kind, device, noiseSuppression) => { + const picked = device ? { deviceId: { exact: device } } : {}; + if (kind !== "audio") return device ? picked : true; + return { ...picked, echoCancellation: true, autoGainControl: true, noiseSuppression }; +}; + +async function getTrack(kind, { noiseSuppression = true } = {}) { + const device = kind === "audio" ? settings.mic : settings.camera; + try { + const stream = await navigator.mediaDevices.getUserMedia({ [kind]: constraint(kind, device, noiseSuppression) }); + return stream.getTracks()[0] ?? null; + } catch (error) { + // the one picked isn't there any more: any will do + if (device && error?.name === "OverconstrainedError") { + try { + const any = constraint(kind, "", noiseSuppression); + return (await navigator.mediaDevices.getUserMedia({ [kind]: any })).getTracks()[0] ?? null; + } catch {} + } + console.warn(`[call] no ${kind}`, error); + return null; + } +} + +// your mic, the noise round it dealt with as settings.noise says. with +// DeepFilterNet not downloaded yet, it's the browser's suppression till it +// is: it's downloaded meanwhile, and then the mic's taken again (refreshMic) +async function getAudio() { + const mode = settings.noise; + if (mode !== "deepfilternet") return getTrack("audio", { noiseSuppression: mode === "browser" }); + if (!(await haveModel())) { + loadModel().then( + () => settings.noise === "deepfilternet" && refreshMic(), + (error) => console.warn("[call] couldn't get DeepFilterNet", error), + ); + return getTrack("audio", { noiseSuppression: true }); + } + const raw = await getTrack("audio", { noiseSuppression: false }); + if (!raw) return null; + try { + return await suppressed(raw, { + // this computer can't keep up: the browser's, then + onOverload: () => { + if (!live.session) return; + setState("call", "problem", "DeepFilterNet's too much for this computer: using the browser's"); + setNoise("browser"); + }, + }); + } catch (error) { + console.warn("[call] couldn't start DeepFilterNet", error); + raw.stop(); + if (live.session) setState("call", "problem", "couldn't start DeepFilterNet: using the browser's"); + return getTrack("audio", { noiseSuppression: true }); + } +} + +// your mic taken again (a different one, or dealt with differently), in +// place of the one you had, muted or not as that was +async function refreshMic() { + const now = live.session; + if (!now || state.call?.joining) return; + // the old one goes first: chrome gives a second track on a mic the first + // one's processing, whatever it asks for, so the new one would come with + // the browser's noise suppression still on (or still off). there's a + // moment's quiet, as it's swapped + trackOf("audio")?.stop(); + const track = await getAudio(); + if (live.session !== now || !track) return track?.stop(); + track.enabled = state.call.mic; + useTrack("audio", track); +} + +/** what's done about the noise round your voice: "browser", "deepfilternet" or "none" (noise.js) */ +export function setNoise(mode) { + if (!isNoise(mode) || settings.noise === mode) return; + setSettings("noise", mode); + saveSettings(); + // downloaded now, chosen out of a call, so it's ready for the next + if (mode === "deepfilternet" && !live.session) loadModel().catch(() => {}); + refreshMic(); +} + +const trackOf = (kind) => live.session?.local.getTracks().find((track) => track.kind === kind) ?? null; + +// put `track` (or none) in as yours of its kind, everywhere it goes +function useTrack(kind, track) { + const old = trackOf(kind); + if (old === track) return; + if (old) { + live.session.local.removeTrack(old); + old.stop(); + } + if (track) live.session.local.addTrack(track); + if (kind === "video") watchMine(track); + if (kind === "audio") listenMine(track); + for (const peer of live.session.peers.values()) { + for (const transceiver of peer.pc.getTransceivers()) { + if (slotOf(peer.pc, transceiver) === kind && !transceiver.stopped) { + transceiver.sender.replaceTrack(track).catch((error) => console.warn("[call] couldn't swap a track", error)); + } + } + } + bump(); +} + +// whether you're speaking, from your mic (speaking.js) +function listenMine(track) { + const now = live.session; + now.stopLevel?.(); + now.stopLevel = null; + setState("call", "speaking", false); + if (!track) return; + now.stopLevel = listen(new MediaStream([track]), (speaking) => { + if (live.session === now) setState("call", "speaking", speaking); + }, "me"); +} + +// your face, in your camera's pictures (face.js), while it's on: said to +// everyone as it moves +function watchMine(track) { + live.session.stopFace?.(); + live.session.stopFace = null; + setState("call", "face", null); + if (!track) return; + live.session.stopFace = watchFace(new MediaStream([track]), (face) => { + if (!live.session) return; + setState("call", "face", face); + send({ [KEY]: "face", face }); + }); +} + +// ── joining, and hanging up ───────────────────────────────────────────── + +/** + * join the call in the room `url` names, or start one: `video` says + * whether with your camera. one that can't be reached says so + * (call.problem), and you're left as you were + */ +export async function join(url, { video = false } = {}) { + setProblem(null); + const roomId = roomUrl(url); + if (live.session && live.session.room.id === roomId) return; + const room = roomId ? await openChannel(roomId) : null; + if (!room) { + setProblem(isKeyRoom(roomId) ? "calls over a key aren't available yet" : "couldn't reach that doc"); + return; + } + if (live.session?.room.id === roomId) return; + hangUp(); + const repo = pageRepo(); + const id = crypto.randomUUID(); + const now = { + id, + room, + local: new MediaStream(), + peers: new Map(), + pendingIce: new Map(), + who: { contact: myContact(), name: "anonymous", colour: "", avatar: "" }, + timers: [], + dead: false, + }; + live.session = now; + setState({ call: { room: roomId, name: call.nameOf(roomId), id, mic: true, camera: !!video, joining: true, problem: null }, peers: {} }); + + // your name and face, when your contact's found: it's said again then, + // if it wasn't in time to say at first + const who = whoAmI(repo).then((who) => { + if (now.dead) return; + now.who = who; + bump(); + sayHere(); + }); + const [audio, camera] = await Promise.all([ + getAudio(), + video ? getTrack("video") : null, + Promise.race([who, new Promise((resolve) => setTimeout(resolve, 800))]), + ]); + if (now.dead) { + audio?.stop(); + camera?.stop(); + return; + } + if (audio) now.local.addTrack(audio); + if (camera) now.local.addTrack(camera); + if (camera) watchMine(camera); + if (audio) listenMine(audio); + const problem = !audio ? (video && !camera ? "no mic or camera" : "no mic") : video && !camera ? "no camera" : null; + setState("call", { joining: false, camera: !!camera, mic: !!audio, problem }); + bump(); + + // what's set going is kept with the session, so any version of this can + // stop it (see the state, above) + now.onMessage = onMessage; + room.on(now.onMessage); + sayHere(); + // whoever's been heard on it already: no waiting till they say so again + for (const person of onCallIn(roomId)) onHere(person.id, person.peer, person); + now.timers.push(setInterval(sayHere, HERE_EVERY)); + now.timers.push(setInterval(forgetGone, GONE_AFTER / 3)); + // the page going (a reload, say): you're back in it if it's back soon + now.onPageHide = () => hangUp({ rejoin: true }); + addEventListener("pagehide", now.onPageHide); + rememberCall(); +} + +/** + * leave the call you're in. `rejoin`: it's the page going, so it's to be + * joined again if the page comes back soon (rejoin.js) + */ +export function hangUp({ rejoin = false } = {}) { + const now = live.session; + if (!now) return; + now.dead = true; + live.session = null; + if (rejoin === true) leavingCall(); + else forgetCall(); + removeEventListener("pagehide", now.onPageHide); + now.timers.forEach(clearInterval); + now.room.off(now.onMessage); + try { + now.room.send({ [KEY]: "bye", from: now.id }); + } catch {} + for (const id of [...now.peers.keys()]) drop(id, now); + now.stopFace?.(); + now.stopLevel?.(); + for (const track of now.local.getTracks()) track.stop(); + now.screen?.stop(); + closeStage(); + setState({ call: null, peers: {} }); + bump(); +} + +// the call you're in, as it is now, kept in the tab for after a reload +function rememberCall() { + const call = state.call; + if (!live.session || !call || call.joining) return; + keepCall({ room: call.room, camera: call.camera, mic: call.mic }); +} + +/** your mic, on or off */ +export function setMic(on) { + if (!live.session || state.call?.joining) return; + const track = trackOf("audio"); + if (on && !track) { + getAudio().then((track) => { + if (!live.session || !track) return setState("call", "problem", "no mic"); + useTrack("audio", track); + setState("call", { mic: true, problem: null }); + sayHere(); + rememberCall(); + }); + return; + } + if (track) track.enabled = on; + setState("call", "mic", on && !!track); + sayHere(); + rememberCall(); +} + +/** your camera, on or off: off, it's let go of (its light goes out) */ +export async function setCamera(on) { + if (!live.session || state.call?.joining) return; + if (!on) { + useTrack("video", null); + setState("call", "camera", false); + sayHere(); + rememberCall(); + return; + } + const now = live.session; + const track = await getTrack("video"); + if (live.session !== now) return track?.stop(); + if (!track) return setState("call", "problem", "no camera"); + useTrack("video", track); + setState("call", { camera: true, problem: null }); + sayHere(); + rememberCall(); +} + +/** which mic or camera ("audio" or "video") to use: a device id, or "" for any */ +export async function setDevice(kind, device) { + setSettings(kind === "audio" ? "mic" : "camera", string(device)); + saveSettings(); + if (!live.session) return; + if (kind === "video" && !state.call?.camera) return; + if (kind === "audio") return refreshMic(); + const now = live.session; + const track = await getTrack(kind); + if (live.session !== now || !track) return track?.stop(); + useTrack(kind, track); +} + +/** the mics and cameras there are: {audio: [{id, label}], video: [...]} */ +export async function devices() { + const all = await navigator.mediaDevices.enumerateDevices().catch(() => []); + const of = (kind) => + all + .filter((device) => device.kind === kind && device.deviceId) + .map((device, at) => ({ id: device.deviceId, label: device.label || `${kind === "audioinput" ? "mic" : "camera"} ${at + 1}` })); + return { audio: of("audioinput"), video: of("videoinput") }; +} + +/** + * where faces are shown: "floating", or "sidebar" (the video, call-video), + * which is only for while something can show it there + */ +export function setFaces(faces) { + if (faces !== "floating" && faces !== "sidebar") return; + if (faces === "sidebar" && !call.canShowVideo()) return; + setSettings("faces", faces); + saveSettings(); +} + +/** where someone's floating face is (`key`: call.floatKey's): fractions of the window */ +export function setFloating(key, at) { + setSettings("floating", key, at); + saveSettings(); +} + +/** someone's floating face (`key`) put away, or (`at`: where) back */ +export function hideFloating(key) { + setFloating(key, { ...call.settings.floating[key], hidden: true }); +} +export function showFloating(key, at) { + setFloating(key, { ...call.settings.floating[key], ...at, hidden: false }); +} + +// ── reading it ────────────────────────────────────────────────────────── + +export const call = { + /** the call you're in, or null: {room, name, id, mic, camera, face, joining, problem} */ + get: () => state.call, + /** everyone else in it: [{id, peer, contact, name, colour, avatar, mic, camera, face, connection}] */ + peers: () => Object.values(state.peers).filter(Boolean), + /** someone's pictures and sound (read in an effect: it says when they change) */ + stream(id) { + version(); + return live.session?.peers.get(id)?.stream ?? null; + }, + /** someone's screen, while they're sharing it (`id`, or "me"), or null */ + screen(id) { + version(); + const now = live.session; + if (!now) return null; + if (id === "me") { + if (!now.screen) return null; + if (now.screenView?.getTracks()[0] !== now.screen) now.screenView = new MediaStream([now.screen]); + return now.screenView; + } + return state.peers[id]?.screen ? (now.peers.get(id)?.screen ?? null) : null; + }, + /** yours */ + local() { + version(); + return live.session && state.call?.camera ? live.session.local : null; + }, + /** who you are in it */ + me: () => (live.session ? { ...live.session.who } : null), + settings, + /** where faces are: "sidebar" while that's chosen and something can show it, else "floating" */ + mode: () => (settings.faces === "sidebar" && call.canShowVideo() ? "sidebar" : "floating"), + /** whether something can show the video (call-video): holdVideoSpot */ + canShowVideo: () => videoSpots() > 0, + /** whether something's showing the video right now */ + videoShowing: () => videoShown() > 0, + /** whether the video's to be shown: you're in a call, with faces in the sidebar */ + videoWanted: () => !!state.call && call.mode() === "sidebar", + /** what went wrong outside a call, or null */ + problem, + /** + * the calls on screen you're not in: [{url, name, others}], others as + * onCallIn's. the rooms held as on screen (hearRoom), with someone in a call + */ + rooms: () => + Object.entries(rooms) + .filter(([url, room]) => room?.onScreen > 0 && url !== state.call?.room) + .map(([url]) => ({ url, name: call.nameOf(url), others: onCallIn(url) })) + .filter((room) => room.others.length > 0), + /** what the room `url` is called: the latest name it's held by, else its doc's title, else "" */ + nameOf(url) { + const id = roomUrl(url); + if (!id) return ""; + const names = rooms[id]?.names; + return names?.length ? names[names.length - 1] : (titles[id] ?? ""); + }, + /** what someone's floating face is kept by: who they are, so it's theirs next call too */ + floatKey: (id) => (id === "me" ? "me" : state.peers[id]?.contact || id), + asking, + stopAsking: () => setAsking(null), + /** whose face fills the panes (one still in the call), or null */ + stage: () => { + const id = view.stage(); + return id && state.peers[id] ? id : null; + }, + /** whether it fills the screen */ + full: () => view.full() && !!call.stage(), + /** + * the one in your call who's `person` from presence ({peer, contact}): by + * the tab they're in, or else by who they are + */ + find(person) { + const peers = call.peers(); + return ( + peers.find((peer) => person.peer && peer.peer === person.peer) ?? + peers.find((peer) => person.contact && peer.contact === person.contact) ?? + null + ); + }, +}; + +// ── sharing your screen ───────────────────────────────────────────────── + +/** whether there's a screen to share, here */ +export const canShare = () => !!navigator.mediaDevices?.getDisplayMedia; + +/** share a screen (the browser asks which), to everyone in the call */ +export async function startScreen() { + const now = live.session; + if (!now || now.screen || state.call?.joining) return; + let stream; + try { + stream = await navigator.mediaDevices.getDisplayMedia({ video: { frameRate: { ideal: 15 } }, audio: false }); + } catch { + // not picked + return; + } + const track = stream.getVideoTracks()[0]; + if (live.session !== now || !track) return stream.getTracks().forEach((track) => track.stop()); + // it's to be read: kept sharp rather than smooth + track.contentHint = "detail"; + // the browser's own "stop sharing" + track.addEventListener("ended", () => now.screen === track && stopScreen()); + now.screen = track; + for (const [id, peer] of now.peers) { + const transceiver = screenOf(peer.pc); + if (transceiver) transceiver.sender.replaceTrack(track).catch((error) => console.warn("[call] couldn't share", error)); + // a connection from before there was room for a screen: made again + else lost(id); + } + setState("call", "screen", true); + bump(); + sayHere(); +} + +/** stop sharing your screen */ +export function stopScreen() { + const now = live.session; + const track = now?.screen; + if (!track) return; + now.screen = null; + for (const peer of now.peers.values()) screenOf(peer.pc)?.sender.replaceTrack(null).catch(() => {}); + track.stop(); + setState("call", "screen", false); + bump(); + sayHere(); +} + +// ── who's big ─────────────────────────────────────────────────────────── + +/** fill the panes with `id`'s face, or, theirs already, put the panes back */ +export function toggleStage(id) { + const now = view.stage() === id ? null : id; + view.setStage(now); + if (!now) view.setFull(false); +} + +/** the face that's big, filling the screen or not */ +export function setFull(full) { + view.setFull(!!full && !!view.stage()); +} + +/** nobody big */ +export function closeStage() { + view.setStage(null); + view.setFull(false); +} + +// how much of your camera's sent, for how many are in the call (sizes.js): +// past a handful, smaller and slower, so it goes to all of them +function tuneVideo() { + const now = live.session; + if (!now) return; + const wanted = videoFor(now.peers.size + 1); + for (const peer of now.peers.values()) { + for (const transceiver of peer.pc.getTransceivers()) { + // (a screen's sent as it is: it's to be read) + if (slotOf(peer.pc, transceiver) !== "video" || transceiver.stopped) continue; + const sender = transceiver.sender; + const parameters = sender.getParameters(); + // none till it's been negotiated: it's tuned when it's connected + const encoding = parameters.encodings?.[0]; + if (!encoding) continue; + for (const key of ["maxBitrate", "scaleResolutionDownBy", "maxFramerate"]) { + if (wanted) encoding[key] = wanted[key]; + else delete encoding[key]; + } + sender.setParameters(parameters).catch((error) => console.warn("[call] couldn't tune video", error)); + } + } +} + +// ── talking ───────────────────────────────────────────────────────────── + +function send(message) { + if (!live.session || live.session.dead) return; + try { + live.session.room.send({ ...message, from: live.session.id }); + } catch (error) { + console.warn("[call] couldn't say", message[KEY], error); + } +} + +// who you are, and your mic and camera: in what you say +const about = () => ({ + who: live.session.who, + mic: !!state.call?.mic, + camera: !!state.call?.camera, + screen: !!live.session?.screen, + face: state.call?.camera ? (state.call.face ?? null) : null, +}); + +function sayHere() { + if (!live.session || state.call?.joining) return; + send({ [KEY]: "here", ...about() }); +} + +function forgetGone() { + const now = Date.now(); + for (const [id, peer] of live.session?.peers ?? []) { + if (now - peer.heard > GONE_AFTER) drop(id); + } +} + +function onMessage(event) { + const message = event.message; + if (!live.session || !isObject(message)) return; + // someone's arriving: say you're here, so they know there's a call on + if (message[LATTICE_HELLO] || message[KEY] === "hello") { + setTimeout(sayHere, Math.random() * 200); + return; + } + if (!(KEY in message)) return; + // the tab they're in, as presence knows it + const peer = string(event.senderId); + const from = string(message.from); + if (!from || from === live.session.id) return; + if (message.to && message.to !== live.session.id) return; + switch (message[KEY]) { + case "here": + return onHere(from, peer, message); + case "bye": + return drop(from); + case "face": + return showPeer(from, { face: faceFrom(message.face) }); + case "again": + // they lost us: start again + drop(from); + return onHere(from, peer, message); + case "offer": + onOffer(from, message); + // what they said of themselves with it + return onHere(from, peer, message); + case "answer": + return onAnswer(from, message); + case "ice": + return onIce(from, message); + } +} + +// ── connections ───────────────────────────────────────────────────────── + +const offers = (theirs) => live.session.id > theirs; + +function showPeer(id, fields) { + if (!live.session?.peers.has(id)) return; + setState("peers", id, (had) => ({ ...(had ?? { id }), ...fields })); +} + +function onHere(from, tab, message) { + const who = whoFrom(message.who); + if (!who) return; + let peer = live.session.peers.get(from); + if (!peer && offers(from)) peer = connect(from, crypto.randomUUID()); + const fields = { ...who, peer: tab, mic: !!message.mic, camera: !!message.camera, face: faceFrom(message.face), screen: !!message.screen }; + if (peer) { + peer.heard = Date.now(); + const was = !!state.peers[from]?.screen; + showPeer(from, fields); + if (fields.screen !== was) onSharing(from, fields.screen); + } else { + // they'll offer: until they do, they're on the call, connecting + live.session.waiting ??= new Map(); + live.session.waiting.set(from, fields); + } +} + +// someone's started sharing their screen: it fills the panes, unless +// someone's there already. stopped, it's put back, if that's what put it there +function onSharing(id, sharing) { + const now = live.session; + if (sharing && !view.stage()) { + view.setStage(id); + now.shown = id; + } else if (!sharing && now.shown === id) { + now.shown = null; + if (view.stage() === id) closeStage(); + } + bump(); +} + +// a connection to `id`, made fresh: `conn` is which one (the offerer's +// pick), so what's said about an old one is left alone +function connect(id, conn) { + drop(id); + const pc = new RTCPeerConnection({ iceServers: ICE_SERVERS }); + const stream = new MediaStream(); + const audio = new Audio(); + audio.autoplay = true; + audio.srcObject = stream; + const peer = { id, conn, pc, stream, audio, heard: Date.now(), interrupted: null }; + live.session.peers.set(id, peer); + setState("peers", id, { id, connection: "connecting", ...(live.session.waiting?.get(id) ?? {}) }); + live.session.waiting?.delete(id); + + pc.ontrack = (event) => { + // their screen's, when they share it: a stream of its own + if (slotOf(pc, event.transceiver) === "screen") { + peer.screen ??= new MediaStream(); + if (!peer.screen.getTrackById(event.track.id)) peer.screen.addTrack(event.track); + event.track.onunmute = bump; + bump(); + return; + } + if (!stream.getTrackById(event.track.id)) stream.addTrack(event.track); + // who's speaking: theirs, once there's sound to hear + if (event.track.kind === "audio" && !peer.stopLevel) { + peer.stopLevel = listen(stream, (speaking) => + showPeer(id, speaking ? { speaking, spoke: Date.now() } : { speaking }), + id, + ); + } + event.track.onunmute = bump; + audio.play().catch(() => {}); + bump(); + }; + pc.onicecandidate = (event) => { + if (event.candidate) send({ [KEY]: "ice", to: id, conn, candidate: event.candidate.toJSON() }); + }; + pc.onconnectionstatechange = () => { + const now = pc.connectionState; + clearTimeout(peer.interrupted); + if (now === "connected") { + showPeer(id, { connection: "connected" }); + tuneVideo(); + } + else if (now === "disconnected") { + showPeer(id, { connection: "interrupted" }); + peer.interrupted = setTimeout(() => pc.connectionState === "disconnected" && lost(id), INTERRUPTED_FOR); + } else if (now === "failed") lost(id); + }; + + if (offers(id)) { + for (const slot of SLOTS) { + const transceiver = pc.addTransceiver(slot === "audio" ? "audio" : "video", { direction: "sendrecv" }); + const track = slot === "screen" ? live.session.screen : trackOf(slot); + if (track) transceiver.sender.replaceTrack(track); + } + pc.onnegotiationneeded = async () => { + try { + if (pc.signalingState !== "stable") return; + await pc.setLocalDescription(); + send({ [KEY]: "offer", to: id, conn, sdp: pc.localDescription.toJSON(), ...about() }); + } catch (error) { + console.warn("[call] couldn't offer", error); + } + }; + } + return peer; +} + +// a connection that's failed: let it go, and start again (the one who +// offers does that on hearing them next; the other asks them to) +function lost(id) { + const peer = live.session?.peers.get(id); + if (!peer) return; + const offering = offers(id); + drop(id); + if (!offering) send({ [KEY]: "again", to: id, ...about() }); +} + +function drop(id, from = live.session) { + const peer = from?.peers.get(id); + from?.pendingIce.delete(id); + if (!peer) { + if (from === live.session) setState("peers", id, undefined); + return; + } + from.peers.delete(id); + clearTimeout(peer.interrupted); + try { + peer.pc.close(); + } catch {} + peer.audio.srcObject = null; + peer.stopLevel?.(); + if (from === live.session) { + setState("peers", id, undefined); + if (view.stage() === id) closeStage(); + bump(); + tuneVideo(); + } +} + +async function onOffer(from, { conn, sdp }) { + if (offers(from) || !isObject(sdp) || sdp.type !== "offer" || typeof sdp.sdp !== "string") return; + let peer = live.session.peers.get(from); + if (!peer || peer.conn !== conn) peer = connect(from, string(conn)); + const { pc } = peer; + try { + await pc.setRemoteDescription(sdp); + for (const transceiver of pc.getTransceivers()) { + const kind = transceiver.receiver.track?.kind; + if (!kind) continue; + transceiver.direction = "sendrecv"; + const slot = slotOf(pc, transceiver) ?? kind; + await transceiver.sender.replaceTrack(slot === "screen" ? (live.session.screen ?? null) : trackOf(kind)); + } + await pc.setLocalDescription(); + send({ [KEY]: "answer", to: from, conn, sdp: pc.localDescription.toJSON() }); + await addPendingIce(peer); + } catch (error) { + console.warn("[call] couldn't answer", error); + } +} + +async function onAnswer(from, { conn, sdp }) { + const peer = live.session.peers.get(from); + if (!peer || peer.conn !== conn || !isObject(sdp) || sdp.type !== "answer") return; + if (peer.pc.signalingState !== "have-local-offer") return; + try { + await peer.pc.setRemoteDescription(sdp); + await addPendingIce(peer); + } catch (error) { + console.warn("[call] couldn't take their answer", error); + } +} + +// candidates can come before what they're for: kept till it's in +async function onIce(from, { conn, candidate }) { + if (!isObject(candidate)) return; + const peer = live.session.peers.get(from); + if (!peer || peer.conn !== conn || !peer.pc.remoteDescription) { + const pending = live.session.pendingIce.get(from) ?? []; + pending.push({ conn, candidate }); + live.session.pendingIce.set(from, pending.slice(-50)); + return; + } + try { + await peer.pc.addIceCandidate(candidate); + } catch (error) { + console.warn("[call] couldn't take a candidate", error); + } +} + +async function addPendingIce(peer) { + const pending = live.session?.pendingIce.get(peer.id) ?? []; + live.session?.pendingIce.set( + peer.id, + pending.filter((ice) => ice.conn !== peer.conn), + ); + for (const { candidate } of pending.filter((ice) => ice.conn === peer.conn)) { + try { + await peer.pc.addIceCandidate(candidate); + } catch {} + } +} diff --git a/call/src/engine/channel.js b/call/src/engine/channel.js new file mode 100644 index 00000000..add7b56b --- /dev/null +++ b/call/src/engine/channel.js @@ -0,0 +1,52 @@ +// what a call's said over, in a room: a doc's ephemeral messages. a key's +// would be subduction's ephemeral topics, which the repo can't hand us yet +// (it routes every inbound ephemeral by the doc id inside it), so a key +// room has no channel, for now +import { isKeyRoom, pageRepo, titleOf } from "./room.js"; + +const found = new Map(); // room id → Promise + +/** + * the channel for room `id`: {id, on(listener), off(listener), send(message), + * title()}, listeners hearing {message, senderId}, title() what the room's + * called (a promise). null when there isn't one + */ +export function openChannel(id) { + if (isKeyRoom(id)) return Promise.resolve(null); + const repo = pageRepo(); + if (!repo) return Promise.resolve(null); + if (!found.has(id)) { + const finding = Promise.resolve() + .then(() => repo.find(id)) + .then((handle) => docChannel(handle, id), (error) => { + console.warn(`[call] couldn't find ${id}`, error); + found.delete(id); + return null; + }); + found.set(id, finding); + } + return found.get(id); +} + +function docChannel(handle, id) { + const wrapped = new Map(); + return { + id, + on(listener) { + if (wrapped.has(listener)) return; + const wrapper = (event) => listener({ message: event.message, senderId: String(event.senderId) }); + wrapped.set(listener, wrapper); + handle.on("ephemeral-message", wrapper); + }, + off(listener) { + const wrapper = wrapped.get(listener); + if (!wrapper) return; + wrapped.delete(listener); + handle.off("ephemeral-message", wrapper); + }, + send(message) { + handle.broadcast(message); + }, + title: () => titleOf(handle), + }; +} diff --git a/call/src/engine/face.js b/call/src/engine/face.js new file mode 100644 index 00000000..8106ced9 --- /dev/null +++ b/call/src/engine/face.js @@ -0,0 +1,138 @@ +// where your face is in your camera's picture, so faces can be shown centred +// on it. it's found in your own camera, four times a second while it's +// on, and goes to everyone else in the call with the rest of what you say +// (call.js), so nobody finds anyone else's. +// +// the browser's own FaceDetector, where there is one (shape detection: only +// behind a flag, mostly); else google's mediapipe face detector, its short +// range model, fetched the first time it's wanted +const MEDIAPIPE = "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.14"; +const MODEL = + "https://storage.googleapis.com/mediapipe-models/face_detector/blaze_face_short_range/float16/1/blaze_face_short_range.tflite"; +const EVERY = 250; +// how far it has to move (a fraction of the picture) to be said again: a +// face held still holds still +const MOVED = 0.01; +// how much of the way to where it's just been found each look goes: the +// rest's where it was, so a jittery find comes out steady +const EASE = 0.4; + +const clamp01 = (n) => Math.max(0, Math.min(1, n)); + +// {find(video, now)}: the biggest face in `video`, {x, y, width, height} in its +// pixels, or null. null if neither can be had +let detector = null; +function load() { + detector ??= (async () => { + if (typeof globalThis.FaceDetector === "function") { + try { + const native = new globalThis.FaceDetector({ fastMode: true, maxDetectedFaces: 1 }); + return { + async find(video) { + const [face] = await native.detect(video); + return face?.boundingBox ?? null; + }, + }; + } catch {} + } + const vision = await import(`${MEDIAPIPE}/vision_bundle.mjs`); + const files = await vision.FilesetResolver.forVisionTasks(`${MEDIAPIPE}/wasm`); + const found = await vision.FaceDetector.createFromOptions(files, { + baseOptions: { modelAssetPath: MODEL }, + runningMode: "VIDEO", + minDetectionConfidence: 0.5, + }); + return { + async find(video, now) { + const boxes = found + .detectForVideo(video, now) + .detections.map((detection) => detection.boundingBox) + .filter(Boolean); + const biggest = boxes.sort((a, b) => b.width * b.height - a.width * a.height)[0]; + return biggest ? { x: biggest.originX, y: biggest.originY, width: biggest.width, height: biggest.height } : null; + }, + }; + })().catch((error) => { + console.warn("[call] can't find faces", error); + return null; + }); + return detector; +} + +/** + * find the face in `stream`'s pictures till stopped. `onFace` hears where it + * is, as fractions of the picture ({x, y}: its middle, and size: its height), + * when that moves. returns a way to stop + */ +export function watchFace(stream, onFace) { + let stopped = false; + let timer = null; + let said = null; + const video = document.createElement("video"); + video.muted = true; + video.playsInline = true; + video.srcObject = stream; + video.play().catch(() => {}); + + async function look() { + timer = null; + if (stopped) return; + const found = await load(); + if (!found || stopped) return; + if (video.readyState >= 2 && video.videoWidth) { + try { + const box = await found.find(video, performance.now()); + if (box && !stopped) { + const face = { + x: clamp01((box.x + box.width / 2) / video.videoWidth), + y: clamp01((box.y + box.height / 2) / video.videoHeight), + size: clamp01(box.height / video.videoHeight), + }; + // eased (and everyone's picture glides there: styles.css) + const toward = (from, to) => from + (to - from) * EASE; + const next = said + ? { x: toward(said.x, face.x), y: toward(said.y, face.y), size: toward(said.size, face.size) } + : face; + if (!said || Math.abs(next.x - said.x) > MOVED || Math.abs(next.y - said.y) > MOVED) { + said = next; + onFace({ x: round(next.x), y: round(next.y), size: round(next.size) }); + } + } + } catch {} + } + if (!stopped) timer = setTimeout(look, EVERY); + } + look(); + + return () => { + stopped = true; + clearTimeout(timer); + video.srcObject = null; + }; +} + +const round = (n) => Math.round(n * 1000) / 1000; + +/** a face from what someone said, or null */ +export function faceFrom(value) { + if (!value || typeof value !== "object") return null; + const { x, y, size } = value; + if (![x, y, size].every((n) => typeof n === "number" && n >= 0 && n <= 1)) return null; + return { x, y, size }; +} + +/** + * show `video` (object-fit: cover) in its box with `face` ({x, y}, fractions + * of the picture) as near its middle as the picture allows + */ +export function centreOn(video, face) { + const { videoWidth: vw, videoHeight: vh, clientWidth: bw, clientHeight: bh } = video; + if (!face || !vw || !vh || !bw || !bh) { + video.style.objectPosition = ""; + return; + } + const scale = Math.max(bw / vw, bh / vh); + const along = (fraction, box, picture) => + Math.abs(picture - box) < 0.5 ? 0.5 : clamp01((box / 2 - fraction * picture) / (box - picture)); + video.style.objectPosition = `${along(face.x, bw, vw * scale) * 100}% ${along(face.y, bh, vh * scale) * 100}%`; +} diff --git a/call/src/engine/kept.js b/call/src/engine/kept.js new file mode 100644 index 00000000..5878dc66 --- /dev/null +++ b/call/src/engine/kept.js @@ -0,0 +1,31 @@ +// what's kept in this tab (its sessionStorage, as json): still there after a +// reload, and after a dev server's changes are swapped in (lasting.js), and +// gone with the tab. nothing's kept, and the fallback's what comes back, +// wherever storage can't be had + +function tabStorage() { + try { + return globalThis.sessionStorage ?? null; + } catch { + return null; + } +} + +/** what's kept as `key`, or `fallback` */ +export function kept(key, fallback = null, storage = tabStorage()) { + try { + const value = storage?.getItem(key); + return value == null ? fallback : JSON.parse(value); + } catch { + return fallback; + } +} + +/** keep `value` as `key` (null or undefined: keep nothing) */ +export function keep(key, value, storage = tabStorage()) { + try { + if (value == null) storage?.removeItem(key); + else storage?.setItem(key, JSON.stringify(value)); + } catch {} +} + diff --git a/call/src/engine/lasting.js b/call/src/engine/lasting.js new file mode 100644 index 00000000..8dee57c2 --- /dev/null +++ b/call/src/engine/lasting.js @@ -0,0 +1,15 @@ +// what has to outlast the module it's made in. a dev server's changes are +// swapped in where you are (as lattice's dev-swap.js does): every module's imported +// again, from a new url, so each starts with its variables empty, while the +// page carries on, and a call in it. what's made here is made once a page: +// every version of a module that asks for `key` gets the same one back. for +// what's live (a call's connections, its streams), which can't be kept in +// storage; what can be, kept.js keeps, and that outlasts a reload too +const ALL = Symbol.for("patchwork-call:lasting"); + +/** the page's one `key`, made by `make` the first time it's asked for */ +export function lasting(key, make) { + const all = (globalThis[ALL] ??= new Map()); + if (!all.has(key)) all.set(key, make()); + return all.get(key); +} diff --git a/call/src/engine/noise.js b/call/src/engine/noise.js new file mode 100644 index 00000000..9467804b --- /dev/null +++ b/call/src/engine/noise.js @@ -0,0 +1,210 @@ +// what's done about the noise round your voice, before it goes to the call: +// browser the browser's own noise suppression (webrtc's): good on +// a steady hum (a fan), not on keys or a dog +// deepfilternet DeepFilterNet3, a small neural net, run here in an +// AudioWorklet (../vendor/deepfilternet): takes out typing +// and chatter too, and keeps the whole voice. its wasm and +// model (23MB) are downloaded the first time it's wanted, +// checked, and kept in indexeddb (not the cache storage: +// the service worker clears what isn't its own there) +// none nothing: the mic as it is (echo cancelling still) +import { createRoot, createSignal } from "solid-js"; +import { lasting } from "./lasting.js"; +import { createDeepFilterNetAudioWorklet, DEEPFILTERNET_SAMPLE_RATE } from "../vendor/deepfilternet/deepfilternet.js"; + +export const NOISE = [ + ["browser", "the browser's"], + ["deepfilternet", "DeepFilterNet"], + ["none", "none"], +]; +export const isNoise = (value) => NOISE.some(([id]) => id === value); + +// where the wasm and model come from, pinned, and what they must hash to +// (sha-256, from the npm tarball): anything else is refused +const VERSION = "0.2.0"; +const FROM = `https://cdn.jsdelivr.net/npm/@workadventure/noise-suppression@${VERSION}/dist/assets/deepfilternet/`; +const FILES = { + wasm: { + name: "df_bg.wasm", + type: "application/wasm", + size: 14643565, + sha256: "8ebf2b217b80fc128505b993c8d7d7d892df5a845ab7a2516fed74b76e7fe708", + }, + model: { + name: "DeepFilterNet3_onnx.tar.gz", + type: "application/gzip", + size: 7983136, + sha256: "c94d91f70911001c946e0fabb4aa9adc37045f45a03b56008cb0c8244cb63616", + }, +}; +export const DOWNLOAD_BYTES = FILES.wasm.size + FILES.model.size; + +// ── keeping them: indexeddb, by name at its hash ──────────────────────── + +const DB = "lattice-wm:deepfilternet"; +const STORE = "files"; + +function openDb() { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB, 1); + request.onupgradeneeded = () => request.result.createObjectStore(STORE); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +} + +async function kept(key) { + try { + const db = await openDb(); + return await new Promise((resolve, reject) => { + const request = db.transaction(STORE).objectStore(STORE).get(key); + request.onsuccess = () => resolve(request.result ?? null); + request.onerror = () => reject(request.error); + }).finally(() => db.close()); + } catch { + return null; + } +} + +async function keep(key, blob) { + try { + const db = await openDb(); + await new Promise((resolve, reject) => { + const transaction = db.transaction(STORE, "readwrite"); + transaction.objectStore(STORE).put(blob, key); + transaction.oncomplete = resolve; + transaction.onerror = () => reject(transaction.error); + }).finally(() => db.close()); + } catch (error) { + // not kept (private browsing, say): it's downloaded again next time + console.warn("[call] couldn't keep DeepFilterNet", error); + } +} + +const keyOf = (file) => `${file.name}@${file.sha256}`; + +async function hex(bytes) { + const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)); + return [...digest].map((byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +// a file, from indexeddb, or downloaded (telling `got` how far it's got) +// and checked and kept +async function file(spec, got) { + const had = await kept(keyOf(spec)); + if (had) { + got(spec.size); + return had; + } + const response = await fetch(FROM + spec.name); + if (!response.ok || !response.body) throw new Error(`couldn't download ${spec.name} (${response.status})`); + const chunks = []; + let length = 0; + const reader = response.body.getReader(); + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + length += value.length; + got(length); + } + const bytes = new Uint8Array(length); + let at = 0; + for (const chunk of chunks) { + bytes.set(chunk, at); + at += chunk.length; + } + if ((await hex(bytes)) !== spec.sha256) throw new Error(`${spec.name} isn't the one it should be: not using it`); + const blob = new Blob([bytes], { type: spec.type }); + await keep(keyOf(spec), blob); + return blob; +} + +// ── getting them, once a page ─────────────────────────────────────────── + +// how it's going, to show: {state: "none" | "loading" | "ready" | "failed", +// got, total, problem} +const status = lasting("patchwork-call:noise:status", () => + createRoot(() => createSignal({ state: "none", got: 0, total: DOWNLOAD_BYTES, problem: null })), +); +export const download = status[0]; +const setDownload = status[1]; + +const loading = lasting("patchwork-call:noise:loading", () => ({ promise: null, urls: null })); + +/** whether the wasm and model are here already (no download needed) */ +export async function haveModel() { + if (loading.urls) return true; + return !!(await kept(keyOf(FILES.wasm))) && !!(await kept(keyOf(FILES.model))); +} + +// kept from before (another visit): it's here, not a download, to show +haveModel().then((have) => { + if (have && download().state === "none") { + setDownload({ state: "ready", got: DOWNLOAD_BYTES, total: DOWNLOAD_BYTES, problem: null }); + } +}); + +/** the wasm's and model's urls (object urls), downloading them the first time */ +export function loadModel() { + let active = true; + loading.promise ??= (async () => { + const got = { wasm: 0, model: 0 }; + const tell = () => { + if (active) setDownload({ state: "loading", got: got.wasm + got.model, total: DOWNLOAD_BYTES, problem: null }); + }; + tell(); + const [wasm, model] = await Promise.all( + ["wasm", "model"].map((which) => + file(FILES[which], (bytes) => { + got[which] = bytes; + tell(); + }), + ), + ); + loading.urls = { wasmUrl: URL.createObjectURL(wasm), modelUrl: URL.createObjectURL(model) }; + setDownload({ state: "ready", got: DOWNLOAD_BYTES, total: DOWNLOAD_BYTES, problem: null }); + return loading.urls; + })().catch((error) => { + active = false; + loading.promise = null; + setDownload({ state: "failed", got: 0, total: DOWNLOAD_BYTES, problem: String(error?.message ?? error) }); + throw error; + }); + return loading.promise; +} + +// ── using it ──────────────────────────────────────────────────────────── + +/** + * `raw` (a mic track, taken with the browser's noise suppression off), with + * DeepFilterNet's taken out of it: a new track. stopping it stops `raw` too, + * and lets go of everything in between. `onOverload` is told if this + * computer can't keep up with it + */ +export async function suppressed(raw, { onOverload } = {}) { + const { wasmUrl, modelUrl } = await loadModel(); + const context = new AudioContext({ sampleRate: DEEPFILTERNET_SAMPLE_RATE }); + try { + const source = context.createMediaStreamSource(new MediaStream([raw])); + const destination = context.createMediaStreamDestination(); + const worklet = await createDeepFilterNetAudioWorklet(context, { wasmUrl, modelUrl, onOverload }); + source.connect(worklet.node).connect(destination); + await worklet.ready; + await context.resume(); + const [track] = destination.stream.getAudioTracks(); + if (!track) throw new Error("DeepFilterNet made no track"); + const stop = track.stop.bind(track); + track.stop = () => { + stop(); + worklet.dispose(); + source.disconnect(); + raw.stop(); + context.close().catch(() => {}); + }; + return track; + } catch (error) { + context.close().catch(() => {}); + throw error; + } +} diff --git a/call/src/engine/rejoin.js b/call/src/engine/rejoin.js new file mode 100644 index 00000000..9a944325 --- /dev/null +++ b/call/src/engine/rejoin.js @@ -0,0 +1,36 @@ +// the call you're in, kept in the tab (kept.js), so a reload puts you back +// in it. it's kept while you're in it, and when the page goes it's stamped +// with when: a page that comes back soon after, to the same room, joins +// again, with your camera and mic as they were. hanging up forgets it, and +// so does too long (reopening a closed tab tomorrow isn't arriving back) +import { keep, kept } from "./kept.js"; + +export const REJOIN_KEY = "patchwork-call:rejoin"; +export const REJOIN_WITHIN = 2 * 60_000; + +const isObject = (value) => !!value && typeof value === "object" && !Array.isArray(value); + +/** you're in the call in `room`, with your camera and mic so */ +export function keepCall({ room, camera, mic }, { storage } = {}) { + keep(REJOIN_KEY, { room: String(room), camera: !!camera, mic: !!mic, at: null }, storage); +} + +/** the page is going (a reload, say), while you're in it */ +export function leavingCall({ at = Date.now(), storage } = {}) { + const call = kept(REJOIN_KEY, null, storage); + if (isObject(call)) keep(REJOIN_KEY, { ...call, at }, storage); +} + +/** you've hung up */ +export function forgetCall({ storage } = {}) { + keep(REJOIN_KEY, null, storage); +} + +/** the call in `room` to join again ({camera, mic}), if there's one */ +export function callToRejoin(room, { now = Date.now(), storage } = {}) { + const call = kept(REJOIN_KEY, null, storage); + if (!isObject(call) || call.room !== room || typeof call.at !== "number") return null; + const ago = now - call.at; + if (ago < 0 || ago > REJOIN_WITHIN) return null; + return { camera: !!call.camera, mic: !!call.mic }; +} diff --git a/call/src/engine/room.js b/call/src/engine/room.js new file mode 100644 index 00000000..107b9832 --- /dev/null +++ b/call/src/engine/room.js @@ -0,0 +1,48 @@ +// a room is where a call is: a doc (its url, bare: no heads, no path, so +// every draft and version of it is the same room) or a key (32 bytes, as +// hex: for meeting where there's no doc, which isn't built yet, see +// channel.js) +import { isValidAutomergeUrl } from "@automerge/automerge-repo/slim"; + +const KEY = /^key:([0-9a-f]{64})$/i; + +/** the room `url` names (a doc url, or "key:"), as a room id, or null */ +export function roomUrl(url) { + if (typeof url !== "string") return null; + const key = KEY.exec(url); + if (key) return `key:${key[1].toLowerCase()}`; + if (!url.startsWith("automerge:")) return null; + const bare = url.split(/[#/]/, 1)[0]; + return isValidAutomergeUrl(bare) ? bare : null; +} + +/** the room for 32 bytes of key */ +export function keyRoom(bytes) { + if (!(bytes instanceof Uint8Array) || bytes.length !== 32) { + throw new RangeError("a key room is 32 bytes"); + } + return `key:${Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("")}`; +} + +export const isKeyRoom = (id) => typeof id === "string" && KEY.test(id); + +/** the page's repo, wherever this host put it (not a draft overlay's) */ +export function pageRepo() { + return globalThis.repo ?? globalThis.patchwork?.repo ?? null; +} + +/** what a doc's called, by its datatype, or its own title, or nothing */ +export async function titleOf(handle) { + const doc = handle?.doc?.(); + if (!doc) return ""; + const type = doc["@patchwork"]?.type; + if (type) { + try { + const { getRegistry } = await import("@inkandswitch/patchwork-plugins"); + const datatype = await getRegistry("patchwork:datatype").load(type); + const title = datatype?.module?.getTitle?.(doc); + if (typeof title === "string" && title) return title; + } catch {} + } + return typeof doc.title === "string" ? doc.title : ""; +} diff --git a/call/src/engine/sizes.js b/call/src/engine/sizes.js new file mode 100644 index 00000000..259c90da --- /dev/null +++ b/call/src/engine/sizes.js @@ -0,0 +1,58 @@ +// how a call's shown, and sent, for how many are in it. nothing imported: +// node's tests read it too +// +// everyone in a call connects to everyone else (call.js), so each camera's +// sent once for each of the others: twenty people is nineteen copies out of +// every browser. past a handful it's sent smaller, slower and fewer frames a +// second, so it still goes. (any more than that wants a server in the middle, +// which this isn't) + +/** the sidebar's faces, for `count` people (you too): "tiles", "grid" or "chips" */ +export function tilesFor(count) { + if (count <= 4) return "tiles"; + if (count <= 9) return "grid"; + return "chips"; +} + +/** + * a video sender's encoding for a call of `count` people (you too), or null + * for as it comes + */ +export function videoFor(count) { + if (count <= 5) return null; + if (count <= 9) return { maxBitrate: 300_000, scaleResolutionDownBy: 2, maxFramerate: 20 }; + return { maxBitrate: 120_000, scaleResolutionDownBy: 4, maxFramerate: 12 }; +} + +// ── who's speaking ──────────────────────────────────────────────────────── + +// how loud's speaking (0 to 1, the sound's root mean square) +export const SPEAKING_LEVEL = 0.04; +// it's speaking once it's been that loud this long (not a click or a cough) +export const SPEAKING_AFTER = 150; +// and stops once it's been quiet this long (not a breath between words) +export const QUIET_AFTER = 700; + +/** how loud `samples` are (a byte time-domain buffer, 128 is silence) */ +export function levelOf(samples) { + if (!samples.length) return 0; + let sum = 0; + for (const sample of samples) { + const x = (sample - 128) / 128; + sum += x * x; + } + return Math.sqrt(sum / samples.length); +} + +/** + * speaking or not, after hearing `level` at `now`, from `state` + * ({speaking, loudSince, quietSince}) + */ +export function speakingStep(state, level, now) { + if (level >= SPEAKING_LEVEL) { + const loudSince = state.loudSince ?? now; + return { speaking: state.speaking || now - loudSince >= SPEAKING_AFTER, loudSince, quietSince: null }; + } + const quietSince = state.quietSince ?? now; + return { speaking: state.speaking && now - quietSince < QUIET_AFTER, loudSince: null, quietSince }; +} diff --git a/call/src/engine/speaking.js b/call/src/engine/speaking.js new file mode 100644 index 00000000..ae50c11e --- /dev/null +++ b/call/src/engine/speaking.js @@ -0,0 +1,58 @@ +// who's speaking: how loud each stream's sound is, a few times a second, +// and whether that's speaking (sizes.js says when it is). one AudioContext +// for the page; a stream from a peer is heard here while its